Part 1 built a working subagents system. A main agent called two specialists as tools, and the numbers showed what that cost. The pattern was about ten lines of wrapper code.
Those ten lines hold every decision that decides whether the system works. This part goes through them one at a time. Each section names a choice, shows the code, and gives the measured result on a local model.
The setup is the same as Part 1. Ollama with qwen3:8b, langchain 1.3.16, and langgraph 1.2.11. The same two specialists answer the same question:
When can I reboot dfw-core-01?
The LangChain docs list five decisions inside the subagents pattern:
| Decision | Options |
|---|---|
| Sync or async | Block on the subagent, or start it in the background |
| Tool pattern | One tool per agent, or one dispatch tool for all of them |
| Subagent specs | How the main agent learns what each specialist does |
| Subagent inputs | Send the task only, or send more context |
| Subagent outputs | Return the last message, or return structured state |
Two words appear throughout this post. Subagent names the role in the pattern, and the docs use it in every heading. Specialist names one concrete agent in the example, such as the inventory specialist. They describe the same thing at different zoom levels.
Names and Descriptions Decide Everything
The docs call names and descriptions “prompting levers”. That undersells it on a local model. They decide whether the model calls the tool at all.
Here is the test. One tool, two arguments, four variants. Only the name and the description change. Each variant ran ten times with the same question and the same system prompt:
cases = {
"task / multi-line": ("task", "Launch a specialist for one task.\n\nAvailable agents:\n- inventory: device records.\n- change: maintenance windows."),
"task / one-line": ("task", "Launch a specialist for one task. Agents: inventory (device records), change (maintenance windows)."),
"delegate / multi-line": ("delegate", "Send one task to a specialist.\n\nAvailable agents:\n- inventory: device records.\n- change: maintenance windows."),
"delegate / one-line": ("delegate", "Send one task to a specialist. Agents: inventory (device records), change (maintenance windows)."),
}
The result:
task / multi-line called the tool in 0/10 runs
task / one-line called the tool in 0/10 runs
delegate / multi-line called the tool in 0/10 runs
delegate / one-line called the tool in 10/10 runs
Three of the four variants never called the tool. The model returned an empty message, the framework treated it as the final answer, and the run ended with a blank reply.
The split is clean at the edges, which is what makes it worth reporting. Tool calling is stochastic, so a 0 against 3 result on three runs is also what a merely unlikely variant would produce. Ten runs each rules that reading out for these four variants on this model. A variant with a 20% call rate would almost never return 0 out of 10.
Two separate levers show up here. The name task failed with both descriptions. A bulleted, multi-line description failed even with a working name. Only the plain name and the single-line description worked, and that combination worked every time.
Do not read this as a rule about the words task and delegate. Read it as a warning about the failure mode. A specialist that the main agent never invokes produces no error, no exception, and no log line. It produces silence. When a multi-agent system returns an empty answer, suspect the tool name and the description before you suspect the model.
Write descriptions the way you would write them for a new colleague on their first day. Say what the specialist does, say what to send it, and keep it to one or two sentences.
One Tool Per Agent
Part 1 used one wrapper per specialist. That gives you a separate description, a separate input shape, and a separate return format for each one:
@tool(
"inventory_expert",
description=(
"Look up a network device by hostname. "
"Returns the vendor, the site code, and the uplink count."
),
)
def call_inventory_expert(query: str) -> str:
result = inventory_agent.invoke({"messages": [{"role": "user", "content": query}]})
return result["messages"][-1].content
Use this when you want control over each specialist. You can shape the input for one and leave the other alone.
One Dispatch Tool
The alternative puts every specialist behind a single tool. The tool takes an agent name and a task description, then looks up the agent in a registry:
from enum import Enum
SUBAGENTS = {
"inventory": create_agent(
model=model, tools=[lookup_device], system_prompt=INVENTORY_POLICY
),
"change": create_agent(
model=model, tools=[change_window], system_prompt=CHANGE_POLICY
),
}
class AgentName(str, Enum):
INVENTORY = "inventory"
CHANGE = "change"
@tool(
"delegate",
description=(
"Send one task to a specialist. "
"Agents: inventory (device records, give it a hostname), "
"change (maintenance windows, give it a site code)."
),
)
def delegate(agent_name: AgentName, description: str) -> str:
agent = SUBAGENTS[agent_name.value]
result = agent.invoke({"messages": [{"role": "user", "content": description}]})
return result["messages"][-1].content
The Enum on agent_name puts the valid names in the tool schema, so the model cannot invent a specialist that does not exist.
New specialists join by adding a line to SUBAGENTS. The coordinator never changes. That is the reason to pick this shape: separate teams can ship agents without touching the main agent’s code.
It costs more. Same question, same model, both designs with the same specialist policies. Each design ran three times and returned identical numbers:
| Design | Model calls | Prompt characters | Correct |
|---|---|---|---|
| One tool per agent | 7 | 5158 | yes |
| One dispatch tool | 9 | 6765 | yes |
The dispatch run spent two extra calls on a wrong first guess:
[dispatch] change <- 'Check maintenance window for dfw-core-01'
[dispatch] inventory <- 'Get site code for dfw-core-01'
[tool] lookup_device('dfw-core-01')
[dispatch] change <- 'Check maintenance window for site code TX-ALPHA-3'
[tool] change_window('TX-ALPHA-3')
The main agent sent a hostname to the change specialist first. That specialist’s policy says to refuse a hostname and ask for a site code, so it did. The main agent then went to inventory, got the site code, and came back.
That recovery is the system working correctly. A per-agent description that says “You must pass a site code, not a hostname” sits right next to the tool call and prevents the mistake. A shared dispatch description carries the same warning further from the point of use, and the model missed it.
Choose one tool per agent below about ten specialists. Choose the dispatch tool when the registry grows or when different teams own different agents.
Subagent Inputs: Watch for Policy Collisions
The wrapper decides what the specialist sees. In Part 1 it sent one string:
result = inventory_agent.invoke({"messages": [{"role": "user", "content": query}]})
The obvious improvement is to send more. LangChain supports this with ToolRuntime, which gives the wrapper access to the main agent’s state:
from langchain.messages import HumanMessage
from langchain.tools import ToolRuntime, tool
def first_user_question(runtime: ToolRuntime) -> str:
"""Return the text of the first human message in the main conversation."""
for message in runtime.state["messages"]:
if isinstance(message, HumanMessage):
return str(message.content)
return ""
@tool("inventory_expert", description="Look up a network device by hostname.")
def call_inventory_expert(query: str, runtime: ToolRuntime) -> str:
task = f"The user asked: {first_user_question(runtime)}\n\nYour task: {query}"
result = inventory_agent.invoke({"messages": [{"role": "user", "content": task}]})
return result["messages"][-1].content
That looks like a strict improvement. The specialist now knows why the main agent called it. Here is what it did to the results:
| Subagent input | Model calls | Correct |
|---|---|---|
| Task only | 7 | 3 of 3 |
| Task plus the user’s question | 3 | 0 of 3 |
The extra context broke the system on all three runs. The trace shows why:
[subagent] inventory_expert <- 'The user asked: When can I reboot dfw-core-01?
Your task: dfw-core-01'
answer: The inventory_expert tool does not provide information about maintenance
windows or reboot schedules. To determine when you can reboot `dfw-core-01`,
please consult your maintenance window calendar or contact the appropriate team.
The inventory specialist read the word “reboot”, checked its own policy, and found this line: “Never quote a maintenance window. That is another team’s job.” So it declined. It was right to decline, and the main agent accepted the refusal and stopped.
The specialist answered the question it saw rather than the task it received. The extra context changed which of those two it treated as the job.
That leaves an obvious question. Does wider input hurt in general, or did it hurt here because it collided with one specific rule? A third script answers it. The wrapper still sends the user’s question, and the only change removes that one line from the inventory policy:
CONFLICT_LINE = "- Never quote a maintenance window. That is another team's job.\n"
TRIMMED_POLICY = INVENTORY_POLICY.replace(CONFLICT_LINE, "")
| Subagent input | Inventory policy | Model calls | Correct |
|---|---|---|---|
| Task only | Full | 7 | 3 of 3 |
| Task plus the user’s question | Full | 3 | 0 of 3 |
| Task plus the user’s question | One line removed | 7 | 3 of 3 |
Deleting one sentence restored the system to a perfect score with the wider input unchanged. So the wider input is not harmful by itself. It became harmful because it carried a word that matched a rule the specialist had been given, and that rule fired.
The practical form of the lesson is narrower than “send less”. Every string you add to a subagent’s input can activate a rule in that subagent’s prompt. You are prompting the specialist whether you mean to or not. Reach for ToolRuntime when the task genuinely depends on earlier state, such as a file the user uploaded three turns ago. Read the specialist’s own prompt before you widen its input, and measure before and after.
Subagent Outputs: Return More Than Text
By default the wrapper returns one string, and everything else the specialist produced disappears. That is usually what you want. Sometimes the main agent needs a value it can use rather than a sentence it has to parse.
A Command return lets the wrapper update the main agent’s state alongside the tool message:
class NetOpsState(AgentState):
"""The main agent's state, plus the site code the inventory specialist found."""
site_code: str
SITE_CODE_RE = re.compile(r"\b[A-Z]{2}-[A-Z]+-\d+\b")
@tool("inventory_expert", description="Look up a network device by hostname.")
def call_inventory_expert(
query: str,
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
result = inventory_agent.invoke({"messages": [{"role": "user", "content": query}]})
answer = result["messages"][-1].content
found = SITE_CODE_RE.search(answer)
return Command(
update={
"site_code": found.group(0) if found else "",
"messages": [ToolMessage(content=answer, tool_call_id=tool_call_id)],
}
)
The main agent gets the state key when you pass state_schema=NetOpsState to create_agent:
[subagent] inventory_expert('dfw-core-01')
[tool] lookup_device('dfw-core-01')
[state] site_code='TX-ALPHA-3'
[subagent] change_expert('TX-ALPHA-3')
[tool] change_window('TX-ALPHA-3')
site_code in final state: 'TX-ALPHA-3'
Cost is unchanged at seven model calls. Your code now holds TX-ALPHA-3 as a value, so you can log it, validate it, or hand it to a function that never talks to a model.
There is a cheaper fix worth trying first. Tell the specialist what to return. The docs name a common failure where a subagent does good tool work and then leaves the results out of its final message. The main agent sees only that final message, so the work is lost. Two lines in the specialist’s prompt fix most of it. State that the supervisor sees only the final message. Then list what that message must contain.
Parallel Calls Cost No Extra Code
The main agent can call several specialists in one turn. Ask about two devices and watch the interleaving:
[subagent] inventory_expert('dfw-core-01')
[subagent] inventory_expert('iad-edge-02')
[tool] lookup_device('iad-edge-02')
[tool] lookup_device('dfw-core-01')
[subagent] change_expert('TX-ALPHA-3')
[subagent] change_expert('VA-BRAVO-9')
[tool] change_window('TX-ALPHA-3')
[tool] change_window('VA-BRAVO-9')
Both inventory lookups started before either finished, and iad-edge-02 returned first even though dfw-core-01 went out first. The second round did the same with both site codes. The whole two-device question took eleven model calls, four more than the single-device version.
You write nothing to get this. The main agent emits two tool calls in one message, and the framework runs them together. Specialists work in isolated context, so they cannot interfere with each other.
The docs draw one more distinction here. This concurrency is synchronous, because the main agent still waits for every specialist before it continues. Asynchronous means something different: the main agent starts a background job in another process and keeps talking to the user. Use that for work measured in minutes, such as a long document review. Accept that you now own a job system.
One Note on Memory
Subagents start with fresh state on every call. That is the default, and it is what makes them safe to run in parallel.
Two consequences follow. A subagent cannot remember an earlier invocation, so anything it needs must arrive in the task string or in the state you pass. LangGraph also cannot see inside a subagent that runs inside a tool function, so get_state with subgraphs will not show you subagent state. If you need to inspect that state during an interrupt, call the subagent from a graph node instead of a tool.
What Comes Next
You can now shape a subagents system with intent. Names and descriptions decide whether specialists get called. The tool pattern decides how the system grows. Inputs and outputs decide how much each specialist knows and how much it returns.
Part 3 leaves this pattern. It covers handoffs, where a tool call changes the agent’s own prompt and tools, the skills pattern, where one agent loads expertise on demand, and routers, which classify first and dispatch in parallel. It ends with the numbers that tell you which one to pick.