From 00f5ab33d634b8aa3e9657f5fb130c644fa58a1e Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sat, 25 Apr 2026 16:54:36 -0700 Subject: [PATCH] feat(entry): interactive mode keeps the root agent alive across cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-migration ``BaseAgent.agent_loop`` ran forever in interactive mode, re-entering a "waiting state" after each finish-tool call so user follow-ups could keep the conversation going. Post-migration our ``Runner.run`` returned on ``StopAtTools(finish_scan)`` and the user's next chat message had no listener — silent dead-end. Restore the legacy "agent never dies" semantics using the SDK's canonical demo-loop pattern (``agents/repl.py:run_demo_loop``): - Add ``AgentMessageBus.wait_for_message(agent_id)`` — blocks until an inbox is non-empty. Backed by a per-agent ``asyncio.Event`` fired from ``send``. - Add ``AgentMessageBus.park(agent_id)`` — sets status to ``waiting`` without finalizing (inbox + tree edges + name preserved). Lets ``send`` keep accepting messages between cycles. - Plumb ``interactive`` through ``make_agent_context`` and the ``create_agent`` graph tool (children inherit). - ``StrixOrchestrationHooks.on_agent_end`` parks the root agent instead of finalizing when ``interactive=True`` and the run completed cleanly. Resets ``agent_finish_called`` / ``turn_count`` for the next cycle. - ``entry.run_strix_scan`` adds an outer loop in interactive mode: after ``Runner.run`` returns, ``await bus.wait_for_message(root_id)``, drain pending user messages, and re-invoke ``Runner.run``. SQLite session preserves prior conversation across cycles. For non-interactive (CLI) mode: unchanged — single ``Runner.run``, return. Verified bus behaviors: wait returns immediately on pre-existing message, blocks then wakes on send, ``park`` keeps agent send-able, ``finalize`` evicts. Lint at baseline (3 ruff / 69 mypy). Co-Authored-By: Claude Opus 4.7 (1M context) --- strix/entry.py | 42 +++++++++++++++++++++++++++++-- strix/orchestration/bus.py | 32 +++++++++++++++++++++++ strix/orchestration/hooks.py | 23 +++++++++++++++-- strix/run_config_factory.py | 2 ++ strix/tools/agents_graph/tools.py | 1 + 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/strix/entry.py b/strix/entry.py index 0d1b30f..57bfbb8 100644 --- a/strix/entry.py +++ b/strix/entry.py @@ -15,6 +15,7 @@ from __future__ import annotations +import asyncio import logging import uuid from pathlib import Path @@ -256,6 +257,7 @@ async def run_strix_scan( model=resolved_model, max_turns=max_turns, is_whitebox=is_whitebox, + interactive=interactive, diff_scope=diff_scope, run_id=run_id, agent_factory=agent_factory, @@ -283,15 +285,51 @@ async def run_strix_scan( session = SQLiteSession(session_id=scan_id, db_path=session_db) task_text = _build_root_task(scan_config) - return await Runner.run( + hooks = StrixOrchestrationHooks() + + result = await Runner.run( root_agent, input=task_text, session=session, run_config=run_config, context=context, - hooks=StrixOrchestrationHooks(), + hooks=hooks, max_turns=max_turns, ) + + if not interactive: + return result + + # Interactive mode: SDK demo-loop pattern. The root agent is + # parked (not finalized) by ``StrixOrchestrationHooks.on_agent_end`` + # at the end of each cycle, so ``bus.send`` from the TUI still + # accepts user messages between cycles. Wake on the next message, + # drain it, and re-invoke ``Runner.run`` with the appended input — + # the SQLite session preserves the prior conversation, so the + # agent picks up with full context. + while True: + try: + await bus.wait_for_message(root_id) + except asyncio.CancelledError: + return result + pending = await bus.drain(root_id) + if not pending: + continue + next_input = "\n\n".join( + str(msg.get("content", "")).strip() for msg in pending if msg.get("content") + ) + if not next_input: + continue + + result = await Runner.run( + root_agent, + input=next_input, + session=session, + run_config=run_config, + context=context, + hooks=hooks, + max_turns=max_turns, + ) except BaseException: # Cancel any descendant tasks the root spawned before unwinding. # cancel_descendants is idempotent and handles the empty-tree case. diff --git a/strix/orchestration/bus.py b/strix/orchestration/bus.py index 5e03927..135fc55 100644 --- a/strix/orchestration/bus.py +++ b/strix/orchestration/bus.py @@ -38,6 +38,7 @@ class AgentMessageBus: names: dict[str, str] = field(default_factory=dict) stats_live: dict[str, dict[str, Any]] = field(default_factory=dict) stats_completed: dict[str, dict[str, Any]] = field(default_factory=dict) + _events: dict[str, asyncio.Event] = field(default_factory=dict) _lock: asyncio.Lock = field(default_factory=asyncio.Lock) async def register( @@ -72,6 +73,23 @@ class AgentMessageBus: if self.statuses[target] in ("completed", "crashed", "stopped"): return self.inboxes.setdefault(target, []).append(msg) + event = self._events.get(target) + if event is not None: + event.set() + + async def wait_for_message(self, agent_id: str) -> None: + """Block until ``agent_id``'s inbox has at least one pending message. + + Used by the interactive-mode outer loop in :func:`run_strix_scan` to + wake on the next user message between ``Runner.run`` cycles. Cheap + if the inbox already has content (returns immediately). + """ + async with self._lock: + if self.inboxes.get(agent_id): + return + event = self._events.setdefault(agent_id, asyncio.Event()) + event.clear() + await event.wait() async def drain(self, agent_id: str) -> list[dict[str, Any]]: """Atomically read and clear ``agent_id``'s pending messages. @@ -117,6 +135,20 @@ class AgentMessageBus: self.inboxes.pop(agent_id, None) self.parent_of.pop(agent_id, None) self.names.pop(agent_id, None) + self._events.pop(agent_id, None) + + async def park(self, agent_id: str) -> None: + """Mark an agent as ``waiting`` without finalizing. + + Used in interactive mode for the root agent between ``Runner.run`` + cycles: the run completed, but the agent stays alive on the bus + so user messages still land in its inbox until the next cycle + starts. Stats stay live (will be merged on actual finalize at + scan teardown). + """ + async with self._lock: + if agent_id in self.statuses: + self.statuses[agent_id] = "waiting" async def total_stats(self) -> dict[str, Any]: """Snapshot of live + completed stats.""" diff --git a/strix/orchestration/hooks.py b/strix/orchestration/hooks.py index 90fbff6..d83217b 100644 --- a/strix/orchestration/hooks.py +++ b/strix/orchestration/hooks.py @@ -146,7 +146,18 @@ class StrixOrchestrationHooks(RunHooks[Any]): if bus is None or me is None: return crashed = (output is None) or not ctx.get("agent_finish_called", False) - final_status = "crashed" if crashed else "completed" + + # Interactive root agents stay alive across ``Runner.run`` cycles — + # ``entry.run_strix_scan``'s outer loop re-invokes ``Runner.run`` + # whenever the user sends a follow-up message, so we just park + # the agent (status=waiting) instead of finalizing. + is_interactive_root = ( + ctx.get("parent_id") is None and bool(ctx.get("interactive", False)) and not crashed + ) + + final_status = ( + "waiting" if is_interactive_root else ("crashed" if crashed else "completed") + ) tracer = ctx.get("tracer") if tracer is not None and me in tracer.agents: @@ -166,7 +177,15 @@ class StrixOrchestrationHooks(RunHooks[Any]): "type": "crash", }, ) - await bus.finalize(me, final_status) + + if is_interactive_root: + await bus.park(me) + # Reset the per-cycle flag so the next ``Runner.run`` invocation + # can detect a fresh ``finish_scan`` call. + ctx["agent_finish_called"] = False + ctx["turn_count"] = 0 + else: + await bus.finalize(me, final_status) except Exception: logger.exception("on_agent_end failed") diff --git a/strix/run_config_factory.py b/strix/run_config_factory.py index 40153d0..1146330 100644 --- a/strix/run_config_factory.py +++ b/strix/run_config_factory.py @@ -121,6 +121,7 @@ def make_agent_context( model_settings: ModelSettings | None = None, max_turns: int = 300, is_whitebox: bool = False, + interactive: bool = False, diff_scope: dict[str, Any] | None = None, run_id: str | None = None, sandbox_client: Any | None = None, @@ -152,6 +153,7 @@ def make_agent_context( "turn_count": 0, "agent_finish_called": False, "is_whitebox": is_whitebox, + "interactive": interactive, "diff_scope": diff_scope, "run_id": run_id, "agent_factory": agent_factory, diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index a71cfa0..2d400e1 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -450,6 +450,7 @@ async def create_agent( model_settings=inner.get("model_settings"), max_turns=int(inner.get("max_turns", 300)), is_whitebox=bool(inner.get("is_whitebox", False)), + interactive=bool(inner.get("interactive", False)), diff_scope=inner.get("diff_scope"), run_id=inner.get("run_id"), agent_factory=factory,