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
This commit is contained in:
jfilipiuk
2026-09-11 00:47:35 +02:00
committed by GitHub
parent 9bd7a37a77
commit be8c23861d
14 changed files with 1093 additions and 180 deletions
+82 -49
View File
@@ -575,10 +575,14 @@ def _route_async_specs_through_evo_middleware(
route all async dispatch through our subclass, we strip AsyncSubAgent
specs from ``subs`` here and hand them to our middleware.
Also folds in ``AsyncSubAgent`` specs for installed
installed expert skills — all pointing at the shared
``expert-container-async`` graph, marked ``is_expert=True`` so the
middleware requires a payload with ``skill_name``.
Also folds in ``AsyncSubAgent`` specs for installed expert skills —
all pointing at the shared ``expert-container-async`` graph, marked
``is_expert=True`` so the middleware requires a payload with
``skill_name``.
The completion watcher (``AsyncWatcherMiddleware``) is found or created
before the middleware so the middleware's resolve-on-miss start tool can
hold the watcher's agent dict by reference — see the wiring block below.
Returns:
``subs`` with ``graph_id``-carrying entries removed. Safe to pass
@@ -596,6 +600,71 @@ def _route_async_specs_through_evo_middleware(
expert_specs = build_expert_async_subagent_specs(cfg=cfg)
async_specs.extend(expert_specs)
# Find or create the completion watcher BEFORE constructing
# ``EvoAsyncSubAgentMiddleware``: the middleware's resolve-on-miss start
# tool must hold the watcher's agent dict by reference, so an expert
# discovered mid-session lands in the dispatch table and the watcher in
# one step. Without the watcher update, dispatch succeeds but the
# watcher's ``get_async(agent_name)`` raises KeyError inside its
# ``try/except`` and the completion notification silently never fires.
watcher_agents: dict | None = None
watcher = next(
(m for m in base_middleware if isinstance(m, AsyncWatcherMiddleware)),
None,
)
if watcher is None:
# No YAML async subagents were registered, so ``_maybe_swap`` did
# not install the watcher. Install it now so experts still get
# completion notifications. Appended before the middleware's
# index-0 insert below, which yields the same final order as
# append-after-insert: ``[EvoAsync..., ..., watcher]``.
if expert_specs:
from .cli import async_notifier
watcher = AsyncWatcherMiddleware(
{s["name"]: s for s in expert_specs},
notifier=async_notifier,
)
base_middleware.append(watcher)
elif expert_specs:
# Extend AsyncWatcherMiddleware's client cache with expert specs so
# start_async_task launches for experts spawn a completion watcher —
# otherwise the watcher's ``get_async(agent_name)`` KeyErrors on the
# expert name, no notification is enqueued, and the main agent never
# learns the task finished. ``_maybe_swap_async_subagents`` above only
# populates the watcher with YAML-defined async subagents
# (writing-agent, data-analysis-agent, scheduler); this hook folds in
# the experts too.
#
# The mutation reaches through two layers of private state:
# ``AsyncWatcherMiddleware._clients`` (our own) and
# ``_ClientCache._agents`` (upstream deepagents). If upstream ever
# renames ``_agents`` or wraps it in an immutable snapshot, the
# ``.update(...)`` below silently lands on nothing — expert
# completion nudges then stop firing without a diagnostic surface.
# Convert that silent-drop into a grep-able error line and leave
# ``watcher_agents`` unset; expert dispatches still work (without
# completion notifications and without mid-session resolution into
# the watcher) until upstream drift is fixed.
if not hasattr(watcher._clients, "_agents"):
logging.getLogger(__name__).error(
"AsyncWatcherMiddleware._clients has no `_agents` slot — "
"deepagents internal renamed; expert completion "
"notifications will not fire until the extension hook is "
"updated to the new attribute name."
)
watcher = None
else:
watcher._clients._agents.update({s["name"]: s for s in expert_specs})
# The second ``hasattr`` is not redundant with the one in the ``elif``
# above: that check only runs when ``expert_specs`` is non-empty. When a
# pre-existing watcher has an empty expert set, this is the only guard
# standing between an upstream rename of ``_ClientCache._agents`` and an
# AttributeError that would kill agent construction — without it, the
# drift degrades to "no completion nudges" instead of crashing.
if watcher is not None and hasattr(watcher._clients, "_agents"):
watcher_agents = watcher._clients._agents
if async_specs:
# ``_maybe_swap_async_subagents`` installs the model-passthrough patch
# only when the yaml-async spec list is non-empty. An expert-only setup
@@ -612,52 +681,16 @@ def _route_async_specs_through_evo_middleware(
# volatile memory tail, invalidating the cached prefix on every
# memory change.
base_middleware.insert(
0, EvoAsyncSubAgentMiddleware(async_subagents=async_specs)
0,
EvoAsyncSubAgentMiddleware(
async_subagents=async_specs,
watcher_agents=watcher_agents,
# The construction cfg, so resolve-on-miss specs the same
# langgraph_dev_port the construction-time specs used instead
# of re-reading config from disk at dispatch time.
cfg=cfg,
),
)
# Extend AsyncWatcherMiddleware's client cache with expert specs so
# start_async_task launches for experts spawn a completion watcher —
# otherwise the watcher's ``get_async(agent_name)`` KeyErrors on the
# expert name, no notification is enqueued, and the main agent never
# learns the task finished. ``_maybe_swap_async_subagents`` above only
# populates the watcher with YAML-defined async subagents (writing-agent,
# data-analysis-agent, scheduler); this hook folds in the experts too.
if expert_specs:
watcher = next(
(m for m in base_middleware if isinstance(m, AsyncWatcherMiddleware)),
None,
)
if watcher is not None:
# The mutation reaches through two layers of private state:
# ``AsyncWatcherMiddleware._clients`` (our own) and
# ``_ClientCache._agents`` (upstream deepagents). If upstream ever
# renames ``_agents`` or wraps it in an immutable snapshot, the
# ``.update(...)`` below silently lands on nothing — expert
# completion nudges then stop firing without a diagnostic surface.
# Convert that silent-drop into a grep-able error line and bail
# out of the extension path; expert dispatches still work, just
# without completion notifications until upstream drift is fixed.
if not hasattr(watcher._clients, "_agents"):
logging.getLogger(__name__).error(
"AsyncWatcherMiddleware._clients has no `_agents` slot — "
"deepagents internal renamed; expert completion "
"notifications will not fire until the extension hook is "
"updated to the new attribute name."
)
return sync_subs
watcher._clients._agents.update({s["name"]: s for s in expert_specs})
else:
# No YAML async subagents were registered, so ``_maybe_swap`` did
# not install the watcher. Install it now so experts still get
# completion notifications.
from .cli import async_notifier
base_middleware.append(
AsyncWatcherMiddleware(
{s["name"]: s for s in expert_specs},
notifier=async_notifier,
)
)
return sync_subs
@@ -229,11 +229,18 @@ class ExpertCommand(Command):
else:
runtime.active_teams = [*runtime.active_teams, canonical]
ctx.ui.append_system(f"Invited expert: {canonical}", style="green")
# Case (c): expert was installed after agent construction, so it
# will not reach ``task()`` until the graph is rebuilt. Cheap
# always-print hint mirrors the /install-skill success message.
# An expert installed mid-session: the background reach
# (``start_async_task``) resolves it on first dispatch, but the
# in-turn ``task`` reach is frozen into the running agent, so it
# needs a rebuilt agent. An expert installed before this session
# started is already inside that frozen set — its in-turn reach
# works without a rebuild — so the hint scopes the /new boundary
# to newly installed experts instead of stating it
# unconditionally.
ctx.ui.append_system(
"If just installed, run /new to activate it.", style="dim"
"Newly installed experts: background dispatch is available "
"immediately; in-turn task dispatch needs /new.",
style="dim",
)
if runtime.active_teams:
ctx.ui.append_system(
+2 -2
View File
@@ -79,14 +79,14 @@ async def get_teams(_request: Request) -> JSONResponse:
"""Return installed expert skills as ``{teams: [...]}`` for the WebUI gallery.
A "team" in the WebUI vocabulary is an installed expert skill — a skill
directory carrying a sibling ``AGENTS.md`` (or, on the deprecated path,
directory carrying a sibling ``EXPERT.md`` (or, on the deprecated path,
``type: expert`` SKILL.md frontmatter). The response is a curated,
gallery-safe projection: name + description, plus optional ``byline`` /
``capability_tags`` / ``avatar_hint`` when the skill populates them.
Cards for experts on the current contract carry name + description only:
the decoration fields were actor metadata in SKILL.md frontmatter, which
that contract removes rather than relocates (``AGENTS.md`` has no
that contract removes rather than relocates (``EXPERT.md`` has no
frontmatter to hold them). The omit-when-unpopulated projection below is
what makes those cards degrade rather than break; restoring richer cards
means sourcing decoration from index metadata, not re-adding frontmatter
@@ -29,6 +29,19 @@ Design
marker so the middleware knows when to add ``skill_name`` to the run
input. Standard specs (``writing-agent`` / ``data-analysis-agent`` /
``scheduler``) reach ``client.runs.create`` with the upstream shape.
- Resolve-on-miss: when ``start_async_task`` is asked for a
``subagent_type`` absent from ``agent_map`` — typically an expert
installed after the agent was built — the tool runs one
``build_expert_async_subagent_specs`` walk and merges every unknown
expert into ``agent_map`` and the watcher's agent dict before
re-validating (see ``_resolve_merge_validate``). New experts become
background-dispatchable the first time they are named, with no agent
rebuild, no registry watcher, and no restart; in-turn ``task`` reach
for a new expert still requires a rebuilt agent (``/new``). The merge
and the map-iterating validation serialize on one per-instance lock
(``self._resolve_lock``); the event loop never touches it — the async
variant miss-checks by keyed lookup and does all lock work on the
``asyncio.to_thread`` worker.
If deepagents ever lands a skill-name-passthrough of its own, delete this
file and rebind ``EvoAsyncSubAgentMiddleware`` → ``AsyncSubAgentMiddleware``
@@ -43,7 +56,9 @@ check, and gets stripped from tool_input at parse time — the coroutine is
then called without ``runtime`` and raises ``TypeError``.
"""
import asyncio
import logging
import threading
from datetime import UTC, datetime
from typing import Any, NotRequired
@@ -125,10 +140,111 @@ def _build_task_envelope(
)
def _resolve_merge_validate(
agent_map: dict[str, AsyncSubAgent],
watcher_agents: dict[str, AsyncSubAgent] | None,
cfg: Any | None,
subagent_type: str,
lock: Any = None,
) -> str | None:
"""Resolve a start-tool miss, merge the walk's specs, re-validate.
Called from ``start_async_task`` only when ``subagent_type`` missed
``agent_map`` — the resolve-on-miss path that makes an expert installed
mid-session dispatchable without an agent rebuild. Returns the
refreshed ``_validate_agent_type`` error for *subagent_type*: ``None``
when the walk resolved it, upstream's unknown-type message (with the
now-updated allowed-type list) for a genuine miss. Both dicts are
mutated in place; the middleware and its tools hold them by reference,
so the update is visible to every tool that resolves a name at call
time (start / check / update / cancel all reach ``agent_map`` or
``_ClientCache._agents``, which share the object).
One walk, every unknown expert: ``build_expert_async_subagent_specs``
already walks the whole skills tree, so merging every not-yet-known
spec costs nothing extra and N newly installed experts resolve on the
first miss rather than one walk each.
``setdefault`` semantics on both dicts — an existing entry is never
overwritten. The middleware's constructor already raised on duplicate
names at build time, so an overwrite here could only smuggle in a spec
the running agent was not validated against.
Known limitation — installs only, never uninstalls: the merge adds
names, nothing removes them, so an expert uninstalled mid-session
stays in the dispatch tables until the next agent rebuild (``/new``).
Its runs fail late — the container graph reads the persona from disk
at dispatch time and reports the unknown skill — rather than at this
start-tool boundary.
*cfg* is the config the agent was constructed with, threaded through
the middleware. The specs must point at the same ``langgraph_dev_port``
the construction-time specs used — re-deriving config from disk here
(the builder's ``get_effective_config()`` fallback) would let a
mid-session port change spec a newly resolved expert onto a port the
running dev subprocess is not on: dispatch accepts the name and only
``runs.create`` fails, an advertise/provide split.
*lock* serializes the merge AND the re-validation — both run under one
acquisition — against every other ``_validate_agent_type`` reader of
``agent_map`` (the sync start tool's initial validation), which
iterates the map to build its error string: an unsynchronized insert
under that reader raises ``RuntimeError: dictionary changed size
during iteration``. The skills-tree walk runs OUTSIDE the lock; only
the ``setdefault`` loop and the validation — microseconds of pure
dict operations — hold it. Keyed lookups (``_ClientCache.get_sync`` /
``get_async``, the update tool) are single GIL-protected operations
and need no lock.
``watcher_agents`` is ``AsyncWatcherMiddleware._clients._agents`` —
a *separate* dict from ``agent_map`` (the watcher's cache was built
from its own spec list). Without updating it, dispatch succeeds but
the watcher's ``get_async(agent_name)`` raises KeyError inside its
``try/except``, and the completion notification silently never fires.
``None`` means no watcher is wired (yaml-async-less setup, or the
upstream ``_agents`` drift guard tripped): dispatch still resolves,
just without completion nudges — matching the pre-existing degradation.
Blocking (a skills-tree walk under ``list_expert_skills``); callers on
an event loop must run it via ``asyncio.to_thread``.
"""
from ..subagents.expert_container_async import build_expert_async_subagent_specs
# The walk is the blocking part — never hold the lock over I/O.
specs = build_expert_async_subagent_specs(cfg=cfg)
if lock is not None:
with lock:
_merge_expert_specs(agent_map, watcher_agents, specs)
return _validate_agent_type(agent_map, subagent_type)
_merge_expert_specs(agent_map, watcher_agents, specs)
return _validate_agent_type(agent_map, subagent_type)
def _merge_expert_specs(
agent_map: dict[str, AsyncSubAgent],
watcher_agents: dict[str, AsyncSubAgent] | None,
specs: list,
) -> None:
"""Merge built expert specs into both dispatch tables.
Split out of ``_resolve_merge_validate`` so the lock guards exactly
this — microseconds of ``setdefault`` — and not the skills-tree walk
that produced *specs*.
"""
for spec in specs:
name = spec["name"]
agent_map.setdefault(name, spec)
if watcher_agents is not None:
watcher_agents.setdefault(name, spec)
def _build_expert_start_tool(
agent_map: dict[str, AsyncSubAgent],
clients: _ClientCache,
tool_description: str,
watcher_agents: dict[str, AsyncSubAgent] | None = None,
cfg: Any | None = None,
map_lock: Any = None,
) -> StructuredTool:
"""Build the skill-name-injecting ``start_async_task`` tool.
@@ -137,16 +253,55 @@ def _build_expert_start_tool(
injects ``skill_name=subagent_type`` into the run input before
dispatch, so the container graph resolves the right persona without
the model contributing (or being able to corrupt) that value.
An unknown ``subagent_type`` triggers one resolve-on-miss pass before
the error is returned (see ``_resolve_merge_validate``); a name that
is still unknown after it is a genuine miss and gets upstream's error
message, now with the refreshed allowed-type list.
``map_lock`` serializes every ``agent_map`` *iteration* against the
resolver's merge: ``_validate_agent_type`` builds its error string by
joining over the map, so an unsynchronized insert from the async
resolver's worker thread (or a concurrent sync miss on another
tool-executor thread) can raise ``RuntimeError: dictionary changed
size during iteration`` under the reader. The two variants divide the
work differently:
- the sync variant validates under the lock up front and delegates
the miss to ``_resolve_merge_validate`` (merge and re-validation
share one lock acquisition, on this tool-executor thread);
- the async variant only does a keyed ``subagent_type not in
agent_map`` check on the event loop — no iteration, and the loop
never touches the lock; the miss path runs merge + re-validation
inside one ``asyncio.to_thread`` acquisition on the worker thread
and returns the refreshed error.
"""
def _locked_validate(agent_type: str) -> str | None:
"""``_validate_agent_type`` under ``map_lock`` when provided.
The validation error message iterates ``agent_map``; the resolver
merges into it under the same lock. Used by the sync variant's
initial validation only. ``None`` lock degrades to the unguarded
read, matching pre-lock behavior.
"""
if map_lock is not None:
with map_lock:
return _validate_agent_type(agent_map, agent_type)
return _validate_agent_type(agent_map, agent_type)
def start_async_task(
description: str,
subagent_type: str,
runtime: ToolRuntime,
) -> str | Command:
error = _validate_agent_type(agent_map, subagent_type)
error = _locked_validate(subagent_type)
if error:
return error
error = _resolve_merge_validate(
agent_map, watcher_agents, cfg, subagent_type, map_lock
)
if error:
return error
spec = agent_map[subagent_type]
input_dict = _build_run_input(spec, subagent_type, description)
try:
@@ -171,9 +326,24 @@ def _build_expert_start_tool(
subagent_type: str,
runtime: ToolRuntime,
) -> str | Command:
error = _validate_agent_type(agent_map, subagent_type)
if error:
return error
# Keyed miss check — no map iteration, and the event loop never
# touches the lock: all lock work runs on the to_thread worker.
# (The validation error message joins over ``agent_map``, so it
# cannot run unlocked here; it runs inside the worker instead.)
if subagent_type not in agent_map:
# to_thread: the resolver walks the skills tree synchronously,
# and this coroutine runs on the event loop where langgraph-dev's
# blockbuster guard raises BlockingError on filesystem calls.
error = await asyncio.to_thread(
_resolve_merge_validate,
agent_map,
watcher_agents,
cfg,
subagent_type,
map_lock,
)
if error:
return error
spec = agent_map[subagent_type]
input_dict = _build_run_input(spec, subagent_type, description)
try:
@@ -223,6 +393,8 @@ class EvoAsyncSubAgentMiddleware(AsyncSubAgentMiddleware):
*,
async_subagents: list[AsyncSubAgent],
system_prompt: str | None = None,
watcher_agents: dict[str, AsyncSubAgent] | None = None,
cfg: Any | None = None,
) -> None:
# Install the model-passthrough patch BEFORE ``super().__init__(...)``
# so upstream's ``_build_async_subagent_tools`` sees the patched
@@ -263,8 +435,24 @@ class EvoAsyncSubAgentMiddleware(AsyncSubAgentMiddleware):
f"- {a['name']}: {a['description']}" for a in async_subagents
)
launch_desc = ASYNC_TASK_TOOL_DESCRIPTION.format(available_agents=agents_desc)
# Serializes ``agent_map`` iteration (the sync start tool's
# validation and the resolver's merge + re-validation, whose error
# message joins over the map) against the resolve-on-miss merge,
# which can run on a worker thread (``asyncio.to_thread`` in the
# async variant) while the event loop keeps reading. Instance-
# scoped: the map is per-middleware, so the lock is too. The async
# variant's event loop never acquires it — the miss check there is
# a keyed lookup and all lock work happens on the worker thread.
self._resolve_lock = threading.Lock()
self.tools = [
_build_expert_start_tool(agent_map, clients, launch_desc),
_build_expert_start_tool(
agent_map,
clients,
launch_desc,
watcher_agents,
cfg,
self._resolve_lock,
),
_build_check_tool(clients),
_build_update_tool(agent_map, clients),
_build_cancel_tool(clients),
+10 -10
View File
@@ -7,7 +7,7 @@ into its subagent list at construction time so the `task` tool can dispatch to
each installed expert in-turn; the same registry is reused by the QuickJS
`task()` global for in-eval fan-out.
A skill declares itself an expert by carrying a sibling `AGENTS.md`, whose body
A skill declares itself an expert by carrying a sibling `EXPERT.md`, whose body
is the actor definition and therefore the runtime prompt — see
`skills_manager._parse_skill_md`. THIS module builds the in-turn (`task()`) spec
for such experts and for legacy `type: expert` frontmatter experts alike;
@@ -55,7 +55,7 @@ def expert_prompt_body(skill_info: SkillInfo) -> str:
it differently, and splitting that decision across call sites is how a
skill ends up registered off one file and prompted off another:
- ``expert_source == "agents_md"`` — the sibling AGENTS.md body (persona
- ``expert_source == "expert_md"`` — the sibling EXPERT.md body (persona
+ result envelope). SKILL.md stays pure knowledge and stays readable
in-turn off the ``/skills/`` mount, so it is deliberately NOT the
prompt here.
@@ -68,12 +68,12 @@ def expert_prompt_body(skill_info: SkillInfo) -> str:
body populated. Returns an empty string if the file can't be read; the
callers treat that as "refuse to register this expert".
"""
if skill_info.expert_source == "agents_md":
if skill_info.agents_body:
return skill_info.agents_body
from ..tools.skills_manager import _read_agents_md
if skill_info.expert_source == "expert_md":
if skill_info.expert_body:
return skill_info.expert_body
from ..tools.skills_manager import _read_expert_md
return _read_agents_md(skill_info.path) or ""
return _read_expert_md(skill_info.path) or ""
if skill_info.body:
return skill_info.body
@@ -95,13 +95,13 @@ def expert_prompt_body(skill_info: SkillInfo) -> str:
def _compose_system_prompt(skill_info: SkillInfo, body: str) -> str:
"""Compose the expert's system_prompt from its role + prompt body.
*body* is what :func:`expert_prompt_body` resolved — an AGENTS.md actor
*body* is what :func:`expert_prompt_body` resolved — an EXPERT.md actor
definition, or a legacy expert's SKILL.md body. Either way it carries
the persona voice, rubrics, and output-style instructions, written in
second person addressing the expert itself.
The legacy `role` frontmatter (one-line role summary) is prepended as an
orientation line when set. AGENTS.md experts declare no `role` — their
orientation line when set. EXPERT.md experts declare no `role` — their
persona section opens with the same orientation in prose — so for those
the body passes through untouched.
"""
@@ -264,7 +264,7 @@ def build_expert_subagent_specs(
_logger.warning(
"Expert skill %r: %s body is empty; skipping registration.",
info.name,
"AGENTS.md" if info.expert_source == "agents_md" else "SKILL.md",
"EXPERT.md" if info.expert_source == "expert_md" else "SKILL.md",
)
continue
specs.append(build_expert_subagent_spec(info, tool_registry=tool_registry))
@@ -2,7 +2,7 @@
One generic graph that reads ``skill_name`` from initial state and loads
that expert skill's actor definition as the sub-agent's system prompt at
invocation time — its ``AGENTS.md`` body under the current skill contract,
invocation time — its ``EXPERT.md`` body under the current skill contract,
or its ``SKILL.md`` body for legacy ``type: expert`` skills. Registered once
in ``langgraph.json``; parameterised per run via the payload the main
agent's ``start_async_task`` passes through
@@ -81,7 +81,7 @@ class ExpertSkillLoaderMiddleware(AgentMiddleware[Any, Any, Any]):
Reads ``state.skill_name``, resolves the corresponding installed expert
skill via ``list_expert_skills()``, composes the system message from
``role`` + the skill's prompt body (AGENTS.md under the current contract,
``role`` + the skill's prompt body (EXPERT.md under the current contract,
SKILL.md for legacy frontmatter experts — resolved by
``expert_container.expert_prompt_body``, mirroring the sync path in
``expert_container._compose_system_prompt``), and overrides
@@ -141,7 +141,7 @@ class ExpertSkillLoaderMiddleware(AgentMiddleware[Any, Any, Any]):
# nonsense.
#
# Which file is checked follows the skill's contract:
# ``expert_prompt_body`` reads AGENTS.md for experts declared that
# ``expert_prompt_body`` reads EXPERT.md for experts declared that
# way and SKILL.md for legacy frontmatter experts. Checking ``.body``
# directly would clear a paper-review-shaped skill on the strength of
# its knowledge file while its actor definition is empty.
@@ -150,7 +150,7 @@ class ExpertSkillLoaderMiddleware(AgentMiddleware[Any, Any, Any]):
body = expert_prompt_body(match)
if not body.strip():
source_file = (
"AGENTS.md" if match.expert_source == "agents_md" else "SKILL.md"
"EXPERT.md" if match.expert_source == "expert_md" else "SKILL.md"
)
return (
f"ERROR: Expert skill '{skill_name}' has an empty {source_file} "
@@ -221,6 +221,13 @@ class ExpertSkillLoaderMiddleware(AgentMiddleware[Any, Any, Any]):
def build_expert_async_subagent_specs(cfg: Any | None = None) -> list[dict[str, Any]]:
"""Build ``AsyncSubAgent``-shaped specs for every installed expert skill.
Called from two places: agent construction (fold-in via
``EvoScientist.py::_route_async_specs_through_evo_middleware``) and the
resolve-on-miss path in ``middleware/expert_async_subagent.py`` — an
expert installed mid-session is spec'd here the first time
``start_async_task`` names it, which is what makes background dispatch
live without an agent rebuild.
Every expert gets a background reach here, and
``build_expert_subagent_specs`` independently gives every expert an
in-turn reach. Nothing classifies a skill into one or the other — the
@@ -249,7 +256,7 @@ def build_expert_async_subagent_specs(cfg: Any | None = None) -> list[dict[str,
if not is_async_subagents_available():
return []
from ..tools.skills_manager import list_expert_skills
from ..tools.skills_manager import _warn_once, list_expert_skills
from .expert_container import _reserved_subagent_names, expert_prompt_body
port = int(getattr(cfg, "langgraph_dev_port", 6174))
@@ -270,16 +277,24 @@ def build_expert_async_subagent_specs(cfg: Any | None = None) -> list[dict[str,
# a body-less expert in ``start_async_task``'s tool schema, then
# rejecting it at loader time, wastes a launch round-trip; filter
# upstream so ``start_async_task`` never sees the broken skill.
# The warnings go through ``_warn_once``: the sync fold-in runs
# once per agent build, but the resolve-on-miss path in
# ``middleware/expert_async_subagent.py`` re-runs this walk on
# every ``start_async_task`` miss — hallucinated names included —
# so a per-walk warning would fire once per miss for the rest
# of the session while the broken skill stays broken.
if not expert_prompt_body(skill).strip():
_logger.warning(
_warn_once(
f"expert-async-empty-body:{skill.name}",
"Expert skill %r: %s body is empty; skipping "
"async-dispatch registration.",
skill.name,
"AGENTS.md" if skill.expert_source == "agents_md" else "SKILL.md",
"EXPERT.md" if skill.expert_source == "expert_md" else "SKILL.md",
)
continue
if skill.name in taken:
_logger.warning(
_warn_once(
f"expert-async-name-collision:{skill.name}",
"Expert skill %r collides with an existing async sub-agent "
"name; skipping async-dispatch registration.",
skill.name,
+2 -2
View File
@@ -207,9 +207,9 @@ def skill_manager(
if info.tags:
lines.append(f"Tags: {', '.join(info.tags)}")
# Expert surface: shown when the skill can also act as an expert —
# declared by a sibling AGENTS.md, or by legacy `type: expert`
# declared by a sibling EXPERT.md, or by legacy `type: expert`
# frontmatter. The decoration lines below exist only on the legacy
# path, so an AGENTS.md expert renders the type line and stops.
# path, so an EXPERT.md expert renders the type line and stops.
if info.type == "expert":
lines.append("Type: expert")
if info.role:
+31 -31
View File
@@ -124,15 +124,15 @@ class SkillInfo:
# source SKILL.md has no body content.
body: str = ""
# How this skill came to be classified as an expert:
# "agents_md" — a sibling AGENTS.md is present (current contract)
# "expert_md" — a sibling EXPERT.md is present (current contract)
# "frontmatter" — legacy ``type: expert`` in SKILL.md (deprecated)
# "" — not an expert
expert_source: str = ""
# AGENTS.md body — the actor definition (persona + result envelope) that
# EXPERT.md body — the actor definition (persona + result envelope) that
# becomes the expert's runtime prompt. Empty for non-experts and for
# experts still on the legacy frontmatter path, whose runtime prompt is
# still sourced from ``body``.
agents_body: str = ""
expert_body: str = ""
# Shared frontmatter split regex. Captures three parts:
@@ -168,7 +168,7 @@ def _split_frontmatter_and_body(content: str) -> tuple[str, str]:
# rule the skill system already lives by, applied one level down: a directory
# is a package, a contract file is a capability, presence is the declaration,
# content is the contract. SKILL.md's presence makes a directory a skill;
# AGENTS.md's presence makes it an actor.
# EXPERT.md's presence makes it an actor.
#
# The file carries no frontmatter — the directory name is the expert's
# identity, its presence is the declaration, and its body (persona + result
@@ -180,7 +180,7 @@ def _split_frontmatter_and_body(content: str) -> tuple[str, str]:
# index-facing consumers (the remote skill index, gallery chips) which can't
# cheaply stat the directory; the runtime resolves presence directly, so
# reading it would create a second classifier that can drift from the file.
_AGENTS_FILENAME = "AGENTS.md"
_EXPERT_FILENAME = "EXPERT.md"
# Legacy ``type: expert`` frontmatter is a deprecated fallback, so the warning
# is one-shot per skill name — ``_parse_skill_md`` runs on every ``list_skills``
@@ -197,32 +197,32 @@ def _warn_once(key: str, message: str, *args: object) -> None:
_logger.warning(message, *args)
def _read_agents_md(skill_dir: Path) -> str | None:
"""Return the AGENTS.md body for *skill_dir*, or None when absent.
def _read_expert_md(skill_dir: Path) -> str | None:
"""Return the EXPERT.md body for *skill_dir*, or None when absent.
Returns a string (possibly empty) whenever the file exists, so callers
can distinguish "not an expert" (None) from "declared expert with an
empty actor definition" (""). The latter is a skill-authoring bug the
container paths refuse loudly rather than papering over.
Frontmatter is stripped if present. The contract says AGENTS.md carries
Frontmatter is stripped if present. The contract says EXPERT.md carries
none, but an author who adds some should not have raw YAML leak into the
expert's system prompt.
"""
agents_path = skill_dir / _AGENTS_FILENAME
expert_path = skill_dir / _EXPERT_FILENAME
try:
if not agents_path.is_file():
if not expert_path.is_file():
return None
content = agents_path.read_text(encoding="utf-8")
content = expert_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
# Presence was the declaration, so an unreadable AGENTS.md is a
# Presence was the declaration, so an unreadable EXPERT.md is a
# broken expert, not a plain skill. Return "" to keep the expert
# classification and let the empty-body guards in the container
# factories refuse it with a skill-name-bearing warning.
_logger.warning(
"Skill %r: could not read %s (%s); treating actor definition as empty.",
skill_dir.name,
agents_path,
expert_path,
exc,
)
return ""
@@ -383,7 +383,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
whether the skill is also an **expert** — an actor with a persona and a
result-envelope contract, dispatchable as its own agent.
Expert classification is structural: a sibling ``AGENTS.md`` in the same
Expert classification is structural: a sibling ``EXPERT.md`` in the same
directory declares the skill an expert, and that file's body is the
expert's runtime prompt. SKILL.md stays pure knowledge — portable to any
harness, and readable in-turn off the ``/skills/`` mount whether or not
@@ -391,7 +391,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
my-expert/
SKILL.md # knowledge: when to use, workflow, references
AGENTS.md # actor: persona + result envelope <- the declaration
EXPERT.md # actor: persona + result envelope <- the declaration
scripts/
references/
@@ -399,7 +399,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
frontmatter, alongside actor fields (``role`` / ``byline`` /
``capability_tags`` / ``avatar_hint`` / ``default_dispatch``). That path
still resolves, with a deprecation warning, and is still the source of
the WebUI gallery's decoration fields. New experts declare via AGENTS.md
the WebUI gallery's decoration fields. New experts declare via EXPERT.md
and set none of those fields; their gallery cards carry name and
description only.
@@ -407,7 +407,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
reachable both in-turn and in the background, and the orchestrator picks
per task — a skill does not declare one dispatch shape for all time.
When both are present, AGENTS.md wins on every axis (prompt source,
When both are present, EXPERT.md wins on every axis (prompt source,
dispatch) and the frontmatter actor fields are reported as ignored — a
skill mid-migration must not behave as a blend of the two contracts.
@@ -433,8 +433,8 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
SkillInfo with path set to the skill's parent directory.
"""
parent = skill_md_path.parent
agents_body = _read_agents_md(parent)
declares_actor = agents_body is not None
expert_body = _read_expert_md(parent)
declares_actor = expert_body is not None
try:
content = skill_md_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
@@ -451,7 +451,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
skill_md_path,
exc,
)
# An unreadable SKILL.md doesn't undo an AGENTS.md declaration — the
# An unreadable SKILL.md doesn't undo an EXPERT.md declaration — the
# actor definition is a separate file and may be perfectly intact —
# so the expert classification is applied here too.
return SkillInfo(
@@ -460,8 +460,8 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
path=parent,
source=source,
type="expert" if declares_actor else "utility",
expert_source="agents_md" if declares_actor else "",
agents_body=agents_body or "",
expert_source="expert_md" if declares_actor else "",
expert_body=expert_body or "",
)
def _info(
@@ -477,7 +477,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
legacy_dispatch: str = "",
body: str = "",
) -> SkillInfo:
# AGENTS.md presence overrides whatever the frontmatter says about
# EXPERT.md presence overrides whatever the frontmatter says about
# actor-hood: the declaration is the file, and a skill mid-migration
# (both present) must resolve to exactly one contract.
if declares_actor:
@@ -497,14 +497,14 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
"capability_tags / avatar_hint / default_dispatch) is "
"ignored and should be removed.",
name,
_AGENTS_FILENAME,
_EXPERT_FILENAME,
)
type_ = "expert"
expert_source = "agents_md"
# AGENTS.md is authoritative on every axis: the actor definition is
expert_source = "expert_md"
# EXPERT.md is authoritative on every axis: the actor definition is
# that file, so legacy decoration from SKILL.md frontmatter must not
# leak onto SkillInfo. ``role`` would otherwise be prepended to the
# AGENTS.md prompt (``_compose_system_prompt`` / the async head), and
# EXPERT.md prompt (``_compose_system_prompt`` / the async head), and
# a mismatched frontmatter ``name`` would desync the registry
# identity from the skill directory.
name = parent.name
@@ -520,7 +520,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
"result envelope instead, and drop the actor fields from "
"frontmatter. The frontmatter path still works for now.",
name,
_AGENTS_FILENAME,
_EXPERT_FILENAME,
)
expert_source = "frontmatter"
else:
@@ -539,7 +539,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
avatar_hint=avatar_hint,
body=body,
expert_source=expert_source,
agents_body=agents_body or "",
expert_body=expert_body or "",
)
frontmatter_yaml, body = _split_frontmatter_and_body(content)
@@ -578,7 +578,7 @@ def _parse_skill_md(skill_md_path: Path, *, source: str = "") -> SkillInfo:
# in-turn and in the background, and the orchestrator picks per task
# rather than the skill declaring one shape for all time. Still
# detected here so the mixed-contract warning can name it among the
# legacy actor fields an AGENTS.md skill should drop.
# legacy actor fields an EXPERT.md skill should drop.
legacy_dispatch = str(frontmatter.get("default_dispatch") or "")
# ``.get("name", parent.name)`` only defaults on missing key; a
# present-but-empty ``name:`` yields None, which would flow into
@@ -1121,7 +1121,7 @@ def list_expert_skills(include_system: bool = True) -> list[SkillInfo]:
"""List installed expert skills.
Filters ``list_skills()`` output to entries classified as experts by
``_parse_skill_md`` — a sibling ``AGENTS.md`` (current contract), or
``_parse_skill_md`` — a sibling ``EXPERT.md`` (current contract), or
legacy ``type: expert`` SKILL.md frontmatter. Read ``expert_source`` on
the returned entries to tell the two apart; use
``expert_container.expert_prompt_body`` rather than ``.body`` to get an
+479 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -238,11 +239,18 @@ class TestStartToolInvocation:
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
result = start.func(
description="hi",
subagent_type="does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
# Patch the resolve-on-miss walk so the negative-miss path stays
# hermetic — an unpatched call would read the real skills tree.
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[],
):
result = start.func(
description="hi",
subagent_type="does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert isinstance(result, str)
assert "Unknown async subagent type" in result
@@ -331,10 +339,471 @@ class TestAstartToolInvocation:
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
result = await start.coroutine(
description="hi",
subagent_type="does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
# Patch the resolve-on-miss walk — see the sync twin.
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[],
):
result = await start.coroutine(
description="hi",
subagent_type="does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert isinstance(result, str)
assert "Unknown async subagent type" in result
def _newly_installed_expert_spec():
"""An expert spec as ``build_expert_async_subagent_specs`` would return
it for a skill installed after the agent was built."""
return {
"name": "brand-new-expert",
"description": "freshly installed expert",
"graph_id": "expert-container-async",
"is_expert": True,
}
class TestResolveOnMiss:
"""Resolve-on-miss: an unknown ``subagent_type`` that names a real,
newly installed expert becomes dispatchable on the first launch —
no agent rebuild, no restart. A name that is still unknown after one
resolution walk gets upstream's error with the refreshed type list."""
def test_unknown_expert_resolves_and_dispatches(self):
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
client = _fake_sync_client()
with (
patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
),
patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_sync",
return_value=client,
),
):
result = start.func(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
# Dispatch succeeded rather than returning the unknown-type error.
assert "async_tasks" in result.update
kwargs = client.runs.create.call_args.kwargs
assert kwargs["input"]["skill_name"] == "brand-new-expert"
def test_resolution_updates_the_watcher_dict(self):
"""The watcher holds a SEPARATE agent dict from ``agent_map``; the
resolution must land in both or the completion notification for the
newly resolved expert silently never fires (the watcher's
``get_async`` KeyError is swallowed by its ``try/except``)."""
watcher_agents: dict = {}
mw = EvoAsyncSubAgentMiddleware(
async_subagents=[_standard_spec()], watcher_agents=watcher_agents
)
start = next(t for t in mw.tools if t.name == "start_async_task")
client = _fake_sync_client()
with (
patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
),
patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_sync",
return_value=client,
),
):
start.func(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert "brand-new-expert" in watcher_agents
def test_resolution_never_overwrites_existing_entries(self):
"""``setdefault`` semantics: a spec already in ``agent_map`` keeps its
identity — an overwrite could smuggle in a spec the running agent
was not validated against (the constructor already raised on
duplicate names at build time)."""
incumbent = {
"name": "literature-review",
"description": "original description",
"graph_id": "incumbent-graph",
"is_expert": True,
}
challenger = {
"name": "literature-review",
"description": "different description",
"graph_id": "challenger-graph",
"is_expert": True,
}
mw = EvoAsyncSubAgentMiddleware(async_subagents=[incumbent])
start = next(t for t in mw.tools if t.name == "start_async_task")
# The miss-walk returns BOTH a new expert and a same-name challenger
# for the incumbent; the dispatch goes to the new name so the walk
# runs, then to the incumbent to observe which spec survived.
client = _fake_sync_client()
with (
patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[challenger, _newly_installed_expert_spec()],
),
patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_sync",
return_value=client,
),
):
start.func(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
start.func(
description="hi",
subagent_type="literature-review",
runtime=SimpleNamespace(tool_call_id="tc2"),
)
# The incumbent's graph_id served both the survivor check and the
# dispatch: had the challenger overwritten it, this would be
# "challenger-graph".
assistant_ids = [
call.kwargs["assistant_id"] for call in client.runs.create.call_args_list
]
assert "incumbent-graph" in assistant_ids
assert "challenger-graph" not in assistant_ids
def test_negative_miss_returns_error_with_refreshed_list(self):
"""A hallucinated name is still an error after the one resolution
walk — and the message's allowed-type list now includes names the
walk just added (the second ``_validate_agent_type`` call reads the
mutated map)."""
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
):
result = start.func(
description="hi",
subagent_type="still-does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert isinstance(result, str)
assert "Unknown async subagent type" in result
assert "brand-new-expert" in result
def test_resolution_uses_the_construction_cfg(self):
"""The miss-walk must spec against the cfg the agent was constructed
with, not a fresh ``get_effective_config()`` read. Re-deriving config
at dispatch time would let a mid-session ``langgraph_dev_port`` change
spec a newly resolved expert onto a port the running dev subprocess
is not on — dispatch accepts the name, only ``runs.create`` fails."""
construction_cfg = SimpleNamespace(enable_async_subagents=True)
mw = EvoAsyncSubAgentMiddleware(
async_subagents=[_standard_spec()], cfg=construction_cfg
)
start = next(t for t in mw.tools if t.name == "start_async_task")
captured: dict = {}
def capture_cfg(cfg=None, **kwargs):
captured["cfg"] = cfg
return [_newly_installed_expert_spec()]
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
side_effect=capture_cfg,
):
start.func(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert captured["cfg"] is construction_cfg
class TestAstartResolveOnMiss:
"""Async twins of ``TestResolveOnMiss`` — the coroutine langgraph_api
actually runs in production."""
@pytest.mark.asyncio
async def test_astart_unknown_expert_resolves_and_dispatches(self):
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
client = _fake_async_client()
to_thread_calls = []
async def _fake_to_thread(fn, *args):
to_thread_calls.append(fn.__name__)
return fn(*args)
with (
patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
),
patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_async",
return_value=client,
),
patch("asyncio.to_thread", new=_fake_to_thread),
):
result = await start.coroutine(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert "async_tasks" in result.update
kwargs = client.runs.create.await_args.kwargs
assert kwargs["input"]["skill_name"] == "brand-new-expert"
# The resolution ran off the event loop — langgraph-dev's blockbuster
# guard turns a skills-tree walk on the loop into a BlockingError.
assert to_thread_calls == ["_resolve_merge_validate"]
@pytest.mark.asyncio
async def test_astart_resolution_updates_the_watcher_dict(self):
watcher_agents: dict = {}
mw = EvoAsyncSubAgentMiddleware(
async_subagents=[_standard_spec()], watcher_agents=watcher_agents
)
start = next(t for t in mw.tools if t.name == "start_async_task")
client = _fake_async_client()
with (
patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
),
patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_async",
return_value=client,
),
):
await start.coroutine(
description="hi",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert "brand-new-expert" in watcher_agents
@pytest.mark.asyncio
async def test_astart_negative_miss_returns_error(self):
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[],
):
result = await start.coroutine(
description="hi",
subagent_type="still-does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert isinstance(result, str)
assert "Unknown async subagent type" in result
@pytest.mark.asyncio
async def test_astart_negative_miss_returns_refreshed_error(self):
"""The async miss path must honor the threaded call's return value:
the error comes from the worker's merge-and-validate under the
lock, so its allowed-type list already includes the names the walk
just merged. A caller that dropped the ``to_thread`` result and
re-derived the error from a stale message would lose the new
names."""
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
):
result = await start.coroutine(
description="hi",
subagent_type="still-does-not-exist",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
assert isinstance(result, str)
assert "Unknown async subagent type" in result
assert "brand-new-expert" in result
@pytest.mark.asyncio
async def test_astart_known_name_dispatch_skips_the_lock(self):
"""A known-name dispatch on the event loop must never touch
``_resolve_lock``: the miss check is a keyed lookup, and all lock
work lives on the ``to_thread`` worker. Holding the lock from this
coroutine pins the property — the dispatch completes while the
lock is unavailable. The pre-reshape shape ran its validation
under the lock on the loop and hung here until the timeout."""
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
client = _fake_async_client()
acquired = mw._resolve_lock.acquire()
assert acquired
try:
with patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_async",
return_value=client,
):
result = await asyncio.wait_for(
start.coroutine(
description="hi",
subagent_type="writing-agent",
runtime=SimpleNamespace(tool_call_id="tc1"),
),
timeout=2.0,
)
finally:
mw._resolve_lock.release()
assert "async_tasks" in result.update
class TestResolveOnMissLocking:
"""The resolver's merge and the start tool's map iteration serialize on
one lock. Deterministic, blocking-based — no timing lottery: each test
blocks a participant on an event we control and asserts the other side
genuinely waits for the lock."""
def test_resolver_merge_waits_for_the_lock(self):
"""With the lock held by an unrelated holder, the resolver's merge
must not insert into ``agent_map`` until the lock is released.
Without the lock parameter (or without locking in the resolver),
the ``setdefault`` lands immediately and the mid-hold assertion
fails. The return value is the refreshed validation: ``None`` once
the merged name resolves."""
import threading
import time
from EvoScientist.middleware.expert_async_subagent import (
_resolve_merge_validate,
)
agent_map: dict = {"writing-agent": _standard_spec()}
watcher_agents: dict = {}
lock = threading.Lock()
done = threading.Event()
def resolver():
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
):
result = _resolve_merge_validate(
agent_map, watcher_agents, None, "brand-new-expert", lock
)
assert result is None
done.set()
with lock:
thread = threading.Thread(target=resolver)
thread.start()
time.sleep(0.05)
# The merge is locked out while we hold the lock.
assert "brand-new-expert" not in agent_map
thread.join(timeout=5)
assert done.is_set()
assert "brand-new-expert" in agent_map
assert "brand-new-expert" in watcher_agents
def test_validate_blocks_while_resolver_holds_the_lock(self):
"""End to end through the middleware's own lock, on the SYNC tool
path (a blocked coroutine would freeze the event loop, making the
blocking unobservable from the same loop; the sync variant shares
the identical locked-validation closure). A resolver whose merge
blocks on an event we control holds the lock; a concurrent
``start_async_task`` at a KNOWN name (validation only, no
resolution) must not complete while the lock is held — its
``_validate_agent_type`` joins over ``agent_map`` under the same
lock. Without the lock, the known-name dispatch completes during
the resolver's block and the ``thread.is_alive()`` assertion
fails."""
import threading
import time
from EvoScientist.middleware import expert_async_subagent as mod
mw = EvoAsyncSubAgentMiddleware(async_subagents=[_standard_spec()])
start = next(t for t in mw.tools if t.name == "start_async_task")
resolver_entered = threading.Event()
resolver_release = threading.Event()
orig_merge = mod._merge_expert_specs
def blocking_merge(agent_map, watcher_agents, specs):
resolver_entered.set()
assert resolver_release.wait(timeout=10)
orig_merge(agent_map, watcher_agents, specs)
def miss_dispatch():
with patch(
"EvoScientist.subagents.expert_container_async"
".build_expert_async_subagent_specs",
return_value=[_newly_installed_expert_spec()],
):
return start.func(
description="one",
subagent_type="brand-new-expert",
runtime=SimpleNamespace(tool_call_id="tc1"),
)
def known_name_dispatch(done_event):
client = _fake_sync_client()
with patch(
"EvoScientist.middleware.expert_async_subagent._ClientCache.get_sync",
return_value=client,
):
start.func(
description="two",
subagent_type="writing-agent",
runtime=SimpleNamespace(tool_call_id="tc2"),
)
done_event.set()
with patch.object(mod, "_merge_expert_specs", blocking_merge):
# Thread A: a miss -> resolver enters the merge, acquires the
# lock, and blocks on our event.
t1 = threading.Thread(target=miss_dispatch)
t1.start()
assert resolver_entered.wait(timeout=10)
# Thread B: a KNOWN name -> validation only. Must block on the
# lock the resolver holds.
b_done = threading.Event()
t2 = threading.Thread(target=known_name_dispatch, args=(b_done,))
t2.start()
time.sleep(0.1)
assert t2.is_alive()
assert not b_done.is_set()
resolver_release.set()
t1.join(timeout=10)
t2.join(timeout=10)
assert b_done.is_set()
assert not t1.is_alive()
assert not t2.is_alive()
+14 -14
View File
@@ -129,8 +129,8 @@ class TestExpertPromptBody:
body = expert_prompt_body(info)
assert body == "Cached body content from SkillInfo."
def test_agents_md_expert_reads_actor_definition_not_skill_md(self, tmp_path):
"""An AGENTS.md expert is prompted from its actor definition.
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
@@ -143,35 +143,35 @@ class TestExpertPromptBody:
path=tmp_path / "paper-review",
source="builtin",
type="expert",
expert_source="agents_md",
expert_source="expert_md",
body="# Knowledge\n\nThe 5-aspect checklist.\n",
agents_body="## Persona\n\nYou are an adversarial reviewer.\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_agents_md_expert_falls_back_to_disk(self, tmp_path):
"""A hand-built SkillInfo without ``agents_body`` still resolves.
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 / "AGENTS.md").write_text("## Persona\n\nFrom disk.\n")
(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="agents_md",
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_agents_md_expert_returns_empty_when_file_missing(self, tmp_path):
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
@@ -184,7 +184,7 @@ class TestExpertPromptBody:
path=tmp_path / "gone",
source="workspace",
type="expert",
expert_source="agents_md",
expert_source="expert_md",
body="SKILL.md body that must not be used.",
)
assert expert_prompt_body(info) == ""
@@ -374,10 +374,10 @@ role: blank
for r in caplog.records
)
def test_agents_md_expert_included_in_sync_registry(self, tmp_path):
"""Both contracts land in the in-turn registry, and its prompt is AGENTS.md.
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 AGENTS.md skill appears here
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.
"""
@@ -395,7 +395,7 @@ description: Knowledge only
The workflow.
"""
)
(actor / "AGENTS.md").write_text("## Persona\n\nYou are the new expert.\n")
(actor / "EXPERT.md").write_text("## Persona\n\nYou are the new expert.\n")
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
+102 -9
View File
@@ -133,8 +133,8 @@ class TestComposePrompt:
assert composed.startswith("ERROR:")
assert "empty SKILL.md body" in composed
def test_agents_md_expert_prompted_from_actor_definition(self):
"""AGENTS.md experts are prompted from AGENTS.md, not SKILL.md.
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
@@ -146,8 +146,8 @@ class TestComposePrompt:
role="",
body="# Knowledge\n\nThe 5-aspect checklist.\n",
)
info.expert_source = "agents_md"
info.agents_body = "## Persona\n\nYou are an adversarial reviewer.\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],
@@ -156,10 +156,10 @@ class TestComposePrompt:
assert "You are an adversarial reviewer." in composed
assert "5-aspect checklist" not in composed
def test_empty_actor_definition_names_agents_md_in_error(self):
def test_empty_actor_definition_names_expert_md_in_error(self):
"""The error cue names the file the author has to fix.
An AGENTS.md expert with a healthy SKILL.md would otherwise be told
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()
@@ -168,15 +168,15 @@ class TestComposePrompt:
role="",
body="# Knowledge\n\nPlenty of content here.\n",
)
info.expert_source = "agents_md"
info.agents_body = " \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 AGENTS.md body" in composed
assert "empty EXPERT.md body" in composed
assert "paper-review" in composed
def test_runtime_context_tail_surfaces_skill_name(self):
@@ -338,3 +338,96 @@ class TestWrapModelCall:
_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
+28 -3
View File
@@ -58,10 +58,10 @@ class _FakeSkillInfo:
# exercise the empty-body reject path pass ``body=""``.
body: str = "persona"
# Legacy-frontmatter expert by default: ``expert_prompt_body`` reads
# ``body`` for these. Set ``expert_source="agents_md"`` plus
# ``agents_body`` to fake an expert on the current contract.
# ``body`` for these. Set ``expert_source="expert_md"`` plus
# ``expert_body`` to fake an expert on the current contract.
expert_source: str = "frontmatter"
agents_body: str = ""
expert_body: str = ""
def _make_ctx(active_teams: list[str] | None = None) -> tuple[CommandContext, _FakeUI]:
@@ -170,6 +170,31 @@ class TestExpertToggle:
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
assert any("Invited expert: idea-brainstorm" in text for text, _ in ui.lines)
async def test_invite_hint_states_the_dispatch_boundary(self):
"""The invite hint must state the exact boundary, scoped to the
case where it holds: an expert installed after this session's
agent was constructed dispatches in the background immediately,
but its in-turn ``task`` reach needs a rebuilt agent. An expert
installed before the session started is already in the frozen
in-turn set, so the hint must NOT state the /new clause
unconditionally. Pins the scoped wording against the old
unconditional phrasing and the original "run /new to activate
it"."""
ctx, ui = _make_ctx()
with patch(
"EvoScientist.tools.skills_manager.list_expert_skills",
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
):
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
texts = [text for text, _ in ui.lines]
assert any(
"Newly installed experts" in text
and "background dispatch is available immediately" in text
and "in-turn task dispatch needs /new" in text
for text in texts
)
assert not any("run /new to activate it" in text for text in texts)
async def test_invite_matches_name_case_insensitively(self):
"""Execute honours the same case-insensitive match as completion."""
ctx, ui = _make_ctx()
+89 -6
View File
@@ -120,19 +120,19 @@ class TestBuildExpertAsyncSubagentSpecs:
specs = build_expert_async_subagent_specs(cfg=cfg)
assert [s["name"] for s in specs] == ["literature-review"]
def test_agents_md_expert_registered_and_gated_on_its_own_file(self):
"""AGENTS.md experts reach async dispatch, and their gate is AGENTS.md.
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 = "agents_md"
healthy.agents_body = "## Persona\n\nYou are an adversarial reviewer.\n"
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 = "agents_md"
blank_actor.agents_body = " \n" # SKILL.md body is fine; actor isn't
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",
@@ -428,3 +428,86 @@ class TestRouteAsyncSpecs:
# 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
+26 -26
View File
@@ -1178,30 +1178,30 @@ def _write_actor_skill(
parent: Path,
name: str,
*,
agents_body: str = "## Persona\n\nYou are the test expert.\n\n## Envelope\n\n{}\n",
expert_body: str = "## Persona\n\nYou are the test expert.\n\n## Envelope\n\n{}\n",
skill_frontmatter: str = "",
skill_body: str = "# Knowledge\n\nThe portable workflow.\n",
) -> Path:
"""Write a skill declaring itself an expert via a sibling AGENTS.md."""
"""Write a skill declaring itself an expert via a sibling EXPERT.md."""
skill_dir = parent / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: A skill that can also act\n"
f"{skill_frontmatter}---\n\n{skill_body}"
)
(skill_dir / "AGENTS.md").write_text(agents_body)
(skill_dir / "EXPERT.md").write_text(expert_body)
return skill_dir
class TestAgentsMdExpertContract:
"""AGENTS.md presence is the expert declaration; its body is the prompt."""
"""EXPERT.md presence is the expert declaration; its body is the prompt."""
def test_presence_classifies_as_expert(self, tmp_path):
skill_dir = _write_actor_skill(tmp_path, "actor-skill")
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.type == "expert"
assert result.expert_source == "agents_md"
assert result.agents_body.startswith("## Persona")
assert result.expert_source == "expert_md"
assert result.expert_body.startswith("## Persona")
# SKILL.md stays pure knowledge and is still cached for in-turn use.
assert "The portable workflow." in result.body
@@ -1217,7 +1217,7 @@ class TestAgentsMdExpertContract:
def test_metadata_type_alone_does_not_classify(self, tmp_path):
"""``metadata.type: [skill, expert]`` is index-facing only.
It is a projection of the AGENTS.md declaration for consumers that
It is a projection of the EXPERT.md declaration for consumers that
can't stat the directory. Reading it in the runtime would create a
second classifier free to drift from the file that actually holds the
persona — a skill would register as an expert with nothing to say.
@@ -1243,40 +1243,40 @@ metadata:
assert result.tags == ["core"]
def test_frontmatter_stripped_from_actor_definition(self, tmp_path):
"""AGENTS.md carries no frontmatter, but YAML must never reach the prompt."""
"""EXPERT.md carries no frontmatter, but YAML must never reach the prompt."""
skill_dir = _write_actor_skill(
tmp_path,
"fm-actor",
agents_body="---\nname: ignored\n---\n\n## Persona\n\nBody only.\n",
expert_body="---\nname: ignored\n---\n\n## Persona\n\nBody only.\n",
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.agents_body == "## Persona\n\nBody only.\n"
assert result.expert_body == "## Persona\n\nBody only.\n"
def test_empty_actor_definition_stays_classified(self, tmp_path):
"""An empty AGENTS.md is still a declaration — a broken expert.
"""An empty EXPERT.md is still a declaration — a broken expert.
Downgrading it to a utility skill would hide the authoring bug; the
registration paths refuse it by name instead.
"""
skill_dir = _write_actor_skill(tmp_path, "blank-actor", agents_body=" \n")
skill_dir = _write_actor_skill(tmp_path, "blank-actor", expert_body=" \n")
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.type == "expert"
assert result.expert_source == "agents_md"
assert result.agents_body.strip() == ""
assert result.expert_source == "expert_md"
assert result.expert_body.strip() == ""
def test_unreadable_skill_md_keeps_expert_declaration(self, tmp_path):
"""SKILL.md and AGENTS.md are separate files with separate failures."""
"""SKILL.md and EXPERT.md are separate files with separate failures."""
skill_dir = tmp_path / "half-broken"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_bytes(b"---\nname: bad\n---\n\xff\xfe")
(skill_dir / "AGENTS.md").write_text("## Persona\n\nStill valid.\n")
(skill_dir / "EXPERT.md").write_text("## Persona\n\nStill valid.\n")
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.description == "(unreadable)"
assert result.type == "expert"
assert result.expert_source == "agents_md"
assert result.agents_body == "## Persona\n\nStill valid.\n"
assert result.expert_source == "expert_md"
assert result.expert_body == "## Persona\n\nStill valid.\n"
def test_agents_md_overrides_legacy_frontmatter(self, tmp_path, caplog):
def test_expert_md_overrides_legacy_frontmatter(self, tmp_path, caplog):
"""A skill mid-migration resolves to one contract, not a blend."""
import logging
@@ -1293,10 +1293,10 @@ metadata:
logging.WARNING, logger="EvoScientist.tools.skills_manager"
):
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.expert_source == "agents_md"
# AGENTS.md wins on every axis: the legacy decoration fields must be
assert result.expert_source == "expert_md"
# EXPERT.md wins on every axis: the legacy decoration fields must be
# cleared, not merely warned about — otherwise `role` is prepended to
# the AGENTS.md prompt and the gallery chips leak stale frontmatter.
# the EXPERT.md prompt and the gallery chips leak stale frontmatter.
assert result.role == ""
assert result.byline == ""
assert result.capability_tags == []
@@ -1306,8 +1306,8 @@ metadata:
for r in caplog.records
)
def test_agents_md_expert_name_is_directory_name(self, tmp_path):
"""Registry identity for an AGENTS.md expert is the directory name.
def test_expert_md_expert_name_is_directory_name(self, tmp_path):
"""Registry identity for an EXPERT.md expert is the directory name.
A frontmatter ``name:`` that disagrees with the directory would desync
the dispatch registry key from the skill the orchestrator names.
@@ -1317,7 +1317,7 @@ metadata:
(skill_dir / "SKILL.md").write_text(
"---\nname: mismatched-name\ndescription: d\n---\n\n# Knowledge\n"
)
(skill_dir / "AGENTS.md").write_text("## Persona\n\nBody.\n")
(skill_dir / "EXPERT.md").write_text("## Persona\n\nBody.\n")
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.name == "real-dir"
@@ -1363,7 +1363,7 @@ role: legacy role
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.type == "utility"
assert result.expert_source == ""
assert result.agents_body == ""
assert result.expert_body == ""
class TestListExpertSkills: