diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 12139fa..aa7094b 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -151,6 +151,7 @@ def build_strix_agent( skills=skills, scan_mode=scan_mode, is_whitebox=is_whitebox, + is_root=is_root, interactive=interactive, system_prompt_context=system_prompt_context, ) diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index 6723c63..feb3c50 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -27,6 +27,7 @@ def _resolve_skills( requested: list[str] | None, scan_mode: str = "deep", is_whitebox: bool = False, + is_root: bool = False, ) -> list[str]: """Build the deduped, ordered skills list for the prompt render. @@ -36,11 +37,15 @@ def _resolve_skills( 2. ``scan_modes/`` (always). 3. ``tooling/agent_browser`` (always — every agent has shell + the agent-browser CLI). - 4. Whitebox-specific skills if applicable. + 4. ``coordination/root_agent`` for the root agent only — orchestration + guidance for delegating to specialist subagents. + 5. Whitebox-specific skills if applicable. """ ordered: list[str] = list(requested or []) ordered.append(f"scan_modes/{scan_mode}") ordered.append("tooling/agent_browser") + if is_root: + ordered.append("coordination/root_agent") if is_whitebox: ordered.append("coordination/source_aware_whitebox") ordered.append("custom/source_aware_sast") @@ -59,6 +64,7 @@ def render_system_prompt( skills: list[str] | None = None, scan_mode: str = "deep", is_whitebox: bool = False, + is_root: bool = False, interactive: bool = False, system_prompt_context: dict[str, Any] | None = None, ) -> str: @@ -70,6 +76,8 @@ def render_system_prompt( skill. is_whitebox: When True, the source-aware whitebox skill stack is loaded too. + is_root: When True, ``coordination/root_agent`` orchestration + guidance is auto-loaded. interactive: When True, the prompt renders the interactive-mode communication rules block. system_prompt_context: Free-form dict that the template's @@ -96,6 +104,7 @@ def render_system_prompt( requested=skills, scan_mode=scan_mode, is_whitebox=is_whitebox, + is_root=is_root, ) skill_content = load_skills(skills_to_load) env.globals["get_skill"] = lambda name: skill_content.get(name, "") diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 8c5882b..100ee09 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -1843,11 +1843,16 @@ class StrixTUIApp(App): # type: ignore[misc] return agent_name, False def action_confirm_stop_agent(self, agent_id: str) -> None: + # Graceful stop: each agent's current turn finishes (and is saved to + # session) before the run loop honors the cancel. The interactive + # outer loop sees ``stopping`` and exits with status="stopped". + # The hard ``cancel_descendants`` path remains for KeyboardInterrupt + # in entry.py where graceful isn't possible. if self._scan_loop is None or self._scan_loop.is_closed(): logging.warning("No active scan loop; cannot stop agent %s", agent_id) return asyncio.run_coroutine_threadsafe( - self.bus.cancel_descendants(agent_id), + self.bus.cancel_descendants_graceful(agent_id), self._scan_loop, ) diff --git a/strix/orchestration/filter.py b/strix/orchestration/filter.py index 09ee8a0..a219d8b 100644 --- a/strix/orchestration/filter.py +++ b/strix/orchestration/filter.py @@ -8,7 +8,6 @@ retries — so a single drain per turn doesn't lose messages on retry. from __future__ import annotations import logging -from datetime import UTC, datetime from typing import TYPE_CHECKING from agents.run_config import CallModelData, ModelInputData @@ -26,11 +25,8 @@ logger = logging.getLogger(__name__) async def inject_messages_filter(data: CallModelData) -> ModelInputData: """Drain bus inbox and append messages as user-role items before the LLM call. - Peer-agent messages are wrapped in the legacy ```` - XML envelope (sender / metadata / content / delivery_info) so the - receiving model gets the same prompt-shape as pre-migration — including - the explicit "DO NOT echo back" instruction. Direct user messages - (``from="user"``) are passed plain. + Peer-agent messages get a one-line labeled header followed by the + body. Direct user messages (``from="user"``) are passed plain. Any exception inside the filter — malformed message dict, bug in ``bus.drain``, etc. — is caught and the original ``data.model_data`` @@ -72,31 +68,24 @@ async def inject_messages_filter(data: CallModelData) -> ModelInputData: def _format_inter_agent_message(bus: AgentMessageBus, msg: dict[str, Any]) -> str: - """Render a peer-agent message in the legacy XML envelope. + """Render a peer-agent message as a labeled header + body. - The wrapper carries an explicit "do not echo back" instruction that - the legacy harness used to keep models from quoting the entire - received message dict in their own next turn. + Format: + [Message from {name} ({id}) | type={type} | priority={priority}] + {content} + + Plain text by design — no XML wrapping, no escaping concerns. The + label line tells the receiver who sent this and why so it doesn't + confuse a peer message with its own work; the rest of the body is + delivered as-is. """ sender_id = str(msg.get("from", "unknown")) sender_name = bus.names.get(sender_id, sender_id) msg_type = msg.get("type", "information") priority = msg.get("priority", "normal") - timestamp = msg.get("timestamp") or datetime.now(UTC).isoformat() content = str(msg.get("content", "")) return ( - "\n" - " You have received a message from another " - "agent. Acknowledge and respond to the sender if needed; DO NOT echo " - "back this entire message block.\n" - f" {sender_name}" - f"{sender_id}\n" - f" {msg_type}" - f"{priority}" - f"{timestamp}\n" - f" {content}\n" - " This message was delivered during your task " - "execution. Please acknowledge and respond if needed." - "\n" - "" + f"[Message from {sender_name} ({sender_id}) " + f"| type={msg_type} | priority={priority}]\n" + f"{content}" ) diff --git a/strix/orchestration/hooks.py b/strix/orchestration/hooks.py index c785a6c..39055b8 100644 --- a/strix/orchestration/hooks.py +++ b/strix/orchestration/hooks.py @@ -56,6 +56,13 @@ class StrixOrchestrationHooks(RunHooks[Any]): # warnings fire exactly once per agent lifetime — surviving # ``run_with_continuation`` cycles, mirroring legacy # ``state.max_iterations_warning_sent``. + # + # The flags are mutated lock-free below. Safe because the SDK + # serializes ``on_llm_start`` per agent (one in-flight LLM call + # per ``Runner.run`` instance), so this hook is the sole writer + # to ``warned_85`` / ``warned_final`` for this agent_id. + # ``record_usage`` (which acquires the lock) only writes + # ``in`` / ``out`` / ``cached`` / ``calls`` — disjoint keys. if cur >= int(max_turns * 0.85) and not stats.get("warned_85"): stats["warned_85"] = True input_items.append( diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 9642dd2..739f19e 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -19,6 +19,7 @@ import asyncio import json import logging import uuid +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Literal from agents import RunContextWrapper, function_tool @@ -52,26 +53,34 @@ def _render_completion_report( findings: list[str], recommendations: list[str], ) -> str: - """Render an ```` XML payload (legacy parity).""" - from datetime import UTC, datetime - from html import escape + """Render a child's completion report as plain structured text. + Goes into the parent's bus inbox; the inject filter prepends a + ``[Message from ...]`` header on top, so this body just carries the + contents. No XML — no escaping concerns, no parser ambiguity. + """ status = "SUCCESS" if success else "FAILED" completion_time = datetime.now(UTC).isoformat() - findings_xml = "".join(f"{escape(f)}" for f in findings) - recs_xml = "".join(f"{escape(r)}" for r in recommendations) - return ( - "\n" - f" {escape(agent_name)}" - f"{escape(agent_id)}" - f"{escape(task)}" - f"{status}" - f"{completion_time}\n" - f" {escape(result_summary)}" - f"{findings_xml}" - f"{recs_xml}\n" - "" - ) + + lines: list[str] = [ + f"== Completion report from {agent_name} ({agent_id}) ==", + f"Status: {status}", + f"Time: {completion_time}", + ] + if task: + lines.append(f"Task: {task}") + lines.append("") + lines.append("Summary:") + lines.append(result_summary or "(none)") + if findings: + lines.append("") + lines.append("Findings:") + lines.extend(f"- {f}" for f in findings) + if recommendations: + lines.append("") + lines.append("Recommendations:") + lines.extend(f"- {r}" for r in recommendations) + return "\n".join(lines) @function_tool(timeout=30) @@ -456,9 +465,9 @@ async def create_agent( { "role": "user", "content": ( - "\n" + "== Inherited context from parent (background only) ==\n" f"{rendered}\n" - "\n" + "== End of inherited context ==\n" "Use the above as background only; do not continue the " "parent's work. Your task follows." ),