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
Metadata
Release files for agent-switch 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| agent_switch-0.2.1.tar.gz | 64.8 kB | With provenance | Details for agent_switch-0.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| agent_switch-0.2.1-py3-none-any.whl | Python 3 | none | any | Details for agent_switch-0.2.1-py3-none-any.whl |
Total release size: 123.6 kB
Release files / agent_switch-0.2.1.tar.gz
| Download URL | agent_switch-0.2.1.tar.gz |
|---|---|
| Size | 64.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6835c96fe187487b93d261824c524c6d87b4c4c590d06620ed76f98c09ef1a58
|
|
BLAKE2b-256 checksum How to use checksums |
3f302c9c511d06757e90470bdce9fecc7aca463e509b13e528143e1007789b27
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.
Transparency logRelease files / agent_switch-0.2.1-py3-none-any.whl
| Download URL | agent_switch-0.2.1-py3-none-any.whl |
|---|---|
| Size | 58.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b4eefc3d5d86ff35b4cf2caf01a72693728848431c888a680b2c594275a2ee94
|
|
BLAKE2b-256 checksum How to use checksums |
6272456fe0b9e0916c1b38cd04e2280114f16386072d2bc43863078ae15f3415
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.
Transparency logSecurity for agent-switch 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for agent_switch-0.2.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.