From 14d6709535535d79f01ba28075565fbe1b8df380 Mon Sep 17 00:00:00 2001 From: Maahir Sachdev Date: Thu, 2 Apr 2026 17:31:10 -0700 Subject: [PATCH 1/4] port over from js --- .../deepagents/middleware/subagents.py | 143 +++++++++++++++++- .../test_subagent_middleware.py | 123 ++++++++++++++- .../tests/unit_tests/test_subagents.py | 141 +++++++++++++++-- 3 files changed, 394 insertions(+), 13 deletions(-) diff --git a/libs/deepagents/deepagents/middleware/subagents.py b/libs/deepagents/deepagents/middleware/subagents.py index 541c9466ed..217f50be22 100644 --- a/libs/deepagents/deepagents/middleware/subagents.py +++ b/libs/deepagents/deepagents/middleware/subagents.py @@ -1,11 +1,15 @@ """Middleware for providing subagents to an agent via a `task` tool.""" +import dataclasses +import json +import warnings from collections.abc import Awaitable, Callable, Sequence 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.structured_output import ResponseFormat from langchain.tools import BaseTool, ToolRuntime from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage, ToolMessage @@ -76,6 +80,34 @@ class SubAgent(TypedDict): skills: NotRequired[list[str]] """Skill source paths for SkillsMiddleware.""" + 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. + + Accepts any format supported by `create_agent`. + + 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-4o", + "tools": [], + "response_format": Findings, + } + ``` + """ + class CompiledSubAgent(TypedDict): """A pre-compiled agent spec. @@ -295,6 +327,100 @@ class _SubagentSpec(TypedDict): runnable: Runnable +def _get_subagents_legacy( + *, + default_model: str | BaseChatModel, + default_tools: Sequence[BaseTool | Callable | dict[str, Any]], + default_middleware: list[AgentMiddleware] | None, + default_interrupt_on: dict[str, bool | InterruptOnConfig] | None, + subagents: Sequence[SubAgent | CompiledSubAgent], + general_purpose_agent: bool, +) -> list[_SubagentSpec]: + """Create subagent instances from specifications. + + Args: + default_model: Default model for subagents that don't specify one. + default_tools: Default tools for subagents that don't specify tools. + default_middleware: Middleware to apply to all subagents. If `None`, + no default middleware is applied. + default_interrupt_on: The tool configs to use for the default general-purpose subagent. These + are also the fallback for any subagents that don't specify their own tool configs. + subagents: List of agent specifications or pre-compiled agents. + general_purpose_agent: Whether to include a general-purpose subagent. + + Returns: + List of subagent specs containing name, description, and runnable. + """ + # Use empty list if None (no default middleware) + default_subagent_middleware = default_middleware or [] + + specs: list[_SubagentSpec] = [] + + # Create general-purpose agent if enabled + if general_purpose_agent: + general_purpose_middleware = [*default_subagent_middleware] + if default_interrupt_on: + general_purpose_middleware.append(HumanInTheLoopMiddleware(interrupt_on=default_interrupt_on)) + general_purpose_subagent = create_agent( + default_model, + system_prompt=DEFAULT_SUBAGENT_PROMPT, + tools=default_tools, + middleware=general_purpose_middleware, + name="general-purpose", + ) + specs.append( + { + "name": "general-purpose", + "description": DEFAULT_GENERAL_PURPOSE_DESCRIPTION, + "runnable": general_purpose_subagent, + } + ) + + # Process custom subagents + for agent_ in subagents: + if "runnable" in agent_: + custom_agent = cast("CompiledSubAgent", agent_) + specs.append( + { + "name": custom_agent["name"], + "description": custom_agent["description"], + "runnable": custom_agent["runnable"], + } + ) + continue + _tools = agent_.get("tools", list(default_tools)) + + subagent_model = agent_.get("model", default_model) + + _middleware = [*default_subagent_middleware, *agent_["middleware"]] if "middleware" in agent_ else [*default_subagent_middleware] + + interrupt_on = agent_.get("interrupt_on", default_interrupt_on) + if interrupt_on: + _middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) + + create_agent_kwargs: dict[str, Any] = {} + if "response_format" in agent_: + create_agent_kwargs["response_format"] = agent_["response_format"] + + specs.append( + { + "name": agent_["name"], + "description": agent_["description"], + "runnable": create_agent( + subagent_model, + system_prompt=agent_["system_prompt"], + tools=_tools, + middleware=_middleware, + name=agent_["name"], + **create_agent_kwargs, + ), + } + ) + + return specs + + + def _build_task_tool( # noqa: C901 subagents: list[_SubagentSpec], task_description: str | None = None, @@ -332,12 +458,18 @@ def _return_command_with_state_update(result: dict, tool_call_id: str) -> Comman raise ValueError(error_msg) state_update = {k: v for k, v in result.items() if k not in _EXCLUDED_STATE_KEYS} - # Strip trailing whitespace to prevent API errors with Anthropic - message_text = result["messages"][-1].text.rstrip() if result["messages"][-1].text else "" + + structured = result.get("structured_response") + if structured is not None: + content: str = structured.model_dump_json() if hasattr(structured, "model_dump_json") else json.dumps(structured) + else: + # Strip trailing whitespace to prevent API errors with Anthropic + content = result["messages"][-1].text.rstrip() if result["messages"][-1].text else "" + return Command( update={ **state_update, - "messages": [ToolMessage(message_text, tool_call_id=tool_call_id)], + "messages": [ToolMessage(content, tool_call_id=tool_call_id)], } ) @@ -501,6 +633,10 @@ def _get_subagents(self) -> list[_SubagentSpec]: if interrupt_on: middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on)) + create_agent_kwargs: dict[str, Any] = {} + if "response_format" in spec: + create_agent_kwargs["response_format"] = spec["response_format"] + specs.append( { "name": spec["name"], @@ -511,6 +647,7 @@ def _get_subagents(self) -> list[_SubagentSpec]: tools=spec["tools"], middleware=middleware, name=spec["name"], + **create_agent_kwargs, ), } ) diff --git a/libs/deepagents/tests/integration_tests/test_subagent_middleware.py b/libs/deepagents/tests/integration_tests/test_subagent_middleware.py index c069916de0..af8440f66d 100644 --- a/libs/deepagents/tests/integration_tests/test_subagent_middleware.py +++ b/libs/deepagents/tests/integration_tests/test_subagent_middleware.py @@ -1,9 +1,12 @@ +import json from typing import ClassVar import pytest from langchain.agents.middleware import AgentMiddleware -from langchain_core.messages import HumanMessage +from langchain.agents.structured_output import ToolStrategy +from langchain_core.messages import AIMessage, HumanMessage from langchain_core.tools import tool +from pydantic import BaseModel, Field from deepagents.backends.state import StateBackend from deepagents.graph import create_agent @@ -201,3 +204,121 @@ def test_defined_subagent_custom_runnable(self): agent, {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, ) + + def test_deprecated_api_subagents_inherit_model(self): + """Test that subagents inherit default_model when not specified.""" + with pytest.warns(DeprecationWarning, match="default_model"): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_model="gpt-4.1", # Custom subagent should inherit this + default_tools=[get_weather], + subagents=[ + { + "name": "custom", + "description": "Custom subagent that gets weather.", + "system_prompt": "Use the get_weather tool.", + # No model specified - should inherit from default_model + } + ], + ) + ], + ) + # Verify the custom subagent uses the inherited model + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "custom"}, "model": "claude-sonnet-4-20250514"}, + {"name": "get_weather", "args": {}, "model": "gpt-4.1-2025-04-14"}, # Inherited model + ] + assert_expected_subgraph_actions( + expected_tool_calls, + agent, + {"messages": [HumanMessage(content="What is the weather in Tokyo?")]}, + ) + + def test_deprecated_api_subagents_inherit_tools(self): + """Test that subagents inherit default_tools when not specified.""" + with pytest.warns(DeprecationWarning, match="default_model"): + agent = create_agent( + model="claude-sonnet-4-20250514", + system_prompt="Use the task tool to call a subagent.", + middleware=[ + SubAgentMiddleware( + default_model="claude-sonnet-4-20250514", + default_tools=[get_weather], # Custom subagent should inherit this + subagents=[ + { + "name": "custom", + "description": "Custom subagent that gets weather.", + "system_prompt": "Use the get_weather tool to get weather.", + # No tools specified - should inherit from default_tools + } + ], + ) + ], + ) + # Verify the custom subagent can use the inherited tools + expected_tool_calls = [ + {"name": "task", "args": {"subagent_type": "custom"}}, + {"name": "get_weather", "args": {}}, # Inherited tool + ] + 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( + backend=StateBackend(), + 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) diff --git a/libs/deepagents/tests/unit_tests/test_subagents.py b/libs/deepagents/tests/unit_tests/test_subagents.py index f46f8f505c..78817b25e4 100644 --- a/libs/deepagents/tests/unit_tests/test_subagents.py +++ b/libs/deepagents/tests/unit_tests/test_subagents.py @@ -5,6 +5,7 @@ and child agents. """ +import json import uuid from pathlib import Path from typing import Any, TypedDict @@ -1040,21 +1041,143 @@ class CityPopulation(BaseModel): "Parent agent state should not contain structured_response key (it should be excluded per _EXCLUDED_STATE_KEYS)" ) - # Verify the exact content of the ToolMessages - # When a subagent uses ToolStrategy for structured output, the default tool message - # content shows the structured response using the Pydantic model's string representation + # When a subagent produces a structured_response, the ToolMessage content is + # the JSON-serialized structured data (not the last message text). weather_tool_message = tool_messages_by_id["call_weather"] - expected_weather_content = "Returning structured response: city='Tokyo' temperature_celsius=22.5 humidity_percent=65" - assert weather_tool_message.content == expected_weather_content, ( - f"Expected weather ToolMessage content:\n{expected_weather_content}\nGot:\n{weather_tool_message.content}" + weather_parsed = json.loads(weather_tool_message.content) + assert weather_parsed == {"city": "Tokyo", "temperature_celsius": 22.5, "humidity_percent": 65}, ( + f"Expected JSON-serialized weather data, got: {weather_tool_message.content}" ) population_tool_message = tool_messages_by_id["call_population"] - expected_population_content = "Returning structured response: city='Tokyo' population=14000000 metro_area_population=37400000" - assert population_tool_message.content == expected_population_content, ( - f"Expected population ToolMessage content:\n{expected_population_content}\nGot:\n{population_tool_message.content}" + population_parsed = json.loads(population_tool_message.content) + assert population_parsed == {"city": "Tokyo", "population": 14000000, "metro_area_population": 37400000}, ( + f"Expected JSON-serialized population data, got: {population_tool_message.content}" ) + def test_structured_response_serialized_as_tool_message(self) -> None: + """Test that structured_response is JSON-serialized as ToolMessage content. + + When a subagent produces a `structured_response`, the middleware should + JSON-serialize it as the ToolMessage content instead of extracting the + last message text. + """ + structured_data = { + "findings": "Renewable energy adoption is accelerating", + "confidence": 0.92, + "sources": 3, + } + + mock_subagent = RunnableLambda( + lambda _: { + "messages": [AIMessage(content="Here are my findings about renewable energy.")], + "structured_response": structured_data, + } + ) + + parent_chat_model = GenericFakeChatModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": { + "description": "Analyze renewable energy trends", + "subagent_type": "analyzer", + }, + "id": "call_structured", + "type": "tool_call", + } + ], + ), + AIMessage(content="Done"), + ] + ) + ) + + agent = create_deep_agent( + model=parent_chat_model, + checkpointer=InMemorySaver(), + subagents=[ + CompiledSubAgent( + name="analyzer", + description="An analysis agent", + runnable=mock_subagent, + ), + ], + ) + + result = agent.invoke( + {"messages": [HumanMessage(content="Analyze renewable energy")]}, + config={"configurable": {"thread_id": f"test-structured-{uuid.uuid4().hex}"}}, + ) + + tool_messages = [msg for msg in result["messages"] if msg.type == "tool"] + assert len(tool_messages) == 1 + task_tool_message = tool_messages[0] + assert task_tool_message.content == json.dumps(structured_data) + + parsed = json.loads(task_tool_message.content) + assert parsed == structured_data + + def test_fallback_to_last_message_without_structured_response(self) -> None: + """Test fallback to last message when no structured_response is present. + + When a subagent does not produce a `structured_response`, the middleware + should fall back to extracting the last message text. + """ + mock_subagent = RunnableLambda( + lambda _: { + "messages": [AIMessage(content="Plain text result without structured response")], + } + ) + + parent_chat_model = GenericFakeChatModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": { + "description": "Do work", + "subagent_type": "worker", + }, + "id": "call_plain", + "type": "tool_call", + } + ], + ), + AIMessage(content="Done"), + ] + ) + ) + + agent = create_deep_agent( + model=parent_chat_model, + checkpointer=InMemorySaver(), + subagents=[ + CompiledSubAgent( + name="worker", + description="A worker agent", + runnable=mock_subagent, + ), + ], + ) + + result = agent.invoke( + {"messages": [HumanMessage(content="Test")]}, + config={"configurable": {"thread_id": f"test-no-structured-{uuid.uuid4().hex}"}}, + ) + + tool_messages = [msg for msg in result["messages"] if msg.type == "tool"] + assert len(tool_messages) == 1 + task_tool_message = tool_messages[0] + assert task_tool_message.content == "Plain text result without structured response" + def test_subagent_streaming_emits_messages_and_updates_from_subgraph(self) -> None: """Test end-to-end subagent streaming with `subgraphs=True`. From 1ed2dd0da9668badba1dff4228c73ae28a5fde70 Mon Sep 17 00:00:00 2001 From: Maahir Sachdev Date: Fri, 3 Apr 2026 11:53:31 -0700 Subject: [PATCH 2/4] dataclass + improved docstring --- .../deepagents/middleware/subagents.py | 17 ++- .../tests/unit_tests/test_subagents.py | 143 ++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/libs/deepagents/deepagents/middleware/subagents.py b/libs/deepagents/deepagents/middleware/subagents.py index 217f50be22..59959f55b3 100644 --- a/libs/deepagents/deepagents/middleware/subagents.py +++ b/libs/deepagents/deepagents/middleware/subagents.py @@ -87,7 +87,15 @@ class SubAgent(TypedDict): given schema. The structured response is JSON-serialized and returned as the ToolMessage content to the parent agent, replacing the default last-message extraction. - Accepts any format supported by `create_agent`. + 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 @@ -461,7 +469,12 @@ def _return_command_with_state_update(result: dict, tool_call_id: str) -> Comman structured = result.get("structured_response") if structured is not None: - content: str = structured.model_dump_json() if hasattr(structured, "model_dump_json") else json.dumps(structured) + if hasattr(structured, "model_dump_json"): + content: str = structured.model_dump_json() + elif dataclasses.is_dataclass(structured) and not isinstance(structured, type): + content = json.dumps(dataclasses.asdict(structured)) + else: + content = json.dumps(structured) else: # Strip trailing whitespace to prevent API errors with Anthropic content = result["messages"][-1].text.rstrip() if result["messages"][-1].text else "" diff --git a/libs/deepagents/tests/unit_tests/test_subagents.py b/libs/deepagents/tests/unit_tests/test_subagents.py index 78817b25e4..b0c2d60041 100644 --- a/libs/deepagents/tests/unit_tests/test_subagents.py +++ b/libs/deepagents/tests/unit_tests/test_subagents.py @@ -5,6 +5,7 @@ and child agents. """ +import dataclasses import json import uuid from pathlib import Path @@ -1122,6 +1123,148 @@ def test_structured_response_serialized_as_tool_message(self) -> None: parsed = json.loads(task_tool_message.content) assert parsed == structured_data + def test_structured_response_dataclass_serialized_as_tool_message(self) -> None: + """Test that a dataclass structured_response is JSON-serialized correctly. + + Dataclass instances don't have `model_dump_json` and aren't natively + JSON-serializable, so the middleware must convert them via + `dataclasses.asdict` before calling `json.dumps`. + """ + + @dataclasses.dataclass + class AnalysisResult: + findings: str + confidence: float + sources: int + + structured_instance = AnalysisResult( + findings="Renewable energy adoption is accelerating", + confidence=0.92, + sources=3, + ) + + mock_subagent = RunnableLambda( + lambda _: { + "messages": [AIMessage(content="Here are my findings.")], + "structured_response": structured_instance, + } + ) + + parent_chat_model = GenericFakeChatModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": { + "description": "Analyze trends", + "subagent_type": "analyzer", + }, + "id": "call_dc", + "type": "tool_call", + } + ], + ), + AIMessage(content="Done"), + ] + ) + ) + + agent = create_deep_agent( + model=parent_chat_model, + checkpointer=InMemorySaver(), + subagents=[ + CompiledSubAgent( + name="analyzer", + description="An analysis agent", + runnable=mock_subagent, + ), + ], + ) + + result = agent.invoke( + {"messages": [HumanMessage(content="Analyze")]}, + config={"configurable": {"thread_id": f"test-dc-structured-{uuid.uuid4().hex}"}}, + ) + + tool_messages = [msg for msg in result["messages"] if msg.type == "tool"] + assert len(tool_messages) == 1 + task_tool_message = tool_messages[0] + + parsed = json.loads(task_tool_message.content) + assert parsed == { + "findings": "Renewable energy adoption is accelerating", + "confidence": 0.92, + "sources": 3, + } + + def test_structured_response_pydantic_serialized_as_tool_message(self) -> None: + """Test that a Pydantic model structured_response uses model_dump_json.""" + + class AnalysisResult(BaseModel): + findings: str + confidence: float + + structured_instance = AnalysisResult( + findings="Solar is growing fast", + confidence=0.95, + ) + + mock_subagent = RunnableLambda( + lambda _: { + "messages": [AIMessage(content="Here are my findings.")], + "structured_response": structured_instance, + } + ) + + parent_chat_model = GenericFakeChatModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": { + "description": "Analyze trends", + "subagent_type": "analyzer", + }, + "id": "call_pydantic", + "type": "tool_call", + } + ], + ), + AIMessage(content="Done"), + ] + ) + ) + + agent = create_deep_agent( + model=parent_chat_model, + checkpointer=InMemorySaver(), + subagents=[ + CompiledSubAgent( + name="analyzer", + description="An analysis agent", + runnable=mock_subagent, + ), + ], + ) + + result = agent.invoke( + {"messages": [HumanMessage(content="Analyze")]}, + config={"configurable": {"thread_id": f"test-pydantic-structured-{uuid.uuid4().hex}"}}, + ) + + tool_messages = [msg for msg in result["messages"] if msg.type == "tool"] + assert len(tool_messages) == 1 + task_tool_message = tool_messages[0] + + parsed = json.loads(task_tool_message.content) + assert parsed == {"findings": "Solar is growing fast", "confidence": 0.95} + def test_fallback_to_last_message_without_structured_response(self) -> None: """Test fallback to last message when no structured_response is present. From d7ab707649551cecdb8ad50b95c25c786cb76624 Mon Sep 17 00:00:00 2001 From: Maahir Sachdev Date: Mon, 6 Apr 2026 12:30:20 -0700 Subject: [PATCH 3/4] update --- libs/deepagents/deepagents/middleware/subagents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/deepagents/deepagents/middleware/subagents.py b/libs/deepagents/deepagents/middleware/subagents.py index 59959f55b3..40d94f128f 100644 --- a/libs/deepagents/deepagents/middleware/subagents.py +++ b/libs/deepagents/deepagents/middleware/subagents.py @@ -2,7 +2,6 @@ import dataclasses import json -import warnings from collections.abc import Awaitable, Callable, Sequence from typing import Any, NotRequired, TypedDict, cast From de8f8f0f1dcc5d27f120bca757fcbbd92c91b683 Mon Sep 17 00:00:00 2001 From: Maahir Sachdev Date: Mon, 6 Apr 2026 12:36:51 -0700 Subject: [PATCH 4/4] fix --- libs/deepagents/deepagents/middleware/subagents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/deepagents/deepagents/middleware/subagents.py b/libs/deepagents/deepagents/middleware/subagents.py index 40d94f128f..1f788ea3f5 100644 --- a/libs/deepagents/deepagents/middleware/subagents.py +++ b/libs/deepagents/deepagents/middleware/subagents.py @@ -427,7 +427,6 @@ def _get_subagents_legacy( return specs - def _build_task_tool( # noqa: C901 subagents: list[_SubagentSpec], task_description: str | None = None,