mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
feat(sdk): support configurable subagent response format (#3882)
Adds SDK support for passing per-dispatch subagent response formats through `RunnableConfig.configurable` instead of runtime context. This gives bridge callers a stable way to request schema-backed task-tool dispatch for raw `SubAgent` specs while keeping compiled/runnable-backed subagents fixed at construction time. The task tool now reads a private configurable response-format key, compiles temporary raw `SubAgent` variants with that response format, and rejects dynamic schemas for compiled subagents. The raw-spec compilation path stays centralized in `create_sub_agent`, and tests cover the dynamic response-format path, compiled-subagent rejection, name/config propagation, and custom state-schema compilation.
This commit is contained in:
@@ -6,7 +6,11 @@ from deepagents.middleware.async_subagents import AsyncSubAgent, AsyncSubAgentMi
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware, FilesystemPermission
|
||||
from deepagents.middleware.memory import MemoryMiddleware
|
||||
from deepagents.middleware.rubric import RubricMiddleware
|
||||
from deepagents.middleware.subagents import CompiledSubAgent, SubAgent, SubAgentMiddleware
|
||||
from deepagents.middleware.subagents import (
|
||||
CompiledSubAgent,
|
||||
SubAgent,
|
||||
SubAgentMiddleware,
|
||||
)
|
||||
from deepagents.profiles.harness.harness_profiles import (
|
||||
GeneralPurposeSubagentProfile,
|
||||
HarnessProfile,
|
||||
|
||||
@@ -64,7 +64,11 @@ from deepagents.middleware.rubric import (
|
||||
RubricState,
|
||||
)
|
||||
from deepagents.middleware.skills import SkillsMiddleware
|
||||
from deepagents.middleware.subagents import CompiledSubAgent, SubAgent, SubAgentMiddleware
|
||||
from deepagents.middleware.subagents import (
|
||||
CompiledSubAgent,
|
||||
SubAgent,
|
||||
SubAgentMiddleware,
|
||||
)
|
||||
from deepagents.middleware.summarization import (
|
||||
SummarizationMiddleware,
|
||||
SummarizationToolMiddleware,
|
||||
|
||||
@@ -8,7 +8,13 @@ from typing import Any, NotRequired, TypedDict, cast
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import HumanInTheLoopMiddleware, InterruptOnConfig
|
||||
from langchain.agents.middleware.types import AgentMiddleware, ContextT, ModelRequest, ModelResponse, ResponseT
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
ContextT,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
ResponseT,
|
||||
)
|
||||
from langchain.agents.structured_output import ResponseFormat
|
||||
from langchain.tools import BaseTool, ToolRuntime
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
@@ -23,6 +29,9 @@ from deepagents.backends.protocol import BackendFactory, BackendProtocol
|
||||
from deepagents.middleware._utils import append_to_system_message
|
||||
from deepagents.middleware.filesystem import FilesystemPermission
|
||||
|
||||
SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY = "__deepagents_subagent_response_format"
|
||||
"""Configurable key used by task-tool callers to request dynamic response format."""
|
||||
|
||||
|
||||
class SubAgent(TypedDict):
|
||||
"""Specification for an agent.
|
||||
@@ -451,18 +460,20 @@ def create_sub_agent(
|
||||
spec: SubAgent,
|
||||
*,
|
||||
state_schema: type | None = None,
|
||||
response_format: ResponseFormat[Any] | type | dict[str, Any] | None = None,
|
||||
) -> Runnable:
|
||||
"""Create a runnable agent from a declarative `SubAgent` spec.
|
||||
"""Create a runnable agent from a raw `SubAgent` spec.
|
||||
|
||||
This is the shared entrypoint for the `create_agent` path used by
|
||||
declarative subagent specs. Pre-compiled `CompiledSubAgent` runnables are
|
||||
already created by the caller and are handled separately by
|
||||
`SubAgentMiddleware`.
|
||||
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 `model` and `tools`.
|
||||
state_schema: Base graph state schema forwarded to `create_agent` for
|
||||
the subagent.
|
||||
response_format: Optional response format override for this compiled
|
||||
subagent instance.
|
||||
|
||||
Returns:
|
||||
Runnable agent ready for task-tool invocation.
|
||||
@@ -486,12 +497,13 @@ def create_sub_agent(
|
||||
if interrupt_on:
|
||||
middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on))
|
||||
|
||||
selected_response_format = response_format if response_format is not None else spec.get("response_format")
|
||||
create_agent_kwargs: dict[str, Any] = {
|
||||
"system_prompt": spec["system_prompt"],
|
||||
"tools": spec["tools"],
|
||||
"middleware": middleware,
|
||||
"name": spec["name"],
|
||||
"response_format": spec.get("response_format"),
|
||||
"response_format": selected_response_format,
|
||||
}
|
||||
if state_schema is not None:
|
||||
create_agent_kwargs["state_schema"] = state_schema
|
||||
@@ -499,28 +511,83 @@ def create_sub_agent(
|
||||
return create_agent(model, **create_agent_kwargs)
|
||||
|
||||
|
||||
def _get_subagent_response_format(
|
||||
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
|
||||
|
||||
|
||||
def _build_task_tool( # noqa: C901, PLR0915
|
||||
subagents: list[CompiledSubAgent],
|
||||
subagents: Sequence[SubAgent | CompiledSubAgent],
|
||||
task_description: str | None = None,
|
||||
*,
|
||||
private_state_keys: frozenset[str] = frozenset(),
|
||||
state_schema: type | None = None,
|
||||
) -> BaseTool:
|
||||
"""Create a task tool from pre-built subagent graphs.
|
||||
"""Create a task tool from subagent specs.
|
||||
|
||||
Args:
|
||||
subagents: List of subagent specs containing name, description, and runnable.
|
||||
subagents: List of raw or compiled subagent specs.
|
||||
task_description: Custom description for the task tool. If `None`,
|
||||
uses default template. Supports `{available_agents}` placeholder.
|
||||
private_state_keys: State keys marked with `PrivateStateAttr` that
|
||||
should be stripped from parent state before invoking subagents.
|
||||
state_schema: Base graph state schema forwarded to raw subagent specs.
|
||||
|
||||
Returns:
|
||||
A StructuredTool that can invoke subagents by type.
|
||||
"""
|
||||
# Build the graphs dict and descriptions from the unified spec list
|
||||
subagent_graphs: dict[str, Runnable] = {spec["name"]: spec["runnable"] for spec in subagents}
|
||||
|
||||
subagent_description_str = "\n".join(f"- {s['name']}: {s['description']}" for s in subagents)
|
||||
def _compile_spec(
|
||||
spec: SubAgent | CompiledSubAgent,
|
||||
*,
|
||||
response_format: ResponseFormat[Any] | type | dict[str, Any] | None = None,
|
||||
) -> CompiledSubAgent:
|
||||
"""Compile one raw spec or configure one provided runnable."""
|
||||
if "runnable" in spec:
|
||||
if response_format is not None:
|
||||
msg = f'response_schema cannot be used with compiled subagent "{spec["name"]}"; dynamic schemas require a raw SubAgent spec.'
|
||||
raise ValueError(msg)
|
||||
|
||||
# Use with_config (not attribute mutation) so the original runnable is
|
||||
# untouched and a shared instance can be registered under multiple names.
|
||||
compiled = cast("CompiledSubAgent", spec)
|
||||
runnable = compiled["runnable"].with_config(
|
||||
{
|
||||
"metadata": {"lc_agent_name": spec["name"]},
|
||||
"run_name": spec["name"],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"name": spec["name"],
|
||||
"description": spec["description"],
|
||||
"runnable": runnable,
|
||||
}
|
||||
return {
|
||||
"name": spec["name"],
|
||||
"description": spec["description"],
|
||||
"runnable": create_sub_agent(
|
||||
spec,
|
||||
state_schema=state_schema,
|
||||
response_format=response_format,
|
||||
),
|
||||
}
|
||||
|
||||
compiled_subagents = [_compile_spec(spec) for spec in subagents]
|
||||
subagents_by_name = {spec["name"]: spec for spec in subagents}
|
||||
|
||||
# Build the graphs dict and descriptions from the unified spec list
|
||||
subagent_graphs: dict[str, Runnable] = {spec["name"]: spec["runnable"] for spec in compiled_subagents}
|
||||
|
||||
subagent_description_str = "\n".join(f"- {s['name']}: {s['description']}" for s in compiled_subagents)
|
||||
|
||||
# Use custom description if provided, otherwise use default template
|
||||
if task_description is None:
|
||||
@@ -570,9 +637,28 @@ def _build_task_tool( # noqa: C901, PLR0915
|
||||
}
|
||||
)
|
||||
|
||||
def _validate_and_prepare_state(subagent_type: str, description: str, runtime: ToolRuntime) -> tuple[Runnable, dict]:
|
||||
def _select_subagent(
|
||||
subagent_type: str,
|
||||
runtime: ToolRuntime,
|
||||
) -> Runnable:
|
||||
"""Return the runnable to use for this task invocation."""
|
||||
response_format = _get_subagent_response_format(runtime)
|
||||
if response_format is not None:
|
||||
new_spec = _compile_spec(
|
||||
subagents_by_name[subagent_type],
|
||||
response_format=response_format,
|
||||
)
|
||||
return new_spec["runnable"]
|
||||
|
||||
return subagent_graphs[subagent_type]
|
||||
|
||||
def _validate_and_prepare_state(
|
||||
subagent_type: str,
|
||||
description: str,
|
||||
runtime: ToolRuntime,
|
||||
) -> tuple[Runnable, dict]:
|
||||
"""Prepare state for invocation."""
|
||||
subagent = subagent_graphs[subagent_type]
|
||||
subagent = _select_subagent(subagent_type, runtime)
|
||||
# Create a new state dict to avoid mutating the original
|
||||
subagent_state = {k: v for k, v in runtime.state.items() if k not in _EXCLUDED_STATE_KEYS}
|
||||
subagent_state = {k: v for k, v in subagent_state.items() if k not in private_state_keys}
|
||||
@@ -590,7 +676,11 @@ def _build_task_tool( # noqa: C901, PLR0915
|
||||
if not runtime.tool_call_id:
|
||||
value_error_msg = "Tool call ID is required for subagent invocation"
|
||||
raise ValueError(value_error_msg)
|
||||
subagent, subagent_state = _validate_and_prepare_state(subagent_type, description, runtime)
|
||||
subagent, subagent_state = _validate_and_prepare_state(
|
||||
subagent_type,
|
||||
description,
|
||||
runtime,
|
||||
)
|
||||
# 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
|
||||
@@ -614,7 +704,11 @@ def _build_task_tool( # noqa: C901, PLR0915
|
||||
if not runtime.tool_call_id:
|
||||
value_error_msg = "Tool call ID is required for subagent invocation"
|
||||
raise ValueError(value_error_msg)
|
||||
subagent, subagent_state = _validate_and_prepare_state(subagent_type, description, runtime)
|
||||
subagent, subagent_state = _validate_and_prepare_state(
|
||||
subagent_type,
|
||||
description,
|
||||
runtime,
|
||||
)
|
||||
# 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
|
||||
@@ -662,8 +756,8 @@ class SubAgentMiddleware(AgentMiddleware[Any, ContextT, ResponseT]):
|
||||
system_prompt: Instructions appended to main agent's system prompt
|
||||
about how to use the task tool.
|
||||
task_description: Custom description for the task tool.
|
||||
state_schema: Base graph state schema forwarded to declarative
|
||||
`SubAgent` specs when their runnables are compiled.
|
||||
state_schema: Base graph state schema forwarded to raw `SubAgent`
|
||||
specs when their runnables are compiled.
|
||||
|
||||
Leave unset to use `create_agent`'s default. `CompiledSubAgent`
|
||||
entries are unaffected — callers own those runnables' schemas.
|
||||
@@ -715,21 +809,20 @@ class SubAgentMiddleware(AgentMiddleware[Any, ContextT, ResponseT]):
|
||||
self._private_state_keys = private_state_keys or frozenset()
|
||||
self._task_description = task_description
|
||||
self._state_schema = state_schema
|
||||
subagent_specs = self._get_subagents()
|
||||
self._subagent_specs = subagent_specs
|
||||
self.subagent_names: frozenset[str] = frozenset(spec["name"] for spec in subagent_specs)
|
||||
self.subagent_names: frozenset[str] = frozenset(spec["name"] for spec in subagents)
|
||||
"""Declared subagent names. Public so streamers can discover them
|
||||
without introspecting the `task` tool's closure."""
|
||||
|
||||
task_tool = _build_task_tool(
|
||||
subagent_specs,
|
||||
self._subagents,
|
||||
task_description,
|
||||
private_state_keys=self._private_state_keys,
|
||||
state_schema=self._state_schema,
|
||||
)
|
||||
|
||||
# Build system prompt with available agents
|
||||
if system_prompt and subagent_specs:
|
||||
agents_desc = "\n".join(f"- {s['name']}: {s['description']}" for s in subagent_specs)
|
||||
if system_prompt and subagents:
|
||||
agents_desc = "\n".join(f"- {s['name']}: {s['description']}" for s in subagents)
|
||||
self.system_prompt = system_prompt + "\n\nAvailable subagent types:\n\n" + agents_desc
|
||||
else:
|
||||
self.system_prompt = system_prompt
|
||||
@@ -745,44 +838,13 @@ class SubAgentMiddleware(AgentMiddleware[Any, ContextT, ResponseT]):
|
||||
def private_state_keys(self, value: frozenset[str]) -> None:
|
||||
self._private_state_keys = value
|
||||
task_tool = _build_task_tool(
|
||||
self._subagent_specs,
|
||||
self._subagents,
|
||||
task_description=self._task_description,
|
||||
private_state_keys=value,
|
||||
state_schema=self._state_schema,
|
||||
)
|
||||
self.tools = [task_tool]
|
||||
|
||||
def _get_subagents(self) -> list[CompiledSubAgent]:
|
||||
"""Create runnable agents from specs.
|
||||
|
||||
Returns:
|
||||
List of subagent specs with name, description, and runnable.
|
||||
"""
|
||||
specs: list[CompiledSubAgent] = []
|
||||
|
||||
for spec in self._subagents:
|
||||
if "runnable" in spec:
|
||||
# Use with_config (not attribute mutation) so the original runnable is
|
||||
# untouched and a shared instance can be registered under multiple names.
|
||||
compiled = cast("CompiledSubAgent", spec)
|
||||
runnable = compiled["runnable"].with_config(
|
||||
{
|
||||
"metadata": {"lc_agent_name": compiled["name"]},
|
||||
"run_name": compiled["name"],
|
||||
}
|
||||
)
|
||||
specs.append({"name": compiled["name"], "description": compiled["description"], "runnable": runnable})
|
||||
continue
|
||||
|
||||
specs.append(
|
||||
{
|
||||
"name": spec["name"],
|
||||
"description": spec["description"],
|
||||
"runnable": create_sub_agent(spec, state_schema=self._state_schema),
|
||||
}
|
||||
)
|
||||
|
||||
return specs
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest[ContextT],
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
"""Unit tests for SubAgentMiddleware initialization and configuration."""
|
||||
|
||||
from typing import get_type_hints
|
||||
import json
|
||||
from typing import Any, get_type_hints
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.messages import AIMessage
|
||||
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 deepagents.backends.state import StateBackend
|
||||
from deepagents.middleware.subagents import (
|
||||
GENERAL_PURPOSE_SUBAGENT,
|
||||
SUBAGENT_RESPONSE_FORMAT_CONFIG_KEY,
|
||||
TASK_SYSTEM_PROMPT,
|
||||
SubAgentMiddleware,
|
||||
_build_task_tool,
|
||||
create_sub_agent,
|
||||
)
|
||||
from tests.unit_tests.chat_model import GenericFakeChatModel
|
||||
|
||||
|
||||
@tool
|
||||
@@ -23,6 +32,47 @@ def get_weather(city: str) -> str:
|
||||
return f"The weather in {city} is sunny."
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -57,50 +107,128 @@ class TestSubagentMiddlewareInit:
|
||||
assert "state_schema" in create_hints
|
||||
assert "return" in create_hints
|
||||
|
||||
def test_create_sub_agent_compiles_declarative_spec(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The public helper should own the declarative create_agent path."""
|
||||
graph = self._make_echo_graph()
|
||||
calls: dict[str, object] = {}
|
||||
def test_create_sub_agent_compiles_declarative_spec(self) -> None:
|
||||
"""The public helper should compile and invoke declarative specs."""
|
||||
|
||||
class CustomState(MessagesState):
|
||||
pass
|
||||
|
||||
def fake_resolve_model(model: object) -> object:
|
||||
calls["model_spec"] = model
|
||||
return "resolved-model"
|
||||
|
||||
def fake_create_agent(model: object, **kwargs: object) -> object:
|
||||
calls["model"] = model
|
||||
calls["kwargs"] = kwargs
|
||||
return graph
|
||||
|
||||
monkeypatch.setattr("deepagents._models.resolve_model", fake_resolve_model)
|
||||
monkeypatch.setattr("deepagents.middleware.subagents.create_agent", fake_create_agent)
|
||||
|
||||
model = GenericFakeChatModel(messages=iter([AIMessage(content="done")]))
|
||||
runnable = create_sub_agent(
|
||||
{
|
||||
"name": "worker",
|
||||
"description": "Does work.",
|
||||
"system_prompt": "Work on the task.",
|
||||
"model": "test-model",
|
||||
"model": model,
|
||||
"tools": [get_weather],
|
||||
"interrupt_on": {"get_weather": True},
|
||||
},
|
||||
state_schema=CustomState,
|
||||
)
|
||||
|
||||
assert runnable is graph
|
||||
assert calls["model_spec"] == "test-model"
|
||||
assert calls["model"] == "resolved-model"
|
||||
kwargs = calls["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
assert kwargs["system_prompt"] == "Work on the task."
|
||||
assert kwargs["tools"] == [get_weather]
|
||||
assert kwargs["name"] == "worker"
|
||||
assert kwargs["state_schema"] is CustomState
|
||||
middleware = kwargs["middleware"]
|
||||
assert isinstance(middleware, list)
|
||||
assert middleware[-1].__class__.__name__ == "HumanInTheLoopMiddleware"
|
||||
assert "HumanInTheLoopMiddleware.after_model" in runnable.nodes
|
||||
|
||||
result = runnable.invoke({"messages": [HumanMessage(content="Do work.")]})
|
||||
|
||||
assert result["messages"][-1].content == "done"
|
||||
assert result["messages"][-1].name == "worker"
|
||||
assert [getattr(tool, "name", None) for tool in model.tools] == ["get_weather"]
|
||||
|
||||
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(
|
||||
backend=StateBackend(),
|
||||
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."""
|
||||
@@ -191,33 +319,58 @@ class TestSubagentMiddlewareInit:
|
||||
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."""
|
||||
graph = self._make_echo_graph()
|
||||
configs: list[dict[str, object]] = []
|
||||
|
||||
middleware = SubAgentMiddleware(
|
||||
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(
|
||||
backend=StateBackend(),
|
||||
subagents=[
|
||||
{
|
||||
"name": "my-subagent",
|
||||
"description": "A custom subagent",
|
||||
"runnable": graph,
|
||||
"runnable": _Runnable(),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
specs = middleware._get_subagents()
|
||||
runnable = specs[0]["runnable"]
|
||||
assert runnable.config is not None
|
||||
assert runnable.config.get("metadata", {}).get("lc_agent_name") == "my-subagent"
|
||||
assert runnable.config.get("run_name") == "my-subagent"
|
||||
assert configs == [
|
||||
{
|
||||
"metadata": {"lc_agent_name": "my-subagent"},
|
||||
"run_name": "my-subagent",
|
||||
}
|
||||
]
|
||||
|
||||
def test_compiled_subagent_does_not_mutate_original_runnable(self) -> None:
|
||||
"""_get_subagents must not mutate the original runnable passed by the caller."""
|
||||
"""Task-tool setup must not mutate the original runnable."""
|
||||
graph = self._make_echo_graph()
|
||||
original_config = getattr(graph, "config", None)
|
||||
|
||||
middleware = SubAgentMiddleware(
|
||||
SubAgentMiddleware(
|
||||
backend=StateBackend(),
|
||||
subagents=[
|
||||
{
|
||||
@@ -228,13 +381,30 @@ class TestSubagentMiddlewareInit:
|
||||
],
|
||||
)
|
||||
|
||||
middleware._get_subagents()
|
||||
|
||||
assert graph.config == original_config, "Original runnable was mutated by _get_subagents(); use with_config instead of attribute assignment"
|
||||
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."""
|
||||
graph = self._make_echo_graph()
|
||||
|
||||
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(
|
||||
backend=StateBackend(),
|
||||
@@ -252,17 +422,20 @@ class TestSubagentMiddlewareInit:
|
||||
],
|
||||
)
|
||||
|
||||
specs = middleware._get_subagents()
|
||||
assert len(specs) == 2
|
||||
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"),
|
||||
)
|
||||
|
||||
alpha_runnable = specs[0]["runnable"]
|
||||
beta_runnable = specs[1]["runnable"]
|
||||
|
||||
assert alpha_runnable.config.get("metadata", {}).get("lc_agent_name") == "agent-alpha"
|
||||
assert beta_runnable.config.get("metadata", {}).get("lc_agent_name") == "agent-beta"
|
||||
assert alpha_runnable.config.get("run_name") == "agent-alpha"
|
||||
assert beta_runnable.config.get("run_name") == "agent-beta"
|
||||
assert alpha_runnable is not beta_runnable
|
||||
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:
|
||||
@@ -273,7 +446,13 @@ class TestSubagentMiddlewareInit:
|
||||
class CustomState(MessagesState):
|
||||
pass
|
||||
|
||||
def fake_create_sub_agent(spec: object, *, state_schema: type | None = None) -> object:
|
||||
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
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from langchain_core.tools import BaseTool, StructuredTool
|
||||
from deepagents._api.deprecation import LangChainDeprecationWarning
|
||||
from deepagents._tools import _apply_tool_description_overrides, _tool_name
|
||||
from deepagents._version import __version__
|
||||
from deepagents.backends import StateBackend
|
||||
from deepagents.graph import (
|
||||
_REQUIRED_MIDDLEWARE_CLASSES,
|
||||
_REQUIRED_MIDDLEWARE_NAMES,
|
||||
@@ -28,7 +27,7 @@ from deepagents.graph import (
|
||||
from deepagents.middleware._tool_exclusion import _ToolExclusionMiddleware
|
||||
from deepagents.middleware.async_subagents import AsyncSubAgentMiddleware
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from deepagents.middleware.subagents import SubAgentMiddleware
|
||||
from deepagents.middleware.subagents import SubAgentMiddleware, create_sub_agent
|
||||
from deepagents.middleware.summarization import _DeepAgentsSummarizationMiddleware
|
||||
from deepagents.profiles import GeneralPurposeSubagentProfile, HarnessProfile, register_harness_profile
|
||||
from deepagents.profiles.harness.harness_profiles import (
|
||||
@@ -768,21 +767,17 @@ class TestStateSchema:
|
||||
class MyState(DeepAgentState):
|
||||
page_url: str
|
||||
|
||||
middleware = SubAgentMiddleware(
|
||||
backend=StateBackend(),
|
||||
subagents=[
|
||||
{
|
||||
"name": "researcher",
|
||||
"description": "Research agent",
|
||||
"system_prompt": "You are a researcher.",
|
||||
"model": GenericFakeChatModel(messages=iter([AIMessage(content="ok")])),
|
||||
"tools": [],
|
||||
}
|
||||
],
|
||||
runnable = create_sub_agent(
|
||||
{
|
||||
"name": "researcher",
|
||||
"description": "Research agent",
|
||||
"system_prompt": "You are a researcher.",
|
||||
"model": GenericFakeChatModel(messages=iter([AIMessage(content="ok")])),
|
||||
"tools": [],
|
||||
},
|
||||
state_schema=MyState,
|
||||
)
|
||||
|
||||
runnable = middleware._get_subagents()[0]["runnable"]
|
||||
properties = runnable.get_input_jsonschema()["properties"]
|
||||
assert "page_url" in properties
|
||||
|
||||
|
||||
Reference in New Issue
Block a user