feat: agent-teams part C - expert selection UX (depends on part B) (#371)
* feat: bias main-agent delegation toward configurable.active_teams * feat: add /experts and /expert TUI commands for expert-skill summoning * feat: align expert-selection wording with WebUI (invite/dismiss) * chore: clear active_teams on /new, cleanup active_team.py comment * fix: use local append_to_system_message in ActiveTeamMiddleware * fix: prevent configurable_extra from overriding thread_id * fix: cache expert-skill lookup for /expert completions * fix: suppress /expert completions past first arg and on exact match * fix: invalidate /expert completion cache on skill install/uninstall * fix: refuse /expert invites for non-dispatchable expert skills * fix: fire /expert cache invalidation on every install_skill / uninstall_skill path * fix: propagate active_teams to Rich CLI and serve dispatch surfaces * fix: keep invited experts across channel shutdown * fix: match /expert completions case-insensitively
This commit is contained in:
@@ -788,6 +788,7 @@ def _get_default_middleware(
|
||||
ModelFallbackMiddleware,
|
||||
ToolErrorHandlerMiddleware,
|
||||
ToolHistoryRepairMiddleware,
|
||||
create_active_team_middleware,
|
||||
create_code_interpreter_middleware,
|
||||
create_context_editing_middleware,
|
||||
create_memory_lifecycle_middleware,
|
||||
@@ -862,6 +863,13 @@ def _get_default_middleware(
|
||||
ErrorNormalizationMiddleware(),
|
||||
ToolHistoryRepairMiddleware(),
|
||||
ConfigurableModelMiddleware(),
|
||||
# Team-binding cue for the main agent only. Reads
|
||||
# `configurable.active_teams: list[str]` and appends a cue biasing
|
||||
# the main agent to consult the invited expert(s). Skipped for
|
||||
# async subagents (a running expert graph shouldn't inject a
|
||||
# "prefer expert X" hint into its own system prompt — the persona
|
||||
# is already baked in). See agent-teams-design.md.
|
||||
*([] if for_async_subagent else [create_active_team_middleware()]),
|
||||
create_context_editing_middleware(model),
|
||||
ModelFallbackMiddleware(events=events),
|
||||
ContextOverflowMapperMiddleware(),
|
||||
|
||||
@@ -16,7 +16,12 @@ import typer
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from ..commands.base import ChannelRuntime, Command, CommandContext
|
||||
from ..commands.base import (
|
||||
ChannelRuntime,
|
||||
Command,
|
||||
CommandContext,
|
||||
active_teams_configurable_extra,
|
||||
)
|
||||
from ..gateway import (
|
||||
GraphGateway,
|
||||
GraphTarget,
|
||||
@@ -1321,6 +1326,7 @@ def _serve_process_message(
|
||||
show_thinking=show_thinking,
|
||||
interactive=True,
|
||||
metadata=meta,
|
||||
configurable_extra=active_teams_configurable_extra(channel_runtime),
|
||||
on_thinking=_send_thinking,
|
||||
on_todo=_send_todo,
|
||||
on_file_write=_send_media,
|
||||
@@ -1352,6 +1358,7 @@ def _serve_drain_notifications(
|
||||
model: str | None,
|
||||
workspace_dir: str,
|
||||
show_thinking: bool,
|
||||
channel_runtime: ChannelRuntime | None = None,
|
||||
) -> None:
|
||||
"""Drain the async-task notification queue in headless serve mode.
|
||||
|
||||
@@ -1383,6 +1390,7 @@ def _serve_drain_notifications(
|
||||
show_thinking=show_thinking,
|
||||
interactive=True,
|
||||
metadata=meta,
|
||||
configurable_extra=active_teams_configurable_extra(channel_runtime),
|
||||
gateway=runtime_state.runtime_gateways.graph_gateway,
|
||||
runtime=runtime_state.async_runtime,
|
||||
)
|
||||
@@ -1659,6 +1667,7 @@ def serve(
|
||||
model=config.model,
|
||||
workspace_dir=ws,
|
||||
show_thinking=effective_channel_thinking,
|
||||
channel_runtime=channel_runtime,
|
||||
)
|
||||
finally:
|
||||
active_cancel_scope = no_active_cancel_scope
|
||||
|
||||
@@ -481,7 +481,7 @@ def cmd_interactive(
|
||||
width = console.size.width
|
||||
console.print(Text("\u2500" * width, style="dim"))
|
||||
|
||||
from ..commands.base import ChannelRuntime
|
||||
from ..commands.base import ChannelRuntime, active_teams_configurable_extra
|
||||
|
||||
channel_runtime = ChannelRuntime()
|
||||
|
||||
@@ -1116,6 +1116,9 @@ def cmd_interactive(
|
||||
show_thinking=show_thinking,
|
||||
interactive=True,
|
||||
metadata=meta,
|
||||
configurable_extra=active_teams_configurable_extra(
|
||||
channel_runtime
|
||||
),
|
||||
on_thinking=_send_thinking_to_channel,
|
||||
on_todo=_send_todo_to_channel,
|
||||
on_file_write=_send_media_to_channel,
|
||||
@@ -1181,6 +1184,7 @@ def cmd_interactive(
|
||||
show_thinking=show_thinking,
|
||||
interactive=True,
|
||||
metadata=meta,
|
||||
configurable_extra=active_teams_configurable_extra(channel_runtime),
|
||||
on_stream_event=_handle_stream_status_event,
|
||||
status_footer_builder=_stream_status_footer,
|
||||
gateway=runtime_gateways.graph_gateway,
|
||||
@@ -1495,6 +1499,9 @@ def cmd_interactive(
|
||||
show_thinking=show_thinking,
|
||||
interactive=True,
|
||||
metadata=_meta,
|
||||
configurable_extra=active_teams_configurable_extra(
|
||||
channel_runtime
|
||||
),
|
||||
on_stream_event=_handle_stream_status_event,
|
||||
status_footer_builder=_stream_status_footer,
|
||||
gateway=runtime_gateways.graph_gateway,
|
||||
|
||||
@@ -32,6 +32,7 @@ class StreamingTUIBackend(Protocol):
|
||||
on_stream_event: Callable[[str, Any], Any] | None = None,
|
||||
status_footer_builder: Callable[[], Any] | None = None,
|
||||
metadata: dict | None = None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
|
||||
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
|
||||
cancel_scope: str | None = None,
|
||||
@@ -61,6 +62,7 @@ class RichStreamingBackend:
|
||||
on_stream_event: Callable[[str, Any], Any] | None = None,
|
||||
status_footer_builder: Callable[[], Any] | None = None,
|
||||
metadata: dict | None = None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
|
||||
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
|
||||
cancel_scope: str | None = None,
|
||||
@@ -79,6 +81,7 @@ class RichStreamingBackend:
|
||||
on_stream_event=on_stream_event,
|
||||
status_footer_builder=status_footer_builder,
|
||||
metadata=metadata,
|
||||
configurable_extra=configurable_extra,
|
||||
hitl_prompt_fn=hitl_prompt_fn,
|
||||
ask_user_prompt_fn=ask_user_prompt_fn,
|
||||
cancel_scope=cancel_scope,
|
||||
|
||||
@@ -1793,6 +1793,10 @@ def run_textual_interactive(
|
||||
summarization_w = None
|
||||
try:
|
||||
_anchor_engaged = False
|
||||
_active_teams = list(self._channel_runtime.active_teams)
|
||||
_configurable_extra = (
|
||||
{"active_teams": _active_teams} if _active_teams else None
|
||||
)
|
||||
async for event in iter_with_stream_cancel(
|
||||
graph_gateway.stream_events(
|
||||
RunRequest(
|
||||
@@ -1805,6 +1809,7 @@ def run_textual_interactive(
|
||||
local_graph=agent,
|
||||
workspace_dir=self._workspace_dir,
|
||||
),
|
||||
configurable_extra=_configurable_extra,
|
||||
)
|
||||
),
|
||||
cancel_scope,
|
||||
|
||||
@@ -118,6 +118,7 @@ def run_streaming(
|
||||
on_stream_event: Callable[[str, Any], Any] | None = None,
|
||||
status_footer_builder: Callable[[], Any] | None = None,
|
||||
metadata: dict | None = None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
|
||||
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
|
||||
cancel_scope: str | None = None,
|
||||
@@ -139,6 +140,7 @@ def run_streaming(
|
||||
on_stream_event=on_stream_event,
|
||||
status_footer_builder=status_footer_builder,
|
||||
metadata=metadata,
|
||||
configurable_extra=configurable_extra,
|
||||
hitl_prompt_fn=hitl_prompt_fn,
|
||||
ask_user_prompt_fn=ask_user_prompt_fn,
|
||||
cancel_scope=cancel_scope,
|
||||
@@ -165,6 +167,7 @@ def run_streaming(
|
||||
on_stream_event=on_stream_event,
|
||||
status_footer_builder=status_footer_builder,
|
||||
metadata=metadata,
|
||||
configurable_extra=configurable_extra,
|
||||
hitl_prompt_fn=hitl_prompt_fn,
|
||||
ask_user_prompt_fn=ask_user_prompt_fn,
|
||||
cancel_scope=cancel_scope,
|
||||
|
||||
@@ -66,20 +66,47 @@ class CommandUI(Protocol):
|
||||
|
||||
@dataclass
|
||||
class ChannelRuntime:
|
||||
"""Mutable handle to the agent + thread bound to running channels."""
|
||||
"""Mutable handle to the agent + thread bound to running channels.
|
||||
|
||||
Also holds session-scoped bindings mutated by slash commands — the
|
||||
``active_teams`` list backs the ``/expert`` command, feeding into
|
||||
``RunRequest.configurable_extra`` at stream call time.
|
||||
"""
|
||||
|
||||
agent: Any = None
|
||||
thread_id: str | None = None
|
||||
active_teams: list[str] = field(default_factory=list)
|
||||
|
||||
def bind(self, agent: Any, thread_id: str) -> None:
|
||||
self.agent = agent
|
||||
self.thread_id = thread_id
|
||||
|
||||
def clear(self) -> None:
|
||||
# ``active_teams`` is session-scoped and reset explicitly by ``/new``
|
||||
# (session.py) and ``/expert clear`` — not tied to channel lifecycle.
|
||||
# Clearing here on channel shutdown would silently dismiss the user's
|
||||
# invited experts, which they never asked for.
|
||||
self.agent = None
|
||||
self.thread_id = None
|
||||
|
||||
|
||||
def active_teams_configurable_extra(
|
||||
runtime: ChannelRuntime | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build ``RunRequest.configurable_extra`` from a channel runtime.
|
||||
|
||||
Returns ``{"active_teams": [...]}`` when the runtime has invited
|
||||
experts, or ``None`` when there is no runtime or no active invites —
|
||||
lets stream call sites forward the field unconditionally without
|
||||
each duplicating the "read runtime slot, build dict, drop when
|
||||
empty" three-liner.
|
||||
"""
|
||||
if runtime is None:
|
||||
return None
|
||||
invited = list(runtime.active_teams)
|
||||
return {"active_teams": invited} if invited else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""Context passed to commands during execution."""
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from . import (
|
||||
autoskills,
|
||||
channel,
|
||||
experts,
|
||||
general,
|
||||
mcp,
|
||||
model,
|
||||
@@ -15,6 +16,7 @@ from . import (
|
||||
__all__ = [
|
||||
"autoskills",
|
||||
"channel",
|
||||
"experts",
|
||||
"general",
|
||||
"mcp",
|
||||
"model",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Slash commands for TUI expert-skill selection.
|
||||
|
||||
``/experts`` — list installed expert skills.
|
||||
``/expert <name>`` — toggle an expert into the current session's
|
||||
``active_teams`` list; the next turn's ``configurable.active_teams`` picks
|
||||
this up and ``ActiveTeamMiddleware`` biases the main-agent's delegation
|
||||
toward the invited expert(s).
|
||||
``/expert clear`` — reset the list.
|
||||
|
||||
User-facing verbs match the WebUI gallery: **invite** to add an expert,
|
||||
**dismiss** to remove one. Internal state field stays ``active_teams``
|
||||
for wire compatibility.
|
||||
|
||||
Backing store is ``ChannelRuntime.active_teams`` (see
|
||||
``EvoScientist/commands/base.py``). WebUI users get the same effect via
|
||||
its gallery + langgraph-sdk ``config.configurable``; these commands are
|
||||
the TUI-side equivalent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
from ..base import Argument, Command, CommandContext, SubCommand
|
||||
from ..manager import manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...tools.skills_manager import SkillInfo
|
||||
|
||||
_dispatchable_experts_cache: list[SkillInfo] | None = None
|
||||
|
||||
|
||||
def invalidate_experts_cache() -> None:
|
||||
"""Reset the /expert dispatchable-experts cache.
|
||||
|
||||
Called after ``install_skill`` / ``uninstall_skill`` mutations so a
|
||||
freshly installed expert shows up in the /expert popup on the next
|
||||
keystroke.
|
||||
"""
|
||||
global _dispatchable_experts_cache
|
||||
_dispatchable_experts_cache = None
|
||||
|
||||
|
||||
def _subscribe_cache_invalidation() -> None:
|
||||
"""Register with ``skills_manager`` so every install/uninstall path
|
||||
(slash commands, agent ``skill_manager`` @tool, onboarding) busts
|
||||
the /expert popup — no caller has to remember.
|
||||
"""
|
||||
try:
|
||||
from ...tools.skills_manager import register_skills_changed_callback
|
||||
|
||||
register_skills_changed_callback(invalidate_experts_cache)
|
||||
except Exception:
|
||||
# ``skills_manager`` not importable in some early-init contexts;
|
||||
# cache staleness is a UX inconvenience, not a correctness bug.
|
||||
pass
|
||||
|
||||
|
||||
_subscribe_cache_invalidation()
|
||||
|
||||
|
||||
def _dispatchable_experts() -> list[SkillInfo]:
|
||||
"""Cached list of experts that /expert can safely invite.
|
||||
|
||||
Filters ``list_expert_skills`` down to those that pass the same
|
||||
empty-body + name-collision guards ``build_expert_subagent_specs``
|
||||
and ``_fold_expert_subagents`` apply at agent-construction time, so
|
||||
the /expert popup and invite-accept path only ever surface names
|
||||
that will actually reach ``ActiveTeamMiddleware``'s cue.
|
||||
"""
|
||||
global _dispatchable_experts_cache
|
||||
if _dispatchable_experts_cache is None:
|
||||
try:
|
||||
from ...subagents.expert_container import list_dispatchable_experts
|
||||
|
||||
_dispatchable_experts_cache = list_dispatchable_experts(include_system=True)
|
||||
except Exception:
|
||||
return []
|
||||
return _dispatchable_experts_cache
|
||||
|
||||
|
||||
class ExpertsCommand(Command):
|
||||
"""List installed expert skills."""
|
||||
|
||||
name: ClassVar[str] = "/experts"
|
||||
description: ClassVar[str] = "List installed expert skills"
|
||||
category: ClassVar[str] = "Experts"
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
from ...tools.skills_manager import list_expert_skills
|
||||
|
||||
experts = list_expert_skills(include_system=True)
|
||||
active = _current_active_teams(ctx)
|
||||
|
||||
if not experts:
|
||||
ctx.ui.append_system("No expert skills installed.", style="dim")
|
||||
ctx.ui.append_system(
|
||||
"Install with: /install-skill <path-or-url>", style="dim"
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(title=f"Expert Skills ({len(experts)})", show_header=True)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Role", style="dim")
|
||||
table.add_column("Dispatch", style="dim")
|
||||
table.add_column("Active", style="green")
|
||||
for skill in experts:
|
||||
marker = "*" if skill.name in active else ""
|
||||
table.add_row(
|
||||
skill.name,
|
||||
skill.role or skill.description,
|
||||
skill.default_dispatch or "sync",
|
||||
marker,
|
||||
)
|
||||
ctx.ui.mount_renderable(table)
|
||||
|
||||
if active:
|
||||
ctx.ui.append_system(
|
||||
f"Active: {', '.join(active)}. Toggle with `/expert <name>`, "
|
||||
"clear with `/expert clear`.",
|
||||
style="dim",
|
||||
)
|
||||
else:
|
||||
ctx.ui.append_system(
|
||||
"No experts invited. `/expert <name>` to invite one.",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
|
||||
class ExpertCommand(Command):
|
||||
"""Invite, dismiss, or clear expert skills for the current thread."""
|
||||
|
||||
name: ClassVar[str] = "/expert"
|
||||
description: ClassVar[str] = "Invite or dismiss an expert skill"
|
||||
category: ClassVar[str] = "Experts"
|
||||
arguments: ClassVar[list[Argument]] = [
|
||||
Argument(
|
||||
name="name_or_clear",
|
||||
type=str,
|
||||
description="Expert skill name to toggle, or 'clear' to reset",
|
||||
required=True,
|
||||
)
|
||||
]
|
||||
subcommands: ClassVar[list[SubCommand]] = [
|
||||
SubCommand("clear", "Dismiss all invited experts"),
|
||||
]
|
||||
|
||||
def _get_expert_candidates(self) -> list[tuple[str, str]]:
|
||||
return [(s.name, s.role or s.description) for s in _dispatchable_experts()]
|
||||
|
||||
def get_completions(self, tokens: list[str]) -> list[tuple[str, str]]:
|
||||
"""Complete expert names + the ``clear`` subcommand."""
|
||||
# /expert takes a single positional arg; anything past it (including a
|
||||
# trailing space that turns tokens into ["name", ""]) has nothing to offer.
|
||||
if len(tokens) > 1:
|
||||
return []
|
||||
prefix = tokens[0].lower() if tokens else ""
|
||||
candidates = [
|
||||
*self._get_expert_candidates(),
|
||||
("clear", "Dismiss all invited experts"),
|
||||
]
|
||||
matches = [
|
||||
(name, desc) for name, desc in candidates if name.lower().startswith(prefix)
|
||||
]
|
||||
# Exact match — argument already complete, hide the popup.
|
||||
if len(matches) == 1 and matches[0][0].lower() == prefix:
|
||||
return []
|
||||
return matches
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
runtime = ctx.channel_runtime
|
||||
if runtime is None:
|
||||
ctx.ui.append_system(
|
||||
"/expert requires a session runtime; not available in this context.",
|
||||
style="yellow",
|
||||
)
|
||||
return
|
||||
|
||||
if not args:
|
||||
ctx.ui.append_system(
|
||||
"Usage: /expert <name> toggle an expert into the invited list",
|
||||
style="yellow",
|
||||
)
|
||||
ctx.ui.append_system(
|
||||
" /expert clear dismiss all invited experts",
|
||||
style="dim",
|
||||
)
|
||||
return
|
||||
|
||||
target = args[0].strip()
|
||||
if target.lower() == "clear":
|
||||
if not runtime.active_teams:
|
||||
ctx.ui.append_system("No experts invited.", style="dim")
|
||||
return
|
||||
dismissed = list(runtime.active_teams)
|
||||
runtime.active_teams = []
|
||||
ctx.ui.append_system(
|
||||
f"Dismissed experts: {', '.join(dismissed)}", style="dim"
|
||||
)
|
||||
return
|
||||
|
||||
dispatchable = {s.name for s in _dispatchable_experts()}
|
||||
if target not in dispatchable:
|
||||
from ...tools.skills_manager import list_expert_skills
|
||||
|
||||
installed = {s.name for s in list_expert_skills(include_system=True)}
|
||||
if target in installed:
|
||||
ctx.ui.append_system(
|
||||
f"Expert '{target}' can't be dispatched (empty SKILL.md body "
|
||||
"or name collision with a built-in sub-agent).",
|
||||
style="red",
|
||||
)
|
||||
else:
|
||||
ctx.ui.append_system(
|
||||
f"No expert skill named '{target}'. `/experts` lists "
|
||||
"installed ones.",
|
||||
style="red",
|
||||
)
|
||||
return
|
||||
|
||||
if target in runtime.active_teams:
|
||||
runtime.active_teams = [n for n in runtime.active_teams if n != target]
|
||||
ctx.ui.append_system(f"Dismissed expert: {target}", style="dim")
|
||||
else:
|
||||
runtime.active_teams = [*runtime.active_teams, target]
|
||||
ctx.ui.append_system(f"Invited expert: {target}", 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.
|
||||
ctx.ui.append_system(
|
||||
"If just installed, run /new to activate it.", style="dim"
|
||||
)
|
||||
if runtime.active_teams:
|
||||
ctx.ui.append_system(
|
||||
f"Active: {', '.join(runtime.active_teams)}", style="dim"
|
||||
)
|
||||
|
||||
|
||||
def _current_active_teams(ctx: CommandContext) -> list[str]:
|
||||
runtime = ctx.channel_runtime
|
||||
return list(runtime.active_teams) if runtime is not None else []
|
||||
|
||||
|
||||
manager.register(ExpertsCommand())
|
||||
manager.register(ExpertCommand())
|
||||
@@ -214,7 +214,22 @@ class NewCommand(Command):
|
||||
category = "Session"
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
# ``/new`` means fresh state — release any invited experts before
|
||||
# starting the new session. Uniform with the ``ChannelRuntime.clear``
|
||||
# path on channel shutdown; avoids the "why is idea-brainstorm still
|
||||
# active in my new thread?" surprise. Users who want to reuse an
|
||||
# invite in the next thread can re-invite explicitly.
|
||||
runtime = ctx.channel_runtime
|
||||
dismissed: list[str] = []
|
||||
if runtime is not None and runtime.active_teams:
|
||||
dismissed = list(runtime.active_teams)
|
||||
runtime.active_teams = []
|
||||
await ctx.ui.start_new_session()
|
||||
if dismissed:
|
||||
ctx.ui.append_system(
|
||||
f"Dismissed experts on new session: {', '.join(dismissed)}",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
|
||||
class ClearCommand(Command):
|
||||
|
||||
@@ -165,6 +165,7 @@ class LocalGraphGateway:
|
||||
metadata=request.metadata,
|
||||
media=request.media,
|
||||
events=self.events,
|
||||
configurable_extra=request.configurable_extra,
|
||||
)
|
||||
try:
|
||||
async for event in inner:
|
||||
|
||||
@@ -41,6 +41,13 @@ class RunRequest:
|
||||
metadata: dict[str, Any] | None = None
|
||||
media: list[str] | None = None
|
||||
target: GraphTarget | None = None
|
||||
configurable_extra: dict[str, Any] | None = None
|
||||
"""Extra keys to merge into the LangGraph ``configurable`` dict alongside
|
||||
``thread_id`` — e.g. ``{"active_teams": [...]}`` from the TUI
|
||||
``/expert`` command. WebUI callers achieve the same effect via
|
||||
``langgraph_sdk``'s ``config.configurable`` on their own; this field is
|
||||
the local-gateway equivalent so CLI / TUI / headless serve can bias
|
||||
the run identically."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -4,6 +4,7 @@ Re-exports middleware classes and factory functions so that existing
|
||||
``from EvoScientist.middleware import X`` imports continue to work.
|
||||
"""
|
||||
|
||||
from .active_team import ActiveTeamMiddleware, create_active_team_middleware
|
||||
from .ask_user import (
|
||||
AskUserMiddleware,
|
||||
AskUserRequest,
|
||||
@@ -40,6 +41,7 @@ from .tool_selector import create_tool_selector_middleware
|
||||
from .utils import disable_thinking
|
||||
|
||||
__all__ = [
|
||||
"ActiveTeamMiddleware",
|
||||
"AskUserMiddleware",
|
||||
"AskUserRequest",
|
||||
"AskUserWidgetResult",
|
||||
@@ -56,6 +58,7 @@ __all__ = [
|
||||
"ToolErrorHandlerMiddleware",
|
||||
"ToolHistoryRepairMiddleware",
|
||||
"compute_context_editing_trigger",
|
||||
"create_active_team_middleware",
|
||||
"create_code_interpreter_middleware",
|
||||
"create_context_editing_middleware",
|
||||
"create_memory_lifecycle_middleware",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""ActiveTeamMiddleware for EvoScientist agent-teams v1.
|
||||
|
||||
Reads ``configurable.active_teams: list[str]`` on every model call and
|
||||
appends a system-prompt cue biasing the main agent to consult the
|
||||
user-invited expert(s) via ``task({subagent_type: ...})``.
|
||||
|
||||
Backend-stateless team binding: WebUI sends ``active_teams`` on every
|
||||
``stream.submit()`` for as long as the invited expert is active; this
|
||||
middleware reads it fresh per turn via ``langgraph.config.get_config()``.
|
||||
Matches the plan's decision to reach for the ``configurable`` primitive
|
||||
rather than a server-side thread-state store (CLAUDE.md #5).
|
||||
|
||||
Naming note: the WIRE FORMAT is ``configurable.active_teams`` (plural,
|
||||
legacy from the earlier "teams" framing that survived the pivot per the
|
||||
WebUI section of the design note). Under the current expert-skill
|
||||
mechanism the semantic content is a list of expert names, but the
|
||||
wire key stays ``active_teams`` for WebUI compatibility. Internal
|
||||
system-prompt tags use ``<active_expert>`` / ``<active_experts>``
|
||||
because that matches what the LLM sees as the semantic target.
|
||||
|
||||
No-op when:
|
||||
- ``configurable.active_teams`` is absent, empty, non-list, or contains
|
||||
no non-empty string entries.
|
||||
- The middleware is invoked outside a runnable context (``get_config``
|
||||
raises).
|
||||
|
||||
Not included in the async-subagent middleware stack: an expert running
|
||||
as its own graph would otherwise inject a "prefer expert X" cue into
|
||||
its own system prompt, where the persona is already baked in. See
|
||||
``EvoScientist.py::_get_default_middleware``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
_TEMPLATE_SINGLE = (
|
||||
"<active_expert>\n"
|
||||
"The user has invited the expert `{expert}` to this thread. "
|
||||
"Consult it via `task({{subagent_type: '{expert}', ...}})` for "
|
||||
"requests within its scope. It stays available for the whole session "
|
||||
"until the user dismisses it.\n"
|
||||
"</active_expert>"
|
||||
)
|
||||
|
||||
_TEMPLATE_MULTI = (
|
||||
"<active_experts>\n"
|
||||
"The user has invited the following experts to this thread: "
|
||||
"{experts}. Consult any of them via "
|
||||
"`task({{subagent_type: '<expert_name>', ...}})` based on which fits "
|
||||
"the current request. Do not consult an expert if the request is "
|
||||
"clearly outside its scope.\n"
|
||||
"</active_experts>"
|
||||
)
|
||||
|
||||
|
||||
def _read_active_teams() -> list[str]:
|
||||
"""Read ``configurable.active_teams`` from the current RunnableConfig.
|
||||
|
||||
Returns an empty list when the config is absent, malformed, or the
|
||||
call happens outside a runnable context.
|
||||
"""
|
||||
try:
|
||||
from langgraph.config import get_config
|
||||
|
||||
cfg = get_config()
|
||||
except Exception:
|
||||
# Outside a runnable context (most common in tests) or
|
||||
# langgraph not importable — nothing to inject.
|
||||
return []
|
||||
if not isinstance(cfg, dict):
|
||||
return []
|
||||
configurable = cfg.get("configurable") or {}
|
||||
if not isinstance(configurable, dict):
|
||||
return []
|
||||
raw = configurable.get("active_teams")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [t for t in raw if isinstance(t, str) and t]
|
||||
|
||||
|
||||
class ActiveTeamMiddleware(AgentMiddleware):
|
||||
"""Bias delegation toward the user's active expert(s) on every turn."""
|
||||
|
||||
name = "active_team"
|
||||
|
||||
def _cue_for(self, experts: list[str]) -> str:
|
||||
if len(experts) == 1:
|
||||
return _TEMPLATE_SINGLE.format(expert=experts[0])
|
||||
experts_str = ", ".join(f"`{e}`" for e in experts)
|
||||
return _TEMPLATE_MULTI.format(experts=experts_str)
|
||||
|
||||
def modify_request(self, request: ModelRequest) -> ModelRequest:
|
||||
"""Append the active-expert cue to the request's system message."""
|
||||
experts = _read_active_teams()
|
||||
if not experts:
|
||||
return request
|
||||
from .utils import append_to_system_message
|
||||
|
||||
new_system = append_to_system_message(
|
||||
request.system_message,
|
||||
self._cue_for(experts),
|
||||
)
|
||||
return request.override(system_message=new_system)
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
return handler(self.modify_request(request))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
return await handler(self.modify_request(request))
|
||||
|
||||
|
||||
def create_active_team_middleware() -> ActiveTeamMiddleware:
|
||||
"""Build ActiveTeamMiddleware."""
|
||||
return ActiveTeamMiddleware()
|
||||
@@ -1440,6 +1440,7 @@ def _run_streaming(
|
||||
on_stream_event: Callable[[str, Any], Any] | None = None,
|
||||
status_footer_builder: Callable[[], Any] | None = None,
|
||||
metadata: dict[str, object] | None = None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
hitl_prompt_fn: Callable[[list], list[dict] | None] | None = None,
|
||||
ask_user_prompt_fn: Callable[[dict], dict] | None = None,
|
||||
cancel_scope: str | None = None,
|
||||
@@ -1526,6 +1527,7 @@ def _run_streaming(
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
metadata=metadata,
|
||||
configurable_extra=configurable_extra,
|
||||
target=_graph_target_for_local_agent(agent, metadata),
|
||||
)
|
||||
)
|
||||
@@ -1768,6 +1770,7 @@ def _run_streaming(
|
||||
on_stream_event=on_stream_event,
|
||||
status_footer_builder=status_footer_builder,
|
||||
metadata=metadata,
|
||||
configurable_extra=configurable_extra,
|
||||
hitl_prompt_fn=hitl_prompt_fn,
|
||||
ask_user_prompt_fn=ask_user_prompt_fn,
|
||||
cancel_scope=cancel_scope,
|
||||
|
||||
@@ -860,6 +860,7 @@ async def stream_agent_events(
|
||||
metadata: dict[str, Any] | None = None,
|
||||
media: list[str] | None = None,
|
||||
events: "ToolSelectionView | None" = None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Stream events from a DeepAgents/LangGraph v3 run.
|
||||
|
||||
@@ -875,6 +876,10 @@ async def stream_agent_events(
|
||||
metadata: Optional metadata dict merged into the LangGraph config
|
||||
(e.g. agent_name, updated_at for checkpoint persistence).
|
||||
media: Optional list of local file paths for attachments.
|
||||
configurable_extra: Optional extra keys merged into ``configurable``
|
||||
alongside ``thread_id`` — e.g. ``{"active_teams": [...]}`` from
|
||||
TUI ``/expert`` bindings, mirroring what WebUI writes via
|
||||
langgraph-sdk. Ignored if ``None`` or empty.
|
||||
|
||||
Yields:
|
||||
Event dicts: thinking, text, tool_call, tool_result,
|
||||
@@ -886,7 +891,11 @@ async def stream_agent_events(
|
||||
|
||||
events = SessionEventSink()
|
||||
|
||||
config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
|
||||
configurable: dict[str, Any] = {
|
||||
**(configurable_extra or {}),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
config: dict[str, Any] = {"configurable": configurable}
|
||||
if metadata:
|
||||
config["metadata"] = metadata
|
||||
emitter = StreamEventEmitter()
|
||||
|
||||
@@ -126,6 +126,75 @@ def build_expert_subagent_spec(
|
||||
}
|
||||
|
||||
|
||||
_reserved_subagent_names_cache: frozenset[str] | None = None
|
||||
|
||||
|
||||
def _reserved_subagent_names() -> frozenset[str]:
|
||||
"""Names ``_fold_expert_subagents`` refuses for expert registration.
|
||||
|
||||
Union of every static yaml sub-agent name (from ``subagents/*.yaml``)
|
||||
plus deepagents' ``general-purpose``. Mirrors the ``taken`` set built
|
||||
inline in ``EvoScientist.py::_fold_expert_subagents`` so callers that
|
||||
need to know "which names will be rejected at fold time" don't have
|
||||
to replay it.
|
||||
|
||||
Cached on first call — yaml sub-agent files are static per process.
|
||||
"""
|
||||
global _reserved_subagent_names_cache
|
||||
if _reserved_subagent_names_cache is not None:
|
||||
return _reserved_subagent_names_cache
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
|
||||
|
||||
from .. import subagents as _subagents_pkg
|
||||
|
||||
names: set[str] = {GENERAL_PURPOSE_SUBAGENT["name"]}
|
||||
for pkg_dir in _subagents_pkg.__path__:
|
||||
for yml_path in Path(pkg_dir).glob("*.yaml"):
|
||||
if yml_path.name.startswith(("_", ".")):
|
||||
continue
|
||||
try:
|
||||
data = yaml.safe_load(yml_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
names.update(str(k) for k in data)
|
||||
_reserved_subagent_names_cache = frozenset(names)
|
||||
return _reserved_subagent_names_cache
|
||||
|
||||
|
||||
def list_dispatchable_experts(*, include_system: bool = True) -> list[SkillInfo]:
|
||||
"""Experts that will actually be dispatchable via ``task()``.
|
||||
|
||||
Combines ``list_expert_skills`` with the same filters
|
||||
``build_expert_subagent_specs`` (empty body) and
|
||||
``_fold_expert_subagents`` (name collision with yaml sub-agents or
|
||||
``general-purpose``) apply at construction time. Callers surfacing
|
||||
experts to the user (e.g. the ``/expert`` slash command) should use
|
||||
this instead of ``list_expert_skills`` directly, otherwise they can
|
||||
accept a name that will silently misroute or per-turn error at
|
||||
dispatch time.
|
||||
|
||||
Read-only filter — construction-time warnings for empty-body /
|
||||
colliding experts are emitted by ``build_expert_subagent_specs`` and
|
||||
``_fold_expert_subagents`` respectively, so nothing is logged here.
|
||||
"""
|
||||
from ..tools.skills_manager import list_expert_skills
|
||||
|
||||
reserved = _reserved_subagent_names()
|
||||
dispatchable: list[SkillInfo] = []
|
||||
for info in list_expert_skills(include_system=include_system):
|
||||
if not _body_of(info).strip():
|
||||
continue
|
||||
if info.name in reserved:
|
||||
continue
|
||||
dispatchable.append(info)
|
||||
return dispatchable
|
||||
|
||||
|
||||
def build_expert_subagent_specs(
|
||||
tool_registry: dict[str, Any] | None = None,
|
||||
*,
|
||||
|
||||
@@ -38,6 +38,7 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -48,6 +49,58 @@ from .. import paths
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- skills-changed publish primitive ---------------------------------------
|
||||
#
|
||||
# ``install_skill`` and ``uninstall_skill`` are called from six places today:
|
||||
# three slash commands, two paths in the agent's ``skill_manager`` @tool, and
|
||||
# the onboarding step. Consumers that cache skill data (currently the
|
||||
# /expert completion popup) need to invalidate on every mutation, and wiring
|
||||
# that at each caller site was the "vibes and hope" pattern reviewers rightly
|
||||
# called out on PR #371 — the next caller silently reintroduces staleness.
|
||||
#
|
||||
# The invariant lives here instead: any subscriber registered via
|
||||
# ``register_skills_changed_callback`` is fired after every install / uninstall
|
||||
# call, so no caller has to remember.
|
||||
|
||||
_skills_changed_callbacks: list[Callable[[], None]] = []
|
||||
|
||||
|
||||
def register_skills_changed_callback(callback: Callable[[], None]) -> None:
|
||||
"""Register a callback fired after every ``install_skill`` /
|
||||
``uninstall_skill`` call.
|
||||
|
||||
Consumers that cache skill data — e.g. the ``/expert`` completion
|
||||
popup — subscribe once at import time so every mutation path
|
||||
(commands, agent ``skill_manager`` @tool, onboarding) invalidates
|
||||
their view. Callbacks are process-scoped and never unregistered
|
||||
automatically; tests should call ``_reset_skills_changed_callbacks``
|
||||
from a fixture to isolate state.
|
||||
"""
|
||||
_skills_changed_callbacks.append(callback)
|
||||
|
||||
|
||||
def _reset_skills_changed_callbacks() -> None:
|
||||
"""Test hook: clear the callback list."""
|
||||
_skills_changed_callbacks.clear()
|
||||
|
||||
|
||||
def _notify_skills_changed() -> None:
|
||||
"""Fire every registered callback.
|
||||
|
||||
Exceptions are logged but swallowed so a misbehaving subscriber can't
|
||||
break the install/uninstall return path.
|
||||
"""
|
||||
for cb in _skills_changed_callbacks:
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"skills-changed callback %r raised; continuing.",
|
||||
getattr(cb, "__qualname__", cb),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
"""Information about an installed skill."""
|
||||
@@ -609,6 +662,17 @@ def install_skill(
|
||||
- path: installed path (if successful)
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
try:
|
||||
return _install_skill_impl(source, dest_dir, global_install)
|
||||
finally:
|
||||
_notify_skills_changed()
|
||||
|
||||
|
||||
def _install_skill_impl(
|
||||
source: str,
|
||||
dest_dir: str | None = None,
|
||||
global_install: bool = True,
|
||||
) -> dict:
|
||||
dest_dir = dest_dir or (
|
||||
str(paths.GLOBAL_SKILLS_DIR) if global_install else str(paths.USER_SKILLS_DIR)
|
||||
)
|
||||
@@ -924,6 +988,13 @@ def uninstall_skill(name: str) -> dict:
|
||||
- success: bool
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
try:
|
||||
return _uninstall_skill_impl(name)
|
||||
finally:
|
||||
_notify_skills_changed()
|
||||
|
||||
|
||||
def _uninstall_skill_impl(name: str) -> dict:
|
||||
# Validate name to prevent path traversal
|
||||
clean_name = _sanitize_name(name)
|
||||
if not clean_name:
|
||||
|
||||
@@ -22,11 +22,14 @@ async def collect_events(
|
||||
thread_id: str = "t1",
|
||||
*,
|
||||
events=None,
|
||||
configurable_extra: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Collect stream_agent_events output for tests.
|
||||
|
||||
``events`` is the frontend tool-selection sink to drive suppression /
|
||||
selection rendering (defaults to the silent NoOpSink inside the stream).
|
||||
``configurable_extra`` is forwarded verbatim to ``stream_agent_events``
|
||||
for tests that assert plumbing into the LangGraph ``configurable`` dict.
|
||||
"""
|
||||
collected = []
|
||||
async for ev in stream_agent_events(
|
||||
@@ -34,6 +37,7 @@ async def collect_events(
|
||||
message,
|
||||
thread_id,
|
||||
events=events,
|
||||
configurable_extra=configurable_extra,
|
||||
):
|
||||
collected.append(ev)
|
||||
return collected
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for EvoScientist.middleware.active_team."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from EvoScientist.middleware.active_team import (
|
||||
ActiveTeamMiddleware,
|
||||
_read_active_teams,
|
||||
create_active_team_middleware,
|
||||
)
|
||||
|
||||
|
||||
def _request():
|
||||
"""A minimal ModelRequest stand-in supporting the fields the middleware
|
||||
reads (`system_message`) and the `.override(**kwargs)` mutator."""
|
||||
request = SimpleNamespace(
|
||||
state={},
|
||||
runtime=object(),
|
||||
system_message=SystemMessage(content="base system"),
|
||||
)
|
||||
request.override = lambda **kwargs: SimpleNamespace(
|
||||
**{
|
||||
"state": request.state,
|
||||
"runtime": request.runtime,
|
||||
"system_message": kwargs.get("system_message", request.system_message),
|
||||
}
|
||||
)
|
||||
return request
|
||||
|
||||
|
||||
def _system_text(modified) -> str:
|
||||
system_message = modified.system_message
|
||||
assert system_message is not None
|
||||
return str(system_message.content)
|
||||
|
||||
|
||||
def _mock_config():
|
||||
cfg = MagicMock()
|
||||
cfg.enable_ask_user = False
|
||||
cfg.auto_mode = False
|
||||
cfg.auto_approve = False
|
||||
cfg.model_fallbacks = None
|
||||
cfg.auxiliary_model = ""
|
||||
cfg.auxiliary_provider = ""
|
||||
cfg.code_interpreter_timeout = 60
|
||||
cfg.code_interpreter_max_result_chars = 6000
|
||||
return cfg
|
||||
|
||||
|
||||
# ---- unit tests: _read_active_teams behavior --------------------------------
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_read_active_teams_returns_list_when_present(mock_get_config):
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {"active_teams": ["idea-brainstorm"]},
|
||||
}
|
||||
assert _read_active_teams() == ["idea-brainstorm"]
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_read_active_teams_returns_empty_when_configurable_missing(mock_get_config):
|
||||
mock_get_config.return_value = {}
|
||||
assert _read_active_teams() == []
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_read_active_teams_returns_empty_when_active_teams_missing(mock_get_config):
|
||||
mock_get_config.return_value = {"configurable": {"other_field": "x"}}
|
||||
assert _read_active_teams() == []
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_read_active_teams_returns_empty_when_value_not_list(mock_get_config):
|
||||
"""WebUI mistakenly sends a scalar instead of a list; must not crash."""
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {"active_teams": "idea-brainstorm"},
|
||||
}
|
||||
assert _read_active_teams() == []
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_read_active_teams_filters_non_string_entries(mock_get_config):
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {
|
||||
"active_teams": ["idea-brainstorm", None, 42, "", "lit-review"]
|
||||
},
|
||||
}
|
||||
assert _read_active_teams() == ["idea-brainstorm", "lit-review"]
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config", side_effect=RuntimeError("outside context"))
|
||||
def test_read_active_teams_returns_empty_outside_runnable_context(mock_get_config):
|
||||
assert _read_active_teams() == []
|
||||
|
||||
|
||||
# ---- unit tests: middleware behavior ---------------------------------------
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_middleware_no_op_when_active_teams_absent(mock_get_config):
|
||||
mock_get_config.return_value = {"configurable": {}}
|
||||
middleware = ActiveTeamMiddleware()
|
||||
request = _request()
|
||||
modified = middleware.modify_request(request)
|
||||
# No override applied: original request returned as-is.
|
||||
assert modified is request
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_middleware_no_op_when_active_teams_empty_list(mock_get_config):
|
||||
mock_get_config.return_value = {"configurable": {"active_teams": []}}
|
||||
middleware = ActiveTeamMiddleware()
|
||||
request = _request()
|
||||
modified = middleware.modify_request(request)
|
||||
assert modified is request
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_middleware_appends_single_expert_cue(mock_get_config):
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {"active_teams": ["idea-brainstorm"]},
|
||||
}
|
||||
middleware = ActiveTeamMiddleware()
|
||||
modified = middleware.modify_request(_request())
|
||||
text = _system_text(modified)
|
||||
assert "<active_expert>" in text
|
||||
assert "`idea-brainstorm`" in text
|
||||
assert "Consult it via `task(" in text
|
||||
assert "base system" in text # original preserved
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_middleware_appends_multi_expert_cue(mock_get_config):
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {"active_teams": ["idea-brainstorm", "literature-review"]},
|
||||
}
|
||||
middleware = ActiveTeamMiddleware()
|
||||
modified = middleware.modify_request(_request())
|
||||
text = _system_text(modified)
|
||||
assert "<active_experts>" in text
|
||||
assert "`idea-brainstorm`" in text
|
||||
assert "`literature-review`" in text
|
||||
assert "Consult any of them" in text
|
||||
assert "base system" in text
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config")
|
||||
def test_middleware_appends_cue_for_unknown_expert_names(mock_get_config):
|
||||
"""Middleware doesn't validate names against the registry; main decides."""
|
||||
mock_get_config.return_value = {
|
||||
"configurable": {"active_teams": ["nonexistent-expert"]},
|
||||
}
|
||||
middleware = ActiveTeamMiddleware()
|
||||
modified = middleware.modify_request(_request())
|
||||
text = _system_text(modified)
|
||||
assert "`nonexistent-expert`" in text
|
||||
|
||||
|
||||
@patch("langgraph.config.get_config", side_effect=RuntimeError("outside context"))
|
||||
def test_middleware_no_op_outside_runnable_context(mock_get_config):
|
||||
middleware = ActiveTeamMiddleware()
|
||||
request = _request()
|
||||
modified = middleware.modify_request(request)
|
||||
assert modified is request
|
||||
|
||||
|
||||
# ---- composition tests: _get_default_middleware ----------------------------
|
||||
|
||||
|
||||
@patch(
|
||||
"EvoScientist.middleware.create_tool_selector_middleware",
|
||||
return_value=[MagicMock(), MagicMock()],
|
||||
)
|
||||
@patch("EvoScientist.EvoScientist._ensure_chat_model")
|
||||
@patch("EvoScientist.EvoScientist._ensure_config")
|
||||
def test_default_middleware_includes_active_team_for_main_agent(
|
||||
mock_config, mock_model, mock_tool_selector
|
||||
):
|
||||
mock_config.return_value = _mock_config()
|
||||
mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
|
||||
|
||||
from EvoScientist.EvoScientist import _get_default_middleware
|
||||
|
||||
middleware = _get_default_middleware()
|
||||
|
||||
assert any(isinstance(m, ActiveTeamMiddleware) for m in middleware)
|
||||
|
||||
|
||||
@patch(
|
||||
"EvoScientist.middleware.create_tool_selector_middleware",
|
||||
return_value=[MagicMock(), MagicMock()],
|
||||
)
|
||||
@patch("EvoScientist.EvoScientist._ensure_chat_model")
|
||||
@patch("EvoScientist.EvoScientist._ensure_config")
|
||||
def test_default_middleware_excludes_active_team_for_async_subagent(
|
||||
mock_config, mock_model, mock_tool_selector
|
||||
):
|
||||
mock_config.return_value = _mock_config()
|
||||
mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
|
||||
|
||||
from EvoScientist.EvoScientist import _get_default_middleware
|
||||
|
||||
middleware = _get_default_middleware(for_async_subagent=True)
|
||||
|
||||
assert not any(isinstance(m, ActiveTeamMiddleware) for m in middleware)
|
||||
|
||||
|
||||
# ---- factory --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_factory_returns_middleware_instance():
|
||||
assert isinstance(create_active_team_middleware(), ActiveTeamMiddleware)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for helpers in ``EvoScientist.commands.base``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from EvoScientist.commands.base import ChannelRuntime, active_teams_configurable_extra
|
||||
|
||||
|
||||
class TestActiveTeamsConfigurableExtra:
|
||||
"""``active_teams_configurable_extra`` is used at every stream-call site
|
||||
that needs to forward /expert invites into ``RunRequest.configurable_extra``.
|
||||
"""
|
||||
|
||||
def test_none_runtime_returns_none(self):
|
||||
assert active_teams_configurable_extra(None) is None
|
||||
|
||||
def test_runtime_without_invites_returns_none(self):
|
||||
# Empty list must produce ``None`` so callers can pass the result
|
||||
# unconditionally without polluting ``configurable`` with an empty
|
||||
# ``active_teams: []`` (which ``ActiveTeamMiddleware`` would treat
|
||||
# as no-op anyway, but the wire stays cleaner without it).
|
||||
runtime = ChannelRuntime()
|
||||
assert active_teams_configurable_extra(runtime) is None
|
||||
|
||||
def test_runtime_with_invites_returns_dict_copy(self):
|
||||
runtime = ChannelRuntime()
|
||||
runtime.active_teams = ["idea-brainstorm", "paper-review"]
|
||||
result = active_teams_configurable_extra(runtime)
|
||||
assert result == {"active_teams": ["idea-brainstorm", "paper-review"]}
|
||||
# Must be a *copy* — mutating the returned list may not leak back
|
||||
# to the runtime's session-scoped invite list.
|
||||
result["active_teams"].append("mutated")
|
||||
assert runtime.active_teams == ["idea-brainstorm", "paper-review"]
|
||||
|
||||
|
||||
class TestChannelRuntimeClear:
|
||||
"""``ChannelRuntime.clear`` runs on channel shutdown; it must leave the
|
||||
session-scoped ``active_teams`` list intact so stopping a channel does
|
||||
not silently dismiss the user's invited experts. ``/new`` and
|
||||
``/expert clear`` handle invite reset explicitly.
|
||||
"""
|
||||
|
||||
def test_clear_preserves_active_teams(self):
|
||||
runtime = ChannelRuntime()
|
||||
runtime.agent = object()
|
||||
runtime.thread_id = "t-42"
|
||||
runtime.active_teams = ["idea-brainstorm"]
|
||||
runtime.clear()
|
||||
assert runtime.agent is None
|
||||
assert runtime.thread_id is None
|
||||
assert runtime.active_teams == ["idea-brainstorm"]
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Unit tests for /experts and /expert slash commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist.commands.base import ChannelRuntime, CommandContext
|
||||
from EvoScientist.commands.implementation.experts import (
|
||||
ExpertCommand,
|
||||
ExpertsCommand,
|
||||
invalidate_experts_cache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bust_experts_cache_between_tests():
|
||||
"""The dispatchable-experts cache in ``experts.py`` is module-level; without
|
||||
resetting it, a test that patches ``list_expert_skills`` sees the previous
|
||||
test's fakes.
|
||||
"""
|
||||
invalidate_experts_cache()
|
||||
yield
|
||||
invalidate_experts_cache()
|
||||
|
||||
|
||||
class _FakeUI:
|
||||
"""Minimal CommandUI capturing outputs for assertion."""
|
||||
|
||||
supports_interactive = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[tuple[str, str]] = []
|
||||
self.mounted: list[Any] = []
|
||||
|
||||
def append_system(self, text: str, style: str = "dim") -> None:
|
||||
self.lines.append((text, style))
|
||||
|
||||
def mount_renderable(self, renderable: Any) -> None:
|
||||
self.mounted.append(renderable)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSkillInfo:
|
||||
"""Enough of ``SkillInfo`` for the commands to render."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
role: str = ""
|
||||
default_dispatch: str = ""
|
||||
type: str = "expert"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
source: str = "builtin"
|
||||
# Non-empty by default so the fake passes the empty-body filter in
|
||||
# ``list_dispatchable_experts``. Tests that specifically want to
|
||||
# exercise the empty-body reject path pass ``body=""``.
|
||||
body: str = "persona"
|
||||
|
||||
|
||||
def _make_ctx(active_teams: list[str] | None = None) -> tuple[CommandContext, _FakeUI]:
|
||||
ui = _FakeUI()
|
||||
runtime = ChannelRuntime()
|
||||
if active_teams:
|
||||
runtime.active_teams = list(active_teams)
|
||||
ctx = CommandContext(
|
||||
agent=None,
|
||||
thread_id="t1",
|
||||
ui=ui,
|
||||
channel_runtime=runtime,
|
||||
)
|
||||
return ctx, ui
|
||||
|
||||
|
||||
class TestExpertsList:
|
||||
async def test_lists_installed_experts_in_table(self):
|
||||
ctx, ui = _make_ctx()
|
||||
with patch(
|
||||
"EvoScientist.tools.skills_manager.list_expert_skills",
|
||||
return_value=[
|
||||
_FakeSkillInfo(
|
||||
name="idea-brainstorm",
|
||||
role="Research idea brainstormer",
|
||||
default_dispatch="sync",
|
||||
),
|
||||
],
|
||||
):
|
||||
await ExpertsCommand().execute(ctx, args=[])
|
||||
# A Rich Table was mounted, and the no-experts-invited hint appeared.
|
||||
assert len(ui.mounted) == 1
|
||||
assert any("No experts invited" in text for text, _ in ui.lines)
|
||||
|
||||
async def test_empty_list_prints_help_hint(self):
|
||||
ctx, ui = _make_ctx()
|
||||
with patch(
|
||||
"EvoScientist.tools.skills_manager.list_expert_skills",
|
||||
return_value=[],
|
||||
):
|
||||
await ExpertsCommand().execute(ctx, args=[])
|
||||
assert any("No expert skills installed" in text for text, _ in ui.lines)
|
||||
assert not ui.mounted
|
||||
|
||||
async def test_active_expert_marked_in_table(self):
|
||||
ctx, ui = _make_ctx(active_teams=["idea-brainstorm"])
|
||||
with patch(
|
||||
"EvoScientist.tools.skills_manager.list_expert_skills",
|
||||
return_value=[
|
||||
_FakeSkillInfo(
|
||||
name="idea-brainstorm",
|
||||
role="Research idea brainstormer",
|
||||
default_dispatch="sync",
|
||||
),
|
||||
],
|
||||
):
|
||||
await ExpertsCommand().execute(ctx, args=[])
|
||||
assert any("Active: idea-brainstorm" in text for text, _ in ui.lines)
|
||||
|
||||
|
||||
class TestExpertToggle:
|
||||
async def test_missing_arg_prints_usage(self):
|
||||
ctx, ui = _make_ctx()
|
||||
await ExpertCommand().execute(ctx, args=[])
|
||||
assert any("Usage:" in text for text, _ in ui.lines)
|
||||
|
||||
async def test_unknown_expert_errors(self):
|
||||
ctx, ui = _make_ctx()
|
||||
with patch(
|
||||
"EvoScientist.tools.skills_manager.list_expert_skills",
|
||||
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
|
||||
):
|
||||
await ExpertCommand().execute(ctx, args=["not-an-expert"])
|
||||
assert any(
|
||||
"No expert skill named 'not-an-expert'" in text for text, _ in ui.lines
|
||||
)
|
||||
assert ctx.channel_runtime.active_teams == []
|
||||
|
||||
async def test_invite_adds_to_active_teams(self):
|
||||
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"])
|
||||
assert ctx.channel_runtime.active_teams == ["idea-brainstorm"]
|
||||
assert any("Invited expert: idea-brainstorm" in text for text, _ in ui.lines)
|
||||
|
||||
async def test_toggle_dismisses_when_already_invited(self):
|
||||
ctx, ui = _make_ctx(active_teams=["idea-brainstorm"])
|
||||
with patch(
|
||||
"EvoScientist.tools.skills_manager.list_expert_skills",
|
||||
return_value=[_FakeSkillInfo(name="idea-brainstorm")],
|
||||
):
|
||||
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
|
||||
assert ctx.channel_runtime.active_teams == []
|
||||
assert any("Dismissed expert: idea-brainstorm" in text for text, _ in ui.lines)
|
||||
|
||||
async def test_clear_dismisses_all(self):
|
||||
ctx, ui = _make_ctx(active_teams=["idea-brainstorm", "second"])
|
||||
await ExpertCommand().execute(ctx, args=["clear"])
|
||||
assert ctx.channel_runtime.active_teams == []
|
||||
assert any(
|
||||
"Dismissed experts: idea-brainstorm, second" in text for text, _ in ui.lines
|
||||
)
|
||||
|
||||
async def test_clear_on_empty_list_reports_nothing_to_do(self):
|
||||
ctx, ui = _make_ctx()
|
||||
await ExpertCommand().execute(ctx, args=["clear"])
|
||||
assert ctx.channel_runtime.active_teams == []
|
||||
assert any("No experts invited" in text for text, _ in ui.lines)
|
||||
|
||||
async def test_no_channel_runtime_prints_warning(self):
|
||||
ui = _FakeUI()
|
||||
ctx = CommandContext(agent=None, thread_id="t1", ui=ui, channel_runtime=None)
|
||||
await ExpertCommand().execute(ctx, args=["idea-brainstorm"])
|
||||
assert any("/expert requires a session runtime" in text for text, _ in ui.lines)
|
||||
|
||||
|
||||
class TestExpertCompletions:
|
||||
"""``ExpertCommand.get_completions`` mixes dynamic expert names with the
|
||||
static ``clear`` subcommand. Regression coverage for the three fixes on
|
||||
PR #371: exact-match suppression, past-first-arg guard, and
|
||||
case-insensitive matching.
|
||||
"""
|
||||
|
||||
def _patched_experts(self, *names: str):
|
||||
# Patch ``list_dispatchable_experts`` directly (not the underlying
|
||||
# ``list_expert_skills``) so the test does not depend on the shipped
|
||||
# yaml sub-agent set — the reserved-name filter would otherwise
|
||||
# silently reject a fake whose name collides with a future yaml
|
||||
# sub-agent.
|
||||
return patch(
|
||||
"EvoScientist.subagents.expert_container.list_dispatchable_experts",
|
||||
return_value=[_FakeSkillInfo(name=n) for n in names],
|
||||
)
|
||||
|
||||
def test_lists_installed_experts_and_clear(self):
|
||||
cmd = ExpertCommand()
|
||||
with self._patched_experts("smoke-test-sync-expert", "smoke-test-alt-expert"):
|
||||
completions = cmd.get_completions([""])
|
||||
names = {name for name, _ in completions}
|
||||
assert names == {"smoke-test-sync-expert", "smoke-test-alt-expert", "clear"}
|
||||
|
||||
def test_case_insensitive_prefix_match(self):
|
||||
# Skill dir names sometimes have uppercase; completion typed
|
||||
# lowercase must still surface them.
|
||||
cmd = ExpertCommand()
|
||||
with self._patched_experts("Smoke-Test-Case-Expert", "smoke-test-sync-expert"):
|
||||
completions = cmd.get_completions(["smoke-test-c"])
|
||||
names = {name for name, _ in completions}
|
||||
assert names == {"Smoke-Test-Case-Expert"}
|
||||
|
||||
def test_exact_match_hides_popup_same_case(self):
|
||||
cmd = ExpertCommand()
|
||||
with self._patched_experts("smoke-test-sync-expert"):
|
||||
completions = cmd.get_completions(["smoke-test-sync-expert"])
|
||||
assert completions == []
|
||||
|
||||
def test_exact_match_hides_popup_different_case(self):
|
||||
# Case-insensitive exact-match suppression: typing the name in a
|
||||
# different case than the skill dir still fully completes it and
|
||||
# hides the popup.
|
||||
cmd = ExpertCommand()
|
||||
with self._patched_experts("Smoke-Test-Case-Expert"):
|
||||
completions = cmd.get_completions(["smoke-test-case-expert"])
|
||||
assert completions == []
|
||||
|
||||
def test_past_first_arg_returns_empty(self):
|
||||
# /expert takes a single positional. Trailing space -> tokens == ["n", ""].
|
||||
cmd = ExpertCommand()
|
||||
with self._patched_experts("smoke-test-sync-expert"):
|
||||
assert cmd.get_completions(["smoke-test-sync-expert", ""]) == []
|
||||
assert cmd.get_completions(["smoke-test-sync-expert", "foo"]) == []
|
||||
@@ -34,3 +34,54 @@ class TestNewCommand:
|
||||
ctx = CommandContext(agent=None, thread_id="tid", ui=ui)
|
||||
# No AttributeError even though ctx.agent is None
|
||||
await NewCommand().execute(ctx, [])
|
||||
|
||||
async def test_clears_invited_experts_and_announces(self):
|
||||
"""/new dismisses invited experts uniformly with channel-shutdown clear."""
|
||||
from EvoScientist.commands.base import ChannelRuntime, CommandContext
|
||||
from EvoScientist.commands.implementation.session import NewCommand
|
||||
|
||||
ui = MagicMock()
|
||||
ui.start_new_session = AsyncMock()
|
||||
runtime = ChannelRuntime()
|
||||
runtime.active_teams = ["idea-brainstorm"]
|
||||
ctx = CommandContext(
|
||||
agent=None,
|
||||
thread_id="tid",
|
||||
ui=ui,
|
||||
channel_runtime=runtime,
|
||||
)
|
||||
await NewCommand().execute(ctx, [])
|
||||
assert runtime.active_teams == []
|
||||
messages = [call.args[0] for call in ui.append_system.call_args_list]
|
||||
assert any(
|
||||
"Dismissed experts on new session: idea-brainstorm" in msg
|
||||
for msg in messages
|
||||
)
|
||||
|
||||
async def test_no_announcement_when_no_experts_invited(self):
|
||||
"""No noise on ``/new`` when the invite list is already empty."""
|
||||
from EvoScientist.commands.base import ChannelRuntime, CommandContext
|
||||
from EvoScientist.commands.implementation.session import NewCommand
|
||||
|
||||
ui = MagicMock()
|
||||
ui.start_new_session = AsyncMock()
|
||||
runtime = ChannelRuntime()
|
||||
ctx = CommandContext(
|
||||
agent=None,
|
||||
thread_id="tid",
|
||||
ui=ui,
|
||||
channel_runtime=runtime,
|
||||
)
|
||||
await NewCommand().execute(ctx, [])
|
||||
ui.append_system.assert_not_called()
|
||||
|
||||
async def test_no_announcement_without_channel_runtime(self):
|
||||
"""Runs cleanly when no ChannelRuntime is attached."""
|
||||
from EvoScientist.commands.base import CommandContext
|
||||
from EvoScientist.commands.implementation.session import NewCommand
|
||||
|
||||
ui = MagicMock()
|
||||
ui.start_new_session = AsyncMock()
|
||||
ctx = CommandContext(agent=None, thread_id="tid", ui=ui, channel_runtime=None)
|
||||
await NewCommand().execute(ctx, [])
|
||||
ui.append_system.assert_not_called()
|
||||
|
||||
@@ -12,6 +12,7 @@ from EvoScientist.tools.skills_manager import (
|
||||
_parse_github_url,
|
||||
_parse_skill_md,
|
||||
_record_install,
|
||||
_reset_skills_changed_callbacks,
|
||||
_validate_skill_dir,
|
||||
fetch_remote_skill_index,
|
||||
get_all_tags,
|
||||
@@ -21,6 +22,7 @@ from EvoScientist.tools.skills_manager import (
|
||||
list_expert_skills,
|
||||
list_skills,
|
||||
list_skills_by_tag,
|
||||
register_skills_changed_callback,
|
||||
resolve_remote_head,
|
||||
uninstall_skill,
|
||||
)
|
||||
@@ -1589,3 +1591,68 @@ class TestSkillManagerInstall:
|
||||
assert "Path: /skills/worked" in result
|
||||
assert "broken" in result
|
||||
assert "corrupt frontmatter" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for the skills-changed publish primitive
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_skills_changed_callbacks():
|
||||
"""Clear the module-level callback list before and after each test so
|
||||
subscribers registered elsewhere (e.g. by importing ``experts.py``) do
|
||||
not leak in or out of these tests.
|
||||
"""
|
||||
_reset_skills_changed_callbacks()
|
||||
yield
|
||||
_reset_skills_changed_callbacks()
|
||||
|
||||
|
||||
class TestSkillsChangedCallback:
|
||||
"""Verifies install_skill / uninstall_skill fire subscribers on every
|
||||
return path — success, early error return, and success-with-real-mutation.
|
||||
"""
|
||||
|
||||
def test_install_skill_fires_callback_on_error_return(
|
||||
self, isolated_skills_changed_callbacks, temp_skills_dir
|
||||
):
|
||||
fired: list[bool] = []
|
||||
register_skills_changed_callback(lambda: fired.append(True))
|
||||
result = install_skill("/nonexistent/path", str(temp_skills_dir))
|
||||
assert result["success"] is False
|
||||
assert fired == [True]
|
||||
|
||||
def test_install_skill_fires_callback_on_success(
|
||||
self, isolated_skills_changed_callbacks, sample_skill_dir, temp_skills_dir
|
||||
):
|
||||
fired: list[bool] = []
|
||||
register_skills_changed_callback(lambda: fired.append(True))
|
||||
result = install_skill(str(sample_skill_dir), str(temp_skills_dir))
|
||||
assert result["success"] is True
|
||||
assert fired == [True]
|
||||
|
||||
def test_uninstall_skill_fires_callback_on_error_return(
|
||||
self, isolated_skills_changed_callbacks
|
||||
):
|
||||
fired: list[bool] = []
|
||||
register_skills_changed_callback(lambda: fired.append(True))
|
||||
result = uninstall_skill("nonexistent-skill")
|
||||
assert result["success"] is False
|
||||
assert fired == [True]
|
||||
|
||||
def test_misbehaving_callback_does_not_break_return(
|
||||
self, isolated_skills_changed_callbacks, temp_skills_dir
|
||||
):
|
||||
good: list[bool] = []
|
||||
|
||||
def bad_callback() -> None:
|
||||
raise RuntimeError("subscriber intentionally raising")
|
||||
|
||||
register_skills_changed_callback(bad_callback)
|
||||
register_skills_changed_callback(lambda: good.append(True))
|
||||
# Bad callback runs first; the good one still fires; the install
|
||||
# return value is unaffected.
|
||||
result = install_skill("/nonexistent/path", str(temp_skills_dir))
|
||||
assert result["success"] is False
|
||||
assert good == [True]
|
||||
|
||||
@@ -158,6 +158,26 @@ class TestV3ProtocolStreaming:
|
||||
assert "stream_mode" not in kwargs
|
||||
assert "subgraphs" not in kwargs
|
||||
|
||||
async def test_configurable_extra_merged_into_config(self):
|
||||
"""``configurable_extra`` from RunRequest lands next to thread_id."""
|
||||
agent = FakeV3Agent([message_delta("hi")])
|
||||
await collect_events(
|
||||
agent,
|
||||
thread_id="t1",
|
||||
configurable_extra={"active_teams": ["idea-brainstorm"]},
|
||||
)
|
||||
_, kwargs = agent.astream_events.call_args
|
||||
configurable = kwargs["config"]["configurable"]
|
||||
assert configurable["thread_id"] == "t1"
|
||||
assert configurable["active_teams"] == ["idea-brainstorm"]
|
||||
|
||||
async def test_configurable_extra_none_leaves_thread_id_only(self):
|
||||
"""When no extras are passed, only ``thread_id`` sits under configurable."""
|
||||
agent = FakeV3Agent([message_delta("hi")])
|
||||
await collect_events(agent, thread_id="t1")
|
||||
_, kwargs = agent.astream_events.call_args
|
||||
assert kwargs["config"]["configurable"] == {"thread_id": "t1"}
|
||||
|
||||
async def test_streamed_non_selector_json_is_replayed(self):
|
||||
"""Normal JSON answers are not swallowed by selector JSON buffering."""
|
||||
agent = FakeV3Agent(
|
||||
|
||||
Reference in New Issue
Block a user