feat(orchestration): full parity with legacy harness — 8 gaps closed via SDK natives

Audit found 8 behavioral gaps between post-migration and the legacy
``BaseAgent.agent_loop``. All 8 are now closed using SDK-native
primitives — no custom workarounds, no shadow state machines.

What was broken / different:

- G1: ``inherit_context`` was dead code; children always started fresh.
- G2: TUI user message couldn't interrupt an in-flight LLM/tool turn.
- G3: ``llm_failed`` state never set; hard failures propagated as crashes.
- G4: No graceful ``stop_agent`` tool.
- G5: Parked subagents waited forever (no auto-resume timeout).
- G6: Inter-agent messages used a plain header instead of legacy XML.
- G7: Completion reports used JSON instead of legacy XML.
- G11/G12: Turn counter reset per cycle; budget warnings could re-fire.

What we did:

Bus extensions (``orchestration/bus.py``):
- ``streams`` registry + ``attach_stream`` ctx manager + ``request_interrupt``
  for SDK-native ``RunResultStreaming.cancel(mode="after_turn")``.
- ``mark_llm_failed`` + ``wait_for_user_message`` (filtered: only ``from="user"``
  satisfies; peer messages don't unstick a stuck model).
- ``stopping: set[str]`` for graceful programmatic exit.
- ``cancel_descendants_graceful`` — leaves-first via ``request_interrupt``.
- ``record_usage`` increments ``calls`` unconditionally so it doubles as the
  per-agent-lifetime turn counter (legacy ``state.iteration`` parity).
- ``warned_85`` / ``warned_final`` flags on ``stats_live`` for once-fire
  budget warnings.

Run loop rewrite (``orchestration/run_loop.py``):
- ``Runner.run`` → ``Runner.run_streamed`` with ``bus.attach_stream`` so
  cancel has a target. Catch ``(AgentsException, APIError)`` after retries
  exhaust; in interactive mode call ``mark_llm_failed`` + wait for user.
- ``UserError`` / ``MaxTurnsExceeded`` / ``CancelledError`` propagate.
- Outer loop: ``asyncio.wait_for(bus.wait_for_message, timeout=300)`` for
  interactive subagents (root waits forever). ``TimeoutError`` injects
  ``"Waiting timeout reached. Resuming execution."``.
- Honors ``bus.stopping`` at top of each iteration.

Hooks (``orchestration/hooks.py``):
- Counter source moved from per-cycle ``ctx["turn_count"]`` to
  per-lifetime ``bus.stats_live[agent_id]["calls"]``.
- Warnings guarded by once-flags — exactly-once across all cycles.

Filter (``orchestration/filter.py``):
- Restored legacy ``<inter_agent_message>`` XML envelope with the
  ``<delivery_notice>DO NOT echo back</delivery_notice>`` instruction.

Agents-graph (``tools/agents_graph/tools.py``):
- G1: ``create_agent`` reads ``ctx.turn_input`` (SDK populates it before
  tool execution at ``run_internal/turn_resolution.py:806``). Wraps as
  one ``<inherited_context_from_parent>`` block.
- G7: ``agent_finish`` emits the legacy ``<agent_completion_report>``
  XML. ``child_ctx["task"] = task`` threaded so the report echoes the
  original task.
- G4: New ``stop_agent`` tool — refuses self-stop, refuses already-
  finalized targets, ``cascade=True`` uses ``cancel_descendants_graceful``.

TUI (``interface/tui.py``):
- ``_send_user_message`` schedules ``bus.send`` AND
  ``bus.request_interrupt(target, mode="after_turn")`` — SDK finishes
  current turn cleanly, next cycle picks up the user's message.

Factory (``agents/factory.py``):
- Registered ``stop_agent`` in ``_BASE_TOOLS``.

Out of scope:
- G8 (``[ABORTED BY USER]`` marker) is auto-resolved by G2 — the SDK
  saves the full assistant message before honoring
  ``cancel(mode="after_turn")``, so partial content is preserved in the
  session.

Verified all bus behaviors with a smoke test. Lint at baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 17:30:29 -07:00
parent 5896f25cec
commit f4834cd6f7
9 changed files with 459 additions and 85 deletions
+149 -21
View File
@@ -42,6 +42,38 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
return ctx.context if isinstance(ctx.context, dict) else {}
def _render_completion_report(
*,
agent_name: str,
agent_id: str,
task: str,
success: bool,
result_summary: str,
findings: list[str],
recommendations: list[str],
) -> str:
"""Render an ``<agent_completion_report>`` XML payload (legacy parity)."""
from datetime import UTC, datetime
from html import escape
status = "SUCCESS" if success else "FAILED"
completion_time = datetime.now(UTC).isoformat()
findings_xml = "".join(f"<finding>{escape(f)}</finding>" for f in findings)
recs_xml = "".join(f"<recommendation>{escape(r)}</recommendation>" for r in recommendations)
return (
"<agent_completion_report>\n"
f" <agent_info><agent_name>{escape(agent_name)}</agent_name>"
f"<agent_id>{escape(agent_id)}</agent_id>"
f"<task>{escape(task)}</task>"
f"<status>{status}</status>"
f"<completion_time>{completion_time}</completion_time></agent_info>\n"
f" <results><summary>{escape(result_summary)}</summary>"
f"<findings>{findings_xml}</findings>"
f"<recommendations>{recs_xml}</recommendations></results>\n"
"</agent_completion_report>"
)
@function_tool(timeout=30)
async def view_agent_graph(ctx: RunContextWrapper) -> str:
"""Print the multi-agent tree — every agent, its parent, its status.
@@ -411,21 +443,26 @@ async def create_agent(
await bus.register(child_id, name, parent_id)
parent_history = inner.get("parent_input_items") if inherit_context else None
# ``ctx.turn_input`` carries the parent's full conversation up to and
# including the call that's currently invoking ``create_agent``
# (populated by SDK at ``run_internal/turn_resolution.py:806``).
# Wrap as a single read-only block so the child sees the parent's
# reasoning as background but doesn't try to continue parent's turns.
parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else []
initial_input: list[TResponseInputItem] = []
if parent_history:
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
initial_input.append(
{
"role": "user",
"content": "[Inherited context from parent — read-only history]",
}
)
initial_input.extend(parent_history)
initial_input.append(
{
"role": "user",
"content": "[End of inherited context]",
}
"content": (
"<inherited_context_from_parent>\n"
f"{rendered}\n"
"</inherited_context_from_parent>\n"
"Use the above as background only; do not continue the "
"parent's work. Your task follows."
),
},
)
initial_input.append(
{
@@ -456,6 +493,9 @@ async def create_agent(
run_id=inner.get("run_id"),
agent_factory=factory,
)
# Stash the task string for ``agent_finish`` to echo back in its
# XML completion report.
child_ctx["task"] = task
child_run_config = make_run_config(
sandbox_session=inner.get("sandbox_session"),
@@ -569,17 +609,14 @@ async def agent_finish(
if report_to_parent:
async with bus._lock:
agent_name = bus.names.get(me, me)
report = json.dumps(
{
"kind": "agent_completion_report",
"from": agent_name,
"agent_id": me,
"success": success,
"summary": result_summary,
"findings": list(findings or []),
"recommendations": list(final_recommendations or []),
},
ensure_ascii=False,
report = _render_completion_report(
agent_name=agent_name,
agent_id=me,
task=str(inner.get("task", "")),
success=success,
result_summary=result_summary,
findings=list(findings or []),
recommendations=list(final_recommendations or []),
)
await bus.send(
parent_id,
@@ -606,3 +643,94 @@ async def agent_finish(
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def stop_agent(
ctx: RunContextWrapper,
target_agent_id: str,
cascade: bool = True,
reason: str = "",
) -> str:
"""Gracefully stop a running agent (and optionally its descendants).
Uses the SDK's ``RunResultStreaming.cancel(mode="after_turn")`` so the
target's current turn finishes — including saving items to its
session — before the run loop honors the cancel. The agent's
interactive outer loop sees ``stopping`` and exits without awaiting
more messages, so ``on_agent_end`` finalizes with status="stopped".
Use sparingly. Prefer ``send_message_to_agent`` (asking the agent
to wrap up) for soft-stop scenarios. Reach for ``stop_agent`` when
a child has gone off-track and won't self-correct.
Args:
target_agent_id: The 8-char id from ``view_agent_graph`` /
``create_agent``. Cannot stop yourself.
cascade: If ``True`` (default), also stop every descendant of
``target_agent_id`` leaves-first. ``False`` stops only the
target.
reason: Optional human-readable reason for the stop, surfaced
in logs and telemetry.
"""
inner = _ctx(ctx)
bus = inner.get("bus")
me = inner.get("agent_id")
if bus is None or me is None:
return json.dumps(
{"success": False, "error": "Bus or agent_id missing in context."},
ensure_ascii=False,
default=str,
)
if target_agent_id == me:
return json.dumps(
{
"success": False,
"error": "Cannot stop yourself; call agent_finish or finish_scan instead.",
},
ensure_ascii=False,
default=str,
)
async with bus._lock:
if target_agent_id not in bus.statuses:
return json.dumps(
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
ensure_ascii=False,
default=str,
)
target_status = bus.statuses.get(target_agent_id)
if target_status in ("completed", "crashed", "stopped"):
return json.dumps(
{
"success": False,
"error": f"Target agent '{target_agent_id}' is already {target_status}.",
},
ensure_ascii=False,
default=str,
)
if cascade:
await bus.cancel_descendants_graceful(target_agent_id)
else:
async with bus._lock:
bus.stopping.add(target_agent_id)
await bus.request_interrupt(target_agent_id, mode="after_turn")
logger.info(
"stop_agent: target=%s cascade=%s reason=%r",
target_agent_id,
cascade,
reason,
)
return json.dumps(
{
"success": True,
"target_agent_id": target_agent_id,
"cascade": cascade,
"reason": reason,
"note": "Cancellation is graceful — current turn completes first.",
},
ensure_ascii=False,
default=str,
)