- Introduced a new reference document for streaming output issues, detailing the differences between streaming APIs and providing solutions for common problems. - Created a structured output issues reference, outlining the use of `with_structured_output`, `response_format`, and schema enforcement strategies. - Added a user query convention guide to standardize structured questions for skill authors, including block types for queries and data gathering. - Implemented a template for a chat model, encapsulating API key management, request payload construction, and response handling. - Established a symlink for the LangChain dev guide in the Claude skills directory for easier access. - Initialized a skills lock file to manage dependencies and versions for the LangChain dev guide.
22 KiB
Multi-Agent Orchestration Issues
Issue 1: Choosing between subagents and handoffs
-
Symptom: When building multi-agent systems, you see both the "subagent as tool" pattern and the
Command(goto=..., graph=Command.PARENT)"handoff" pattern in the docs and don't know which to choose. Picking wrong leads to: the main agent can't get the sub-agent's output, or the sub-agent can't talk to the user directly. -
Cause: The two patterns solve completely different orchestration needs:
- Subagents (recommended default): a central supervisor treats sub-agents as tool calls — the main agent decides when to call, what query to send, and how to use the return value. Sub-agents don't talk to the user directly; each call starts from a clean context, and the result returns to the main agent.
- Handoffs: a tool updates a state variable (e.g.
current_step/active_agent), based on which the system switches "the currently active agent / config". The agent switched-to directly takes over the conversation with the user, with state persisting across turns.
-
Solution: Use this decision table:
Need Pick Main agent needs the sub-agent's result to decide next step Subagents (sync call) Sub-agent needs to run a task in a clean context, avoid polluting the main conversation Subagents (context isolation) Multiple domains (calendar / email / CRM…) need centralized routing Subagents Customer service flow: first collect warranty id, then refund — must unlock in order Handoffs Different stages need different system prompts / tool sets, and need to interact with user directly Handoffs sales ↔ support transfer between each other, each agent talks to the user Handoffs -
Lessons learned: Default to subagents — its semantics are simplest (just a tool call), and it has the fewest failure modes. Only when an agent needs to converse with the user across multiple turns directly (instead of returning results to the upper layer) should you use handoffs. The two can be mixed: the supervisor uses subagents to manage multiple sub-agents, and one sub-agent internally uses handoffs for multi-stage flow.
Issue 2: tool-per-agent or single dispatch tool in subagent mode
-
Symptom: Subagent mode has two ways to expose: "wrap each sub-agent as a separate tool" vs. "write a general
task(agent_name, description)tool that routes by name to a sub-agent in the registry". You don't know which to choose, or you started with tool-per-agent and later found adding a new agent requires heavy changes to the supervisor. -
Cause: The two are inverse in "customizability" vs "extensibility":
- Tool per agent: each sub-agent is wrapped as its own
@tool, allowing per-sub-agent control over input/output/state passing. The cost: every new agent requires modifying the supervisor'stools=[...]. - Single dispatch tool: only one
task(agent_name, description)tool; sub-agents are looked up in a registry dict; adding agents only modifies the registry, not the supervisor. The cost: all sub-agents share the same "query passed as user message, last message as return value" convention, no per-agent customization.
- Tool per agent: each sub-agent is wrapped as its own
-
Solution:
- Tool per agent (few agents, each needs separate context engineering):
from langchain.tools import tool from langchain.agents import create_agent research_subagent = create_agent(model="...", tools=[...]) @tool("research", description="Research a topic and return findings") def call_research(query: str) -> str: result = research_subagent.invoke({"messages": [{"role": "user", "content": query}]}) return result["messages"][-1].content supervisor = create_agent(model="...", tools=[call_research])- Single dispatch tool (many agents, multi-team, prefer convention over configuration):
from langchain.tools import tool from langchain.agents import create_agent SUBAGENTS = { "research": create_agent(model="gpt-5.4", prompt="You are a research specialist..."), "writer": create_agent(model="gpt-5.4", prompt="You are a writing specialist..."), } @tool def task(agent_name: str, description: str) -> str: """Launch an ephemeral subagent for a task. Available agents: - research: Research and fact-finding - writer: Content creation and editing """ agent = SUBAGENTS[agent_name] result = agent.invoke({"messages": [{"role": "user", "content": description}]}) return result["messages"][-1].content supervisor = create_agent( model="gpt-5.4", tools=[task], system_prompt="You coordinate specialized sub-agents. Use the task tool to delegate work.", )- Tell the main agent which sub-agents exist under single dispatch, pick by scale:
Registry size / change rate Recommended approach <10, mostly static List agent names + descriptions in the supervisor's system_prompt <10, want type safety Constrain agent_name: AgentNamewith anEnumas the tool param>10, dynamically registered or maintained by multiple teams Provide a separate list_agents(query)tool so the main agent looks them up on demand -
Lessons learned: When unsure, start with tool-per-agent; switch to single dispatch when the agent count exceeds 5 or you clearly need multi-team independent delivery. The "cheap" of single dispatch shows in "no supervisor code changes when adding agents", but you pay with "all agents must share the same behavior contract" — switching too early forces customization to take detours.
Issue 3: Can't get the sub-agent's internal state, no way to review at interrupt time
-
Symptom: You want to use
get_state(subgraphs=True)at the supervisor level to see where the subagent is in its run and what its current state is — but the subagent state is never returned. Or you want the subagent to preserve conversation history across multiple calls (e.g. a long-memory research assistant), but every invoke starts with empty state. -
Cause: The subagent is invoked inside a tool function, and LangGraph can't statically discover this nested graph at compile time —
get_state(subgraphs=True)can only find "subgraphs added withadd_node" or "subgraphs invoked in a node function", but not subagents called inside tools. Separately, the subagent'scheckpointerparameter controls three persistence modes; without explicit configuration it uses per-invocation (checkpointer=None, default), which doesn't preserve state across calls — this is what most subagents want, but it can mislead you into thinking "subagents can't use interrupt / can't see state at all". -
Solution: First understand the three
checkpointermodes, then pick by need:Mode checkpointer=Cross-call memory Interrupt within one call State inspection Parallel calls to the same subagent per-invocation None(default)❌ ✅ ⚠️ Only "during the current call/at interrupt" ✅ per-thread True✅ ✅ ✅ ❌ (namespace conflict) stateless False❌ ❌ ❌ ✅ In all modes, the parent graph must be compiled with a checkpointer, otherwise interrupt / state inspection / per-thread memory all fail to work.
- Inspect nested state mid-subagent-run (works in per-invocation too): the subagent must be "in the middle of a single call" (typical case: triggered
interrupt()and waiting for resume). Thengraph.get_state(config, subgraphs=True).tasks[0].statereturns the nested state. Once that call ends, in per-invocation mode the state doesn't accumulate, and the next call is fresh. - View the subagent's accumulated full state: subagent must use
checkpointer=True, and the parent graph must also have a checkpointer. Thenget_state(subgraphs=True)returns the subagent state accumulated on that thread. - Let the subagent preserve conversation history across calls (continuations mode):
from langchain.agents import create_agent from langchain.agents.middleware import ToolCallLimitMiddleware from langgraph.checkpoint.memory import MemorySaver fruit_agent = create_agent( model="gpt-5.4-mini", tools=[fruit_info], prompt="You are a fruit expert. Respond in one sentence.", checkpointer=True, # per-thread persistence ) @tool def ask_fruit_expert(question: str) -> str: """Ask the fruit expert. Use for ALL fruit questions.""" resp = fruit_agent.invoke({"messages": [{"role": "user", "content": question}]}) return resp["messages"][-1].content agent = create_agent( model="gpt-5.4-mini", tools=[ask_fruit_expert], prompt="ALWAYS delegate fruit questions to ask_fruit_expert.", middleware=[ # Must forbid parallel calls, otherwise two calls write to the same namespace → checkpoint conflict ToolCallLimitMiddleware(tool_name="ask_fruit_expert", run_limit=1), ], checkpointer=MemorySaver(), # Parent graph checkpointer is a hard requirement )Pitfall: per-thread subagents don't support parallel LLM calls to the same tool — e.g. "ask about apple and banana at the same time" causes the model to concurrently call
ask_fruit_experttwice, both writing to the same namespace and conflicting. UseToolCallLimitMiddlewareto rate-limit, or disable parallel tool calls at the model layer.- Multiple different per-thread subagents coexisting (both fruit and veggie need memory): each subagent needs to be wrapped in a
StateGraphwith a unique node name, otherwise LangGraph assigns namespaces by "call order", and reordering calls scrambles state:
from langgraph.graph import MessagesState, StateGraph def create_sub_agent(model, *, name, **kwargs): """Wrap with a unique node name to get a stable namespace.""" agent = create_agent(model=model, name=name, **kwargs) return ( StateGraph(MessagesState) .add_node(name, agent) .add_edge("__start__", name) .compile() ) fruit_agent = create_sub_agent("gpt-5.4-mini", name="fruit_agent", tools=[fruit_info], prompt="...", checkpointer=True) veggie_agent = create_sub_agent("gpt-5.4-mini", name="veggie_agent", tools=[veggie_info], prompt="...", checkpointer=True)-
No checkpoint overhead needed for the subagent (short tool-style calls, clearly no interrupt needed): use
checkpointer=Falseto enter stateless mode. The subagent runs as an ordinary function with no durable execution — if it crashes, it runs again from scratch. -
Need to access nested state at the main graph layer for debugging (not just at interrupt time): change the subagent from "invoked inside a tool" to "called inside a graph". Two options:
- Call subgraph inside a node: use when parent/child schemas differ; write a wrapper in the node function to convert state;
- Add subgraph as a node: use when parent/child share state keys (typical: both use
MessagesState). Directlyadd_nodethe compiled subagent — no wrapper needed.
Both forms are statically recognizable by LangGraph, and
get_state(subgraphs=True)returns nested state. The cost is giving up the "subagent as tool" natural routing ability — you have to design the graph yourself. -
Just want to see what went wrong with the subagent: enable LangSmith tracing. The subagent's run appears as a nested trace under the main agent's trace, far more intuitive than
get_state.
- Inspect nested state mid-subagent-run (works in per-invocation too): the subagent must be "in the middle of a single call" (typical case: triggered
-
Lessons learned:
- The default per-invocation mode for subagents is the right choice for most scenarios — it supports interrupt, supports state inspection within a single call, and supports parallel calls; it just doesn't have cross-call memory. Treat it as a "side-effect-free pure function".
- Before upgrading to
checkpointer=True(per-thread), confirm two things: ① the parent graph has a checkpointer; ② the main agent won't concurrently call the same per-thread subagent (middleware or model config handles this). - "Main graph needs nested state" and "subagent as tool" are mutually exclusive — for nested visibility, accept "writing a custom graph"; don't try to invoke in a tool and then expect the supervisor to
get_state(subgraphs=True).
Issue 4: Too much subagent wrapping boilerplate / need per-agent input-output customization
-
Symptom: Following Issue 2's approach to wrap subagents as tools, every agent has to repeatedly write "
@tool→agent.invoke({"messages": [...]})→result["messages"][-1].content". When you need to add input preprocessing to a subagent (passing the main agent context along) or output post-processing (returning structured results back to main agent state), you either write nested lambdas or jam inCommand(update=...)— repetitive and error-prone. -
Cause: LangChain natively only exposes low-level mechanisms like
Command/ToolRuntime, without abstracting "wrap agent as tool" and "intercept input/output" into a separate interface. Every new subagent requires writing the boilerplate from scratch. -
Solution: Use
wrap_agent_as_tool/wrap_all_agents_as_toolfrom the third-partylangchain-dev-utils, combined withpre_input_hooks/post_output_hooksfor context engineering.- Wrap a single subagent (replacing the hand-written tool-per-agent boilerplate):
from langchain_dev_utils.agents import wrap_agent_as_tool schedule_event = wrap_agent_as_tool( calendar_agent, tool_name="schedule_event", tool_description=( "Schedule a calendar event using natural language." "Input: natural language calendar scheduling request (e.g. 'meeting with design team next Tuesday 2pm')" ), ) manage_email = wrap_agent_as_tool(email_agent, tool_name="manage_email", tool_description="...") supervisor = create_agent(model="...", tools=[schedule_event, manage_email])Both
tool_nameandtool_descriptionare optional, but strongly recommended to set explicitly — the default name istransfer_to_{agent_name}and the default description isThis tool transforms input to {agent_name}. The main agent has almost no useful information to base tool selection on.- Wrap multiple subagents as a single dispatch tool (replacing the hand-written single dispatch registry):
from langchain_dev_utils.agents import wrap_all_agents_as_tool call_subagent = wrap_all_agents_as_tool( [calendar_agent, email_agent], tool_name="call_subagent", tool_description=( "Call a sub-agent to execute a task. Available agents: " "- calendar_agent: for scheduling calendar events\n" "- email_agent: for sending emails" ), ) main_agent = create_agent(model="...", tools=[call_subagent])- Use
pre_input_hooksto inject context into the subagent (pass the main agent state / original user message through to the sub-agent):
from langchain.tools import ToolRuntime def process_input(request: str, runtime: ToolRuntime) -> str: original = next(m for m in runtime.state["messages"] if m.type == "human") return ( "You are helping handle the following user query:\n\n" f"{original.text}\n\n" "You've been assigned the following sub-task:\n\n" f"{request}" ) call_agent = wrap_agent_as_tool(agent, pre_input_hooks=process_input)When the hook returns
str, it's auto-wrapped as aHumanMessageas subagent input; when it returnsdict, it's used directly as input (for scenarios needing extra state fields). Pass a(sync_fn, async_fn)tuple to handle sync/async paths separately.- Use
post_output_hooksto feed structured results back to the main agent (replacing hand-writtenCommand(update=...)):
import json def process_output(request, response, runtime): return json.dumps({ "status": "success", "event_id": "evt_123", "summary": response["messages"][-1].text, }) call_agent = wrap_agent_as_tool(agent, post_output_hooks=process_output)The hook return value can be a string (used directly as tool result) or a
Commandobject (also updates main agent state).- Handle hooks per-subagent-name under
wrap_all_agents_as_tool:
from langchain_dev_utils.agents.wrap import get_subagent_name def process_input(request: str, runtime: ToolRuntime): if get_subagent_name(runtime) == "weather_agent": city = runtime.state.get("city", "Unknown city") return f"Current city is: {city}. Please complete the task based on the above. " + request return request -
Lessons learned: Native
Command + ToolRuntimeare more flexible, but most subagent wrapping just needs "change the name / inject context / wrap return value" — those three things. Usewrap_agent_as_tooldirectly to skip the boilerplate. To preserve single dispatch tool's extensibility (Issue 2), usewrap_all_agents_as_tool+get_subagent_namefor per-name hook handling — much cleaner than maintaining a registry dict with if/elif.
Issue 5: How to quickly build a handoff-capable multi-Agent system
-
Symptom: Building multi-agent handoff with 4 agents transferring between each other requires writing 12
transfer_to_xxxtools — each repeating "get last_ai_message from state + construct a paired ToolMessage + wrap withCommand(goto=..., graph=Command.PARENT)". Missing the ToolMessage pairing causes the receiving agent to see an illegal history of "tool_call without tool_response", directly reporting invalid message sequence. If you switch to "single agent + middleware" to dodge message pairing, you have to writewrap_model_callto swap prompts and tools based onactive_agent/current_step— still not lightweight. -
Cause: Handoffs are essentially a state machine — "switch the currently available prompt / tools based on active_agent". LangChain only provides low-level components like
Command/ToolRuntime; it doesn't abstract "declare which agents exist and who can transfer to whom" into a high-level interface. -
Solution: Use
HandoffAgentMiddlewarefrom the third-partylangchain-dev-utilsfor declarative configuration — write each agent's prompt / tools / transfer targets as a dict, and the middleware auto-generates corresponding transfer tools. Message pairing, Command construction, and dynamic prompt/tool switching are all built in.from langchain.agents import create_agent from langchain_dev_utils.agents.middleware import HandoffAgentMiddleware from langchain_dev_utils.agents.middleware.handoffs import AgentConfig agent_config: dict[str, AgentConfig] = { "time_agent": { "prompt": "You are a time assistant", "tools": [get_current_time], "handoffs": ["default_agent"], # can only hand off back to default }, "weather_agent": { "prompt": "You are a weather assistant", "tools": [get_current_weather, get_current_city], "handoffs": ["default_agent"], }, "code_agent": { "model": "openai:gpt-5.4", # can specify model individually, overriding the fallback model "prompt": "You are a code assistant", "tools": [run_code], "handoffs": ["default_agent"], }, "default_agent": { "prompt": "You are an assistant", "default": True, # globally there must be exactly one default "handoffs": "all", # can hand off to any agent }, } agent = create_agent( model="openai:gpt-5.4", # fallback model (reused when agents_config doesn't declare model) middleware=[HandoffAgentMiddleware(agents_config=agent_config)], )When using this middleware,
create_agent's owntoolsandsystem_promptare ignored — all prompts/tools come fromagents_config.- To customize the description of the transfer tool (without changing the implementation), pass
custom_handoffs_tool_descriptions:
HandoffAgentMiddleware( agents_config=agent_config, custom_handoffs_tool_descriptions={ "code_agent": "This tool is for handing off to the code assistant for code questions", ... }, )- To fully customize the transfer tool implementation (e.g. log / audit during handoff), pass
handoffs_tool_overrides. Custom tools must returnCommand, withupdate.messagescontaining the tool response andupdate.active_agentpointing to the target agent name:
@tool def transfer_to_code_agent(runtime: ToolRuntime) -> Command: """This tool helps you hand off to the code assistant""" # ... your custom logic (logging, auditing, etc.) ... return Command(update={ "messages": [ToolMessage(content="transfer to code agent", tool_call_id=runtime.tool_call_id)], "active_agent": "code_agent", }) HandoffAgentMiddleware(agents_config=agent_config, handoffs_tool_overrides={"code_agent": transfer_to_code_agent}) - To customize the description of the transfer tool (without changing the implementation), pass
-
Lessons learned: The core mental model for handoffs is "switch prompt + tools by active_agent" — a declarative configuration. Use
HandoffAgentMiddlewareto drop the mental load from "write N transfer tools + handle message pairing" to "fill in a dict". Also, checkpointer is a hard requirement —active_agentis cross-turn state; without a checkpointer the next conversation restarts from the default agent.