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,