simbiot-harness/venv/lib/python3.12/site-packages/strands_harness/tools/subagent.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

766 lines
35 KiB
Python

"""Delegation tool with a config-derived model-facing schema (design 0017-subagents).
Each axis — instructions, tools, mcp_servers, model, context — takes an authority mode that decides
whether it becomes a model-facing parameter:
- ``Fixed(value)`` — pinned by the developer; no parameter.
- ``Inherit()`` — take the parent's value; no parameter.
- ``Open(...)`` — model writes freely; string parameter.
- ``Choice(...)`` — model picks from a developer-supplied set of ``Option``s by ``name``; a
single-value enum, or an array-of-enum when ``multiple=True``. The picked name maps back to the
option's ``value``. For ``tools`` the chosen set is re-validated at call time, so a child can
never gain a capability the parent lacked.
Named ``presets`` add an ``agent_type`` enum. Children are built through an injected ``builder``.
"""
from __future__ import annotations
import copy
import logging
import re
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from strands.experimental.context_manager import ContextManager
from strands.models import Model, ModelRouter
from strands.tools.mcp import MCPClient
from strands.tools.tools import AgentTool
from strands.types._events import ToolInterruptEvent, ToolResultEvent, ToolStreamEvent
from strands.types.content import ContentBlock, Message
from strands.types.tools import ToolGenerator, ToolUse
from strands_harness import defaults
from strands_harness.memory import resolve_memory
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from strands import Agent
@dataclass(frozen=True)
class Fixed:
"""The developer pins the value; the axis contributes no model-facing parameter."""
value: Any = None
@dataclass(frozen=True)
class Inherit:
"""The child takes the parent's value; the axis contributes no model-facing parameter."""
@dataclass(frozen=True)
class Open:
"""The model writes the value freely; the axis contributes a string parameter."""
_UNSET: Any = object()
@dataclass(frozen=True)
class Option:
"""One selectable option. ``name`` is what the model picks (the enum entry and the key we map
back on); ``value`` is what that choice resolves to, defaulting to ``name`` so a string-valued
axis needs only a name. ``description`` guides the model."""
name: str
value: Any = _UNSET
description: str = ""
def resolved(self) -> Option:
"""This option with ``value`` filled in from ``name`` when it was left unset."""
return self if self.value is not _UNSET else Option(self.name, self.name, self.description)
@dataclass(frozen=True)
class Choice:
"""The model picks from a developer-supplied set by ``name``; the axis contributes an enum
parameter, or an array-of-enum when ``multiple`` is set (letting the model pick several).
Each entry in ``options`` is a bare name or an ``Option(name, value=name, description="")``. The
picked name maps back to the option's ``value``; descriptions render into the parameter's
``description`` (JSON Schema has no per-enum-value doc), the same way ``Preset`` descriptions
surface under ``agent_type``. For ``tools`` the chosen set is re-validated at call time, so a
delegate can never exceed the parent's tools.
"""
options: Sequence[Any]
multiple: bool = False
def normalized(self) -> list[Option]:
"""The options as ``Option``s with ``value`` resolved, wrapping any bare name."""
return [(o if isinstance(o, Option) else Option(str(o))).resolved() for o in self.options]
def value_for(self, name: str) -> Any:
"""The value the picked ``name`` resolves to; the name itself if it isn't a known option."""
for option in self.normalized():
if option.name == name:
return option.value
return name
def has(self, name: str) -> bool:
"""Whether ``name`` is one of the offered options (used to reject off-enum model input)."""
return any(option.name == name for option in self.normalized())
@dataclass(frozen=True)
class Preset:
"""A named role: a partially applied child configuration selected via ``agent_type``."""
instructions: str | None = None
tools: Sequence[str] | None = None # None inherits the parent's tools
model: Model | ModelRouter | str | None = None # None inherits the parent's model
context: str = "none" # "none" | "all" | "no_tools"
last_messages: int | None = None # cap shared context to the last N messages; None = all
description: str = ""
GENERALIST = Preset(
instructions=(
"You are a general-purpose subagent handling a focused subtask on behalf of a parent "
"agent. You run in your own fresh conversation and cannot ask follow-up questions, so "
"work from the task as given, make reasonable assumptions where it is underspecified, "
"and see it through to a verified result. Return a self-contained answer: state what you "
"did, what you found, and anything the parent needs to act on. Your final message is the "
"only thing that returns to the parent, so put the substance there rather than in "
"intermediate steps."
),
description="a general-purpose agent for a focused subtask that runs in its own context",
)
@dataclass
class AgentSpec:
"""The resolved child configuration handed to the builder: config + the model's arguments."""
task: str
agent_type: str | None = None
instructions: str | None = None
tools: list[str] | None = None # None inherits the parent's tools
mcp_servers: list[str] | None = None # None inherits all the parent's MCP servers
model: Model | ModelRouter | str | None = None # None inherits the parent's model
context: str = "none" # "none" | "all" | "no_tools"
last_messages: int | None = None # cap shared context to the last N messages; None = all
# The builder turns a resolved spec into a child agent, built the way the parent was.
AgentBuilder = Callable[["AgentSpec"], "Agent"]
def _allowed_names(axis: Any) -> list[str] | None:
"""The option names the model may request on a ``Choice`` axis (tools, mcp_servers), or ``None``
when the axis exposes no such parameter."""
if isinstance(axis, Choice):
return [o.name for o in axis.normalized()]
return None
def _description(base: str, presets: Mapping[str, Preset]) -> str:
if not presets:
return base
roles = "\n".join(f"- {name}: {p.description or name}" for name, p in presets.items())
return f"{base}\n\nAvailable subagents (agent_type):\n{roles}"
def _choice_prop(choice: Choice, base: str = "") -> dict[str, Any]:
"""A ``string`` enum prop (an array-of-enum when ``choice.multiple``); per-option descriptions
render into the prop's ``description`` so each value guides the model (JSON Schema has no
per-enum-value doc)."""
options = choice.normalized()
values = [o.name for o in options]
lines = [f"- {o.name}: {o.description}" for o in options if o.description]
desc = base
if lines:
block = "Options:\n" + "\n".join(lines)
desc = f"{base}\n{block}" if base else block
prop: dict[str, Any] = (
{"type": "array", "items": {"type": "string", "enum": values}}
if choice.multiple
else {"type": "string", "enum": values}
)
if desc:
prop["description"] = desc
return prop
def _build_schema(
*,
presets: Mapping[str, Preset],
instructions: Any,
tools: Any,
mcp_servers: Any,
model: Any,
context: Any,
) -> dict[str, Any]:
"""Derive the input schema from the axis modes: each axis adds zero or one parameter."""
props: dict[str, Any] = {
"task": {
"type": "string",
"description": "The self-contained task, including all context the subagent needs. It "
"starts from a blank conversation and cannot ask follow-up questions.",
}
}
required = ["task"]
if presets:
props["agent_type"] = {
"type": "string",
"enum": list(presets),
"description": "Which subagent role to use. Omit to use the default.",
}
if isinstance(instructions, Open):
props["instructions"] = {"type": "string", "description": "A system prompt defining the subagent's role."}
elif isinstance(instructions, Choice):
props["instructions"] = _choice_prop(instructions, "A system prompt defining the subagent's role.")
if isinstance(tools, Choice):
props["tools"] = _choice_prop(
tools, "Subset of tools to grant the subagent. Fewer is safer; it cannot exceed your own."
)
if isinstance(mcp_servers, Choice):
props["mcp_servers"] = _choice_prop(
mcp_servers,
"Subset of MCP servers whose tools to grant the subagent. Fewer is safer; it cannot "
"exceed your own. Omit to grant all of them.",
)
if isinstance(model, Choice):
props["model"] = _choice_prop(model, "Which model the subagent runs on.")
if isinstance(context, Choice):
props["context"] = _choice_prop(
context,
"How much of this conversation the subagent sees: 'none' (fresh start), 'all' (full "
"history including tool calls and their results — can be large), 'no_tools' (text turns "
"only — tool calls and their results removed). More context costs more tokens.",
)
if any(str(o.value) != "none" for o in context.normalized()):
props["last_messages"] = {
"type": "integer",
"description": "Optional: limit the shared context to the last N messages. Omit to "
"share all of the selected context.",
}
return {"type": "object", "properties": props, "required": required}
def _resolve_spec(
raw: Mapping[str, Any],
*,
presets: Mapping[str, Preset],
default_preset: str | None,
instructions: Any,
tools: Any,
mcp_servers: Any,
model: Any,
context: Any,
allowed_tools: Sequence[str] | None,
allowed_mcp: Sequence[str] | None,
) -> AgentSpec:
"""Combine the model's arguments, the selected preset, and the fixed axes into a spec.
Precedence per axis: a model-supplied argument wins, then the preset's value, then the axis
default (``Fixed``/``Inherit``). An omitted ``agent_type`` falls back to the default preset, so
a bare ``subagent(task=...)`` behaves like the default role.
"""
task = str(raw["task"])
# agent_type is a closed enum: a provided value must be an exact preset name (absent = no role).
raw_agent_type = raw.get("agent_type")
if raw_agent_type is not None and raw_agent_type not in presets:
raise ValueError(f"Unknown agent_type {raw_agent_type!r}; valid values: {sorted(presets)}.")
# Ad-hoc instructions (only possible when the axis exposes one) override the default preset.
overriding_instructions = isinstance(instructions, (Open, Choice)) and "instructions" in raw
agent_type = raw_agent_type or (None if overriding_instructions else default_preset)
preset = presets.get(agent_type) if agent_type else None
spec = AgentSpec(task=task, agent_type=agent_type)
# instructions — honored only when the axis exposes one (Open, or a Choice offering the name).
if "instructions" in raw and isinstance(instructions, Open):
spec.instructions = str(raw["instructions"])
elif "instructions" in raw and isinstance(instructions, Choice) and instructions.has(str(raw["instructions"])):
spec.instructions = str(instructions.value_for(str(raw["instructions"])))
elif preset is not None:
spec.instructions = preset.instructions
elif isinstance(instructions, Fixed):
spec.instructions = instructions.value
# tools — model picks option names; each maps to its value (the real tool name granted), clamped.
if "tools" in raw and allowed_tools is not None:
raw_tools = raw["tools"]
requested = [raw_tools] if isinstance(raw_tools, str) else raw_tools if isinstance(raw_tools, list) else []
spec.tools = [tools.value_for(t) for t in requested if t in allowed_tools]
elif preset is not None and preset.tools is not None:
if allowed_tools is not None:
allowed_values = {tools.value_for(n) for n in allowed_tools}
spec.tools = [t for t in preset.tools if t in allowed_values]
else:
spec.tools = list(preset.tools)
elif isinstance(tools, Choice) and tools.multiple and allowed_tools is not None:
spec.tools = [tools.value_for(n) for n in allowed_tools]
elif isinstance(tools, Fixed):
spec.tools = list(tools.value) if tools.value is not None else None
# mcp_servers — model picks server names, clamped to the parent's set; omitted inherits all.
if "mcp_servers" in raw and allowed_mcp is not None:
raw_mcp = raw["mcp_servers"]
requested = [raw_mcp] if isinstance(raw_mcp, str) else raw_mcp if isinstance(raw_mcp, list) else []
spec.mcp_servers = [mcp_servers.value_for(n) for n in requested if n in allowed_mcp]
elif isinstance(mcp_servers, Choice) and mcp_servers.multiple and allowed_mcp is not None:
spec.mcp_servers = [mcp_servers.value_for(n) for n in allowed_mcp]
elif isinstance(mcp_servers, Fixed):
spec.mcp_servers = list(mcp_servers.value) if mcp_servers.value is not None else None
# model — the picked name maps back to its value; an off-enum name is ignored.
if "model" in raw and isinstance(model, Choice) and model.has(str(raw["model"])):
spec.model = model.value_for(str(raw["model"]))
elif preset is not None and preset.model is not None:
spec.model = preset.model
elif isinstance(model, Fixed):
spec.model = model.value
# context — honored only when the Choice offers the name; off-enum can't flip a Fixed("none") delegate.
if "context" in raw and isinstance(context, Choice) and context.has(str(raw["context"])):
spec.context = str(context.value_for(str(raw["context"])))
elif preset is not None:
spec.context = preset.context
spec.last_messages = preset.last_messages
elif isinstance(context, Fixed) and context.value is not None:
spec.context = str(context.value)
# last_messages — model-supplied cap wins when the axis exposes it.
if "last_messages" in raw and raw["last_messages"] is not None:
try:
spec.last_messages = int(raw["last_messages"])
except (TypeError, ValueError):
spec.last_messages = None
return spec
_CONTEXT_MODES = ("none", "all", "no_tools")
def _is_valid_trim_point(messages: list[Message], index: int) -> bool:
"""Whether the SDK's conversation managers would cut the history at ``index`` — a user message that
is not a tool result and not a tool use without its result right after. Mirrors the SDK's
``find_valid_trim_point(messages, index) == index``, duplicated here until the SDK exports it."""
message = messages[index]
if message["role"] != "user" or any("toolResult" in b for b in message["content"]):
return False
if any("toolUse" in b for b in message["content"]):
return index + 1 < len(messages) and any("toolResult" in b for b in messages[index + 1]["content"])
return True
def _fork_messages(parent: Agent | None, last_n: int | None) -> list[Message]:
"""The parent's messages for ``"all"`` mode — real content blocks (tool calls, results, images),
not a text rendering. Tool calls still in flight — the delegating call itself and any parallel
siblings, which nothing has answered yet — are dropped so the history stays valid, and so are
``reasoningContent`` blocks: they are the parent model's own signed state, and other models
reject them (Bedrock: "User messages cannot contain reasoning content"). ``last_n``
is widened back to the nearest boundary the SDK's own conversation managers would cut at
(``_is_valid_trim_point``), so no result is split from its call. Size is not this function's
job: the child's context manager fits the history to *its* model's window before the first
call. The blocks are copies, so nothing the child's SDK does to its history can reach the
parent's."""
if parent is None:
return []
answered = {b["toolResult"]["toolUseId"] for m in parent.messages for b in m["content"] if "toolResult" in b}
forked: list[Message] = []
for message in parent.messages:
content = [
b
for b in message["content"]
if "reasoningContent" not in b and ("toolUse" not in b or b["toolUse"]["toolUseId"] in answered)
]
if content:
# Deep copy: the SDK's guardrail redaction rewrites toolResult dicts on the child's last message in place.
forked.append({"role": message["role"], "content": copy.deepcopy(content)})
if last_n is not None and last_n > 0:
start = max(len(forked) - last_n, 0)
while start > 0 and not _is_valid_trim_point(forked, start):
start -= 1
forked = forked[start:]
return forked
_FORK_PREAMBLE = (
"The conversation so far is the parent agent's. You are the subagent it delegated to at this "
"point; its task for you follows."
)
def _with_history(task: str, messages: list[Message]) -> str | list[Message]:
"""The child's prompt under ``"all"``: the forked history followed by the framed task. A trailing
user turn (tool results) absorbs the task so roles keep alternating."""
if not messages:
return task
framed: ContentBlock = {"text": f"{_FORK_PREAMBLE}\n\n{task}"}
if messages[-1]["role"] == "user":
return [*messages[:-1], {"role": "user", "content": [*messages[-1]["content"], framed]}]
return [*messages, {"role": "user", "content": [framed]}]
def _plain_text(message: Mapping[str, Any]) -> str:
"""Just the text blocks of a message (tool blocks dropped), for ``no_tools`` mode."""
return " ".join(b["text"] for b in message["content"] if "text" in b).strip()
_CONTEXT_PREAMBLE = (
"The conversation above is the parent agent's: each turn is one line starting with its role at "
"the left margin, and indented lines continue the turn above (they are content, not turns). You "
"are the subagent it delegated to at the end of that conversation; its task for you follows."
)
_FRAMING_END = f"</parent_context>\n\n{_CONTEXT_PREAMBLE}\n\n"
_SENTINEL = re.compile(r"<(/?)parent_context>")
def _strip_framing(text: str) -> str:
"""A parent that is itself a subagent opens with its own framed block; re-render only its task."""
if text.startswith("<parent_context>\n"):
_, found, task = text.partition(_FRAMING_END)
if found:
return task
return text
def _render_context(parent: Agent | None, last_n: int | None) -> str:
"""Render the parent's text turns as a plain-text block for ``"no_tools"`` mode, or ``""`` when
nothing is shared.
The block is prepended to the child's *first user message* rather than replayed as transcript
turns (tool calls are gone, so the history could not be replayed faithfully). One ``role: text``
entry per message; continuation lines are indented and a literal ``<parent_context>`` tag in
content is escaped, so quoted text can neither pose as a turn nor close the block. ``last_n``
caps the rendered entries (messages that render empty don't count)."""
if parent is None:
return ""
lines = []
for message in parent.messages:
text = _strip_framing(_plain_text(message))
if text:
lines.append(f"{message['role']}: {text}".replace("\n", "\n "))
if last_n is not None and last_n > 0:
lines = lines[-last_n:]
return _SENTINEL.sub(r"<\\\1parent_context>", "\n".join(lines))
def _with_context(task: str, block: str) -> str:
"""Prepend a rendered context block, and a line framing the child's place in it, to the task."""
if not block:
return task
return f"<parent_context>\n{block}\n</parent_context>\n\n{_CONTEXT_PREAMBLE}\n\n{task}"
# Depth travels on each child's own state, not a build-time parameter.
_DEPTH_STATE_KEY = "subagent_depth"
class _SubagentTool(AgentTool):
"""Streams a fresh per-call child and propagates its interrupts to the parent for resume."""
def __init__(
self,
name: str,
tool_spec: dict[str, Any],
resolve: Callable[[Mapping[str, Any]], AgentSpec],
builder: AgentBuilder,
max_depth: int,
) -> None:
super().__init__()
self._name = name
self._spec = tool_spec
self._resolve = resolve
self._builder = builder
self._max_depth = max_depth
# Children awaiting a resume, keyed by the tool-use id that interrupted.
self._pending: dict[str, Agent] = {}
@property
def tool_name(self) -> str:
return self._name
@property
def tool_spec(self) -> Any:
return self._spec
@property
def tool_type(self) -> str:
return "agent"
async def stream(self, tool_use: ToolUse, invocation_state: dict[str, Any], **kwargs: Any) -> ToolGenerator:
tool_use_id = tool_use["toolUseId"]
raw = tool_use.get("input", {}) or {}
parent = invocation_state.get("agent")
# resolve/build/render can raise; surface it as an error tool result, not an uncaught exception.
try:
child = self._pending.get(tool_use_id)
if child is not None and child._interrupt_state.activated:
# Resume: the parent set responses on the shared interrupt objects the child still holds.
prompt: Any = [
{"interruptResponse": {"interruptId": interrupt.id, "response": interrupt.response}}
for interrupt in child._interrupt_state.interrupts.values()
if interrupt.response is not None
]
else:
stored_depth = None if parent is None else parent.state.get(_DEPTH_STATE_KEY)
depth = self._max_depth if stored_depth is None else stored_depth
if depth <= 0:
yield ToolResultEvent(
{
"toolUseId": tool_use_id,
"status": "error",
"content": [
{
"text": f"Delegation depth limit reached ({self._max_depth} levels); you "
"cannot delegate further. Complete this task yourself instead of calling "
"subagent again."
}
],
}
)
return
spec = self._resolve(raw)
child = self._builder(spec)
child.state.set(_DEPTH_STATE_KEY, depth - 1)
if spec.context == "all":
prompt = _with_history(spec.task, _fork_messages(parent, spec.last_messages))
elif spec.context == "no_tools":
prompt = _with_context(spec.task, _render_context(parent, spec.last_messages))
else:
prompt = spec.task
# Cancelling the parent's tool call cancels the delegation too (mirrors the SDK's _AgentAsTool).
cancel_signal = getattr(parent, "cancel_signal", None)
# Shallow copy: the SDK rewrites invocation_state["agent"] per cycle; a shared dict would bleed agent=child.
child_state = {**invocation_state} if isinstance(invocation_state, dict) else invocation_state
result = None
async for event in child.stream_async(prompt, invocation_state=child_state, cancel_signal=cancel_signal):
if "result" in event:
result = event["result"]
else:
yield ToolStreamEvent(tool_use, event)
if result is None:
self._pending.pop(tool_use_id, None)
yield ToolResultEvent(
{"toolUseId": tool_use_id, "status": "error", "content": [{"text": "Subagent produced no result."}]}
)
return
if result.stop_reason == "interrupt" and result.interrupts:
self._pending[tool_use_id] = child
yield ToolInterruptEvent(tool_use, list(result.interrupts))
return
if result.stop_reason == "cancelled":
# Cancellation is data, not an exception: surface it as an error result, not a success.
self._pending.pop(tool_use_id, None)
yield ToolResultEvent(
{"toolUseId": tool_use_id, "status": "error", "content": [{"text": "Subagent was cancelled."}]}
)
return
self._pending.pop(tool_use_id, None)
yield ToolResultEvent({"toolUseId": tool_use_id, "status": "success", "content": [{"text": str(result)}]})
except Exception as exc:
self._pending.pop(tool_use_id, None)
yield ToolResultEvent(
{"toolUseId": tool_use_id, "status": "error", "content": [{"text": f"Subagent error: {exc}"}]}
)
def make_subagent(
*,
builder: AgentBuilder,
presets: Mapping[str, Preset] | None = None,
default_preset: str | None = None,
instructions: Open | Choice | Fixed = None, # type: ignore[assignment]
tools: Choice | Fixed | Inherit = None, # type: ignore[assignment]
mcp_servers: Choice | Fixed | Inherit = None, # type: ignore[assignment]
model: Inherit | Choice | Fixed = None, # type: ignore[assignment]
context: Fixed | Choice = None, # type: ignore[assignment]
inherited_tools: Sequence[str] = (),
inherited_mcp_servers: Sequence[str] = (),
max_depth: int = defaults.DEFAULT_SUBAGENT_MAX_DEPTH,
name: str = "subagent",
) -> AgentTool:
"""Build a ``subagent`` tool whose schema follows the axis modes and preset map.
``max_depth`` bounds delegation depth: each child's remaining budget is stored on its own
``agent.state`` right after it's built (not passed to ``builder``), read back the next time this
tool is called on that child, and decremented again. At 0 the tool refuses with an error result
rather than building another child, so recursion is bounded without threading depth through the
builder's construction path.
"""
presets = dict(presets or {})
instructions = instructions if instructions is not None else Open()
model = model if model is not None else Inherit()
context = context if context is not None else Fixed("none")
if default_preset is None and presets:
default_preset = next(iter(presets))
# Default the tools axis to a multiple Choice over the inherited tools; a tools Choice must be multiple.
if tools is None:
tools = Choice(list(inherited_tools), multiple=True)
elif isinstance(tools, Choice):
if not tools.options:
raise ValueError("tools=Choice([]) offers no options; use Fixed([]) for a toolless child, or Inherit().")
if not tools.multiple:
raise ValueError("tools=Choice(...) must be multiple=True; a subagent selects a subset of tools.")
# mcp_servers mirrors tools: a multiple Choice over the parent's servers, or no parameter (Inherit)
# when the parent has none.
if mcp_servers is None:
mcp_servers = Choice(list(inherited_mcp_servers), multiple=True) if inherited_mcp_servers else Inherit()
elif isinstance(mcp_servers, Choice):
if not mcp_servers.options:
raise ValueError("mcp_servers=Choice([]) offers no options; use Fixed([]) for no servers, or Inherit().")
if not mcp_servers.multiple:
raise ValueError("mcp_servers=Choice(...) must be multiple=True; a subagent selects a subset of servers.")
allowed_tools = _allowed_names(tools)
allowed_mcp = _allowed_names(mcp_servers)
schema = _build_schema(
presets=presets,
instructions=instructions,
tools=tools,
mcp_servers=mcp_servers,
model=model,
context=context,
)
base_description = (
"Delegate a self-contained task to a subagent that runs in its own context and returns a "
"final report. Reach for this when a subtask would otherwise flood your context with "
"intermediate work and you only need its conclusion. Do not poll for progress or redo its work."
)
tool_spec = {
"name": name,
"description": _description(base_description, presets),
"inputSchema": {"json": schema},
}
def resolve(raw: Mapping[str, Any]) -> AgentSpec:
return _resolve_spec(
raw,
presets=presets,
default_preset=default_preset,
instructions=instructions,
tools=tools,
mcp_servers=mcp_servers,
model=model,
context=context,
allowed_tools=allowed_tools,
allowed_mcp=allowed_mcp,
)
return _SubagentTool(name, tool_spec, resolve, builder, max_depth)
def _mcp_clients_by_name(tools: Sequence[Any]) -> dict[str, MCPClient]:
"""Key the parent's ``MCPClient`` entries by ``client_name`` for the ``mcp_servers`` axis. The model
picks servers by name, so a client without one can't be offered and is left out of delegation; when
two share a name the first keeps it and the rest are dropped. Both are warned about."""
clients: dict[str, MCPClient] = {}
for client in tools:
if not isinstance(client, MCPClient):
continue
name = client.client_name
if name is None:
logger.warning(
"An MCPClient in `tools` has no application_name; it can't be offered to subagents on "
"`mcp_servers`, so delegates won't get its tools. Set application_name to name it."
)
elif name in clients:
logger.warning(
"Two MCPClients in `tools` share the name %r; only the first is offered to subagents on `mcp_servers`.",
name,
)
else:
clients[name] = client
return clients
def build_default_subagent(
build_agent: Callable[..., Agent],
parent_config: dict[str, Any],
*,
memory: bool = True,
max_depth: int = defaults.DEFAULT_SUBAGENT_MAX_DEPTH,
) -> AgentTool:
"""The ``subagent`` tool the harness wires by default: the ``generalist`` preset and a builder that
rebuilds the child through ``build_agent`` (the ``create_harness`` factory, injected to avoid
importing ``agent.py``) with the resolved spec applied.
Consumer ``tools`` join the selectable set (same objects, same inherited ``interventions``); the
builder splits a selection back into ``builtin_tools`` (names) and ``tools`` (objects). The parent's
live MCP clients are the ``MCPClient`` entries in its ``tools`` (named by ``client_name``); the selected
ones stay in the child's ``tools`` (shared, not reconnected: a started client hands out its cached
tools) so the delegate keeps the parent's MCP tools, narrowable per server through the ``mcp_servers``
axis (omitted grants all). The clients are tool providers with no fixed tool name at schema-build
time, so the axis selects whole servers, not individual MCP tools.
``parent_config`` holds the parent's ``create_harness`` keyword arguments, with ``builtin_tools``
already normalized to ``{name: True | False | cfg}`` (never a list or ``"*"``) and ``memory`` as
``False`` or a ``MemoryConfig`` (a parent ``MemoryManager`` instance is not forwarded).
``memory`` (default ``True``) shares the parent's store(s) with the delegate read-only; ``False``
builds delegates with no memory. ``max_depth`` is ``make_subagent``'s delegation-depth bound.
"""
# Only named tools can join the selectable set; the SDK also accepts unnamed forms (lists, dicts).
consumer_by_name = {t.tool_name: t for t in (parent_config.get("tools") or []) if hasattr(t, "tool_name")}
mcp_clients_by_name = _mcp_clients_by_name(parent_config.get("tools") or [])
parent_builtin: dict[str, Any] = dict(parent_config["builtin_tools"])
def builder(spec: AgentSpec) -> Agent:
child_config = dict(parent_config)
if isinstance(child_config.get("context_manager"), ContextManager):
# A ContextManager instance carries per-agent state (stash, retrieval bookkeeping), so a
# delegate must not share the parent's. Fall back to the default strategy for children.
child_config["context_manager"] = defaults.DEFAULT_CONTEXT_MANAGER
if spec.instructions is not None:
child_config["instructions"] = spec.instructions
if spec.tools is not None:
# spec.tools is already clamped to the allowed set; split it back into the two channels. A
# selected built-in keeps its parent config; web_search isn't selectable (the child resolves it for
# its own model) and carries over as-is.
child_config["builtin_tools"] = {
name: setting if name == "web_search" or name in spec.tools else False
for name, setting in parent_builtin.items()
}
child_config["tools"] = [consumer_by_name[t] for t in spec.tools if t in consumer_by_name]
if spec.model is not None:
child_config["model"] = spec.model
# The clamped selection (or all servers, unnarrowed) replaces any parent clients in `tools`, so
# the child re-offers exactly these on its own mcp_servers axis and can pass them to a grandchild.
selected_names = mcp_clients_by_name if spec.mcp_servers is None else spec.mcp_servers
selected = [mcp_clients_by_name[n] for n in dict.fromkeys(selected_names) if n in mcp_clients_by_name]
child_tools = [t for t in child_config.get("tools") or [] if not isinstance(t, MCPClient)]
child_config["tools"] = [*child_tools, *selected]
# Recall-only manager: the delegate searches shared memory but never writes.
parent_memory = child_config.get("memory")
if memory and isinstance(parent_memory, Mapping):
child_config["memory"] = resolve_memory(
stores=parent_memory.get("stores"),
memory_dir=parent_memory.get("dir") or defaults.DEFAULT_MEMORY_DIR,
writable=False,
)
else:
child_config["memory"] = False
return build_agent(**child_config)
# web_search is resolved per model, not selectable; subagent stays in, bounded by the depth guard.
builtin_names = [name for name, setting in parent_builtin.items() if setting is not False and name != "web_search"]
inherited_tools = [*builtin_names, *consumer_by_name]
return make_subagent(
builder=builder,
presets={"generalist": GENERALIST},
inherited_tools=inherited_tools,
inherited_mcp_servers=list(mcp_clients_by_name),
max_depth=max_depth,
# Offer context sharing; the generalist preset defaults to "none" (isolated).
context=Choice(list(_CONTEXT_MODES)),
)