Files
strix/strix/orchestration/bus.py
T
0xallam 24355016a0 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).
2026-04-25 16:54:36 -07:00

185 lines
7.5 KiB
Python

"""``AgentMessageBus`` — peer-to-peer multi-agent state for one scan.
A single ``asyncio.Lock``-protected dataclass that owns inboxes,
parent edges, statuses, and per-agent stats for the lifetime of one
Strix scan.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
@dataclass
class AgentMessageBus:
"""Shared state for multi-agent orchestration.
All mutations happen under ``_lock``; readers also take the lock for
consistent snapshots. The bus owns:
- ``inboxes``: per-agent FIFO list of pending messages (drained by the
``inject_messages_filter`` at the top of each LLM turn).
- ``tasks``: per-agent ``asyncio.Task`` handle so the parent (or signal
handler) can cancel descendants.
- ``statuses``: per-agent lifecycle state — ``running | waiting |
completed | crashed | stopped``.
- ``parent_of``: tree edges; root agents have ``None``.
- ``names``: human-readable per-agent names.
- ``stats_live`` / ``stats_completed``: token + call counters that hooks
keep up to date for live and finalized agents respectively.
"""
inboxes: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
tasks: dict[str, asyncio.Task[Any]] = field(default_factory=dict)
statuses: dict[str, str] = field(default_factory=dict)
parent_of: dict[str, str | None] = field(default_factory=dict)
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(
self,
agent_id: str,
name: str,
parent_id: str | None,
) -> None:
"""Add a new agent to the bus before its Runner.run task starts."""
async with self._lock:
self.inboxes[agent_id] = []
self.statuses[agent_id] = "running"
self.parent_of[agent_id] = parent_id
self.names[agent_id] = name
self.stats_live[agent_id] = {
"in": 0,
"out": 0,
"cached": 0,
"cost": 0.0,
"calls": 0,
}
async def send(self, target: str, msg: dict[str, Any]) -> None:
"""Append a message to ``target``'s inbox.
Messages addressed to a finalized agent are dropped silently —
:meth:`finalize` clears the inbox so they can't accumulate.
"""
async with self._lock:
if target not in self.statuses:
return
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.
Called by ``inject_messages_filter`` before every model call.
Filter output is captured by SDK in a lambda closure for retries
(verified `model_retry.py:34-35`), so a single drain per turn does
not lose messages on retry.
"""
async with self._lock:
msgs = self.inboxes.get(agent_id, [])
self.inboxes[agent_id] = []
return msgs
async def record_usage(self, agent_id: str, usage: Any) -> None:
"""Accumulate per-call usage from RunHooks.on_llm_end.
Tolerates ``usage=None`` (some providers omit usage on streaming).
"""
if usage is None:
return
async with self._lock:
stats = self.stats_live.setdefault(
agent_id,
{"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0},
)
stats["in"] += getattr(usage, "input_tokens", 0) or 0
stats["out"] += getattr(usage, "output_tokens", 0) or 0
details = getattr(usage, "input_tokens_details", None)
if details is not None:
stats["cached"] += getattr(details, "cached_tokens", 0) or 0
stats["calls"] += 1
async def finalize(self, agent_id: str, status: str) -> None:
"""Move an agent from live to completed; clean up routing state.
Also clears ``inboxes``, ``parent_of``, ``names`` so siblings
that send to a finished agent can't accumulate orphan messages.
"""
async with self._lock:
self.statuses[agent_id] = status
self.stats_completed[agent_id] = self.stats_live.pop(agent_id, {})
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."""
async with self._lock:
agg = {"in": 0, "out": 0, "cached": 0, "cost": 0.0, "calls": 0}
for stats in (*self.stats_live.values(), *self.stats_completed.values()):
for key, value in stats.items():
agg[key] = agg.get(key, 0) + value
return agg
async def cancel_descendants(self, root_agent_id: str) -> None:
"""Cancel ``root_agent_id`` and every transitive child, leaves first.
Wired into the CLI Ctrl+C handler and TUI stop button —
the SDK's ``result.cancel`` doesn't cascade to children spawned
via ``asyncio.create_task``, so we walk the tree ourselves.
"""
async with self._lock:
queue = [root_agent_id]
order: list[str] = []
while queue:
aid = queue.pop()
order.append(aid)
queue.extend(child for child, parent in self.parent_of.items() if parent == aid)
tasks_to_cancel = [self.tasks[a] for a in reversed(order) if a in self.tasks]
for task in tasks_to_cancel:
if not task.done():
task.cancel()
# Wait for cancellations to settle so on_agent_end can mark statuses.
await asyncio.gather(
*(t for t in tasks_to_cancel if not t.done()),
return_exceptions=True,
)