diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index a48ab35..30ce5c8 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -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(), diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index fd9b3c4..e23d369 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -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 diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index 658c8a2..ec566ef 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -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, diff --git a/EvoScientist/cli/tui_backends.py b/EvoScientist/cli/tui_backends.py index e5cb84c..b576c35 100644 --- a/EvoScientist/cli/tui_backends.py +++ b/EvoScientist/cli/tui_backends.py @@ -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, diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 3054cc6..55f07e8 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -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, diff --git a/EvoScientist/cli/tui_runtime.py b/EvoScientist/cli/tui_runtime.py index b903d1c..14d776b 100644 --- a/EvoScientist/cli/tui_runtime.py +++ b/EvoScientist/cli/tui_runtime.py @@ -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, diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py index 19d4a7e..302d6cc 100644 --- a/EvoScientist/commands/base.py +++ b/EvoScientist/commands/base.py @@ -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.""" diff --git a/EvoScientist/commands/implementation/__init__.py b/EvoScientist/commands/implementation/__init__.py index 2f748e7..e109b59 100644 --- a/EvoScientist/commands/implementation/__init__.py +++ b/EvoScientist/commands/implementation/__init__.py @@ -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", diff --git a/EvoScientist/commands/implementation/experts.py b/EvoScientist/commands/implementation/experts.py new file mode 100644 index 0000000..7193a87 --- /dev/null +++ b/EvoScientist/commands/implementation/experts.py @@ -0,0 +1,247 @@ +"""Slash commands for TUI expert-skill selection. + +``/experts`` — list installed expert skills. +``/expert `` — 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 ", 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 `, " + "clear with `/expert clear`.", + style="dim", + ) + else: + ctx.ui.append_system( + "No experts invited. `/expert ` 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 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()) diff --git a/EvoScientist/commands/implementation/session.py b/EvoScientist/commands/implementation/session.py index 7f01699..62cc724 100644 --- a/EvoScientist/commands/implementation/session.py +++ b/EvoScientist/commands/implementation/session.py @@ -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): diff --git a/EvoScientist/gateway/local.py b/EvoScientist/gateway/local.py index 4473eb6..fb1ba5f 100644 --- a/EvoScientist/gateway/local.py +++ b/EvoScientist/gateway/local.py @@ -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: diff --git a/EvoScientist/gateway/types.py b/EvoScientist/gateway/types.py index e68d853..01745ea 100644 --- a/EvoScientist/gateway/types.py +++ b/EvoScientist/gateway/types.py @@ -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) diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py index e26f0fc..d30f723 100644 --- a/EvoScientist/middleware/__init__.py +++ b/EvoScientist/middleware/__init__.py @@ -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", diff --git a/EvoScientist/middleware/active_team.py b/EvoScientist/middleware/active_team.py new file mode 100644 index 0000000..bf12596 --- /dev/null +++ b/EvoScientist/middleware/active_team.py @@ -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 ```` / ```` +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 = ( + "\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" + "" +) + +_TEMPLATE_MULTI = ( + "\n" + "The user has invited the following experts to this thread: " + "{experts}. Consult any of them via " + "`task({{subagent_type: '', ...}})` based on which fits " + "the current request. Do not consult an expert if the request is " + "clearly outside its scope.\n" + "" +) + + +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() diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 20e8f16..01c4b11 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -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, diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index 6a69803..a8d5de6 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -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() diff --git a/EvoScientist/subagents/expert_container.py b/EvoScientist/subagents/expert_container.py index e08bc2b..c7ef1b4 100644 --- a/EvoScientist/subagents/expert_container.py +++ b/EvoScientist/subagents/expert_container.py @@ -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, *, diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py index a994f40..34931c9 100644 --- a/EvoScientist/tools/skills_manager.py +++ b/EvoScientist/tools/skills_manager.py @@ -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: diff --git a/tests/stream_v3_fakes.py b/tests/stream_v3_fakes.py index 681e486..bd1aefd 100644 --- a/tests/stream_v3_fakes.py +++ b/tests/stream_v3_fakes.py @@ -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 diff --git a/tests/test_active_team_middleware.py b/tests/test_active_team_middleware.py new file mode 100644 index 0000000..7666460 --- /dev/null +++ b/tests/test_active_team_middleware.py @@ -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 "" 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 "" 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) diff --git a/tests/test_commands_base.py b/tests/test_commands_base.py new file mode 100644 index 0000000..2f46d79 --- /dev/null +++ b/tests/test_commands_base.py @@ -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"] diff --git a/tests/test_experts_command.py b/tests/test_experts_command.py new file mode 100644 index 0000000..d910314 --- /dev/null +++ b/tests/test_experts_command.py @@ -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"]) == [] diff --git a/tests/test_new_command.py b/tests/test_new_command.py index dc67671..e91ee96 100644 --- a/tests/test_new_command.py +++ b/tests/test_new_command.py @@ -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() diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index 7ed7edc..175ff7f 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -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] diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 828f8b3..55f656e 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -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(