1f3e8f57a3
* 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>
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Tests for ``EvoScientist._maybe_swap_async_subagents``.
|
|
|
|
Covers the fallback / swap / strip-internal-flag paths that decide whether
|
|
sub-agents are routed in-process (sync ``task`` tool) or to the langgraph
|
|
dev subprocess (``AsyncSubAgent`` over HTTP).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from EvoScientist.EvoScientist import _maybe_swap_async_subagents
|
|
|
|
|
|
def _sub(
|
|
name: str,
|
|
*,
|
|
async_flag: bool,
|
|
description: str = "desc",
|
|
tool_names: list[str] | None = None,
|
|
) -> dict:
|
|
"""Build a sub-agent dict shaped like ``utils.load_subagents`` output."""
|
|
return {
|
|
"name": name,
|
|
"description": description,
|
|
"system_prompt": "x",
|
|
"tools": [],
|
|
"_tool_names": tool_names or [],
|
|
"_async": async_flag,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Async disabled in config → return unchanged
|
|
# =============================================================================
|
|
|
|
|
|
def test_returns_unchanged_when_async_disabled_and_strips_flag():
|
|
"""No swap when config.enable_async_subagents is False (the default).
|
|
|
|
Even in this disabled-path, the internal ``_async`` flag must be stripped
|
|
before sub-agents reach deepagents (which may schema-validate the dicts).
|
|
"""
|
|
cfg = SimpleNamespace(enable_async_subagents=False)
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False),
|
|
_sub("writing-agent", async_flag=True),
|
|
]
|
|
with patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out is subs
|
|
for s in out:
|
|
assert "_async" not in s, f"_async leaked into {s['name']}"
|
|
assert "_tool_names" not in s
|
|
|
|
|
|
# =============================================================================
|
|
# Async enabled but langgraph dev unreachable → strip flag, return as sync
|
|
# =============================================================================
|
|
|
|
|
|
class TestFallbackPath:
|
|
def _setup(self):
|
|
return SimpleNamespace(
|
|
enable_async_subagents=True,
|
|
langgraph_dev_port=6174,
|
|
)
|
|
|
|
def test_returns_subs_unchanged(self):
|
|
cfg = self._setup()
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False),
|
|
_sub("writing-agent", async_flag=True),
|
|
]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out is subs
|
|
|
|
def test_strips_async_flag_from_all_subs(self):
|
|
"""Even fallback path must strip _async before deepagents handoff."""
|
|
cfg = self._setup()
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False),
|
|
_sub("writing-agent", async_flag=True),
|
|
]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
for s in out:
|
|
assert "_async" not in s, f"_async leaked into {s['name']}"
|
|
|
|
|
|
# =============================================================================
|
|
# Async enabled + reachable + nothing flagged async → return all as sync (stripped)
|
|
# =============================================================================
|
|
|
|
|
|
def test_no_async_flagged_subs_strips_and_returns():
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False),
|
|
_sub("research-agent", async_flag=False),
|
|
]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out is subs # nothing to swap, returned as-is
|
|
for s in out:
|
|
assert "_async" not in s
|
|
|
|
|
|
# =============================================================================
|
|
# Async enabled + reachable + has async-flagged subs → swap to AsyncSubAgent
|
|
# =============================================================================
|
|
|
|
|
|
def test_swaps_async_flagged_subs():
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False, description="plan"),
|
|
_sub("writing-agent", async_flag=True, description="write report"),
|
|
_sub("data-analysis-agent", async_flag=True, description="analyze"),
|
|
]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
|
|
assert len(out) == 3
|
|
|
|
# Sync sub kept as a plain dict, _async stripped.
|
|
by_name = {s["name"]: s for s in out}
|
|
planner = by_name["planner-agent"]
|
|
assert isinstance(planner, dict)
|
|
assert "_async" not in planner
|
|
|
|
# Async subs are AsyncSubAgent specs (TypedDict) pointing at the right URL.
|
|
writing = by_name["writing-agent"]
|
|
assert writing["graph_id"] == "writing-agent"
|
|
assert writing["url"] == "http://127.0.0.1:6174"
|
|
assert writing["description"] == "write report"
|
|
|
|
data = by_name["data-analysis-agent"]
|
|
assert data["graph_id"] == "data-analysis-agent"
|
|
assert data["url"] == "http://127.0.0.1:6174"
|
|
|
|
|
|
def test_only_in_process_specs_resolve_against_the_caller_registry():
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
sync_tool = object()
|
|
subs = [
|
|
_sub("planner-agent", async_flag=False, tool_names=["think_tool"]),
|
|
_sub("writing-agent", async_flag=True, tool_names=["remote_only"]),
|
|
]
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
):
|
|
out = _maybe_swap_async_subagents(
|
|
subs,
|
|
tool_registry={"think_tool": sync_tool},
|
|
cfg=cfg,
|
|
)
|
|
|
|
planner = next(s for s in out if s["name"] == "planner-agent")
|
|
assert planner["tools"] == [sync_tool]
|
|
assert "_tool_names" not in planner
|
|
writing = next(s for s in out if s["name"] == "writing-agent")
|
|
assert writing["graph_id"] == "writing-agent"
|
|
|
|
|
|
def test_swap_uses_configured_port():
|
|
"""AsyncSubAgent.url should reflect cfg.langgraph_dev_port, not hardcoded."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=9999)
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out[0]["url"] == "http://127.0.0.1:9999"
|
|
|
|
|
|
def test_swap_uses_configured_host():
|
|
"""A backend pinned to one interface can't be self-dispatched over
|
|
loopback, so the URL must track cfg.langgraph_dev_host too."""
|
|
cfg = SimpleNamespace(
|
|
enable_async_subagents=True,
|
|
langgraph_dev_port=6174,
|
|
langgraph_dev_host="192.168.1.5",
|
|
)
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out[0]["url"] == "http://192.168.1.5:6174"
|
|
|
|
|
|
def test_swap_maps_wildcard_host_to_loopback():
|
|
"""A 0.0.0.0 bind includes loopback, and connecting *to* 0.0.0.0 is
|
|
rejected outright on Windows — the client URL must collapse to
|
|
127.0.0.1 rather than echo the bind address back."""
|
|
cfg = SimpleNamespace(
|
|
enable_async_subagents=True,
|
|
langgraph_dev_port=6174,
|
|
langgraph_dev_host="0.0.0.0",
|
|
)
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
out = _maybe_swap_async_subagents(subs)
|
|
assert out[0]["url"] == "http://127.0.0.1:6174"
|
|
|
|
|
|
# =============================================================================
|
|
# AsyncWatcherMiddleware appended on swap
|
|
# =============================================================================
|
|
|
|
|
|
def test_maybe_swap_appends_watcher_middleware_when_enabled():
|
|
"""When async subagents are swapped, AsyncWatcherMiddleware must be appended."""
|
|
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
|
|
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
|
|
middleware: list = []
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
_maybe_swap_async_subagents(subs, middleware)
|
|
|
|
assert len(middleware) == 1
|
|
assert isinstance(middleware[0], AsyncWatcherMiddleware)
|
|
|
|
|
|
def test_maybe_swap_skips_middleware_when_no_async_flagged():
|
|
"""Without any _async-flagged subagents, no middleware is appended."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
|
|
middleware: list = []
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
subs = [_sub("planner-agent", async_flag=False)]
|
|
_maybe_swap_async_subagents(subs, middleware)
|
|
|
|
assert middleware == []
|
|
|
|
|
|
def test_maybe_swap_skips_middleware_when_langgraph_unreachable():
|
|
"""Fallback path must not append middleware (no watchers will fire)."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
|
|
middleware: list = []
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
),
|
|
):
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
_maybe_swap_async_subagents(subs, middleware)
|
|
|
|
assert middleware == []
|
|
|
|
|
|
def test_maybe_swap_no_middleware_arg_does_not_crash():
|
|
"""Backward-compat: middleware parameter is optional."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
|
|
with (
|
|
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
subs = [_sub("writing-agent", async_flag=True)]
|
|
out = _maybe_swap_async_subagents(subs)
|
|
|
|
assert len(out) == 1
|
|
assert out[0]["graph_id"] == "writing-agent"
|