Files
EvoScientist-Multi/tests/test_expert_container_async.py
jfilipiuk be8c23861d feat: dispatch newly installed experts in the background without /new (#420)
* 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
2026-09-10 22:47:35 +00:00

434 lines
17 KiB
Python

"""Tests for the async expert container graph builder + loader middleware.
The full ``build_expert_container_async_graph()`` factory is exercised end-
to-end at langgraph dev startup; here we cover the load-bearing piece —
``ExpertSkillLoaderMiddleware._compose_prompt`` — in isolation so a
regression on skill resolution surfaces without needing a live langgraph
subprocess.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from langchain_core.messages import SystemMessage
from EvoScientist.subagents.expert_container_async import (
_PERSONA_SENTINEL,
ExpertContainerState,
ExpertSkillLoaderMiddleware,
)
from EvoScientist.tools.skills_manager import SkillInfo
# Two base-stack witness blocks used across the wrap_model_call tests. Their
# contents mirror the section headers deepagents emits per-turn — regressing
# the compose logic would drop these from the composed system_message.
_TASK_WITNESS = "## `task` (subagent spawner)\n\nUse ``task`` to delegate ..."
_SKILLS_WITNESS = "## Skills System\n\nInstalled skills are mounted under ..."
# =============================================================================
# _compose_prompt — the load-bearing logic
# =============================================================================
def _skill_info(
*,
name: str = "literature-review",
role: str = "literature-review strategist",
body: str = "You produce manuscript-quality surveys.\n\nPipeline: ...\n",
description: str = "d",
) -> SkillInfo:
return SkillInfo(
name=name,
description=description,
path=Path("/tmp/does-not-matter"),
source="builtin",
type="expert",
role=role,
body=body,
)
class TestComposePrompt:
def test_returns_role_and_body_for_known_skill(self):
mw = ExpertSkillLoaderMiddleware()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info()],
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
# Role prepended, body preserved, trailing newline guaranteed.
assert composed.startswith("You are literature-review strategist.")
assert "You produce manuscript-quality surveys." in composed
assert composed.endswith("\n")
def test_omits_role_line_when_absent(self):
mw = ExpertSkillLoaderMiddleware()
info = _skill_info(role="", body="Second-person persona body.\n")
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[info],
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
assert not composed.startswith("You are ")
assert "Second-person persona body." in composed
def test_missing_skill_name_returns_error_cue(self):
mw = ExpertSkillLoaderMiddleware()
composed = mw._compose_prompt({})
assert composed.startswith("ERROR:")
assert "skill_name" in composed
assert "wiring bug" in composed
def test_unknown_skill_returns_error_cue_with_installed_list(self):
mw = ExpertSkillLoaderMiddleware()
installed = [_skill_info(name="literature-review"), _skill_info(name="other")]
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=installed,
):
composed = mw._compose_prompt({"skill_name": "not-installed"})
assert composed.startswith("ERROR:")
assert "'not-installed' is not installed" in composed
# Names of the installed experts are listed so the LLM's error
# envelope can suggest the correct spelling.
assert "literature-review" in composed
assert "other" in composed
def test_no_installed_experts_reports_none(self):
mw = ExpertSkillLoaderMiddleware()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills", return_value=[]
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
assert composed.startswith("ERROR:")
assert "(none)" in composed
def test_empty_body_returns_error_cue(self):
"""A skill with an empty SKILL.md body would otherwise run against a
persona-less system prompt (just the role line). Mirror the sync
fold-in's policy: refuse to compose a prompt at all and surface the
skill-authoring bug through the LLM's error envelope."""
mw = ExpertSkillLoaderMiddleware()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info(body="")],
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
assert composed.startswith("ERROR:")
assert "empty SKILL.md body" in composed
assert "literature-review" in composed # names the offending skill
def test_whitespace_only_body_returns_error_cue(self):
"""A body that's just whitespace (` \\n\\n`) is still empty in the
sense that matters — no persona, no pipeline. Same error cue."""
mw = ExpertSkillLoaderMiddleware()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info(body=" \n\n \n")],
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
assert composed.startswith("ERROR:")
assert "empty SKILL.md body" in composed
def test_expert_md_expert_prompted_from_actor_definition(self):
"""EXPERT.md experts are prompted from EXPERT.md, not SKILL.md.
Both files exist for these skills, so composing from ``.body`` would
silently work — and hand the expert a knowledge document written for
a different reader in place of its persona.
"""
mw = ExpertSkillLoaderMiddleware()
info = _skill_info(
name="paper-review",
role="",
body="# Knowledge\n\nThe 5-aspect checklist.\n",
)
info.expert_source = "expert_md"
info.expert_body = "## Persona\n\nYou are an adversarial reviewer.\n"
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[info],
):
composed = mw._compose_prompt({"skill_name": "paper-review"})
assert "You are an adversarial reviewer." in composed
assert "5-aspect checklist" not in composed
def test_empty_actor_definition_names_expert_md_in_error(self):
"""The error cue names the file the author has to fix.
An EXPERT.md expert with a healthy SKILL.md would otherwise be told
its SKILL.md body is empty, sending the author to the wrong file.
"""
mw = ExpertSkillLoaderMiddleware()
info = _skill_info(
name="paper-review",
role="",
body="# Knowledge\n\nPlenty of content here.\n",
)
info.expert_source = "expert_md"
info.expert_body = " \n"
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[info],
):
composed = mw._compose_prompt({"skill_name": "paper-review"})
assert composed.startswith("ERROR:")
assert "empty EXPERT.md body" in composed
assert "paper-review" in composed
def test_runtime_context_tail_surfaces_skill_name(self):
"""The tail block re-asserts ``skill_name`` on every model call so the
expert knows its own persona name after summarization. Since
``output_path`` moved to the task description (payload dropped in
PR #391 review X-4), the tail carries no path — the LLM pins it into
its own todo list per SKILL.md contract."""
mw = ExpertSkillLoaderMiddleware()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info()],
):
composed = mw._compose_prompt({"skill_name": "literature-review"})
assert "## Runtime context" in composed
assert "``skill_name``: ``literature-review``" in composed
# Path retention is no longer a middleware responsibility.
assert "``output_path``" not in composed
assert "verbatim" not in composed
# =============================================================================
# ExpertContainerState — state schema smoke check
# =============================================================================
class TestExpertContainerState:
"""The state schema carries ``skill_name`` only. ``output_path`` was
dropped in PR #391 review X-4 — the main agent now embeds the desired
path in the task description (natural language) and the expert's
SKILL.md contract pins it via ``write_todos`` on turn 1."""
def test_state_shape(self):
# TypedDicts don't runtime-validate — assert the field is declared
# so downstream code can rely on ``state.get("skill_name")``.
annotations = ExpertContainerState.__annotations__
assert "skill_name" in annotations
assert "output_path" not in annotations
# =============================================================================
# wrap_model_call — override via ModelRequest.override
# =============================================================================
def _system_message_with_sentinel_and_witnesses() -> SystemMessage:
"""Base-stack-shaped ``SystemMessage``: the fallback (sentinel-bearing)
block, then two witness blocks that represent deepagents' composed
sections. This is the exact shape our middleware sees at model-call
time when the container graph was built with
``system_prompt=_FALLBACK_SYSTEM_PROMPT`` and the base stack has
appended its sections on top."""
from EvoScientist.subagents.expert_container_async import _FALLBACK_SYSTEM_PROMPT
return SystemMessage(
content=[
{"type": "text", "text": _FALLBACK_SYSTEM_PROMPT},
{"type": "text", "text": _TASK_WITNESS},
{"type": "text", "text": _SKILLS_WITNESS},
]
)
def _mock_request(system_message: SystemMessage):
"""Stub ``ModelRequest`` supporting ``state`` and ``override``. Returns
``(request, seen, handler)`` — ``seen`` is a list the handler pushes the
post-override ``system_message`` into for post-call assertions."""
seen: list[SystemMessage] = []
overridden = SimpleNamespace()
def override(*, system_message):
overridden.system_message = system_message
return overridden
def handler(new_request):
seen.append(new_request.system_message)
return SimpleNamespace()
request = SimpleNamespace(
state={"skill_name": "literature-review"},
system_message=system_message,
override=override,
)
return request, seen, handler
class TestWrapModelCall:
def test_wrap_composes_persona_into_base_stack_system_message(self):
"""Persona swaps for the sentinel block; base-stack witness blocks
stay in place. The whole point of the fix — replacing the whole
system_message (the pre-fix behaviour) dropped every base-stack
section (measured live: 9,608 → 382 chars) and broke ``task()``
for async experts."""
mw = ExpertSkillLoaderMiddleware()
request, seen, handler = _mock_request(
_system_message_with_sentinel_and_witnesses()
)
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info()],
):
mw.wrap_model_call(request, handler)
composed = seen[0]
block_texts = [b.get("text", "") for b in composed.content_blocks]
# Persona landed — role prepend visible.
assert any(
t.startswith("You are literature-review strategist.") for t in block_texts
)
# Sentinel gone (block was replaced, not appended).
assert not any(_PERSONA_SENTINEL in t for t in block_texts)
# Witnesses preserved verbatim — the base-stack sections stay live.
assert _TASK_WITNESS in block_texts
assert _SKILLS_WITNESS in block_texts
# Block count unchanged — replace, not append.
assert len(block_texts) == 3
def test_wrap_appends_persona_when_sentinel_missing(self, caplog):
"""When the sentinel block isn't found (e.g. deepagents refactors
how ``system_prompt=`` reaches ``content_blocks``), the persona is
appended instead of silently dropped, and the drift is logged."""
import logging
mw = ExpertSkillLoaderMiddleware()
# No sentinel block — only witnesses.
request, seen, handler = _mock_request(
SystemMessage(
content=[
{"type": "text", "text": _TASK_WITNESS},
{"type": "text", "text": _SKILLS_WITNESS},
]
)
)
with (
patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_skill_info()],
),
caplog.at_level(
logging.WARNING,
logger="EvoScientist.subagents.expert_container_async",
),
):
mw.wrap_model_call(request, handler)
composed = seen[0]
block_texts = [b.get("text", "") for b in composed.content_blocks]
# Persona appended as a new block.
assert any(
t.startswith("You are literature-review strategist.") for t in block_texts
)
# Witnesses still present.
assert _TASK_WITNESS in block_texts
assert _SKILLS_WITNESS in block_texts
# Original two blocks + persona = 3.
assert len(block_texts) == 3
# Drift-detected warning surfaced.
assert any(
_PERSONA_SENTINEL in r.message and "not found" in r.message
for r in caplog.records
)
class TestSpecWalkSkipsWarnOnce:
"""The resolve-on-miss path re-runs ``build_expert_async_subagent_specs``
on every ``start_async_task`` miss — hallucinated names included — so the
two skip-path warnings must fire once per skill per process, not once per
walk (``skills_manager._warn_once``). Per-walk warnings would fire on
every miss for the rest of the session while the broken skill stays
broken."""
def _walk(self, skills):
from EvoScientist.subagents.expert_container_async import (
build_expert_async_subagent_specs,
)
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
with (
patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=skills,
),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
return build_expert_async_subagent_specs(cfg=cfg)
def test_empty_body_warns_once_across_walks(self, tmp_path, caplog):
"""Two walks over a body-less expert register nothing and warn
exactly once. Without ``_warn_once`` the second walk re-warns and
the count assertion fails."""
import logging
broken = SkillInfo(
name="warn-once-empty-body-expert",
description="d",
path=tmp_path,
source="builtin",
type="expert",
expert_source="expert_md",
expert_body="",
)
with caplog.at_level(
logging.WARNING, logger="EvoScientist.tools.skills_manager"
):
first = self._walk([broken])
second = self._walk([broken])
assert first == []
assert second == []
warnings_ = [r for r in caplog.records if "body is empty" in r.getMessage()]
assert len(warnings_) == 1
def test_name_collision_warns_once_across_walks(self, tmp_path, caplog):
"""Two walks over an expert named after a reserved async sub-agent
register nothing and warn exactly once. Without ``_warn_once`` the
second walk re-warns and the count assertion fails.
The collision name is deliberately NOT one of the real reserved
names (patched in via ``_reserved_subagent_names``, mirroring
``test_route_async_specs.py``): ``_warn_once`` keys are
process-global, so a real reserved name here would consume the
key that suite's own collision test asserts on."""
import logging
colliding = SkillInfo(
name="warn-once-collision-expert",
description="d",
path=tmp_path,
source="builtin",
type="expert",
expert_source="expert_md",
expert_body="Solid persona.\n",
)
with (
patch(
"EvoScientist.subagents.expert_container._reserved_subagent_names",
return_value=frozenset({"warn-once-collision-expert"}),
),
caplog.at_level(
logging.WARNING, logger="EvoScientist.tools.skills_manager"
),
):
first = self._walk([colliding])
second = self._walk([colliding])
assert first == []
assert second == []
warnings_ = [r for r in caplog.records if "collides with" in r.getMessage()]
assert len(warnings_) == 1