This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.2.1 instead.
Reason given by maintainers: bad release
agent-switch
Unified abstraction layer for agent SDKs (deepagents, Qcoder SDK, etc.)
agent-switch gives your business code a single, stable API — create_agent + run / stream —
so you can switch underlying agent frameworks (deepagents, qcoder, …) without touching
your upper-layer types or call sites.
Features
- One API, many backends:
create_agent(AgentBackend.DEEPAGENTS | "qcoder", config)returns an adapter exposing the samerun(input) -> AgentResponseandstream(input) -> AsyncIterator[AgentChunk]. - Rich, validated type system:
AgentConfig,AgentMessage,AgentTool,AgentSkillsConfig,AgentMcpConfig,AgentSubagent,AgentChunk,AgentResponse, … (Pydantic v2,extra="forbid"). - Hooks lifecycle: 12 async hook events (
beforeAgent…afterStop) withBLOCK/MODIFYoutcomes and a per-AgentConfighook list. - Structured logging: redacts secrets, summarizes config/input, Dev & JSON formatters,
configured only for the
agent_switchnamespace (no global handlers). - Lazy dependencies:
deepagentsis imported only when thedeepagentsbackend is actually used;import agent_switchnever requires it.
Installation
pip install agent-switch
# or with extras
pip install "agent-switch[deepagents]" # deepagents backend
pip install "agent-switch[qcoder]" # qcoder backend (qoder-agent-sdk)
pip install "agent-switch[all]"
The
qcoderbackend runs the realqoder-agent-sdk, which spawns theqodercliCLI. Install the CLI and log in once (qodercli auth) before use.
Quick start
from agent_switch import AgentConfig, AgentMessage, MessageRole, create_agent, AgentBackend
# qcoder runs on the real qoder-agent-sdk (needs `qodercli` installed & logged in)
config = AgentConfig(system_prompt="Be concise.")
agent = create_agent(AgentBackend.QCODER, config)
response = agent.run("Tell me a joke")
print(response.content)
async def demo_stream() -> None:
async for chunk in agent.stream("Hello"):
print(chunk.delta_content, end="")
Run the demos
python -m examples # unified API entry-point demo (all call styles)
python -m examples.basic_usage # DEEPAGENTS + QCODER sync run & QCODER stream
python -m examples.deepseek_flash_usage # DeepSeek Flash via env config (needs DEEPSEEK_API_KEY)
Reusable hook implementations live in examples/hooks.py (audit logging, rate
limiting, sensitive-word blocking, context injection) — import them in your own
entry point and pass them to AgentConfig(hooks=[...]).
## DeepAgents + `extra["model"]`
For the real `deepagents` backend, pass a pre-built LangChain `ChatModel` through
`AgentConfig.extra["model"]` — it takes priority over `AgentModel`:
```python
from langchain_deepseek import ChatDeepSeek
from agent_switch import AgentConfig, create_agent, AgentBackend
model = ChatDeepSeek(model="deepseek-v4-flash", api_key="sk-...")
config = AgentConfig(
system_prompt="You are a helpful assistant.",
tools=[AgentTool(name="search", handler=my_search_tool)],
extra={"model": model}, # ← pre-built ChatModel wins
)
agent = create_agent(AgentBackend.DEEPAGENTS, config)
response = agent.run("What is the weather in Paris?")
Alternatively let agent-switch build the model: AgentModel(name=..., api_key=..., base_url=...)
maps to langchain.chat_models.init_chat_model, while a bare AgentModel(name="openai:gpt-4o-mini")
passes the string straight through.
Qcoder ↔ agent-switch mapping
The qcoder backend runs on the real qoder-agent-sdk (which drives the
qodercli CLI). It supports message format normalization, the unified hooks
lifecycle, streaming, tools / skills / MCP configuration, and session identity.
Message normalization
Input direction (AgentMessage → qoder CLI wire format, via
agent_switch.backends.qcoder.mapping.agent_messages_to_qoder_wire):
| agent-switch | qoder wire |
|---|---|
MessageRole.USER |
{"type":"user","message":{"role":"user","content":<str>}} |
MessageRole.ASSISTANT |
text + tool_use blocks ({"type":"tool_use","id","name","input"}) in one user message |
MessageRole.TOOL |
{"type":"tool_result","tool_use_id","content","is_error"} block |
MessageRole.SYSTEM |
not sent (mapped to QoderAgentOptions.system_prompt) |
thinking / meta |
not sent (input direction) |
Output direction (qoder SDK Message → AgentMessage):
| qoder SDK | agent-switch |
|---|---|
AssistantMessage |
role=assistant, content (joined TextBlocks) |
ThinkingBlock |
thinking |
ToolUseBlock |
ToolCall(id, name, input→arguments) |
UserMessage |
role=user |
SystemMessage |
role=system (only meta) |
ResultMessage |
terminal → AgentResponse (content, raw, backend) |
Hooks mapping
Session-level events (beforeAgent / beforePrompt / beforeLLM / afterLLM /
afterAgent / afterStop) fire at the adapter level once per run / stream,
exactly as documented in the Hooks chapter. Call-level events are bridged into
the Qoder SDK native hook system:
| agent-switch hook | Qoder HookEvent | BLOCK / MODIFY mapping |
|---|---|---|
beforeTool |
PreToolUse |
BLOCK → continue_:False, decision:"block" + permissionDecision:"deny"; MODIFY(updated_input) → updatedInput |
afterTool |
PostToolUse |
MODIFY(updated_tool_output) → updatedToolOutput |
afterToolError |
PostToolUseFailure |
notification only |
beforePermission |
PermissionRequest |
BLOCK → permissionDecision:"deny" |
beforeSubagent |
SubagentStart |
notification only |
afterSubagent |
SubagentStop |
notification only |
Only events whose hook class actually overrides the method are registered, so an empty hooks list adds no callbacks to the CLI.
Configuration mapping (AgentConfig → QoderAgentOptions)
| agent-switch | QoderAgentOptions |
|---|---|
AgentModel.name / extra["model"] (str) |
model |
system_prompt |
system_prompt |
tools (with handler) |
in-process SDK MCP server via create_sdk_mcp_server + allowed_tools |
skills |
skills (sources list / enable_all → "all") |
mcp (AgentMcpConfig) |
mcp_servers (stdio / http) + allowed_mcp_server_names |
extra whitelist |
permission_mode, max_turns, session_id, cwd, auth, allowed_tools, disallowed_tools, can_use_tool, include_partial_messages, continue_conversation, resume, settings, agents, agent, user, env, cli_path |
| default | auth=qodercli_auth() (reuse local login state) |
Streaming
stream() iterates qoder_agent_sdk.query(prompt=wire_messages, options=...);
each SDK AssistantMessage is mapped to one or more AgentChunk
(delta_thinking / delta_content / delta_tool_call), and the stream always
ends with a chunk carrying is_finish=True. run() wraps the same async flow
with asyncio.run and returns the terminal ResultMessage as AgentResponse
(falling back to the accumulated assistant text if no result message arrives).
Token-level partial messages (StreamEvent) are not enabled by default.
Runtime requirements
pip install "agent-switch[qcoder]"(pullsqoder-agent-sdk,mcp,anyio)- Install the
qodercliCLI and log in once (qodercli auth) - Sync
run()usesasyncio.runinternally: calling it inside a running event loop raisesRuntimeError— usestream()in async code.
Hooks
from agent_switch import (
AgentConfig, AgentHookEvent, BaseAgentHooks, HookOutcome, HookResult, create_agent,
)
class AuditHooks(BaseAgentHooks):
async def before_llm(self, context) -> None:
print(f"[audit] beforeLLM model={context.model}")
class RateLimitHooks(BaseAgentHooks):
async def before_prompt(self, context):
if len(context.messages) > 10:
return HookResult(outcome=HookOutcome.BLOCK, reason="rate limit exceeded")
# single instance or a list — both are normalized
config = AgentConfig(hooks=[AuditHooks(), RateLimitHooks()])
# or: AgentConfig(hooks=AuditHooks())
agent = create_agent(AgentBackend.QCODER, config)
response = agent.run("hello")
Run/stream trigger six lifecycle events in order:
beforeAgent → beforePrompt → beforeLLM → [SDK] → afterLLM → afterAgent → afterStop.
Hooks may return HookResult(outcome=BLOCK, reason=...) (raises HookBlockedError)
or HookResult(outcome=MODIFY, data={"messages": [...]}) (replaces the prompt messages).
Hooks fire on two layers:
- Agent level (once per agent execution):
beforeAgent,beforePromptandafterAgent— for thedeepagentsbackend these fire inside the SDK, on the graph'sbefore_agent/after_agententry/exit nodes. - Call level (once per LLM / tool call inside the agent loop):
beforeLLM,afterLLM,beforeTool,afterTool,afterToolError— bridged through an injectedAgentHooksMiddleware(wrap_model_call/wrap_tool_call), so they fire at the real model/tool call points (e.g. several times when the agent loops over tools). afterStop(reasoncomplete/error) is fired by the adapter at therun/streamboundary —after_agentonly runs on the success path, so the adapter re-firesafterAgent(error)+afterStop(error)when the run fails.
For the qcoder backend, the six session-level events fire at the adapter level
(one per run / stream), while beforeTool / afterTool / afterToolError /
beforePermission / beforeSubagent / afterSubagent are bridged to the Qoder
SDK's native hooks (PreToolUse / PostToolUse / PostToolUseFailure /
PermissionRequest / SubagentStart / SubagentStop) and fire inside the CLI.
beforePermission / beforeSubagent / afterSubagent are declared but not yet
bridged for the deepagents backend.
Hooks ↔ deepagents implementation mapping
| agent-switch hook | deepagents implementation | Level / timing |
|---|---|---|
beforeAgent |
AgentHooksMiddleware.before_agent / abefore_agent (entry node) |
agent, once per agent execution |
beforePrompt |
AgentHooksMiddleware.before_agent / abefore_agent (entry node) |
agent, once per agent execution |
beforeLLM |
AgentHooksMiddleware.wrap_model_call / awrap_model_call, before handler(request) |
call, once per LLM call |
afterLLM |
AgentHooksMiddleware.wrap_model_call / awrap_model_call, after handler(request) |
call, once per LLM call |
beforeTool |
AgentHooksMiddleware.wrap_tool_call / awrap_tool_call, before executing the tool |
call, once per tool call |
afterTool |
AgentHooksMiddleware.wrap_tool_call / awrap_tool_call, after the tool returned |
call, once per tool call |
afterToolError |
AgentHooksMiddleware.wrap_tool_call exception branch, then re-raise |
call, once per failed tool call |
afterAgent |
AgentHooksMiddleware.after_agent / aafter_agent (exit node); adapter re-fires afterAgent(error) on failure |
agent, once per successful execution |
afterStop |
adapter (_finalize_run_success_* / _finalize_run_error_*, reason complete / error) |
agent, once per run / stream |
beforePermission / beforeSubagent / afterSubagent |
not bridged yet | — |
Implementation details for the deepagents backend:
DeepAgentsAdapter._build_agent()appends anAgentHooksMiddlewareinstance tocreate_deep_agent(middleware=[...])wheneverAgentConfig.hooksis non-empty; it coexists with user middleware passed viaconfig.extra["middleware"].- The middleware reads the current session ids through a
session_providerclosure (bound to the adapter's_session_id/_correlation_id), so all contexts share the same session (session_id / correlation_id) as the adapter-level ones. before_agent/after_agentare the graph's entry / exit nodes: each fires exactly once per agent execution (sub-agents are separately compiled graphs and do not trigger them).beforePromptreturningMODIFYrewrites the initial state via{"messages": [...]};BLOCKraisesHookBlockedErrorinside the SDK, aborting the whole run.beforeLLMreturningMODIFYrewrites the real request viarequest.override(messages=...).- Because these events fire inside the SDK,
DeepAgentsAdaptersetscall_hooks_via_middleware = Trueandagent_hooks_via_middleware = Trueso the adapter layer does not fire them a second time;afterStop(and the error path) remain at the adapter, sinceafter_agentnever runs when the graph raises. - The built graph is cached; the cache key includes a fingerprint of the configured hooks, so changing hooks rebuilds the agent instead of reusing a stale graph.
Streaming
async for chunk in agent.stream("hello"):
if chunk.delta_content:
print(chunk.delta_content, end="")
if chunk.delta_thinking:
print(f"\n[thinking] {chunk.delta_thinking}")
AgentChunk fields: delta_content, delta_thinking, delta_tool_call, is_finish, meta.
The stream always ends with a chunk carrying is_finish=True.
Message model
| Field | Type | Notes |
|---|---|---|
role |
MessageRole |
user / assistant / system / tool |
content |
str |
text content |
thinking |
str | None |
reasoning content (model dependent) |
tool_calls |
list[ToolCall] |
{id, name, arguments} |
tool_result |
ToolResult | None |
{tool_call_id, content} |
meta |
dict |
backend metadata (langchain_type, …) |
_raw |
PrivateAttr |
adapter debugging only — never serialized |
DeepAgents ↔ agent-switch mapping
| agent-switch | deepagents / LangChain |
|---|---|
MessageRole.USER |
HumanMessage |
MessageRole.SYSTEM |
SystemMessage |
MessageRole.ASSISTANT |
AIMessage (with tool_calls: [{id, name, args}]) |
MessageRole.TOOL |
ToolMessage (tool_call_id) |
AgentMessage.thinking |
extracted with priority: additional_kwargs.reasoning_content → additional_kwargs.thinking → content_blocks of type reasoning / thinking |
AgentTool.handler |
deepagents tools (or resolved via extra["tools"]) |
AgentSkillsConfig.sources |
deepagents skills |
AgentSubagent |
deepagents subagents dicts |
AgentConfig.extra |
whitelisted passthrough: middleware, memory, permissions, backend, interrupt_on, response_format, state_schema, context_schema, checkpointer, store, debug, name, cache |
AgentResponse.raw |
raw graph invoke / astream result |
| streaming chunks | graph.astream(stream_mode="messages") → one LangChain chunk may produce several AgentChunks (delta_content / delta_thinking / delta_tool_call) |
thinking / meta are not sent to the backend (input direction); they are only
extracted on the way back.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_switch-0.2.0.tar.gz.
File metadata
- Download URL: agent_switch-0.2.0.tar.gz
- Upload date:
- Size: 64.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
127522eba203d140024372b2a040a641e064572968840f09cb2e2f491c0751d1
|
|
| MD5 |
3a50d9af28211e79d7f0754c2e1a26e5
|
|
| BLAKE2b-256 |
11bdb7f15b431f4e06ce01761fc90ad3dd29d8e65e80a72ef7ac0b33030a2e7a
|
Provenance
The following attestation bundles were made for agent_switch-0.2.0.tar.gz:
Publisher:
release.yml on Mr1Mao/agent-switch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_switch-0.2.0.tar.gz -
Subject digest:
127522eba203d140024372b2a040a641e064572968840f09cb2e2f491c0751d1 - Sigstore transparency entry: 2649417693
- Sigstore integration time:
-
Permalink:
Mr1Mao/agent-switch@7f535d40af3036767a7808bd85e5a45eac32aaf7 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Mr1Mao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7f535d40af3036767a7808bd85e5a45eac32aaf7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_switch-0.2.0-py3-none-any.whl.
File metadata
- Download URL: agent_switch-0.2.0-py3-none-any.whl
- Upload date:
- Size: 6.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
24d10a8a7c43940b6fd1aef8dbf4c74b056b94d24ce7c0103f0ca0c873b85416
|
|
| MD5 |
e6bf566343a883d77aea49e48bf627a8
|
|
| BLAKE2b-256 |
8e498b05e2d6849e044a3e8089a0fbcc03c9cf04f0bf1e9cacb0b404a5b362c9
|
Provenance
The following attestation bundles were made for agent_switch-0.2.0-py3-none-any.whl:
Publisher:
release.yml on Mr1Mao/agent-switch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_switch-0.2.0-py3-none-any.whl -
Subject digest:
24d10a8a7c43940b6fd1aef8dbf4c74b056b94d24ce7c0103f0ca0c873b85416 - Sigstore transparency entry: 2649417863
- Sigstore integration time:
-
Permalink:
Mr1Mao/agent-switch@7f535d40af3036767a7808bd85e5a45eac32aaf7 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Mr1Mao
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7f535d40af3036767a7808bd85e5a45eac32aaf7 -
Trigger Event:
push
-
Statement type: