be8c23861d
* refactor: rename AGENTS.md to EXPERT.md per EvoSkills convention * feat: resolve newly installed experts on start_async_task miss * chore: reword the /expert invite hint to state the dispatch boundary * docs: note the resolve-on-miss caller in build_expert_async_subagent_specs * fix: thread the construction cfg through resolve-on-miss and symmetric setdefault * fix: guard agent_map iteration against concurrent resolve-on-miss writes * fix: warn once per broken expert on repeated resolve-on-miss walks * fix: scope the /expert invite hint to newly installed experts * docs: note live uninstalls as a resolve-on-miss limitation * chore: isolate the warn-once collision test key from the route-specs suite
514 lines
21 KiB
Python
514 lines
21 KiB
Python
"""Tests for the AsyncSubAgent → EvoAsyncSubAgentMiddleware routing helper.
|
|
|
|
Covers:
|
|
- ``_route_async_specs_through_evo_middleware`` splits AsyncSubAgent specs
|
|
out of the ``subs`` list and folds them into the base middleware.
|
|
- ``build_expert_async_subagent_specs`` gives every installed expert a
|
|
background reach, gated only on the async-enable flag + langgraph dev
|
|
reachability.
|
|
- ``build_expert_subagent_specs`` (in-turn fold-in) covers the same
|
|
experts, so each name holds both reaches without colliding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from EvoScientist.subagents.expert_container import build_expert_subagent_specs
|
|
from EvoScientist.subagents.expert_container_async import (
|
|
build_expert_async_subagent_specs,
|
|
)
|
|
from EvoScientist.tools.skills_manager import SkillInfo
|
|
|
|
|
|
def _skill(name: str) -> SkillInfo:
|
|
return SkillInfo(
|
|
name=name,
|
|
description=f"{name} description",
|
|
path=Path("/tmp/does-not-matter"),
|
|
source="builtin",
|
|
type="expert",
|
|
role=f"{name} role",
|
|
body="body\n",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# build_expert_async_subagent_specs
|
|
# =============================================================================
|
|
|
|
|
|
class TestBuildExpertAsyncSubagentSpecs:
|
|
def test_empty_when_async_disabled(self):
|
|
cfg = SimpleNamespace(enable_async_subagents=False)
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_skill("literature-review")],
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert specs == []
|
|
|
|
def test_empty_when_langgraph_dev_unreachable(self):
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_skill("literature-review")],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert specs == []
|
|
|
|
def test_every_expert_gets_a_background_reach(self):
|
|
"""No classification: every installed expert becomes an AsyncSubAgent spec."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
skills = [
|
|
_skill("idea-brainstorm"),
|
|
_skill("literature-review"),
|
|
_skill("panel-expert"),
|
|
]
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=skills,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert {s["name"] for s in specs} == {
|
|
"idea-brainstorm",
|
|
"literature-review",
|
|
"panel-expert",
|
|
}
|
|
for spec in specs:
|
|
assert spec["graph_id"] == "expert-container-async"
|
|
assert spec["is_expert"] is True
|
|
assert "http://localhost:6174" in spec["url"]
|
|
|
|
def test_empty_body_experts_skipped(self):
|
|
"""Empty-body async experts are filtered out at spec-build time so
|
|
``start_async_task``'s tool schema never advertises a broken skill.
|
|
Mirrors the sync fold-in in
|
|
``expert_container.py::build_expert_subagent_specs``."""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
skills = [
|
|
_skill("literature-review"), # normal body from _skill()
|
|
_skill("empty-persona"),
|
|
]
|
|
# Second skill has no body — dataclass field default is ``""``, but
|
|
# helper sets it to "body\n" — override to empty.
|
|
skills[1].body = ""
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=skills,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert [s["name"] for s in specs] == ["literature-review"]
|
|
|
|
def test_expert_md_expert_registered_and_gated_on_its_own_file(self):
|
|
"""EXPERT.md experts reach async dispatch, and their gate is EXPERT.md.
|
|
|
|
The empty-persona gate has to follow the skill's contract: a healthy
|
|
SKILL.md must not vouch for a skill whose actor definition is blank.
|
|
"""
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
healthy = _skill("paper-review")
|
|
healthy.expert_source = "expert_md"
|
|
healthy.expert_body = "## Persona\n\nYou are an adversarial reviewer.\n"
|
|
blank_actor = _skill("blank-actor")
|
|
blank_actor.expert_source = "expert_md"
|
|
blank_actor.expert_body = " \n" # SKILL.md body is fine; actor isn't
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[healthy, blank_actor],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert [s["name"] for s in specs] == ["paper-review"]
|
|
|
|
def test_reserved_name_collision_skipped(self, caplog):
|
|
"""A skill named after a yaml async sub-agent (or ``general-purpose``)
|
|
must skip async-dispatch registration with a warning, not raise. Without
|
|
this guard ``AsyncSubAgentMiddleware.__init__`` would ``ValueError:
|
|
Duplicate async subagent names`` on the merged spec list and kill CLI
|
|
startup — see reviewer thread on PR #391."""
|
|
import logging
|
|
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
skills = [
|
|
_skill("writing-agent"), # collides with yaml async agent
|
|
_skill("literature-review"),
|
|
]
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=skills,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"EvoScientist.subagents.expert_container._reserved_subagent_names",
|
|
return_value=frozenset({"writing-agent", "general-purpose"}),
|
|
),
|
|
caplog.at_level(
|
|
logging.WARNING,
|
|
logger="EvoScientist.subagents.expert_container_async",
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
assert [s["name"] for s in specs] == ["literature-review"]
|
|
assert any(
|
|
"writing-agent" in r.message and "collides" in r.message
|
|
for r in caplog.records
|
|
)
|
|
|
|
def test_workspace_duplicate_name_skipped(self, caplog):
|
|
"""Two workspace-tier expert skills sharing a frontmatter ``name`` must
|
|
register only the first — the workspace listing uses
|
|
``check_seen=False`` so both survive to this point. Without a local
|
|
seen-set the second would collide inside
|
|
``AsyncSubAgentMiddleware.__init__``."""
|
|
import logging
|
|
|
|
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
|
|
skills = [
|
|
_skill("literature-review"),
|
|
_skill("literature-review"), # duplicate name
|
|
]
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=skills,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"EvoScientist.subagents.expert_container._reserved_subagent_names",
|
|
return_value=frozenset({"general-purpose"}),
|
|
),
|
|
caplog.at_level(
|
|
logging.WARNING,
|
|
logger="EvoScientist.subagents.expert_container_async",
|
|
),
|
|
):
|
|
specs = build_expert_async_subagent_specs(cfg=cfg)
|
|
# Only the first `literature-review` survives.
|
|
assert [s["name"] for s in specs] == ["literature-review"]
|
|
assert any(
|
|
"literature-review" in r.message and "collides" in r.message
|
|
for r in caplog.records
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# build_expert_subagent_specs (in-turn side) — same experts, second reach
|
|
# =============================================================================
|
|
|
|
|
|
class TestBuildExpertSubagentSpecsCoversEveryExpert:
|
|
"""The in-turn fold-in emits a spec for every expert, async ones included.
|
|
|
|
Sharing a name across the two registries is intentional. They land on
|
|
different tools with separate schemas (``task`` vs
|
|
``start_async_task``), and deepagents' duplicate-name check is scoped to
|
|
the async list alone, so one expert holding both reaches never collides.
|
|
"""
|
|
|
|
def test_every_expert_gets_an_in_turn_reach(self):
|
|
skills = [
|
|
_skill("idea-brainstorm"),
|
|
_skill("literature-review"),
|
|
_skill("panel-expert"),
|
|
]
|
|
with patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=skills,
|
|
):
|
|
specs = build_expert_subagent_specs(tool_registry={})
|
|
assert {s["name"] for s in specs} == {
|
|
"idea-brainstorm",
|
|
"literature-review",
|
|
"panel-expert",
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# _route_async_specs_through_evo_middleware
|
|
# =============================================================================
|
|
|
|
|
|
class TestRouteAsyncSpecs:
|
|
"""The routing helper splits AsyncSubAgent specs from ``subs`` and
|
|
hands them to ``EvoAsyncSubAgentMiddleware``. Verifies:
|
|
- Sync subagents pass through untouched.
|
|
- AsyncSubAgent specs are stripped from the returned ``subs``.
|
|
- Expert async specs (from ``build_expert_async_subagent_specs``) are
|
|
merged in.
|
|
- The middleware is appended to ``base_middleware`` only when there
|
|
are async specs (either standard or expert).
|
|
"""
|
|
|
|
def _cfg(self, *, enable_async: bool = True, port: int = 6174):
|
|
return SimpleNamespace(
|
|
enable_async_subagents=enable_async, langgraph_dev_port=port
|
|
)
|
|
|
|
def test_sync_subagents_pass_through(self):
|
|
from EvoScientist.EvoScientist import _route_async_specs_through_evo_middleware
|
|
|
|
subs = [{"name": "sync-a", "system_prompt": ""}]
|
|
middleware: list = []
|
|
# Disable async path via cfg + patched reachability.
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
):
|
|
result = _route_async_specs_through_evo_middleware(
|
|
subs, middleware, cfg=self._cfg(enable_async=False)
|
|
)
|
|
assert result == [{"name": "sync-a", "system_prompt": ""}]
|
|
assert middleware == [] # no async → no middleware added
|
|
|
|
def test_async_specs_moved_to_middleware(self):
|
|
from EvoScientist.EvoScientist import _route_async_specs_through_evo_middleware
|
|
from EvoScientist.middleware.expert_async_subagent import (
|
|
EvoAsyncSubAgentMiddleware,
|
|
)
|
|
|
|
subs = [
|
|
{"name": "sync-a", "system_prompt": ""},
|
|
{
|
|
"name": "writing-agent",
|
|
"description": "std",
|
|
"graph_id": "writing_agent",
|
|
"url": "http://localhost:6174",
|
|
},
|
|
]
|
|
middleware: list = []
|
|
# Disable expert-async fold-in to isolate the standard-spec routing.
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=False,
|
|
):
|
|
result = _route_async_specs_through_evo_middleware(
|
|
subs, middleware, cfg=self._cfg(enable_async=False)
|
|
)
|
|
# `writing-agent` stripped from subs (it has graph_id).
|
|
assert [s["name"] for s in result] == ["sync-a"]
|
|
# Middleware appended.
|
|
assert len(middleware) == 1
|
|
assert isinstance(middleware[0], EvoAsyncSubAgentMiddleware)
|
|
|
|
def test_expert_async_specs_merged_in(self):
|
|
from EvoScientist.EvoScientist import _route_async_specs_through_evo_middleware
|
|
from EvoScientist.middleware.expert_async_subagent import (
|
|
EvoAsyncSubAgentMiddleware,
|
|
)
|
|
|
|
subs = [{"name": "sync-a", "system_prompt": ""}]
|
|
middleware: list = []
|
|
cfg = self._cfg(enable_async=True)
|
|
# Enable expert-async by patching skills list + reachability.
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_skill("literature-review")],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
):
|
|
result = _route_async_specs_through_evo_middleware(
|
|
subs, middleware, cfg=cfg
|
|
)
|
|
# sync-a stays; middleware got the expert spec, and an
|
|
# AsyncWatcherMiddleware was installed so expert launches spawn
|
|
# completion watchers (previously the watcher's client cache had no
|
|
# entry for the expert name, KeyErrored on `get_async`, and silently
|
|
# dropped the notification).
|
|
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
|
|
|
|
assert [s["name"] for s in result] == ["sync-a"]
|
|
assert len(middleware) == 2
|
|
evo_mw = next(
|
|
m for m in middleware if isinstance(m, EvoAsyncSubAgentMiddleware)
|
|
)
|
|
watcher_mw = next(
|
|
m for m in middleware if isinstance(m, AsyncWatcherMiddleware)
|
|
)
|
|
# The middleware's start tool schema advertises literature-review.
|
|
start = next(t for t in evo_mw.tools if t.name == "start_async_task")
|
|
assert "literature-review" in start.description
|
|
# The watcher's client cache knows how to construct a client for the
|
|
# expert so the completion nudge can spawn.
|
|
assert "literature-review" in watcher_mw._clients._agents
|
|
|
|
def test_watcher_cache_extends_when_yaml_watcher_preinstalled(self):
|
|
"""Default deployed shape — ``_maybe_swap_async_subagents`` installed
|
|
``AsyncWatcherMiddleware`` for a yaml async agent, then the routing
|
|
helper extends the cache with expert specs. Without the extension
|
|
branch, an expert completion nudge would KeyError on the watcher's
|
|
``get_async(<expert>)`` and silently drop the notification."""
|
|
from EvoScientist.cli import async_notifier
|
|
from EvoScientist.EvoScientist import _route_async_specs_through_evo_middleware
|
|
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
|
|
from EvoScientist.middleware.expert_async_subagent import (
|
|
EvoAsyncSubAgentMiddleware,
|
|
)
|
|
|
|
yaml_async_spec = {
|
|
"name": "writing-agent",
|
|
"description": "std",
|
|
"graph_id": "writing_agent",
|
|
"url": "http://localhost:6174",
|
|
}
|
|
subs = [{"name": "sync-a", "system_prompt": ""}, yaml_async_spec]
|
|
# Simulate the state after ``_maybe_swap_async_subagents``: watcher is
|
|
# already installed and carries the yaml async agent.
|
|
middleware: list = [
|
|
AsyncWatcherMiddleware(
|
|
{"writing-agent": yaml_async_spec}, notifier=async_notifier
|
|
)
|
|
]
|
|
cfg = self._cfg(enable_async=True)
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_skill("literature-review")],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"EvoScientist.subagents.expert_container._reserved_subagent_names",
|
|
return_value=frozenset({"general-purpose"}),
|
|
),
|
|
):
|
|
result = _route_async_specs_through_evo_middleware(
|
|
subs, middleware, cfg=cfg
|
|
)
|
|
# `writing-agent` (graph_id-carrying) stripped from subs; sync-a stays.
|
|
assert [s["name"] for s in result] == ["sync-a"]
|
|
evo_mw = next(
|
|
m for m in middleware if isinstance(m, EvoAsyncSubAgentMiddleware)
|
|
)
|
|
watcher_mw = next(
|
|
m for m in middleware if isinstance(m, AsyncWatcherMiddleware)
|
|
)
|
|
# Both the yaml async agent and the expert reach the start-task schema.
|
|
start = next(t for t in evo_mw.tools if t.name == "start_async_task")
|
|
assert "writing-agent" in start.description
|
|
assert "literature-review" in start.description
|
|
# The pre-existing watcher was extended in place — both names route.
|
|
assert "writing-agent" in watcher_mw._clients._agents
|
|
assert "literature-review" in watcher_mw._clients._agents
|
|
|
|
def test_watcher_dict_wired_into_resolve_on_miss(self):
|
|
"""The middleware constructed by the routing helper must hold the
|
|
watcher's agent dict BY REFERENCE, so an expert installed after the
|
|
agent was built lands in the watcher when ``start_async_task``
|
|
resolve-on-miss fires. Without the wiring, dispatch succeeds but the
|
|
completion notification for the new expert silently never fires."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from EvoScientist.cli import async_notifier
|
|
from EvoScientist.EvoScientist import _route_async_specs_through_evo_middleware
|
|
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
|
|
from EvoScientist.middleware.expert_async_subagent import (
|
|
EvoAsyncSubAgentMiddleware,
|
|
)
|
|
|
|
yaml_async_spec = {
|
|
"name": "writing-agent",
|
|
"description": "std",
|
|
"graph_id": "writing_agent",
|
|
"url": "http://localhost:6174",
|
|
}
|
|
subs = [{"name": "sync-a", "system_prompt": ""}, yaml_async_spec]
|
|
middleware: list = [
|
|
AsyncWatcherMiddleware(
|
|
{"writing-agent": yaml_async_spec}, notifier=async_notifier
|
|
)
|
|
]
|
|
cfg = self._cfg(enable_async=True)
|
|
with (
|
|
patch(
|
|
"EvoScientist.tools.skills_manager.list_expert_skills",
|
|
return_value=[_skill("literature-review")],
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"EvoScientist.subagents.expert_container._reserved_subagent_names",
|
|
return_value=frozenset({"general-purpose"}),
|
|
),
|
|
):
|
|
_route_async_specs_through_evo_middleware(subs, middleware, cfg=cfg)
|
|
|
|
evo_mw = next(
|
|
m for m in middleware if isinstance(m, EvoAsyncSubAgentMiddleware)
|
|
)
|
|
watcher_mw = next(
|
|
m for m in middleware if isinstance(m, AsyncWatcherMiddleware)
|
|
)
|
|
start = next(t for t in evo_mw.tools if t.name == "start_async_task")
|
|
|
|
# An expert installed after the build: the first miss-walk returns
|
|
# it, the dispatch goes through, and the watcher's dict gains it.
|
|
late_expert = {
|
|
"name": "late-expert",
|
|
"description": "installed mid-session",
|
|
"graph_id": "expert-container-async",
|
|
"is_expert": True,
|
|
}
|
|
client = MagicMock()
|
|
client.threads.create.return_value = {"thread_id": "t1"}
|
|
client.runs.create.return_value = {"run_id": "r1"}
|
|
with (
|
|
patch(
|
|
"EvoScientist.subagents.expert_container_async"
|
|
".build_expert_async_subagent_specs",
|
|
return_value=[late_expert],
|
|
),
|
|
patch(
|
|
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_sync",
|
|
return_value=client,
|
|
),
|
|
):
|
|
result = start.func(
|
|
description="hi",
|
|
subagent_type="late-expert",
|
|
runtime=SimpleNamespace(tool_call_id="tc1"),
|
|
)
|
|
|
|
assert "async_tasks" in result.update
|
|
assert "late-expert" in watcher_mw._clients._agents
|