diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 5203d5d..1a9cfbf 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -213,8 +213,7 @@ def _wait_tool_parked(tool_name: str, output: Any) -> bool: return bool( isinstance(parsed, dict) and parsed.get("success") - and parsed.get("agent_waiting") - and parsed.get("status") == "waiting" + and parsed.get("wait_outcome") == "waiting" ) diff --git a/strix/interface/tui/renderers/agents_graph_renderer.py b/strix/interface/tui/renderers/agents_graph_renderer.py index 8292373..92ad1d4 100644 --- a/strix/interface/tui/renderers/agents_graph_renderer.py +++ b/strix/interface/tui/renderers/agents_graph_renderer.py @@ -61,12 +61,12 @@ class SendMessageToAgentRenderer(BaseToolRenderer): status = tool_data.get("status", "unknown") message = args.get("message", "") - agent_id = args.get("agent_id", "") + target_agent_id = args.get("target_agent_id", "") text = Text() text.append("→ ", style="#60a5fa") - if agent_id: - text.append(f"to {agent_id}", style="dim") + if target_agent_id: + text.append(f"to {target_agent_id}", style="dim") else: text.append("sending message", style="dim") @@ -138,3 +138,38 @@ class WaitForMessageRenderer(BaseToolRenderer): css_classes = cls.get_css_classes(status) return Static(text, classes=css_classes) + + +@register_tool_renderer +class StopAgentRenderer(BaseToolRenderer): + tool_name: ClassVar[str] = "stop_agent" + css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] + + @classmethod + def render(cls, tool_data: dict[str, Any]) -> Static: + args = tool_data.get("args", {}) + result = tool_data.get("result") + status = tool_data.get("status", "unknown") + + target_agent_id = args.get("target_agent_id", "") + cascade = args.get("cascade", True) + reason = args.get("reason", "") + + text = Text() + text.append("◼ ", style="#ef4444") + text.append("stopping", style="dim") + if target_agent_id: + text.append(f" {target_agent_id}", style="bold #ef4444") + if cascade: + text.append(" + descendants", style="dim italic") + + if reason: + text.append("\n ") + text.append(reason, style="dim") + + if isinstance(result, dict) and result.get("success") is False and result.get("error"): + text.append("\n ") + text.append(str(result["error"]), style="#ef4444") + + css_classes = cls.get_css_classes(status) + return Static(text, classes=css_classes) diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py index 87ab96b..1eac077 100644 --- a/strix/skills/__init__.py +++ b/strix/skills/__init__.py @@ -8,6 +8,52 @@ logger = logging.getLogger(__name__) _FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) +# Categories loaded by the prompt template via explicit slash-form paths +# (``scan_modes/``, ``coordination/``). They're not +# user-selectable through ``create_agent``'s ``skills`` argument. +_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"}) + + +def get_all_skill_names() -> set[str]: + """Return every user-selectable skill name (bare, no category prefix). + + Used by :func:`validate_requested_skills` so ``create_agent`` can + reject typos / hallucinated names with a useful list, and by callers + that need to enumerate the catalog. + """ + skills_dir = get_strix_resource_path("skills") + if not skills_dir.exists(): + return set() + names: set[str] = set() + for category_dir in skills_dir.iterdir(): + if not category_dir.is_dir() or category_dir.name.startswith("__"): + continue + if category_dir.name in _INTERNAL_SKILL_CATEGORIES: + continue + for file_path in category_dir.glob("*.md"): + names.add(file_path.stem) + return names + + +def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None: + """Validate a list of user-passed skill names. + + Returns ``None`` on success, or a model-readable error message + describing what was wrong (count exceeded, unknown names). + """ + if len(skill_list) > max_skills: + return ( + f"Cannot specify more than {max_skills} skills per agent; " + f"got {len(skill_list)}. Aim for 1-3 related skills per specialist." + ) + if not skill_list: + return None + available = get_all_skill_names() + invalid = sorted({s for s in skill_list if s not in available}) + if invalid: + return f"Invalid skill name(s): {invalid}. Available skills: {sorted(available)}" + return None + def load_skills(skill_names: list[str]) -> dict[str, str]: """Load skill markdown bodies (frontmatter stripped) by name. diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 3b8c5e5..7c33db6 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -15,12 +15,20 @@ import asyncio import json import logging import uuid +from collections import Counter from datetime import UTC, datetime -from typing import Any, Literal +from typing import Any, Literal, get_args from agents import RunContextWrapper, function_tool -from strix.core.agents import coordinator_from_context +from strix.core.agents import Status, coordinator_from_context +from strix.skills import validate_requested_skills + + +# An agent is "active" when stop_agent can meaningfully act on it. Anything +# else (completed / stopped / crashed / failed) is terminal — request_stop +# would forcibly overwrite that status, erasing the original outcome. +_ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"}) logger = logging.getLogger(__name__) @@ -107,14 +115,13 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str: for root in roots: render(root, 0) - summary = { - "total": len(parent_of), - "running": sum(1 for s in statuses.values() if s == "running"), - "waiting": sum(1 for s in statuses.values() if s == "waiting"), - "completed": sum(1 for s in statuses.values() if s == "completed"), - "crashed": sum(1 for s in statuses.values() if s == "crashed"), - "stopped": sum(1 for s in statuses.values() if s == "stopped"), - } + # Derive per-status counts from the canonical ``Status`` literal so a + # new status added in core.agents auto-flows into this summary without + # the buckets silently going out of sync with the source of truth. + counts = Counter(statuses.values()) + summary: dict[str, int] = {"total": len(parent_of)} + for status_name in get_args(Status): + summary[status_name] = counts.get(status_name, 0) return json.dumps( { "success": True, @@ -169,6 +176,18 @@ async def send_message_to_agent( ensure_ascii=False, default=str, ) + if target_agent_id == me: + return json.dumps( + { + "success": False, + "error": ( + "Cannot send a message to yourself. Use `think` to record a " + "private note, or `agent_finish` / `finish_scan` to terminate." + ), + }, + ensure_ascii=False, + default=str, + ) msg_id = f"msg_{uuid.uuid4().hex[:8]}" delivered = await coordinator.send( @@ -263,7 +282,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "stopped", + "wait_outcome": "stopped", "reason": reason, "note": "Wait ended because this agent is stopped.", }, @@ -277,7 +296,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "message_arrived", + "wait_outcome": "message_arrived", "pending_messages": pending, "messages": _session_items_payload(items), "reason": reason, @@ -291,8 +310,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "waiting", - "agent_waiting": True, + "wait_outcome": "waiting", "reason": reason, "note": "Agent parked; execution will resume when a message arrives.", }, @@ -308,7 +326,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "timeout", + "wait_outcome": "timeout", "timeout_seconds": timeout_seconds, "reason": reason, "note": "No messages within timeout — continue work or call agent_finish.", @@ -323,7 +341,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "stopped", + "wait_outcome": "stopped", "reason": reason, "note": "Wait ended because this agent is stopped.", }, @@ -337,7 +355,7 @@ async def wait_for_message( # noqa: PLR0911 return json.dumps( { "success": True, - "status": "message_arrived", + "wait_outcome": "message_arrived", "pending_messages": pending, "messages": _session_items_payload(items), "reason": reason, @@ -415,6 +433,15 @@ async def create_agent( default=str, ) + skill_list = list(skills or []) + skill_error = validate_requested_skills(skill_list) + if skill_error: + return json.dumps( + {"success": False, "error": skill_error, "agent_id": None}, + ensure_ascii=False, + default=str, + ) + # ``ctx.turn_input`` carries the parent's full conversation up to and # including the call that's currently invoking ``create_agent``. parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else [] @@ -423,7 +450,7 @@ async def create_agent( parent_ctx=inner, name=name, task=task, - skills=list(skills or []), + skills=skill_list, parent_history=parent_history, ) except Exception as e: @@ -439,7 +466,7 @@ async def create_agent( result.get("agent_id"), name, parent_id or "-", - len(skills or []), + len(skill_list), len(task or ""), ) @@ -508,11 +535,9 @@ async def agent_finish( return json.dumps( { "success": False, - "agent_completed": False, "error": ( "agent_finish is for subagents. Root/main agents must call finish_scan instead." ), - "parent_notified": False, }, ensure_ascii=False, default=str, @@ -621,6 +646,25 @@ async def stop_agent( default=str, ) + current_status = statuses[target_agent_id] + if current_status not in _ACTIVE_STATUSES: + return json.dumps( + { + "success": False, + "error": ( + f"Agent {target_agent_id} is already '{current_status}'; " + "stop_agent only acts on running/waiting agents. Use " + "view_agent_graph to find still-active descendants and " + "stop them individually, or send_message_to_agent if you " + "want to wake this one with new instructions." + ), + "target_agent_id": target_agent_id, + "current_status": current_status, + }, + ensure_ascii=False, + default=str, + ) + if cascade: await coordinator.cancel_descendants_graceful(target_agent_id) else: