Recipe: Agent Factory MCP Server (Meta-Agent Pattern)¶
Goal¶
Use the Agent Factory MCP server as a tool so a main agent can create and run specialized child agents.
Example: meta-agent spawning a child agent¶
from doc_processing_toolkit import Agent
from doc_processing_toolkit.mcp_servers import agent_factory_server
meta_agent = Agent(
model="gpt-4o",
instructions=(
"You orchestrate subtasks. "
"Use create_agent when delegation is useful and report the child result."
),
tools=[agent_factory_server],
max_iterations=10,
)
result = await meta_agent.run(
"Create a child agent with claude-haiku-4-5 and solve: What is 5 + 7?"
)
print(result.output)
print(result.tools_used)
Direct tool usage example¶
from doc_processing_toolkit.mcp_servers.agent_factory import create_agent
child_result = create_agent(
model="claude-sonnet-4-5",
instructions="You are a concise analyst.",
task="Summarize the advantages of async I/O in one paragraph.",
enable_web_search=False,
detailed_costs=True,
)
print(child_result["output"])
print(child_result["cost"]) # None when the run raised after it started: spend unknown, not zero
When another Agent calls create_agent as a tool, the child run's cost is added to the calling
agent's cost through the result's _dpt_spend marker (see
Tool-reported spend). A child run that failed
carries no marker, because its spend is unknown.
Persistent sub-agent sessions (A2A)¶
create_agent discards its agent after one task. When the sub-agent needs to remember earlier turns,
create a session instead and address it by id:
from doc_processing_toolkit.mcp_servers.agent_factory import (
create_agent_session,
send_to_agent_session,
get_agent_session_status,
)
created = await create_agent_session(
model="claude-sonnet-4-5",
instructions="You are a research assistant. Keep track of what you have already found.",
initial_message="Research the 2026 EU battery regulations.",
compaction=True, # recommended for long-lived sessions
)
session_id = created["session_id"]
# Later turns remember everything above.
follow_up = await send_to_agent_session(session_id, "Now compare that to the US rules.")
print(follow_up["reply"])
status = get_agent_session_status(session_id)
print(status["run_count"], status["total_cost"])
Running a turn in the background¶
Pass wait=False to schedule a turn and return immediately, then poll:
import asyncio
await send_to_agent_session(session_id, "Draft the full report.", wait=False)
while get_agent_session_status(session_id)["busy"]:
await asyncio.sleep(2)
print(get_agent_session_status(session_id)["last_reply_preview"])
A second send while a turn is still running returns status="busy" rather than racing two turns
against the same history. To have it wait its turn instead, queue it:
await send_to_agent_session(session_id, "Draft the full report.", wait=False)
# Runs after the report turn finishes; returns this turn's own reply.
summary = await send_to_agent_session(session_id, "Now summarise it in three lines.", on_busy="queue")
Routing between two sessions¶
The toolkit does not connect sessions to each other — your code is the message bus, which is what lets you decide who may talk to whom:
researcher = (await create_agent_session(
model="claude-sonnet-4-5", instructions="You research topics thoroughly."
))["session_id"]
critic = (await create_agent_session(
model="gpt-5.6-terra", instructions="You critique research for gaps and bias."
))["session_id"]
findings = await send_to_agent_session(researcher, "Summarize solid-state battery progress.")
critique = await send_to_agent_session(critic, f"Critique these findings:\n{findings['reply']}")
revised = await send_to_agent_session(researcher, f"Address this critique:\n{critique['reply']}")
print(revised["reply"])
See A2A: Agent-to-Agent Sessions for the full tool reference, and Hooks and Steering for routing messages into a session that is already mid-run.
Sessions live until the process exits
The registry is in-process and has no close or evict tool, so each session holds its agent and client state for the lifetime of the host process. Create them deliberately.
Checkpointing a session across a server restart¶
from doc_processing_toolkit.mcp_servers.agent_factory import export_agent_session, import_agent_session
saved = await export_agent_session(session_id, path="checkpoints/reviewer.json") # after every completed turn
# ... the server restarts ...
restored = await import_agent_session(
model="claude-haiku-4-5", instructions="You review contracts.", path="checkpoints/reviewer.json"
)
restored["session_id"], restored["run_count"], restored["converted_from"] # the same id, the turns so far, None
export_agent_session returns the envelope text when no path is given ("busy" while a turn
runs) and takes the media / media_store policy of to_json; import_agent_session rebuilds
the agent from the options create_agent_session takes (the rest through config_yaml), registers
the session under the envelope's id or session_id=, and under another provider converts the
history and reports it (converted_from, conversion_report_count; get_agent_session_status
shows converted_from too).
Discovering models¶
Before a meta-agent picks a model for a child, it can ask the registry what exists.
list_available_models and get_model_info read model_information.json only — no API call, no
key needed — and are callable directly as well as through MCP:
from doc_processing_toolkit.mcp_servers.agent_factory import get_model_info, list_available_models
stt = list_available_models(modality="stt")
stt["openai"]["models"] # ['gpt-transcribe', 'gpt-4o-transcribe-diarize', 'whisper-1']
stt["google"]["models"] # ['gemini-3.5-transcribe']
stt["openai"]["model_details"]["whisper-1"]
# {'modality': 'stt', 'supports_diarization': False, 'supports_word_timestamps': True,
# 'supports_streaming_transcription': False, 'supports_keywords': False,
# 'supports_language_hints': False, 'session_kinds': ['transcription', 'translation']}
translators = list_available_models(session_kind="translation")
translators["openai"]["models"] # ['gpt-realtime-translate', 'whisper-1']
The result is grouped by provider. models is the flat name list; model_details holds a small
discovery block per model. modality takes "text", "image", "tts", "stt" or "realtime"
(a row with no modality counts as text); session_kind takes one of utils.SESSION_KINDS and
matches any model that serves that kind among others. An unknown filter value returns empty lists,
not everything.
get_model_info returns the full record for one model:
info = get_model_info("gpt-realtime")
info["deprecation"]
# {'date': '2027-01-20', 'replacement': 'gpt-realtime-2.1', 'note': "OpenAI's deprecations page ..."}
info["session_kinds"] # ['realtime', 'transcription']
info["supported_input_types"] # ['text', 'audio']
info["realtime"]["toolkit_features"]["translation_sessions"] # True
Fields worth reading before choosing a model:
deprecation—{"date", "replacement", "note"}when the vendor has retired or superseded the row;dateisNonewhere a replacement is recommended but no shutdown is announced. Route new work toreplacement.realtime.toolkit_features— on a realtime row, what the toolkit's adapter supports for that provider:tool_dispatch,visual_input,transparent_reconnect, and whether dedicatedtranscription_sessions,translation_sessionsandlive_sessionscan be opened. The fullrealtimeblock is left out oflist_available_modelsto keep the listing small.supported_input_types— what you may attach:text,audio,base64-image/png,video, ... A part outside this list is dropped (or refused withon_unsupported_part="raise").session_kinds— the session families the model serves. A conversational realtime row liststranscriptionbecause its own socket transcribes; that does not make it a dedicated transcription session, so route on the kind the row declares.
An absent key is not false
Discovery omits a field the registry row does not carry rather than filling in None or
False. Read every field with .get(), and treat "missing" as "the registry says nothing", not
as "unsupported":
get_model_info("whisper-1")["capabilities"].get("supports_streaming_transcription") # False — stated
get_model_info("gpt-5.6-luna")["capabilities"].get("supports_streaming_transcription") # None — absent
whisper-1 states False on purpose, because the API accepts the flag and ignores it; a text row
simply never mentions transcription. session_kinds is the one field that is always present. A
model the registry does not know raises ValueError here (an error result over MCP).
See Discovery reports what kind of model a row is for the complete field list.
What to validate¶
create_agentappears intools_usedfor the meta-agent run- child result payload includes output + token/cost fields + error status
- delegation succeeds without manual MCP wiring beyond
tools=[agent_factory_server] - a session's second turn reflects context from its first (history is retained)
send_to_agent_sessionon an unknown id returnsstatus="not_found"rather than raising- a send during an in-flight
wait=Falseturn returnsstatus="busy", or runs next withon_busy="queue" suggest_agent_configreturns a recommendation on every provider, including whenAGENT_FACTORY_SUGGESTION_MODELnames a Gemini model- when the suggestion sub-agent's own run fails, the returned
errornames that upstream failure rather than a JSON-parse message — a failed run used to surface asExpecting value: line 1 column 1, which said nothing about the real cause list_available_models(modality="stt")lists only speech-to-text rows, and a field a row does not carry is absent frommodel_details, neverNone
Test sources¶
tests/e2e/test_agent_factory.py(test_meta_agent_spawns_child)tests/e2e/test_agent_factory.py(test_create_agent_basic,test_suggest_agent_config)tests/unit/test_a2a_sessions.py(TestCreateAgentSession,TestSendToAgentSession,TestNonBlockingRuns,TestGetAgentSessionStatus)tests/unit/test_discovery_fields.py(discovery filters and fields)