Add comprehensive documentation and templates for LangChain skill development
- 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.
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
# 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's `tools=[...]`.
|
||||
- **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.
|
||||
- **Solution**:
|
||||
- **Tool per agent** (few agents, each needs separate context engineering):
|
||||
```python
|
||||
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):
|
||||
```python
|
||||
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: AgentName` with an `Enum` as 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 with `add_node`" or "subgraphs invoked in a node function", but not subagents called inside tools. Separately, the subagent's `checkpointer` parameter 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 `checkpointer` modes, 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). Then `graph.get_state(config, subgraphs=True).tasks[0].state` returns 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. Then `get_state(subgraphs=True)` returns the subagent state accumulated on that thread.
|
||||
- **Let the subagent preserve conversation history across calls** (continuations mode):
|
||||
```python
|
||||
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_expert` twice, both writing to the same namespace and conflicting. Use `ToolCallLimitMiddleware` to 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 `StateGraph` with a unique node name, otherwise LangGraph assigns namespaces by "call order", and reordering calls scrambles state:
|
||||
```python
|
||||
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=False` to 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`). Directly `add_node` the 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`.
|
||||
|
||||
- **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 in `Command(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_tool` from the third-party `langchain-dev-utils`, combined with `pre_input_hooks` / `post_output_hooks` for context engineering.
|
||||
|
||||
- **Wrap a single subagent** (replacing the hand-written tool-per-agent boilerplate):
|
||||
```python
|
||||
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_name` and `tool_description` are optional, but **strongly recommended to set explicitly** — the default name is `transfer_to_{agent_name}` and the default description is `This 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):
|
||||
```python
|
||||
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_hooks` to inject context into the subagent** (pass the main agent state / original user message through to the sub-agent):
|
||||
```python
|
||||
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 a `HumanMessage` as subagent input; when it returns `dict`, 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_hooks` to feed structured results back to the main agent** (replacing hand-written `Command(update=...)`):
|
||||
```python
|
||||
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 `Command` object (also updates main agent state).
|
||||
|
||||
- **Handle hooks per-subagent-name under `wrap_all_agents_as_tool`**:
|
||||
```python
|
||||
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 + ToolRuntime` are more flexible, but most subagent wrapping just needs "change the name / inject context / wrap return value" — those three things. Use `wrap_agent_as_tool` directly to skip the boilerplate. To preserve single dispatch tool's extensibility (Issue 2), use `wrap_all_agents_as_tool` + `get_subagent_name` for 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_xxx` tools — each repeating "get last_ai_message from state + construct a paired ToolMessage + wrap with `Command(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 write `wrap_model_call` to swap prompts and tools based on `active_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 `HandoffAgentMiddleware` from the third-party `langchain-dev-utils` for 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.
|
||||
|
||||
```python
|
||||
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 own `tools` and `system_prompt` are ignored — all prompts/tools come from `agents_config`.
|
||||
|
||||
- To customize the **description** of the transfer tool (without changing the implementation), pass `custom_handoffs_tool_descriptions`:
|
||||
```python
|
||||
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 return `Command`, with `update.messages` containing the tool response and `update.active_agent` pointing to the target agent name:
|
||||
```python
|
||||
@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})
|
||||
```
|
||||
|
||||
- **Lessons learned**: The core mental model for handoffs is "switch prompt + tools by active_agent" — a declarative configuration. Use `HandoffAgentMiddleware` to drop the mental load from "write N transfer tools + handle message pairing" to "fill in a dict". Also, **checkpointer is a hard requirement** — `active_agent` is cross-turn state; without a checkpointer the next conversation restarts from the default agent.
|
||||
Reference in New Issue
Block a user