Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions strix/core/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
logger = logging.getLogger(__name__)

Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
_TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "stopped", "crashed", "failed"})


@dataclass(slots=True)
Expand Down Expand Up @@ -115,19 +116,55 @@ async def park_waiting(self, agent_id: str) -> None:
await self.set_status(agent_id, "waiting")

async def set_status(
self, agent_id: str, status: Status | str, *, error: str | None = None
self,
agent_id: str,
status: Status | str,
*,
error: str | None = None,
notify_parent: bool = True,
) -> None:
"""Set an agent status and notify its parent once on a terminal transition."""
async with self._lock:
if agent_id not in self.statuses:
return
previous_status = self.statuses[agent_id]
self.statuses[agent_id] = status # type: ignore[assignment]
if error is not None:
self.errors[agent_id] = error
elif status == "running":
self.errors.pop(agent_id, None)
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.wake.set()
parent_id = self.parent_of.get(agent_id)
should_notify_parent = (
notify_parent
and parent_id is not None
and previous_status not in _TERMINAL_STATUSES
and status in _TERMINAL_STATUSES
)
agent_name = self.names.get(agent_id, agent_id)
logger.info("agent.status %s=%s", agent_id, status)
if should_notify_parent and parent_id is not None:
detail = f" Reason: {error}" if error else ""
delivered = await self.send(
parent_id,
{
"from": agent_id,
"type": "terminal_status",
"priority": "high",
"content": (
f"[Agent status] {agent_name} ({agent_id}) entered terminal "
f"status '{status}'.{detail} Do not keep waiting for a "
"completion message from this child."
),
},
)
if not delivered:
logger.warning(
"agent.status could not notify parent=%s of terminal child=%s",
parent_id,
agent_id,
)
await self._maybe_snapshot()

async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
Expand Down Expand Up @@ -190,17 +227,15 @@ async def consume_pending(
items = await session.get_items()
return count, list(items[-count:])

async def request_stop(self, agent_id: str) -> None:
async def request_stop(self, agent_id: str, *, notify_parent: bool = True) -> None:
async with self._lock:
if agent_id not in self.statuses:
return
self.statuses[agent_id] = "stopped"
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
runtime.wake.set()
stream = runtime.stream
await self.set_status(agent_id, "stopped", notify_parent=notify_parent)
if stream is not None:
stream.cancel(mode="after_turn")
await self._maybe_snapshot()

async def cancel_descendants(self, agent_id: str) -> None:
tasks = []
Expand All @@ -214,11 +249,21 @@ async def cancel_descendants(self, agent_id: str) -> None:
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)

async def cancel_descendants_graceful(self, agent_id: str) -> None:
async def cancel_descendants_graceful(
self,
agent_id: str,
*,
notify_parent: bool = True,
) -> None:
async with self._lock:
order = self._subtree_order_locked(agent_id)
for aid in reversed(order):
await self.request_stop(aid)
# Descendant parents are part of the same stop cascade, so only the
# top-level target's parent needs a terminal notification.
await self.request_stop(
aid,
notify_parent=notify_parent and aid == agent_id,
)
await self._maybe_snapshot()

async def attach_stream(
Expand Down
28 changes: 0 additions & 28 deletions strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,6 @@ async def _run_noninteractive_until_lifecycle(

if invalid_final_outputs >= invalid_final_output_limit:
await coordinator.set_status(agent_id, "crashed")
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
raise MaxTurnsExceeded(
"Agent exhausted non-interactive recovery attempts without calling "
"finish_scan or agent_finish."
Expand Down Expand Up @@ -438,7 +437,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
status = "crashed"
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await _notify_parent_on_crash(coordinator, agent_id, status)
return None
else:
await _settle_run_result(coordinator, agent_id, interactive)
Expand Down Expand Up @@ -502,32 +500,6 @@ async def _append_noninteractive_tool_required_message(
return []


async def _notify_parent_on_crash(
coordinator: AgentCoordinator,
agent_id: str,
status: str,
) -> None:
if status != "crashed":
return
async with coordinator._lock:
parent = coordinator.parent_of.get(agent_id)
name = coordinator.names.get(agent_id, agent_id)
if parent is None:
return
await coordinator.send(
parent,
{
"from": agent_id,
"type": "crash",
"priority": "high",
"content": (
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
"Stop waiting on this child unless you want to message it again."
),
},
)


async def _start_child_runner(
*,
parent_ctx: dict[str, Any],
Expand Down
25 changes: 19 additions & 6 deletions strix/tools/agents_graph/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ async def agent_finish(
findings=list(findings or []),
recommendations=list(final_recommendations or []),
)
await coordinator.send(
parent_notified = await coordinator.send(
parent_id,
{
"id": f"report_{uuid.uuid4().hex[:8]}",
Expand All @@ -563,7 +563,6 @@ async def agent_finish(
"priority": "high",
},
)
parent_notified = True

logger.info(
"agent_finish: %s success=%s findings=%d parent_notified=%s",
Expand All @@ -572,7 +571,14 @@ async def agent_finish(
len(findings or []),
parent_notified,
)
await coordinator.set_status(me, "completed")
# The completion report above is normally the parent notification. If that
# delivery failed, let the coordinator make one generic fallback attempt;
# an explicit report_to_parent=False still suppresses all parent messaging.
await coordinator.set_status(
me,
"completed",
notify_parent=report_to_parent and not parent_notified,
)

return json.dumps(
{
Expand Down Expand Up @@ -635,7 +641,7 @@ async def stop_agent(
ensure_ascii=False,
default=str,
)
_, statuses, _, _ = await coordinator.graph_snapshot()
parent_of, statuses, _, _ = await coordinator.graph_snapshot()
if target_agent_id not in statuses:
return json.dumps(
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
Expand All @@ -662,10 +668,17 @@ async def stop_agent(
default=str,
)

notify_target_parent = parent_of.get(target_agent_id) != me
if cascade:
await coordinator.cancel_descendants_graceful(target_agent_id)
await coordinator.cancel_descendants_graceful(
target_agent_id,
notify_parent=notify_target_parent,
)
else:
await coordinator.request_stop(target_agent_id)
await coordinator.request_stop(
target_agent_id,
notify_parent=notify_target_parent,
)

logger.info(
"stop_agent: target=%s cascade=%s reason=%r",
Expand Down
Loading