Files
EvoScientist-Multi/tests/test_expert_container.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

641 lines
23 KiB
Python

"""Tests for EvoScientist.subagents.expert_container factory."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from EvoScientist.subagents.expert_container import (
_compose_system_prompt,
build_expert_subagent_spec,
build_expert_subagent_specs,
expert_prompt_body,
list_dispatchable_experts,
)
from EvoScientist.tools.skills_manager import SkillInfo
# =============================================================================
# Fixtures
# =============================================================================
def _write_expert_skill_file(
parent: Path,
name: str,
*,
body: str = "You are a test expert.\n\nDo the thing.\n",
role: str = "test expert",
description: str = "A test expert skill",
) -> Path:
"""Write a minimal expert SKILL.md file and return the parent directory."""
skill_dir = parent / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"""---
name: {name}
description: {description}
type: expert
role: {role}
---
{body}"""
)
return skill_dir
def _skill_info(
path: Path,
*,
name: str = "expert-a",
description: str = "A test expert skill",
role: str = "test expert",
) -> SkillInfo:
return SkillInfo(
name=name,
description=description,
path=path,
source="workspace",
type="expert",
role=role,
)
class _FakeTool:
"""Stand-in for a resolved tool callable — the factory only cares that
the value is present in the registry, not what it is."""
def __init__(self, name: str) -> None:
self.name = name
# =============================================================================
# expert_prompt_body
# =============================================================================
class TestExpertPromptBody:
def test_extracts_body_after_frontmatter(self, tmp_path):
skill_dir = _write_expert_skill_file(tmp_path, "expert-a")
info = _skill_info(skill_dir)
body = expert_prompt_body(info)
assert body.startswith("You are a test expert.")
assert "Do the thing." in body
assert "---" not in body
assert "type: expert" not in body
def test_returns_empty_on_missing_file(self, tmp_path, caplog):
# A SkillInfo pointing at a nonexistent SKILL.md — factory should
# gracefully degrade with a warning rather than raise.
info = _skill_info(tmp_path / "nonexistent")
body = expert_prompt_body(info)
assert body == ""
# Warning surfaced — SEV so the malformed skill isn't invisible.
assert any("could not read SKILL.md" in r.message for r in caplog.records)
def test_returns_empty_on_non_utf8_file(self, tmp_path, caplog):
# A SKILL.md whose bytes aren't valid UTF-8. `read_text` raises
# UnicodeDecodeError (not OSError); the factory must degrade to an
# empty body rather than aborting agent construction.
skill_dir = tmp_path / "bad-utf8"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_bytes(b"\xff\xfe garbage")
info = _skill_info(skill_dir, name="bad-utf8")
body = expert_prompt_body(info)
assert body == ""
assert any("could not read SKILL.md" in r.message for r in caplog.records)
def test_handles_no_frontmatter(self, tmp_path):
"""A SKILL.md with no frontmatter — body is the whole file."""
skill_dir = tmp_path / "no-fm"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# Body Only\n\nContent here.\n")
info = _skill_info(skill_dir, name="no-fm")
body = expert_prompt_body(info)
assert "# Body Only" in body
assert "Content here." in body
def test_prefers_cached_body_over_disk_read(self, tmp_path):
"""When ``SkillInfo.body`` is populated (the ``_parse_skill_md`` path),
``expert_prompt_body`` uses it directly without touching disk. Guards against
the double-read regression flagged by pre-PR review."""
info = SkillInfo(
name="cached",
description="d",
path=tmp_path / "does-not-exist",
source="workspace",
type="expert",
body="Cached body content from SkillInfo.",
)
body = expert_prompt_body(info)
assert body == "Cached body content from SkillInfo."
def test_expert_md_expert_reads_actor_definition_not_skill_md(self, tmp_path):
"""An EXPERT.md expert is prompted from its actor definition.
SKILL.md stays pure knowledge under the current contract — it is
reachable in-turn via ``load_skill`` — so leaking it into the system
prompt would both bloat the prompt and hand the expert a document
written for a different reader.
"""
info = SkillInfo(
name="paper-review",
description="d",
path=tmp_path / "paper-review",
source="builtin",
type="expert",
expert_source="expert_md",
body="# Knowledge\n\nThe 5-aspect checklist.\n",
expert_body="## Persona\n\nYou are an adversarial reviewer.\n",
)
body = expert_prompt_body(info)
assert body == "## Persona\n\nYou are an adversarial reviewer.\n"
assert "5-aspect checklist" not in body
def test_expert_md_expert_falls_back_to_disk(self, tmp_path):
"""A hand-built SkillInfo without ``expert_body`` still resolves.
Mirrors the SKILL.md fallback below it — external callers construct
SkillInfo objects without going through ``_parse_skill_md``.
"""
skill_dir = tmp_path / "on-disk"
skill_dir.mkdir()
(skill_dir / "EXPERT.md").write_text("## Persona\n\nFrom disk.\n")
info = SkillInfo(
name="on-disk",
description="d",
path=skill_dir,
source="workspace",
type="expert",
expert_source="expert_md",
body="SKILL.md body that must not be used.",
)
assert expert_prompt_body(info) == "## Persona\n\nFrom disk.\n"
def test_expert_md_expert_returns_empty_when_file_missing(self, tmp_path):
"""Declared expert, no resolvable actor definition -> empty.
Empty is the signal every registration path checks; falling back to
the SKILL.md body here would register a knowledge document as a
persona instead of refusing.
"""
info = SkillInfo(
name="gone",
description="d",
path=tmp_path / "gone",
source="workspace",
type="expert",
expert_source="expert_md",
body="SKILL.md body that must not be used.",
)
assert expert_prompt_body(info) == ""
# =============================================================================
# _compose_system_prompt
# =============================================================================
class TestComposeSystemPrompt:
def test_prepends_role_line_when_present(self):
info = SkillInfo(
name="expert-a",
description="d",
path=Path("/tmp"),
source="workspace",
type="expert",
role="research idea brainstormer",
)
prompt = _compose_system_prompt(info, "Follow these rules.\n")
assert prompt.startswith("You are research idea brainstormer.\n")
assert "Follow these rules." in prompt
def test_omits_role_line_when_absent(self):
info = SkillInfo(
name="expert-a",
description="d",
path=Path("/tmp"),
source="workspace",
type="expert",
role="",
)
prompt = _compose_system_prompt(info, "Do the thing.\n")
assert not prompt.startswith("You are")
assert prompt.rstrip() == "Do the thing."
# =============================================================================
# build_expert_subagent_spec
# =============================================================================
class TestBuildExpertSubagentSpec:
def test_produces_expected_shape(self, tmp_path):
skill_dir = _write_expert_skill_file(
tmp_path,
"expert-a",
body="Second-person persona instructions.\n",
role="research idea brainstormer",
description="Brainstorms research ideas",
)
info = _skill_info(
skill_dir,
name="expert-a",
description="Brainstorms research ideas",
role="research idea brainstormer",
)
registry = {
"think_tool": _FakeTool("think_tool"),
"skill_manager": _FakeTool("skill_manager"),
}
spec = build_expert_subagent_spec(info, tool_registry=registry)
# Same field set as `load_subagents._build_one` returns for a YAML subagent.
assert set(spec.keys()) == {
"name",
"description",
"system_prompt",
"tools",
"skills",
"_async",
}
assert spec["name"] == "expert-a"
assert spec["description"] == "Brainstorms research ideas"
assert spec["_async"] is False
assert spec["skills"] == ["/skills/"]
assert spec["tools"] == [
registry["think_tool"],
registry["skill_manager"],
]
# Role prepended, body preserved.
assert spec["system_prompt"].startswith("You are research idea brainstormer.\n")
assert "Second-person persona instructions." in spec["system_prompt"]
def test_missing_tool_in_registry_is_skipped_not_raised(self, tmp_path, caplog):
skill_dir = _write_expert_skill_file(tmp_path, "expert-a")
info = _skill_info(skill_dir)
# Registry has no `think_tool`. Factory logs a warning and returns
# an empty tools list rather than raising.
spec = build_expert_subagent_spec(info, tool_registry={})
assert spec["tools"] == []
assert any(
"default tool 'think_tool' not in registry" in r.message
for r in caplog.records
)
def test_tool_registry_optional(self, tmp_path):
"""Passing no registry is legal (used by tests / adhoc introspection)."""
skill_dir = _write_expert_skill_file(tmp_path, "expert-a")
info = _skill_info(skill_dir)
spec = build_expert_subagent_spec(info)
# No registry → tools empty; other fields still populated.
assert spec["tools"] == []
assert spec["name"] == "expert-a"
assert spec["system_prompt"]
# =============================================================================
# build_expert_subagent_specs (bulk over list_expert_skills)
# =============================================================================
class TestBuildExpertSubagentSpecs:
def test_returns_one_spec_per_installed_expert_skill(self, tmp_path):
# Two expert skills + one utility skill.
_write_expert_skill_file(tmp_path, "expert-a")
_write_expert_skill_file(tmp_path, "expert-b")
util = tmp_path / "util-c"
util.mkdir()
(util / "SKILL.md").write_text(
"""---
name: util-c
description: Not an expert
---
# Body
"""
)
registry = {
"think_tool": _FakeTool("think_tool"),
"skill_manager": _FakeTool("skill_manager"),
}
# Patch USER_SKILLS_DIR to point at our temp dir; patch GLOBAL and
# SKILLS_DIR to empty locations so `list_expert_skills(include_system=True)`
# only surfaces our two experts.
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", tmp_path),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_dir),
patch("EvoScientist.EvoScientist.SKILLS_DIR", str(empty_dir)),
):
specs = build_expert_subagent_specs(tool_registry=registry)
names = sorted(s["name"] for s in specs)
assert names == ["expert-a", "expert-b"]
for s in specs:
assert s["_async"] is False
assert s["skills"] == ["/skills/"]
assert s["tools"] == [
registry["think_tool"],
registry["skill_manager"],
]
def test_skips_expert_with_empty_body(self, tmp_path, caplog):
# A well-formed expert-frontmatter skill whose body is only whitespace.
# Registering it would advertise a personaless expert in the `task`
# schema — cleaner to drop it and log.
_write_expert_skill_file(tmp_path, "expert-a")
blank = tmp_path / "expert-blank"
blank.mkdir()
(blank / "SKILL.md").write_text(
"""---
name: expert-blank
description: An expert with no body
type: expert
role: blank
---
"""
)
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", tmp_path),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_dir),
patch("EvoScientist.EvoScientist.SKILLS_DIR", str(empty_dir)),
):
specs = build_expert_subagent_specs(tool_registry={})
assert [s["name"] for s in specs] == ["expert-a"]
assert any(
"SKILL.md body is empty" in r.message and "expert-blank" in r.message
for r in caplog.records
)
def test_expert_md_expert_included_in_sync_registry(self, tmp_path):
"""Both contracts land in the in-turn registry, and its prompt is EXPERT.md.
Every expert gets both reaches, so an EXPERT.md skill appears here
as well as in the async specs. Sharing the name across the two is
safe: they land on different tools with separate schemas.
"""
_write_expert_skill_file(tmp_path, "legacy-expert")
actor = tmp_path / "new-expert"
actor.mkdir()
(actor / "SKILL.md").write_text(
"""---
name: new-expert
description: Knowledge only
---
# Knowledge
The workflow.
"""
)
(actor / "EXPERT.md").write_text("## Persona\n\nYou are the new expert.\n")
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", tmp_path),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_dir),
patch("EvoScientist.EvoScientist.SKILLS_DIR", str(empty_dir)),
):
specs = build_expert_subagent_specs(tool_registry={})
by_name = {s["name"]: s for s in specs}
assert set(by_name) == {"legacy-expert", "new-expert"}
# Prompted from the actor definition, not the knowledge file.
assert "You are the new expert." in by_name["new-expert"]["system_prompt"]
assert "The workflow." not in by_name["new-expert"]["system_prompt"]
def test_legacy_async_dispatch_still_in_sync_registry(self, tmp_path):
"""A legacy ``default_dispatch: async`` skill still gets an in-turn spec.
``default_dispatch`` is no longer read: every expert is reachable both
ways and the orchestrator picks per task, so an async-declared legacy
skill must not be dropped from the sync registry. This is the
behavioural counterpart to the parser's not-read contract.
"""
actor = tmp_path / "async-legacy"
actor.mkdir()
(actor / "SKILL.md").write_text(
"""---
name: async-legacy
description: Legacy expert that declared async dispatch
type: expert
role: legacy async expert
default_dispatch: async
---
You are the legacy async expert.
"""
)
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", tmp_path),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_dir),
patch("EvoScientist.EvoScientist.SKILLS_DIR", str(empty_dir)),
):
specs = build_expert_subagent_specs(tool_registry={})
assert "async-legacy" in {s["name"] for s in specs}
def test_returns_empty_when_no_expert_skills(self, tmp_path):
# A utility skill only — no experts.
util = tmp_path / "util-only"
util.mkdir()
(util / "SKILL.md").write_text(
"""---
name: util-only
description: Utility
---
# Body
"""
)
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", tmp_path),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_dir),
patch("EvoScientist.EvoScientist.SKILLS_DIR", str(empty_dir)),
):
specs = build_expert_subagent_specs(tool_registry={})
assert specs == []
# =============================================================================
# _fold_expert_subagents (name-collision guard shared by both construction paths)
# =============================================================================
def _spec(name: str) -> dict:
"""Minimal expert spec — the fold helper only reads ``name``."""
return {"name": name, "description": f"{name} expert"}
class TestFoldExpertSubagents:
"""Both ``_build_base_kwargs`` and ``load_mcp_and_build_kwargs`` delegate
to ``_fold_expert_subagents``, so testing the helper directly covers the
"same behaviour in both paths" reviewer requirement."""
def test_appends_expert_specs_when_no_collisions(self):
from EvoScientist.EvoScientist import _fold_expert_subagents
subs: list[dict] = [{"name": "research"}, {"name": "code"}]
with patch(
"EvoScientist.subagents.expert_container.build_expert_subagent_specs",
return_value=[_spec("idea-brainstorm"), _spec("critic")],
):
_fold_expert_subagents(subs, tool_registry={})
assert [s["name"] for s in subs] == [
"research",
"code",
"idea-brainstorm",
"critic",
]
def test_skips_expert_that_collides_with_yaml_subagent(self, caplog):
from EvoScientist.EvoScientist import _fold_expert_subagents
subs: list[dict] = [{"name": "research"}, {"name": "planner"}]
with patch(
"EvoScientist.subagents.expert_container.build_expert_subagent_specs",
return_value=[_spec("planner"), _spec("idea-brainstorm")],
):
_fold_expert_subagents(subs, tool_registry={})
# Colliding expert dropped; non-colliding one appended.
assert [s["name"] for s in subs] == [
"research",
"planner",
"idea-brainstorm",
]
# Original YAML `planner` untouched (not shadowed by the expert).
assert subs[1] == {"name": "planner"}
assert any(
"collides with an existing sub-agent name" in r.message
and "planner" in r.message
for r in caplog.records
)
def test_skips_duplicate_expert_names(self, caplog):
from EvoScientist.EvoScientist import _fold_expert_subagents
subs: list[dict] = []
with patch(
"EvoScientist.subagents.expert_container.build_expert_subagent_specs",
return_value=[_spec("critic"), _spec("critic")],
):
_fold_expert_subagents(subs, tool_registry={})
assert [s["name"] for s in subs] == ["critic"]
assert any(
"collides with an existing sub-agent name" in r.message
and "critic" in r.message
for r in caplog.records
)
def test_reserves_general_purpose_name(self, caplog):
"""The default subagent slot is reserved even when no ``general-purpose``
entry exists in ``subs`` yet — ``_ensure_general_purpose_subagent``
runs right after the fold and would otherwise treat the expert entry
as the default subagent, silently losing the DeepAgents default prompt."""
from EvoScientist.EvoScientist import _fold_expert_subagents
subs: list[dict] = [{"name": "research"}]
with patch(
"EvoScientist.subagents.expert_container.build_expert_subagent_specs",
return_value=[_spec("general-purpose")],
):
_fold_expert_subagents(subs, tool_registry={})
assert [s["name"] for s in subs] == ["research"]
assert any(
"collides with an existing sub-agent name" in r.message
and "general-purpose" in r.message
for r in caplog.records
)
def test_forwards_tool_registry_to_specs_factory(self):
from EvoScientist.EvoScientist import _fold_expert_subagents
registry = {"think_tool": object()}
with patch(
"EvoScientist.subagents.expert_container.build_expert_subagent_specs",
return_value=[],
) as mock_specs:
_fold_expert_subagents([], tool_registry=registry)
mock_specs.assert_called_once_with(tool_registry=registry)
# =============================================================================
# list_dispatchable_experts honest surface
# =============================================================================
class TestListDispatchableExpertsSurvivesAsyncOutage:
"""``list_dispatchable_experts`` never drops an expert for async reasons.
Under the old per-skill classification an async-declared expert vanished
from every surface whenever ``enable_async_subagents`` was off or
langgraph dev was unreachable — installed, listed in the gallery, and
reachable by nothing. Every expert now keeps its in-turn reach, so an
async outage degrades the reach rather than removing the expert.
"""
def _skill(self, name: str) -> SkillInfo:
return SkillInfo(
name=name,
description=f"{name} description",
path=Path("/tmp/nope"),
source="builtin",
type="expert",
role=f"{name} role",
body="persona body\n",
)
def test_experts_survive_async_flag_disabled(self):
cfg = SimpleNamespace(enable_async_subagents=False)
skills = [self._skill("idea-brainstorm"), self._skill("lit")]
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=skills,
):
result = list_dispatchable_experts(cfg=cfg)
assert {s.name for s in result} == {"idea-brainstorm", "lit"}
def test_experts_survive_dev_unreachable(self):
cfg = SimpleNamespace(enable_async_subagents=True)
skills = [self._skill("idea-brainstorm"), self._skill("lit")]
with (
patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=skills,
),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=False,
),
):
result = list_dispatchable_experts(cfg=cfg)
assert {s.name for s in result} == {"idea-brainstorm", "lit"}
def test_empty_actor_definition_still_dropped(self):
"""The filters that remain are about broken experts, not reach."""
cfg = SimpleNamespace(enable_async_subagents=True)
blank = self._skill("blank")
blank.body = " \n"
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[self._skill("idea-brainstorm"), blank],
):
result = list_dispatchable_experts(cfg=cfg)
assert [s.name for s in result] == ["idea-brainstorm"]