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:
+40
-2
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user