mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
feat(langchain): port SubAgentMiddleware from deepagents
This commit is contained in:
1 parent
789c6abdc2
commit
85ae167845
6 files changed
+3843
-2
No files matched your search
@@ -0,0 +1,23 @@
|
||||
"""Utility functions for middleware."""
|
||||
|
||||
from langchain_core.messages import ContentBlock, SystemMessage
|
||||
|
||||
|
||||
def append_to_system_message(
|
||||
system_message: SystemMessage | None,
|
||||
text: str,
|
||||
) -> SystemMessage:
|
||||
"""Append text to a system message.
|
||||
|
||||
Args:
|
||||
system_message: Existing system message or None.
|
||||
text: Text to add to the system message.
|
||||
|
||||
Returns:
|
||||
New SystemMessage with the text appended.
|
||||
"""
|
||||
new_content: list[ContentBlock] = list(system_message.content_blocks) if system_message else []
|
||||
if new_content:
|
||||
text = f"\n\n{text}"
|
||||
new_content.append({"type": "text", "text": text})
|
||||
return SystemMessage(content_blocks=new_content)
|
||||
@@ -0,0 +1,817 @@
|
||||
"""Middleware for providing subagents to an agent via a `task` tool."""
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable, Generator, Sequence
|
||||
from typing import Annotated, Any, TypedDict, cast, get_args, get_origin, get_type_hints
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware._utils import append_to_system_message
|
||||
from langchain.agents.middleware.shell_tool import DEFAULT_TOOL_DESCRIPTION
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
ContextT,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
PrivateStateAttr,
|
||||
ResponseT,
|
||||
StateT,
|
||||
)
|
||||
from langchain.agents.structured_output import ResponseFormat
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langgraph.types import Command
|
||||
from langsmith.run_helpers import get_tracing_context, tracing_context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain.tools.tool_node import ToolRuntime
|
||||
|
||||
class SubAgent(TypedDict):
|
||||
"""Specification for a raw subagent created by the `task` tool.
|
||||
|
||||
Required fields:
|
||||
name: Unique identifier used as the task tool's `subagent_type`.
|
||||
model: Model used to create the subagent. This may be a model instance or a
|
||||
provider-qualified model name such as `'openai:gpt-5.5'`.
|
||||
|
||||
Optional fields:
|
||||
description: Concise, action-oriented explanation of the subagent's role.
|
||||
It is shown to the main agent when it decides whether to delegate. If
|
||||
omitted, the subagent name is shown without a description.
|
||||
system_prompt: Instructions for the subagent, including tool-use guidance
|
||||
and expected response format. If omitted, no system prompt is provided.
|
||||
tools: Tools available to the subagent. If omitted, the subagent is created
|
||||
without tools.
|
||||
middleware: Additional middleware used when creating the subagent.
|
||||
response_format: Schema or structured-output strategy for the subagent.
|
||||
Structured responses are serialized as JSON and returned to the main
|
||||
agent instead of the subagent's final AI-message text.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Unique identifier for the subagent."""
|
||||
|
||||
description: NotRequired[str]
|
||||
"""What this subagent does, shown to the main agent when delegating."""
|
||||
|
||||
system_prompt: NotRequired[str]
|
||||
"""Instructions for the subagent."""
|
||||
|
||||
tools: NotRequired[Sequence[BaseTool | Callable | dict[str, Any]]]
|
||||
"""Tools the subagent can use. Defaults to an empty sequence."""
|
||||
|
||||
model: str | BaseChatModel
|
||||
"""Override the main agent's model.
|
||||
|
||||
Use `'provider:model-name'` format.
|
||||
"""
|
||||
|
||||
middleware: NotRequired[list[AgentMiddleware]]
|
||||
"""Additional middleware for custom behavior."""
|
||||
|
||||
response_format: NotRequired[ResponseFormat[Any] | type | dict[str, Any]]
|
||||
"""Structured output response format for the subagent.
|
||||
|
||||
When specified, the subagent will produce a `structured_response` conforming
|
||||
to the given schema. The structured response is JSON-serialized and returned
|
||||
as the `ToolMessage` content to the parent agent, replacing the default
|
||||
last-message extraction.
|
||||
|
||||
Accepted formats (from `langchain.agents.structured_output`):
|
||||
|
||||
- `ToolStrategy(schema)`: Use tool calling to extract structured output from the model.
|
||||
- `ProviderStrategy(schema)`: Use the model provider's native structured output mode.
|
||||
- `AutoStrategy(schema)`: Automatically select the best strategy.
|
||||
- A bare Python `type`: A Pydantic `BaseModel` subclass, `dataclass`,
|
||||
or `TypedDict` class.
|
||||
|
||||
Equivalent to `AutoStrategy(schema)`.
|
||||
- `dict[str, Any]`: A JSON schema dictionary
|
||||
(e.g., `{"type": "object", "properties": {...}, "required": [...]}`).
|
||||
|
||||
Example:
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Findings(BaseModel):
|
||||
findings: str
|
||||
confidence: float
|
||||
|
||||
analyzer: SubAgent = {
|
||||
"name": "analyzer",
|
||||
"description": "Analyzes data and returns structured findings",
|
||||
"system_prompt": "Analyze the data and return your findings.",
|
||||
"model": "openai:gpt-5.5",
|
||||
"tools": [],
|
||||
"response_format": Findings,
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class CompiledSubAgent(TypedDict):
|
||||
"""A pre-compiled agent spec.
|
||||
|
||||
!!! note
|
||||
|
||||
The `runnable`'s state schema must include a 'messages' key.
|
||||
|
||||
This is required for the subagent to communicate results back to
|
||||
the main agent.
|
||||
|
||||
!!! note
|
||||
|
||||
`CompiledSubAgent` runnables are used as provided. They do not
|
||||
inherit `create_deep_agent(state_schema=...)`; if the runnable
|
||||
needs custom state fields, compile it with a compatible state
|
||||
schema yourself.
|
||||
|
||||
When the subagent completes, the parent reads the returned state:
|
||||
if `structured_response` is non-`None`, it is JSON-serialized and used as
|
||||
the `ToolMessage` content; otherwise, the last non-empty `AIMessage`
|
||||
text is used.
|
||||
|
||||
Examples:
|
||||
Using `create_agent` with `response_format`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from langchain.agents import create_agent
|
||||
|
||||
|
||||
class Findings(BaseModel):
|
||||
summary: str
|
||||
confidence: float
|
||||
|
||||
|
||||
researcher: CompiledSubAgent = {
|
||||
"name": "researcher",
|
||||
"description": "Researches a topic and returns findings.",
|
||||
"runnable": create_agent(
|
||||
"openai:gpt-5.5",
|
||||
tools=[], # your tools here
|
||||
response_format=Findings,
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
Custom `langgraph` graph (write `structured_response` directly):
|
||||
|
||||
```python
|
||||
def node(state):
|
||||
return {
|
||||
"messages": [...],
|
||||
"structured_response": Findings(summary="...", confidence=0.9),
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Unique identifier for the subagent."""
|
||||
|
||||
description: str
|
||||
"""What this subagent does.
|
||||
|
||||
The main agent uses this to decide when to delegate.
|
||||
"""
|
||||
|
||||
runnable: Runnable
|
||||
"""A custom agent implementation.
|
||||
|
||||
Create a custom agent using either:
|
||||
|
||||
1. LangChain's [`create_agent()`](https://docs.langchain.com/oss/python/langchain/quickstart)
|
||||
2. A custom graph using [`langgraph`](https://docs.langchain.com/oss/python/langgraph/quickstart)
|
||||
|
||||
If you're creating a custom graph, make sure the state schema includes
|
||||
a 'messages' key. This is required for the subagent to communicate
|
||||
results back to the main agent.
|
||||
"""
|
||||
|
||||
|
||||
_EXCLUDED_STATE_KEYS = {
|
||||
"messages",
|
||||
"todos",
|
||||
"structured_response",
|
||||
}
|
||||
"""State keys that are excluded when passing state to subagents and when
|
||||
returning updates from subagents.
|
||||
|
||||
When returning updates:
|
||||
|
||||
1. The messages key is handled explicitly to ensure only the final message
|
||||
is included
|
||||
2. The todos and `structured_response` keys are excluded as they do not have
|
||||
a defined reducer and no clear meaning for returning them from a subagent
|
||||
to the main agent.
|
||||
3. Agent-private fields on middleware state schemas are excluded from both
|
||||
subagent output and subagent inputs.
|
||||
"""
|
||||
|
||||
|
||||
class TaskToolSchema(BaseModel):
|
||||
"""Input schema for the `task` tool."""
|
||||
|
||||
description: str = Field(
|
||||
description=(
|
||||
"A detailed description of the task for the subagent to perform autonomously. "
|
||||
"Include all necessary context and specify the expected output format."
|
||||
)
|
||||
)
|
||||
|
||||
subagent_type: str = Field(description=("The type of subagent to use. Must be one of the available agent types listed in the tool description."))
|
||||
|
||||
|
||||
TASK_TOOL_DESCRIPTION = """Launch an ephemeral subagent to handle complex, multi-step independent tasks with isolated context windows.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
{available_agents}
|
||||
|
||||
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
## Usage notes:
|
||||
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
|
||||
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
|
||||
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
|
||||
4. The agent's outputs should generally be trusted
|
||||
5. Clearly tell the agent whether you expect it to create content, perform analysis, or just do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
|
||||
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
7. When only the general-purpose agent is provided, you should use it for all tasks. It is great for isolating context and token usage, and completing specific, complex tasks, as it has all the same capabilities as the main agent.
|
||||
|
||||
### Example usage of the general-purpose agent:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"general-purpose": use this agent for general purpose tasks, it has access to all tools as the main agent.
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
User: "I want to conduct research on the accomplishments of Lebron James, Michael Jordan, and Kobe Bryant, and then compare them."
|
||||
Assistant: *Uses the task tool in parallel to conduct isolated research on each of the three players*
|
||||
Assistant: *Synthesizes the results of the three isolated research tasks and responds to the User*
|
||||
<commentary>
|
||||
Research is a complex, multi-step task in it of itself.
|
||||
The research of each individual player is not dependent on the research of the other players.
|
||||
The assistant uses the task tool to break down the complex objective into three isolated tasks.
|
||||
Each research task only needs to worry about context and tokens about one player, then returns synthesized information about each player as the Tool Result.
|
||||
This means each research task can dive deep and spend tokens and context deeply researching each player, but the final result is synthesized information, and saves us tokens in the long run when comparing the players to each other.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: "Analyze a single large code repository for security vulnerabilities and generate a report."
|
||||
Assistant: *Launches a single `task` subagent for the repository analysis*
|
||||
Assistant: *Receives report and integrates results into final summary*
|
||||
<commentary>
|
||||
Subagent is used to isolate a large, context-heavy task, even though there is only one. This prevents the main thread from being overloaded with details.
|
||||
If the user then asks followup questions, we have a concise report to reference instead of the entire history of analysis and tool calls, which is good and saves us time and money.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: "Schedule two meetings for me and prepare agendas for each."
|
||||
Assistant: *Calls the task tool in parallel to launch two `task` subagents (one per meeting) to prepare agendas*
|
||||
Assistant: *Returns final schedules and agendas*
|
||||
<commentary>
|
||||
Tasks are simple individually, but subagents help silo agenda preparation.
|
||||
Each subagent only needs to worry about the agenda for one meeting.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: "I want to order a pizza from Dominos, order a burger from McDonald's, and order a salad from Subway."
|
||||
Assistant: *Calls tools directly in parallel to order a pizza from Dominos, a burger from McDonald's, and a salad from Subway*
|
||||
<commentary>
|
||||
The assistant did not use the task tool because the objective is super simple and clear and only requires a few trivial tool calls.
|
||||
It is better to just complete the task directly and NOT use the `task` tool.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
### Example usage with custom agents:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"content-reviewer": use this agent after you are done creating significant content or documents
|
||||
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
|
||||
"research-analyst": use this agent to conduct thorough research on complex topics
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: Sure let me write a function that checks if a number is prime
|
||||
assistant: First let me use the Write tool to write a function that checks if a number is prime
|
||||
assistant: I'm going to use the Write tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {{
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {{
|
||||
if (n % i === 0) return false
|
||||
}}
|
||||
return true
|
||||
}}
|
||||
</code>
|
||||
<commentary>
|
||||
Since significant content was created and the task was completed, now use the content-reviewer agent to review the work
|
||||
</commentary>
|
||||
assistant: Now let me use the content-reviewer agent to review the code
|
||||
assistant: Uses the Task tool to launch with the content-reviewer agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you help me research the environmental impact of different renewable energy sources and create a comprehensive report?"
|
||||
<commentary>
|
||||
This is a complex research task that would benefit from using the research-analyst agent to conduct thorough analysis
|
||||
</commentary>
|
||||
assistant: I'll help you research the environmental impact of renewable energy sources. Let me use the research-analyst agent to conduct comprehensive research on this topic.
|
||||
assistant: Uses the Task tool to launch with the research-analyst agent, providing detailed instructions about what research to conduct and what format the report should take
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the Task tool to launch with the greeting-responder agent"
|
||||
</example>""" # noqa: E501
|
||||
|
||||
TASK_SYSTEM_PROMPT = """## `task` (subagent spawner)
|
||||
|
||||
You have access to a `task` tool to launch short-lived subagents that handle isolated tasks. These agents are ephemeral — they live only for the duration of the task and return a single result.
|
||||
|
||||
When to use the task tool:
|
||||
|
||||
- When a task is complex and multi-step, and can be fully delegated in isolation
|
||||
- When a task is independent of other tasks and can run in parallel
|
||||
- When a task requires focused reasoning or heavy token/context usage that would bloat the orchestrator thread
|
||||
- When sandboxing improves reliability (e.g. code execution, structured searches, data formatting)
|
||||
- When you only care about the output of the subagent, and not the intermediate steps (ex. performing a lot of research and then returned a synthesized report, performing a series of computations or lookups to achieve a concise, relevant answer.)
|
||||
|
||||
Subagent lifecycle:
|
||||
|
||||
1. **Spawn** → Provide clear role, instructions, and expected output
|
||||
2. **Run** → The subagent completes the task autonomously
|
||||
3. **Return** → The subagent provides a single structured result
|
||||
4. **Reconcile** → Incorporate or synthesize the result into the main thread
|
||||
|
||||
When NOT to use the task tool:
|
||||
|
||||
- If you need to see the intermediate reasoning or steps after the subagent has completed (the task tool hides them)
|
||||
- If the task is trivial (a few tool calls or simple lookup)
|
||||
- If delegating does not reduce token usage, complexity, or context switching
|
||||
- If splitting would add latency without benefit
|
||||
|
||||
## Important Task Tool Usage Notes to Remember
|
||||
|
||||
- Whenever possible, parallelize the work that you do. This is true for both tool_calls, and for tasks. Whenever you have independent steps to complete - make tool_calls, or kick off tasks (subagents) in parallel to accomplish them faster. This saves time for the user, which is incredibly important.
|
||||
- Remember to use the `task` tool to silo independent tasks within a multi-part objective.
|
||||
- You should use the `task` tool whenever you have a complex task that will take multiple steps, and is independent from other tasks that the agent needs to complete. These agents are highly competent and efficient.
|
||||
|
||||
Available subagent types:
|
||||
|
||||
{available_agents}""" # noqa: E501
|
||||
|
||||
SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY = "__deepagents_subagent_response_format"
|
||||
"""Configurable key used by task-tool callers to request dynamic response format."""
|
||||
|
||||
def _get_subagent_response_format_config(
|
||||
runtime: ToolRuntime,
|
||||
) -> ResponseFormat[Any] | type | dict[str, Any] | None:
|
||||
"""Return the response format carried in this task tool call's config."""
|
||||
config = runtime.config
|
||||
configurable = config.get("configurable") if isinstance(config, dict) else None
|
||||
if not isinstance(configurable, dict):
|
||||
return None
|
||||
value = configurable.get(SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY)
|
||||
if value is None:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _subagent_tracing_context() -> Generator[None, None, None]:
|
||||
"""Context manager that tags subagent runs with `ls_agent_type="subagent"`.
|
||||
|
||||
Sets `ls_agent_type` on the langsmith tracing context `metadata`, which is
|
||||
propagated to LangSmith runs. This mirrors
|
||||
langchain's `ls_agent_type="root"` tagging behavior.
|
||||
|
||||
Forwards all other current tracing-context fields (parent, client, tags,
|
||||
etc.) unchanged so this wrapper does not clobber the enclosing context.
|
||||
"""
|
||||
current = get_tracing_context()
|
||||
|
||||
merged_metadata = {**(current.get("metadata") or {}), "ls_agent_type": "subagent"}
|
||||
# Pass every field from the current tracing context through to
|
||||
# `tracing_context` so we don't accidentally clobber fields that may be
|
||||
# added to langsmith in the future. The only change is `metadata`.
|
||||
|
||||
kwargs: dict[str, Any] = {**current, "metadata": merged_metadata}
|
||||
|
||||
with tracing_context(**kwargs):
|
||||
yield
|
||||
|
||||
|
||||
def _has_marker(annotation: object, marker: object) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
args = get_args(annotation)
|
||||
return any(meta is marker for meta in args[1:])
|
||||
if origin is not None:
|
||||
return any(_has_marker(arg, marker) for arg in get_args(annotation))
|
||||
return False
|
||||
|
||||
def _private_state_field_names(state_schema: type[object]) -> frozenset[str]:
|
||||
"""Return fields annotated with `PrivateStateAttr` across state schemas."""
|
||||
names: set[str] = set()
|
||||
with contextlib.suppress(Exception):
|
||||
hints = get_type_hints(state_schema, include_extras=True)
|
||||
for name, annotation in hints.items():
|
||||
if _has_marker(annotation, PrivateStateAttr):
|
||||
names.add(name)
|
||||
return frozenset(names)
|
||||
|
||||
def _compile_sub_agent_spec(
|
||||
spec: SubAgent,
|
||||
*,
|
||||
state_schema: type | None = None,
|
||||
) -> Runnable:
|
||||
"""Create a runnable agent from a raw `SubAgent` spec.
|
||||
|
||||
This is the shared entrypoint for the `create_agent` path used by
|
||||
raw subagent specs. Pre-compiled `CompiledSubAgent` runnables are already
|
||||
created by the caller and are handled separately by `SubAgentMiddleware`.
|
||||
|
||||
Args:
|
||||
spec: Subagent spec to compile. Must specify `name` and `model`.
|
||||
state_schema: Optional state schema for the raw subagent agent.
|
||||
|
||||
Returns:
|
||||
Runnable agent ready for task-tool invocation.
|
||||
|
||||
Raises:
|
||||
ValueError: If `spec` is missing `name` or `model`.
|
||||
"""
|
||||
if "name" not in spec:
|
||||
msg = "SubAgent must specify 'name'"
|
||||
raise ValueError(msg)
|
||||
if "model" not in spec:
|
||||
msg = f"SubAgent '{spec['name']}' must specify 'model'"
|
||||
raise ValueError(msg)
|
||||
|
||||
create_agent_kwargs: dict[str, Any] = {
|
||||
"model": spec["model"],
|
||||
"system_prompt": spec.get("system_prompt"),
|
||||
"tools": list(spec.get("tools", [])),
|
||||
"middleware": list(spec.get("middleware", [])),
|
||||
"name": spec["name"],
|
||||
"response_format": spec.get("response_format", None),
|
||||
}
|
||||
if state_schema is not None:
|
||||
create_agent_kwargs["state_schema"] = state_schema
|
||||
return create_agent(**create_agent_kwargs).with_config(
|
||||
{
|
||||
"metadata": {"lc_agent_name": spec["name"]},
|
||||
"run_name": spec["name"],
|
||||
}
|
||||
)
|
||||
|
||||
def _build_task_tool(
|
||||
subagents: Sequence[SubAgent | CompiledSubAgent],
|
||||
*,
|
||||
description: str | None = TASK_TOOL_DESCRIPTION,
|
||||
excluded_state_keys: frozenset[str] | None = None,
|
||||
state_schema: type | None = None,
|
||||
) -> BaseTool:
|
||||
"""Create a task tool from subagent specs.
|
||||
|
||||
Args:
|
||||
subagents: List of raw or compiled subagent specs.
|
||||
description: Custom description for the task tool. If `None`,
|
||||
uses default template. Supports `{available_agents}` placeholder.
|
||||
excluded_state_keys: State keys marked with `PrivateStateAttr` that
|
||||
should be stripped from input state before invoking subagents.
|
||||
state_schema: State schema passed when compiling raw subagent specs.
|
||||
|
||||
Returns:
|
||||
A StructuredTool that can invoke subagents by type.
|
||||
"""
|
||||
if not subagents:
|
||||
msg = "At least one subagent must be specified"
|
||||
raise ValueError(msg)
|
||||
|
||||
subagents_by_name: dict[str, SubAgent | CompiledSubAgent] = {}
|
||||
for spec in subagents:
|
||||
name = spec.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
msg = "Every subagent must specify a non-empty string 'name'"
|
||||
raise ValueError(msg)
|
||||
if name in subagents_by_name:
|
||||
msg = f"Duplicate subagent name: {name!r}"
|
||||
raise ValueError(msg)
|
||||
if "runnable" in spec:
|
||||
if spec["runnable"] is None:
|
||||
msg = f"CompiledSubAgent {name!r} must specify 'runnable'"
|
||||
raise ValueError(msg)
|
||||
elif "model" not in spec:
|
||||
msg = f"SubAgent {name!r} must specify 'model'"
|
||||
raise ValueError(msg)
|
||||
subagents_by_name[name] = spec
|
||||
|
||||
subagent_runnables: dict[str, Runnable] = {
|
||||
name: (
|
||||
cast("CompiledSubAgent", spec)["runnable"]
|
||||
if "runnable" in spec
|
||||
else _compile_sub_agent_spec(
|
||||
cast("SubAgent", spec),
|
||||
state_schema=state_schema,
|
||||
)
|
||||
)
|
||||
for name, spec in subagents_by_name.items()
|
||||
}
|
||||
|
||||
def _get_subagent_spec(subagent_type: str) -> SubAgent | CompiledSubAgent:
|
||||
"""Validates and returns a subagent spec from the provided input"""
|
||||
try:
|
||||
return subagents_by_name[subagent_type]
|
||||
except KeyError:
|
||||
allowed_types = ", ".join(f"`{name}`" for name in subagents_by_name)
|
||||
msg = (
|
||||
f"Cannot use subagent type `{subagent_type}` because it does "
|
||||
f"not exist. The only allowed types are {allowed_types}"
|
||||
)
|
||||
raise ValueError(msg) from None
|
||||
|
||||
def _get_subagent_runnable(
|
||||
spec: SubAgent | CompiledSubAgent,
|
||||
runtime: ToolRuntime,
|
||||
) -> Runnable:
|
||||
"""Return the baseline runnable or compile a dynamic-format raw spec."""
|
||||
response_format = _get_subagent_response_format_config(runtime)
|
||||
|
||||
if "runnable" in spec:
|
||||
if response_format is not None:
|
||||
msg = (
|
||||
f'response_format cannot be used with compiled subagent "{spec["name"]}"; '
|
||||
"dynamic schemas require a raw SubAgent spec."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return subagent_runnables[spec["name"]].with_config(
|
||||
{
|
||||
"metadata": {"lc_agent_name": spec["name"]},
|
||||
"run_name": spec["name"],
|
||||
}
|
||||
)
|
||||
|
||||
if response_format is None:
|
||||
return subagent_runnables[spec["name"]]
|
||||
|
||||
# When a custom response format is provided,
|
||||
dynamic_spec = cast(
|
||||
"SubAgent",
|
||||
{**spec, "response_format": response_format},
|
||||
)
|
||||
return _compile_sub_agent_spec(dynamic_spec, state_schema=state_schema)
|
||||
|
||||
def _filter_subagent_state(result: dict) -> dict:
|
||||
"""Filter out excluded keys from input given to the subagent"""
|
||||
return {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS and k not in excluded_state_keys}
|
||||
|
||||
def _extract_subagent_response(result: dict) -> str:
|
||||
"""Extract the response returned by a completed subagent run.
|
||||
|
||||
Structured responses take precedence and are serialized as JSON. Otherwise,
|
||||
the text of the last non-empty `AIMessage` is returned.
|
||||
|
||||
Args:
|
||||
result: Final state returned by the subagent runnable. The state must
|
||||
contain a `messages` sequence and may contain a
|
||||
`structured_response`.
|
||||
|
||||
Returns:
|
||||
The serialized structured response or the final AI message text.
|
||||
|
||||
Raises:
|
||||
ValueError: If `result` has no `messages` key or contains neither a
|
||||
structured response nor an AI message with text.
|
||||
"""
|
||||
if "messages" not in result:
|
||||
error_msg = (
|
||||
"CompiledSubAgent must return a dict containing a 'messages' key. "
|
||||
"Custom StateGraphs used with CompiledSubAgent should include 'messages' "
|
||||
"in their state schema to communicate results back to the main agent."
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# If the subagent has a structured response output, serialize it and return it
|
||||
structured = result.get("structured_response")
|
||||
if structured is not None:
|
||||
if hasattr(structured, "model_dump_json"):
|
||||
return structured.model_dump_json()
|
||||
elif dataclasses.is_dataclass(structured) and not isinstance(structured, type):
|
||||
return json.dumps(dataclasses.asdict(structured))
|
||||
else:
|
||||
return json.dumps(structured)
|
||||
|
||||
# Walk back to the last AIMessage with non-empty text.
|
||||
for msg in reversed(result["messages"]):
|
||||
if isinstance(msg, AIMessage):
|
||||
text = msg.text.rstrip() if msg.text else ""
|
||||
if text:
|
||||
return text
|
||||
|
||||
error_msg = (
|
||||
"SubAgent didn't return a usable response. Subagent runs must return either "
|
||||
"contain a message list with vali text content in the last AIMessage, or a "
|
||||
"structured response dict"
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def _format_subagent_update(result: Any, tool_call_id: str) -> Command:
|
||||
return Command(
|
||||
update={
|
||||
**_filter_subagent_state(result),
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content=_extract_subagent_response(result),
|
||||
tool_call_id=tool_call_id
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
def task(
|
||||
description: str,
|
||||
subagent_type: str,
|
||||
runtime: ToolRuntime,
|
||||
) -> str | Command:
|
||||
spec = _get_subagent_spec(subagent_type)
|
||||
subagent = _get_subagent_runnable(spec, runtime)
|
||||
subagent_input = _filter_subagent_state(runtime.state)
|
||||
subagent_input["messages"] = [HumanMessage(content=description)]
|
||||
# The parent's callbacks, tags and configurable reach the subagent
|
||||
# automatically: langgraph's `ensure_config` seeds each run from the
|
||||
# ambient parent config and (as of langgraph#7926) merges it per-key, so
|
||||
# the subagent's bound config still wins collisions (e.g. `lc_agent_name`,
|
||||
# `recursion_limit`) and parent metadata propagates (deepagents#3634).
|
||||
# Forwarding those keys explicitly would double-count under the merge
|
||||
# (e.g. duplicate `tags`), so we only stamp the subagent tracing tag.
|
||||
subagent_config: RunnableConfig = {"configurable": {"ls_agent_type": "subagent"}}
|
||||
with _subagent_tracing_context():
|
||||
result = subagent.invoke(subagent_input, subagent_config)
|
||||
if not runtime.tool_call_id:
|
||||
return _extract_subagent_response(result)
|
||||
return _format_subagent_update(result, runtime.tool_call_id)
|
||||
|
||||
async def atask(
|
||||
description: str,
|
||||
subagent_type: str,
|
||||
runtime: ToolRuntime
|
||||
) -> str | Command:
|
||||
spec = _get_subagent_spec(subagent_type)
|
||||
subagent = _get_subagent_runnable(spec, runtime)
|
||||
subagent_input = _filter_subagent_state(runtime.state)
|
||||
subagent_input["messages"] = [HumanMessage(content=description)]
|
||||
# The parent's callbacks, tags and configurable reach the subagent
|
||||
# automatically: langgraph's `ensure_config` seeds each run from the
|
||||
# ambient parent config and (as of langgraph#7926) merges it per-key, so
|
||||
# the subagent's bound config still wins collisions (e.g. `lc_agent_name`,
|
||||
# `recursion_limit`) and parent metadata propagates (deepagents#3634).
|
||||
# Forwarding those keys explicitly would double-count under the merge
|
||||
# (e.g. duplicate `tags`), so we only stamp the subagent tracing tag.
|
||||
subagent_config: RunnableConfig = {"configurable": {"ls_agent_type": "subagent"}}
|
||||
with _subagent_tracing_context():
|
||||
result = await subagent.ainvoke(subagent_input, subagent_config)
|
||||
if not runtime.tool_call_id:
|
||||
return _extract_subagent_response(result)
|
||||
return _format_subagent_update(result, runtime.tool_call_id)
|
||||
|
||||
if description is not None:
|
||||
subagent_description_str = "\n".join(
|
||||
f"- {s['name']}: {s.get('description', '')}" for s in subagents
|
||||
)
|
||||
description = description.format(available_agents=subagent_description_str)
|
||||
|
||||
return StructuredTool.from_function(
|
||||
name="task",
|
||||
func=task,
|
||||
coroutine=atask,
|
||||
description=description,
|
||||
infer_schema=False,
|
||||
args_schema=TaskToolSchema
|
||||
)
|
||||
|
||||
class SubAgentMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
||||
"""Middleware for providing subagents to an agent via a `task` tool.
|
||||
|
||||
This middleware adds a `task` tool to the agent that can be used
|
||||
to invoke subagents.
|
||||
|
||||
Subagents are useful for handling complex tasks that require multiple steps,
|
||||
or tasks that require a lot of context to resolve.
|
||||
|
||||
A chief benefit of subagents is that they can handle multi-step tasks,
|
||||
and then return a clean, concise response to the main agent.
|
||||
|
||||
Subagents are also great for different domains of expertise that require
|
||||
a narrower subset of tools and focus.
|
||||
|
||||
Args:
|
||||
subagents: Subagents available to the `task` tool. Raw `SubAgent` specs must
|
||||
define `name` and `model`; `description`, `system_prompt`, and `tools`
|
||||
are optional. Precompiled specs must define `name`, `description`, and
|
||||
`runnable`.
|
||||
Names must be unique because the task's `subagent_type` selects a spec by
|
||||
name.
|
||||
system_prompt: Instructions appended to the main agent's system prompt.
|
||||
The `{available_agents}` placeholder is replaced with the configured
|
||||
subagent names and descriptions.
|
||||
tool_description: Description for the `task` tool. The
|
||||
`{available_agents}` placeholder is replaced with the configured
|
||||
subagent names and descriptions.
|
||||
state_schema: Main-agent state schema passed to raw subagent compilation.
|
||||
Fields annotated with `PrivateStateAttr` are excluded from state passed to
|
||||
subagents and from state updates returned to the main agent. Compiled
|
||||
subagents retain the schema of their caller-provided runnables.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware.subagents import SubAgentMiddleware
|
||||
|
||||
agent = create_agent(
|
||||
"openai:gpt-5.5",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "researcher",
|
||||
"description": "Research a topic and summarize findings.",
|
||||
"system_prompt": "You are a research specialist.",
|
||||
"model": "openai:gpt-5.5",
|
||||
"tools": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
```
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
subagents: Sequence[SubAgent | CompiledSubAgent],
|
||||
system_prompt: str | None = TASK_SYSTEM_PROMPT,
|
||||
tool_description: str | None = DEFAULT_TOOL_DESCRIPTION,
|
||||
state_schema: type | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
subagent_description_str = "\n".join(
|
||||
f"- {s.get('name', '')}: {s.get('description', '')}" for s in subagents
|
||||
)
|
||||
self._system_prompt = (
|
||||
system_prompt.format(available_agents=subagent_description_str)
|
||||
if system_prompt is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self.tools = [
|
||||
_build_task_tool(
|
||||
subagents,
|
||||
description=tool_description,
|
||||
excluded_state_keys=(
|
||||
_private_state_field_names(state_schema)
|
||||
if state_schema is not None
|
||||
else frozenset()
|
||||
),
|
||||
state_schema=state_schema,
|
||||
)
|
||||
]
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]],
|
||||
) -> ModelResponse[ResponseT]:
|
||||
"""Update the system message to include instructions on using subagents."""
|
||||
if self._system_prompt is not None:
|
||||
new_system_message = append_to_system_message(request.system_message, self._system_prompt)
|
||||
return handler(request.override(system_message=new_system_message))
|
||||
return handler(request)
|
||||
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]],
|
||||
) -> ModelResponse[ResponseT]:
|
||||
"""Update the system message to include instructions on using subagents."""
|
||||
if self._system_prompt is not None:
|
||||
new_system_message = append_to_system_message(request.system_message, self._system_prompt)
|
||||
return await handler(request.override(system_message=new_system_message))
|
||||
return await handler(request)
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.tools import tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.subagents import SubAgent, SubAgentMiddleware
|
||||
from langchain.agents.structured_output import ToolStrategy
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather in a city."""
|
||||
return f"The weather in {city} is sunny."
|
||||
|
||||
|
||||
class WeatherMiddleware(AgentMiddleware):
|
||||
tools: ClassVar = [get_weather]
|
||||
|
||||
|
||||
GENERAL_PURPOSE_SUBAGENT: SubAgent = {
|
||||
"name": "general-purpose",
|
||||
"description": "General-purpose agent for answering weather questions.",
|
||||
"system_prompt": "Use the available tools to answer the user's weather question.",
|
||||
}
|
||||
|
||||
|
||||
def assert_expected_subgraph_actions(expected_tool_calls, agent, inputs):
|
||||
current_idx = 0
|
||||
for update in agent.stream(
|
||||
inputs,
|
||||
subgraphs=True,
|
||||
stream_mode="updates",
|
||||
):
|
||||
if "model" in update[1]:
|
||||
ai_message = update[1]["model"]["messages"][-1]
|
||||
tool_calls = ai_message.tool_calls
|
||||
for tool_call in tool_calls:
|
||||
if tool_call["name"] == expected_tool_calls[current_idx]["name"]:
|
||||
if "model" in expected_tool_calls[current_idx]:
|
||||
# Providers may return date-suffixed names
|
||||
expected_model = expected_tool_calls[current_idx]["model"]
|
||||
actual_model = ai_message.response_metadata["model_name"]
|
||||
assert actual_model == expected_model or actual_model.startswith(expected_model + "-"), (
|
||||
f"Expected model {expected_model!r}, got {actual_model!r}"
|
||||
)
|
||||
for arg in expected_tool_calls[current_idx]["args"]:
|
||||
assert arg in tool_call["args"]
|
||||
assert tool_call["args"][arg] == expected_tool_calls[current_idx]["args"][arg]
|
||||
current_idx += 1
|
||||
assert current_idx == len(expected_tool_calls)
|
||||
|
||||
|
||||
@pytest.mark.requires("langchain_anthropic", "langchain_openai")
|
||||
class TestSubagentMiddleware:
|
||||
"""Integration tests for the SubagentMiddleware class."""
|
||||
|
||||
def test_general_purpose_subagent(self):
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the general-purpose subagent to get the weather in a city.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
**GENERAL_PURPOSE_SUBAGENT,
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tools": [get_weather],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert "task" in agent.nodes["tools"].bound._tools_by_name
|
||||
response = agent.invoke({"messages": [HumanMessage(content="What is the weather in Tokyo?")]})
|
||||
assert response["messages"][1].tool_calls[0]["name"] == "task"
|
||||
assert response["messages"][1].tool_calls[0]["args"]["subagent_type"] == "general-purpose"
|
||||
|
||||
def test_defined_subagent_tool_calls(self):
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the task tool to call a subagent.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "This subagent can get weather in cities.",
|
||||
"system_prompt": "Use the get_weather tool to get the weather in a city.",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tools": [get_weather],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
expected_tool_calls = [
|
||||
{"name": "task", "args": {"subagent_type": "weather"}},
|
||||
{"name": "get_weather", "args": {}},
|
||||
]
|
||||
assert_expected_subgraph_actions(
|
||||
expected_tool_calls,
|
||||
agent,
|
||||
{"messages": [HumanMessage(content="What is the weather in Tokyo?")]},
|
||||
)
|
||||
|
||||
def test_defined_subagent_custom_model(self):
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the task tool to call a subagent.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "This subagent can get weather in cities.",
|
||||
"system_prompt": "Use the get_weather tool to get the weather in a city.",
|
||||
"tools": [get_weather],
|
||||
"model": "gpt-5.4",
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
expected_tool_calls = [
|
||||
{
|
||||
"name": "task",
|
||||
"args": {"subagent_type": "weather"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
},
|
||||
{"name": "get_weather", "args": {}, "model": "gpt-5.4"},
|
||||
]
|
||||
assert_expected_subgraph_actions(
|
||||
expected_tool_calls,
|
||||
agent,
|
||||
{"messages": [HumanMessage(content="What is the weather in Tokyo?")]},
|
||||
)
|
||||
|
||||
def test_defined_subagent_custom_middleware(self):
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the task tool to call a subagent.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "This subagent can get weather in cities.",
|
||||
"system_prompt": "Use the get_weather tool to get the weather in a city.",
|
||||
"tools": [], # No tools, only in middleware
|
||||
"model": "gpt-5.4",
|
||||
"middleware": [WeatherMiddleware()],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
expected_tool_calls = [
|
||||
{
|
||||
"name": "task",
|
||||
"args": {"subagent_type": "weather"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
},
|
||||
{"name": "get_weather", "args": {}, "model": "gpt-5.4"},
|
||||
]
|
||||
assert_expected_subgraph_actions(
|
||||
expected_tool_calls,
|
||||
agent,
|
||||
{"messages": [HumanMessage(content="What is the weather in Tokyo?")]},
|
||||
)
|
||||
|
||||
def test_defined_subagent_custom_runnable(self):
|
||||
custom_subagent = create_agent(
|
||||
model="gpt-5.4",
|
||||
system_prompt="Use the get_weather tool to get the weather in a city.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the task tool to call a subagent.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "This subagent can get weather in cities.",
|
||||
"runnable": custom_subagent,
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
expected_tool_calls = [
|
||||
{
|
||||
"name": "task",
|
||||
"args": {"subagent_type": "weather"},
|
||||
"model": "claude-sonnet-4-6",
|
||||
},
|
||||
{"name": "get_weather", "args": {}, "model": "gpt-5.4"},
|
||||
]
|
||||
assert_expected_subgraph_actions(
|
||||
expected_tool_calls,
|
||||
agent,
|
||||
{"messages": [HumanMessage(content="What is the weather in Tokyo?")]},
|
||||
)
|
||||
|
||||
def test_subagent_response_format_serialized_as_json(self):
|
||||
"""Test that subagent responseFormat produces JSON-serialized ToolMessage content.
|
||||
|
||||
Verifies the end-to-end flow when `response_format` is set directly on a
|
||||
`SubAgent` spec: the subagent's `structured_response` is JSON-serialized
|
||||
into the ToolMessage content returned to the parent agent.
|
||||
"""
|
||||
|
||||
class SubagentFindings(BaseModel):
|
||||
findings: str = Field(description="The findings")
|
||||
confidence: float = Field(description="Confidence score")
|
||||
summary: str = Field(description="Brief summary")
|
||||
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-20250514",
|
||||
system_prompt="You are an orchestrator. Always delegate tasks to the appropriate subagent via the task tool.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "foo",
|
||||
"description": "Call this when the user says 'foo'",
|
||||
"system_prompt": "You are a foo agent",
|
||||
"model": "claude-haiku-4-5",
|
||||
"tools": [],
|
||||
"response_format": ToolStrategy(schema=SubagentFindings),
|
||||
},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = agent.invoke(
|
||||
{"messages": [HumanMessage(content="foo - tell me how confident you are that pineapple belongs on pizza")]},
|
||||
{"recursion_limit": 100},
|
||||
)
|
||||
|
||||
agent_messages = [msg for msg in result["messages"] if isinstance(msg, AIMessage)]
|
||||
tool_calls = [tc for msg in agent_messages for tc in (msg.tool_calls or [])]
|
||||
assert any(tc["name"] == "task" and tc["args"].get("subagent_type") == "foo" for tc in tool_calls)
|
||||
|
||||
task_tool_messages = [msg for msg in result["messages"] if msg.type == "tool" and msg.name == "task"]
|
||||
assert len(task_tool_messages) > 0
|
||||
|
||||
task_tool_message = task_tool_messages[0]
|
||||
parsed = json.loads(task_tool_message.content)
|
||||
assert "findings" in parsed
|
||||
assert "confidence" in parsed
|
||||
assert "summary" in parsed
|
||||
assert isinstance(parsed["findings"], str)
|
||||
assert isinstance(parsed["confidence"], (int, float))
|
||||
assert isinstance(parsed["summary"], str)
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
"""Unit tests for SubAgentMiddleware initialization and configuration."""
|
||||
|
||||
import json
|
||||
from typing import Any, get_type_hints
|
||||
|
||||
from langchain.agents.middleware.subagents import SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY, SubAgent, SubAgentMiddleware
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.structured_output import AutoStrategy
|
||||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.callbacks import CallbackManagerForLLMRun
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from langchain_core.outputs import ChatResult
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.graph import START, MessagesState, StateGraph
|
||||
from tests.unit_tests.chat_model import GenericFakeChatModel
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather in a city."""
|
||||
return f"The weather in {city} is sunny."
|
||||
|
||||
GENERAL_PURPOSE_SUBAGENT: SubAgent = {
|
||||
"name": "general-purpose",
|
||||
"description": "General-purpose agent for answering weather questions.",
|
||||
"system_prompt": "Use the available tools to answer the user's weather question.",
|
||||
}
|
||||
|
||||
|
||||
class _DynamicStructuredOutputModel(GenericFakeChatModel):
|
||||
"""Fake model that calls whichever structured-output tool is bound."""
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None,
|
||||
run_manager: CallbackManagerForLLMRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
tool_name = getattr(self.tools[-1], "name", None)
|
||||
if not isinstance(tool_name, str):
|
||||
msg = "Expected a structured-output tool to be bound"
|
||||
raise TypeError(msg)
|
||||
self.messages = iter(
|
||||
[
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": tool_name,
|
||||
"args": {
|
||||
"name": "Maya Thornton",
|
||||
"age": 29,
|
||||
"city": "Portland",
|
||||
},
|
||||
"id": "call_payload",
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
return super()._generate(
|
||||
messages,
|
||||
stop=stop,
|
||||
run_manager=run_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TestSubagentMiddlewareInit:
|
||||
"""Tests for SubAgentMiddleware initialization that don't require LLM invocation."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set dummy API key for model initialization."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
def test_subagent_middleware_init(self) -> None:
|
||||
"""Test basic SubAgentMiddleware initialization with general-purpose subagent."""
|
||||
middleware = SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
**GENERAL_PURPOSE_SUBAGENT,
|
||||
"model": "gpt-5.4-mini",
|
||||
"tools": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert middleware is not None
|
||||
assert "Available subagent types:" in middleware.system_prompt
|
||||
assert len(middleware.tools) == 1
|
||||
assert middleware.tools[0].name == "task"
|
||||
|
||||
def test_task_tool_compiles_dynamic_response_format_for_declarative_subagent(self) -> None:
|
||||
"""Dynamic schemas are present when declarative subagent variants compile."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
"city": {"type": "string"},
|
||||
},
|
||||
"required": ["name", "age", "city"],
|
||||
}
|
||||
response_format = AutoStrategy(schema)
|
||||
middleware = SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "worker",
|
||||
"description": "Does work.",
|
||||
"system_prompt": "Return structured data.",
|
||||
"model": _DynamicStructuredOutputModel(messages=iter(())),
|
||||
"tools": [],
|
||||
}
|
||||
],
|
||||
system_prompt=None,
|
||||
)
|
||||
task_tool = middleware.tools[0]
|
||||
runtime = ToolRuntime(
|
||||
state={},
|
||||
context={},
|
||||
config={
|
||||
"configurable": {
|
||||
SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY: response_format,
|
||||
}
|
||||
},
|
||||
stream_writer=lambda _chunk: None,
|
||||
tools=[task_tool],
|
||||
tool_call_id="call_worker",
|
||||
store=None,
|
||||
)
|
||||
|
||||
result = task_tool.func(
|
||||
description="Make a person.",
|
||||
subagent_type="worker",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
assert json.loads(result.update["messages"][0].content) == {
|
||||
"name": "Maya Thornton",
|
||||
"age": 29,
|
||||
"city": "Portland",
|
||||
}
|
||||
|
||||
def test_task_tool_rejects_response_format_for_compiled_subagent(self) -> None:
|
||||
"""Dynamic schemas require a declarative spec to compile a variant."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"ok": {"type": "boolean"}},
|
||||
"required": ["ok"],
|
||||
}
|
||||
response_format = AutoStrategy(schema)
|
||||
|
||||
runnable = RunnableLambda(lambda _state, _config: (_ for _ in ()).throw(AssertionError("compiled runnable should not be invoked")))
|
||||
|
||||
task_tool = _build_task_tool(
|
||||
[
|
||||
{
|
||||
"name": "worker",
|
||||
"description": "Does work.",
|
||||
"runnable": runnable,
|
||||
}
|
||||
]
|
||||
)
|
||||
runtime = ToolRuntime(
|
||||
state={},
|
||||
context={},
|
||||
config={
|
||||
"configurable": {
|
||||
SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY: response_format,
|
||||
}
|
||||
},
|
||||
stream_writer=lambda _chunk: None,
|
||||
tools=[task_tool],
|
||||
tool_call_id="call_worker",
|
||||
store=None,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match='response_schema cannot be used with compiled subagent "worker"',
|
||||
):
|
||||
task_tool.func(
|
||||
description="Do work.",
|
||||
subagent_type="worker",
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
def test_subagent_middleware_with_custom_subagent(self) -> None:
|
||||
"""Test SubAgentMiddleware initialization with a custom subagent."""
|
||||
middleware = SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "Weather subagent",
|
||||
"system_prompt": "Get weather.",
|
||||
"model": "gpt-5.4-mini",
|
||||
"tools": [get_weather],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert middleware is not None
|
||||
# System prompt includes TASK_SYSTEM_PROMPT plus available subagent types
|
||||
assert middleware.system_prompt.startswith(TASK_SYSTEM_PROMPT)
|
||||
assert "weather" in middleware.system_prompt
|
||||
|
||||
def test_subagent_middleware_custom_system_prompt(self) -> None:
|
||||
"""Test SubAgentMiddleware with a custom system prompt."""
|
||||
middleware = SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "Weather subagent",
|
||||
"system_prompt": "Get weather.",
|
||||
"model": "gpt-5.4-mini",
|
||||
"tools": [],
|
||||
}
|
||||
],
|
||||
system_prompt="Use the task tool to call a subagent.",
|
||||
)
|
||||
assert middleware is not None
|
||||
# Custom system prompt plus available subagent types
|
||||
assert middleware.system_prompt.startswith("Use the task tool to call a subagent.")
|
||||
|
||||
def test_requires_subagents(self) -> None:
|
||||
"""Test that at least one subagent is required."""
|
||||
with pytest.raises(ValueError, match="At least one subagent"):
|
||||
SubAgentMiddleware(
|
||||
subagents=[],
|
||||
)
|
||||
|
||||
def test_subagent_requires_model(self) -> None:
|
||||
"""Test that subagents must specify model."""
|
||||
with pytest.raises(ValueError, match="must specify 'model'"):
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "test",
|
||||
"description": "Test",
|
||||
"system_prompt": "Test.",
|
||||
"tools": [],
|
||||
# Missing "model"
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_subagent_requires_tools(self) -> None:
|
||||
"""Test that subagents must specify tools."""
|
||||
with pytest.raises(ValueError, match="must specify 'tools'"):
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "test",
|
||||
"description": "Test",
|
||||
"system_prompt": "Test.",
|
||||
"model": "gpt-5.4-mini",
|
||||
# Missing "tools"
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def _make_echo_graph(self) -> object:
|
||||
"""Build a minimal MessagesState graph for use in CompiledSubAgent tests."""
|
||||
|
||||
def echo_node(_state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage(content="hello")]}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("echo", echo_node)
|
||||
builder.add_edge(START, "echo")
|
||||
return builder.compile()
|
||||
|
||||
def _task_runtime(self, task_tool: object, tool_call_id: str) -> ToolRuntime:
|
||||
return ToolRuntime(
|
||||
state={},
|
||||
context={},
|
||||
config={"configurable": {}},
|
||||
stream_writer=lambda _chunk: None,
|
||||
tools=[task_tool],
|
||||
tool_call_id=tool_call_id,
|
||||
store=None,
|
||||
)
|
||||
|
||||
def test_compiled_subagent_name_propagated_via_config(self) -> None:
|
||||
"""CompiledSubAgent.name is forwarded into metadata.lc_agent_name and run_name."""
|
||||
configs: list[dict[str, object]] = []
|
||||
|
||||
class _Runnable:
|
||||
def with_config(self, config: dict[str, object]) -> "_Runnable":
|
||||
configs.append(config)
|
||||
return self
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
state: dict[str, object],
|
||||
config: object = None,
|
||||
) -> dict[str, object]:
|
||||
del state, config
|
||||
return {"messages": [AIMessage(content="hello")]}
|
||||
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "my-subagent",
|
||||
"description": "A custom subagent",
|
||||
"runnable": _Runnable(),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert configs == [
|
||||
{
|
||||
"metadata": {"lc_agent_name": "my-subagent"},
|
||||
"run_name": "my-subagent",
|
||||
}
|
||||
]
|
||||
|
||||
def test_compiled_subagent_does_not_mutate_original_runnable(self) -> None:
|
||||
"""Task-tool setup must not mutate the original runnable."""
|
||||
graph = self._make_echo_graph()
|
||||
original_config = getattr(graph, "config", None)
|
||||
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "my-subagent",
|
||||
"description": "A custom subagent",
|
||||
"runnable": graph,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert graph.config == original_config, "Original runnable was mutated; use with_config instead of attribute assignment"
|
||||
|
||||
def test_same_runnable_reused_across_multiple_subagents(self) -> None:
|
||||
"""Same runnable registered under two different names must not cross-contaminate configs."""
|
||||
|
||||
class _Runnable:
|
||||
def __init__(self, config: dict[str, object] | None = None) -> None:
|
||||
self.config = config
|
||||
|
||||
def with_config(self, config: dict[str, object]) -> "_Runnable":
|
||||
return _Runnable(config)
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
state: dict[str, object],
|
||||
config: object = None,
|
||||
) -> dict[str, object]:
|
||||
del state, config
|
||||
if self.config is None:
|
||||
msg = "Expected configured runnable clone"
|
||||
raise AssertionError(msg)
|
||||
return {"messages": [AIMessage(content=str(self.config["run_name"]))]}
|
||||
|
||||
graph = _Runnable()
|
||||
|
||||
middleware = SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "agent-alpha",
|
||||
"description": "First binding",
|
||||
"runnable": graph,
|
||||
},
|
||||
{
|
||||
"name": "agent-beta",
|
||||
"description": "Second binding",
|
||||
"runnable": graph,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
task_tool = middleware.tools[0]
|
||||
alpha = task_tool.func(
|
||||
description="Do alpha work.",
|
||||
subagent_type="agent-alpha",
|
||||
runtime=self._task_runtime(task_tool, "call_alpha"),
|
||||
)
|
||||
beta = task_tool.func(
|
||||
description="Do beta work.",
|
||||
subagent_type="agent-beta",
|
||||
runtime=self._task_runtime(task_tool, "call_beta"),
|
||||
)
|
||||
|
||||
assert alpha.update["messages"][0].content == "agent-alpha"
|
||||
assert beta.update["messages"][0].content == "agent-beta"
|
||||
assert graph.config is None
|
||||
|
||||
def test_middleware_delegates_to_create_sub_agent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Middleware should use the shared entrypoint for declarative subagents."""
|
||||
graph = self._make_echo_graph()
|
||||
calls: list[tuple[object, type | None]] = []
|
||||
|
||||
class CustomState(MessagesState):
|
||||
pass
|
||||
|
||||
def fake_create_sub_agent(
|
||||
spec: object,
|
||||
*,
|
||||
state_schema: type | None = None,
|
||||
response_format: object = None,
|
||||
) -> object:
|
||||
del response_format
|
||||
calls.append((spec, state_schema))
|
||||
return graph
|
||||
|
||||
monkeypatch.setattr("deepagents.middleware.subagents.create_sub_agent", fake_create_sub_agent)
|
||||
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "agent-alpha",
|
||||
"description": "First binding",
|
||||
"system_prompt": "Work on the task.",
|
||||
"model": "test-model",
|
||||
"tools": [],
|
||||
},
|
||||
],
|
||||
state_schema=CustomState,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] is CustomState
|
||||
|
||||
def test_multiple_subagents_with_interrupt_on(self) -> None:
|
||||
"""Test creating agent with multiple subagents that have interrupt_on configured."""
|
||||
agent = create_agent(
|
||||
model="claude-sonnet-4-6",
|
||||
system_prompt="Use the task tool to call subagents.",
|
||||
middleware=[
|
||||
SubAgentMiddleware(
|
||||
subagents=[
|
||||
{
|
||||
"name": "subagent1",
|
||||
"description": "First subagent.",
|
||||
"system_prompt": "You are subagent 1.",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tools": [get_weather],
|
||||
},
|
||||
{
|
||||
"name": "subagent2",
|
||||
"description": "Second subagent.",
|
||||
"system_prompt": "You are subagent 2.",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tools": [get_weather],
|
||||
},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
# This would error if the middleware was accumulated incorrectly
|
||||
assert agent is not None
|
||||
+2284
File diff suppressed because it is too large.
Load diff
Generated
+2
-2
@@ -2123,7 +2123,7 @@ typing = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-anthropic"
|
||||
version = "1.4.8"
|
||||
version = "1.5.0"
|
||||
source = { editable = "../partners/anthropic" }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
@@ -2465,7 +2465,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-openai"
|
||||
version = "1.3.5"
|
||||
version = "1.4.0"
|
||||
source = { editable = "../partners/openai" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in new issue
Block a user