0051 — A2A realtime streaming & component rendering
- Status: Accepted
- Date: 2026-06-14
- Builds on: ADR 0050 (background subagents), ADR 0039 (plugin event bus), ADR 0038 (generative-UI artifacts), ADR 0006/0003 (the A2A terminal hook), and the
protolabs_a2aDataPart/extension contract.
Context
ADR 0050 shipped background subagents whose completion is surfaced (next-turn drain, live in-chat push, a jobs widget) but whose in-flight progress is not — a background job runs as a detached A2A turn and its tool-by-tool frames go to that turn's own A2A event queue, which nobody reads (the manager fire-and-forgets the POST). The deferred "live progress card" (bd-1sr) and the missing stop/output controls (Phase 4, bd-20c) both stem from the same root: we don't expose the realtime stream of a detached turn, and we don't surface the task handle needed to control it.
A protocol-alignment audit of the a2a-sdk (v2 / 1.0 proto surface) corrected two beliefs baked into our docstrings and memory:
- Cancel genuinely stops a running turn.
CancelTask→ActiveTask.cancelcancels the producer asyncio task, injectingCancelledErrorintoProtoAgentExecutor.execute(which unwinds the LangGraph stream). It is not a mark-only no-op. The only missing piece for a realstop_taskis surfacing the A2A task_id (A2A is task-scoped — there is no cancel-by-contextId). - Live resubscribe exists.
SubscribeToTaskre-attaches an SSE tap to a live in-process task. (PollingGetTaskremains the cross-restart ceiling, since the in-memory task is gone after a restart.)
The audit also found the protocol mechanics solidly 1.0-conformant (correct method names, error codes, version enforcement, SSE shape, durable + reconciled stores, SSRF-guarded push), with small real gaps: no TurnOutcome/telemetry on cancel, no push-config TTL, and card polish (documentation_url/icon_url/explicit top-level protocol_version).
Separately, the component-rendering substrate already works end-to-end: a typed DataPart (metadata.mimeType discriminator + JSON payload) emitted over the A2A envelope, decoded by the console's dataByMime/*FromParts helpers, and rendered by a React component — that's exactly how tool-call-v1 and the HITL hitl-v1 card already work. Richer component rendering is a new MIME + a render switch on that proven pipeline, not new plumbing.
Decision
Expose the realtime stream of any turn through a small executor progress/lifecycle hook (the same pattern as set_terminal_hook), and render richer components over the existing typed-DataPart contract. Delivered in three slices.
Slice 1 — realtime progress + background control (this ADR's first PR)
- Executor progress hook.
a2a_impl/executor.pygainsset_progress_hook(hook)and fires_notify_progress(context_id, task_id, frame)at turn start (turn_started, which carries the task_id) and on eachtool_start/tool_end. No-op when unset (like the terminal hook), so live turns — which already stream over their own SSE — pay nothing. background.progresschannel. A host hook (server/a2a.py) filterscontext_id.startswith("background:"), recovers the job_id (same path the terminal hook uses), records the A2Atask_idon the job row onturn_started, and publishesbackground.progresson the event bus. The console's jobs widget threads these into a live per-tool card (reusing the chatToolCallmodel), closing bd-1sr.stop_task(job_id)— looks up the recorded A2A task_id and self-POSTs a realCancelTask; marks the jobcanceled.task_output(job_id, block, timeout)— reads the durable registry, optionally awaiting a terminal state (the cc-2.18 ergonomic).Correction (later):
task_outputwas removed — its blocking-to-wait behavior was an attractive nuisance that defeated fire-and-forget (agents polled it instead of yielding, racing the push path into duplicate/out-of-order delivery). Push (drain_pending) is now the sole delivery path;stop_taskis retained for cancellation. See ADR 0050.- Foreground→auto-background. A synchronous
taskdelegation that exceeds a time budget transparently detaches to the background (returns a job id), so a long inline subagent run stops freezing the turn — the direct cure for the audited melt-down. - Cancel-path telemetry.
executerecords astate="canceled"TurnOutcomeso a canceled turn isn't an observability hole and a canceled background job settles.
Slice 2 — component rendering (generative-UI path A)
Shipped. A typed application/vnd.protolabs.component-v1+json DataPart {component, props}, emitted over the A2A envelope like any other DataPart, decoded by componentFromParts, and rendered inline in chat by a curated registry of data-only widgets (no code execution → safe without a sandbox). Free-form generated UI stays on the ADR 0038 sandboxed-iframe path (the artifact plugin).
The agent calls a show_component(component, props, title?) tool; its return carries the payload past the LangGraph stream behind a sentinel (graph/components.py), which server/chat.py lifts into a ("component", …) frame on on_tool_end (stripping the sentinel from the tool card), and the executor emits as the DataPart — the same relay as the realtime hooks, reusing the tool-call-v1/hitl-v1 decode+render pipeline. Widgets in the first registry: table (columns/rows), keyvalue (label/value items), timeline (steps with done/active/todo state). Chart was deliberately skipped (would add a charting dep); a new widget is a registry entry + a render fn, no new transport.
Slice 3 — alignment polish (shipped)
- Outbound
A2A-Versionfix (real bug). The delegate A2A client (plugins/delegates/ adapters.py_rpc) omitted the header → a strict 1.0 peer rejects it-32009. Now sendsA2A-Version: 1.0, matching the scheduler/inbox/background self-POSTs. - Cancel/resubscribe docstring correction.
ProtoAgentExecutor's docstring claimed cancel is mark-only — corrected to reflect thatCancelTasktruly cancels the coroutine and that liveSubscribeToTaskexists (the audit finding; memory updated too). - Agent-card polish.
documentation_url+icon_urlset on the served card (build_agent_carddoesn't, but the 1.0 proto has them); overridable viaa2a.documentation_url/a2a.icon_url. - Cheap realtime bus wins.
turn.usage(per-turn cost/tokens, independent of the SQL telemetry store → a live cost HUD) andgoal.iteration(the goal loop's per-continuation progress, previously onlygoal.achieved/failedwere on the bus).
Slice 3 follow-ups (after the initial PR): scheduler.fired now fires on the bus (the scheduler got the same injected-publish treatment as the background manager), and orphaned push-configs are swept — the SDK push store has no timestamp, so instead of a TTL the config's lifetime is tied to its task: sweep_orphaned_push_configs drops push rows whose task_id is no longer a live task, run at boot + on the periodic task-prune tick. Push-on-terminal: verified working — the SDK's PushNotificationEvent is an alias (Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent), so the terminal TaskStatusUpdateEvent from updater.complete() satisfies the consumer's isinstance(event, PushNotificationEvent) trigger and push_sender.send_notification fires for a registered webhook (on every status/artifact frame, including terminal). No explicit PushNotificationEvent enqueue needed. Slice 3 is complete.
Consequences
- Detached work becomes observable and controllable without changing the protocol — the progress hook is a host-side tap on frames the SDK already produces, and stop uses the SDK's real
CancelTask. - One general seam, many uses. The progress hook fires for every turn; today only the
background:filter consumes it, butturn.started/turn.progressfor all contexts is a trivial extension (Slice 3). - Component rendering rides the proven DataPart pipeline — new widgets are a MIME + a registry entry, not new transport.
- Cost note. The progress hook is a no-op unless a host hook is registered; the
background.progresspublisher only fires for background contexts. Bus overflow drops oldest (progress is best-effort;background.completedis the source of truth). - The auto-background time budget changes
taskbehavior: a long sync delegation now detaches instead of blocking. Tunable; the model can still force foreground when it needs the result inline.
References
- The A2A alignment audit + realtime/component research that informed this (the cancel/ resubscribe correction; the executor-hook seam; the DataPart component contract).
a2a_impl/executor.py(frame loop,set_terminal_hook),server/a2a.py(_a2a_terminal),background/(ADR 0050),apps/web/src/lib/api.ts(dataByMime/*FromParts).