feat(entry): interactive mode keeps the root agent alive across cycles

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) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 16:54:36 -07:00
parent fc96716956
commit 00f5ab33d6
5 changed files with 96 additions and 4 deletions
+40 -2
View File
@@ -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.
+32
View File
@@ -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."""
+21 -2
View File
@@ -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")
+2
View File
@@ -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,
+1
View File
@@ -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,