fix: resolve subagent tools at execution decision (#381)

* fix: defer subagent tool resolution

Signed-off-by: Aditya Datta <crazyme07071996@gmail.com>

* fix: preserve inherited subagent tools

* test: cover injected subagent tools

* style: format subagent regression

---------

Signed-off-by: Aditya Datta <crazyme07071996@gmail.com>
This commit is contained in:
ADITYA
2026-08-11 18:43:04 +05:30
committed by GitHub
parent fb329e4aaa
commit 1f3e8f57a3
5 changed files with 183 additions and 116 deletions
+31 -19
View File
@@ -442,7 +442,11 @@ def _fold_expert_subagents(subs: list[dict], tool_registry: dict) -> None:
def _maybe_swap_async_subagents(
subs: list, middleware: list | None = None, *, cfg=None
subs: list,
middleware: list | None = None,
*,
tool_registry: dict | None = None,
cfg=None,
) -> list:
"""Replace ``_async``-flagged sub-agents with ``AsyncSubAgent`` specs when enabled.
@@ -458,17 +462,24 @@ def _maybe_swap_async_subagents(
Adding a new async sub-agent requires no change here — flip
``async: true`` in its yaml and create the matching deployment graph.
All return paths strip the internal ``_async`` field from sub-agent dicts
before handoff, since deepagents may schema-validate the kwarg.
YAML tool names stay in the internal ``_tool_names`` field until this
decision point. In-process specs resolve them against ``tool_registry``;
swapped remote specs discard them because their graph factory resolves
tools in its own process. All return paths strip internal fields before
handoff, since deepagents may schema-validate the kwargs.
When async subagents are actually swapped in and ``middleware`` is provided,
appends ``AsyncWatcherMiddleware`` so launches spawn an
``async_notifier`` watcher.
"""
from .utils import resolve_subagent_tools
cfg = cfg if cfg is not None else _ensure_config()
tool_registry = tool_registry or {}
if not getattr(cfg, "enable_async_subagents", False):
# Async fully disabled — strip the internal flag before handoff.
# Async fully disabled: every spec will run in-process.
for s in subs:
resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
@@ -482,9 +493,9 @@ def _maybe_swap_async_subagents(
"enable_async_subagents=true but langgraph dev is not reachable; "
"falling back to in-process sync delegation for all sub-agents."
)
# Strip the internal ``_async`` flag (carried from ``load_subagents``)
# before sub-agents reach deepagents — it's never a deepagents key.
# Every spec falls back to in-process execution.
for s in subs:
resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
@@ -496,6 +507,7 @@ def _maybe_swap_async_subagents(
if not async_specs:
for s in subs:
resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
return subs
@@ -526,7 +538,7 @@ def _maybe_swap_async_subagents(
agent_specs[name] = spec
out.append(spec)
else:
# Strip the internal flag before handoff to deepagents.
resolve_subagent_tools(s, tool_registry)
s.pop("_async", None)
out.append(s)
@@ -662,21 +674,20 @@ def _build_base_kwargs(
tool_registry["tavily_search"] = tavily_search
base_tools = [think_tool, skill_manager]
# ``async_swap_pending=True`` because ``_maybe_swap_async_subagents``
# below re-resolves tools for async subagents against the deployed
# graph's own registry (via ``subagents/_factory.py``). A tool missing
# from ``tool_registry`` for an async spec logs at DEBUG, not WARNING.
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=tool_registry,
async_swap_pending=True,
)
_fold_expert_subagents(subs, tool_registry)
_ensure_general_purpose_subagent(subs)
_inject_subagent_middleware(
subs, workspace_dir=workspace_dir, cfg=cfg, chat_model=chat_model
)
subs = _maybe_swap_async_subagents(subs, base_middleware, cfg=cfg)
subs = _maybe_swap_async_subagents(
subs,
base_middleware,
tool_registry=tool_registry,
cfg=cfg,
)
# Route AsyncSubAgent specs (both standard and expert) through
# EvoAsyncSubAgentMiddleware so the payload-aware start_async_task tool
# replaces upstream's non-parameterisable one.
@@ -746,12 +757,8 @@ def load_mcp_and_build_kwargs(
mcp_main = mcp_by_agent.pop("main", [])
# Same rationale as ``_build_base_kwargs``: async subagents get
# re-resolved downstream by the deployed graph's factory registry.
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=registry,
async_swap_pending=True,
)
_fold_expert_subagents(subs, registry)
@@ -767,7 +774,12 @@ def load_mcp_and_build_kwargs(
# Swap selected sub-agents to AsyncSubAgent (must happen AFTER MCP injection
# since async sub-agents are remote graphs that load their own tools).
subs = _maybe_swap_async_subagents(subs, base_middleware, cfg=cfg)
subs = _maybe_swap_async_subagents(
subs,
base_middleware,
tool_registry=registry,
cfg=cfg,
)
# Mirror the base path: route AsyncSubAgent specs through
# EvoAsyncSubAgentMiddleware so the payload-aware start_async_task tool
# is the one composed into the main agent.
+2 -2
View File
@@ -52,7 +52,7 @@ def build_async_subagent_graph(name: str) -> Any:
_inject_subagent_middleware,
)
from EvoScientist.tools import skill_manager, tavily_search, think_tool
from EvoScientist.utils import load_subagents
from EvoScientist.utils import load_subagents, resolve_subagent_tools
# Surface API keys as env vars so downstream SDKs (openai, anthropic, …)
# find them on subprocess invocations from langgraph dev.
@@ -68,7 +68,6 @@ def build_async_subagent_graph(name: str) -> Any:
# all wired the same way as the in-process sync version.
specs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=tool_registry,
)
spec = next((s for s in specs if s.get("name") == name), None)
if spec is None:
@@ -76,6 +75,7 @@ def build_async_subagent_graph(name: str) -> Any:
f"Sub-agent {name!r} not found in {SUBAGENTS_CONFIG}. "
f"Available: {[s.get('name') for s in specs]}"
)
resolve_subagent_tools(spec, tool_registry)
# Load MCP tools routed to THIS agent via ``expose_to: <name>`` in
# ``mcp.yaml``. Use the cached helper so multiple ``build_async_subagent_graph``
+36 -48
View File
@@ -18,6 +18,28 @@ logger = logging.getLogger(__name__)
console = Console()
def resolve_subagent_tools(
subagent: dict[str, Any], tool_registry: dict[str, Any]
) -> dict[str, Any]:
"""Resolve deferred YAML tool names while preserving injected tool objects."""
tool_names = subagent.pop("_tool_names", None)
if tool_names is None:
return subagent
resolved = list(subagent.get("tools", []))
for tool_name in tool_names:
if tool_name in tool_registry:
resolved.append(tool_registry[tool_name])
else:
logger.warning(
"Subagent %r: tool %r not in registry, skipping",
subagent.get("name"),
tool_name,
)
subagent["tools"] = resolved
return subagent
def format_message_content(message):
"""Convert message content to displayable string.
@@ -112,11 +134,9 @@ def show_prompt(prompt_text: str, title: str = "Prompt", border_style: str = "bl
def load_subagents(
config_path: Path,
*,
tool_registry: dict[str, Any],
prompt_refs: dict[str, str] | None = None,
async_swap_pending: bool = False,
) -> list[dict[str, Any]]:
"""Load subagent definitions from a directory of YAML files and wire up tools.
"""Load subagent definitions from a directory of YAML files.
NOTE: This is a custom utility. deepagents does not natively load subagents
from files - they're normally defined inline in the create_deep_agent() call.
@@ -141,16 +161,9 @@ def load_subagents(
system_prompt: |
...
``async_swap_pending`` tells the loader that an async-subagent swap will
run downstream against a different tool registry (typically the deployed
graph's own registry in ``subagents/_factory.py``). When True, a tool
missing from ``tool_registry`` for an ``async: true`` spec is logged at
DEBUG (it will be re-resolved by the async graph). When False, the
caller IS the terminal registry — any missing tool is a genuine typo
and logs at WARNING. Sync in-process callers
(``EvoScientist._build_base_kwargs``, ``load_mcp_and_build_kwargs``)
should pass True; the factory
(``subagents/_factory.build_async_subagent_graph``) leaves it False.
Tool names are retained until the caller selects an execution mode. This
avoids resolving async specs against the in-process registry; the selected
in-process or remote graph resolves names against its terminal registry.
"""
prompt_refs = prompt_refs or {}
@@ -224,9 +237,14 @@ def load_subagents(
if "skills" in spec:
subagent["skills"] = spec["skills"]
# Compute the async flag up-front so the tools-resolution block below
# can pick the right log level for missing tools. Internal field:
# carries the ``async:`` yaml flag through to
# Defer YAML-name resolution until the caller decides whether this
# spec will run in-process. Async specs are replaced by remote graph
# references and must not warn against the caller's unrelated registry.
if "tools" in spec:
subagent["_tool_names"] = list(spec["tools"])
subagent["tools"] = []
# Internal field: carries the ``async:`` yaml flag through to
# ``_maybe_swap_async_subagents`` so the swap doesn't need a second
# yaml pass to discover async-flagged agents. Underscore prefix marks
# it as internal — must be popped before passing to deepagents.
@@ -240,30 +258,6 @@ def load_subagents(
f"got {type(async_val).__name__}: {async_val!r}"
)
if "tools" in spec:
resolved = []
for t in spec["tools"]:
if t in tool_registry:
resolved.append(tool_registry[t])
elif async_val and async_swap_pending:
# Caller expects an async-subagent swap downstream against
# a different registry. The in-process registry is
# intentionally minimal for async agents — a miss here is
# not degraded state.
logger.debug(
"Subagent %r: tool %r not in the in-process registry; "
"resolved by the async graph",
name,
t,
)
else:
# Terminal registry (factory) OR sync subagent — a missing
# tool is a genuine typo and won't be resolved anywhere.
logger.warning(
"Subagent %r: tool %r not in registry, skipping", name, t
)
subagent["tools"] = resolved
subagent["_async"] = async_val
return subagent
@@ -280,18 +274,12 @@ def load_subagent(
*,
tool_registry: dict[str, Any],
prompt_refs: dict[str, str] | None = None,
async_swap_pending: bool = False,
) -> dict[str, Any]:
"""Load a single sub-agent by name from YAML.
See :func:`load_subagents` for ``async_swap_pending`` semantics.
"""
"""Load and resolve a single sub-agent by name from YAML."""
for agent in load_subagents(
config_path,
tool_registry=tool_registry,
prompt_refs=prompt_refs,
async_swap_pending=async_swap_pending,
):
if agent.get("name") == name:
return agent
return resolve_subagent_tools(agent, tool_registry)
raise KeyError(f"Sub-agent not found: {name}")