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.
Amended by ADR 0112. A fourth type,
code-ref, joins the registry — a pointer into a fenced project file that opens the console code pane. Unlike the free-form widgets above it has a strict prop schema (validate_component_props; an invalid payload is dropped at extraction), is emitted only by theshow_codefs tool, andshow_componentrefuses to build one. The transport is unchanged.
Amended 2026-09-25 (#3617) — plugin-contributed kinds. A plugin can add its OWN kind with
registry.register_component(name, validator): the loader collects it,server/agent_initpushes the live set intograph/components.pyon every (re)load, and extraction forwards a payload only when that plugin's validator accepts it (a raising validator drops it). A plugin kind can never shadow a core one (so nothing loosenscode-ref),show_componentnever builds one, and a disabled plugin's kind stops extracting on the next reload. The console renders it throughregisterChatComponent(name, render, { onLive })(src/ext/), whoseonLivehook fires only for a component on the LIVE turn stream — never on hydration or reattach. First consumer: the artifact plugin'sartifact-refchip (ADR 0038). The transport is unchanged.
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).Slice 3 landed (#2361) —
chat.progress. A second host filter republishes a SERVER-FIRED turn's frames into its chat session, so a scheduled fire / watch reaction / background push-resume is watchable while it runs instead of showing a typing indicator (#1767) for minutes and then the whole answer at once. Three things the shape of the extension turned on:- Gate on ORIGIN, not on context shape. A turn the browser is streaming itself must never be republished or every tool card renders twice. The gate is
server.chat.is_autonomous_origin— the same set that decides HITL auto-answer, made public precisely so the two can't drift. - Narration needed a new tap. Only
tool_start/tool_endwent through the hook; answer text went straight to the artifact stream._flush_textnow also notifies, which is where the substance of a turn lives — tool cards alone prove the agent is busy, not what it is doing. It is already batched to_FLUSH_CHARS, so this is ~a frame per paragraph, not per token. - Published UNRETAINED (
EventBus.publish(..., retain=False), added for this). The replay ring holds 128 events total, so one long turn's progress would evict the durable events a reconnecting client needs — and replaying a progress frame from a finished turn renders work that is already over. - The steer-consumed boundary rides it too (
phase: "steer_consumed",items: [{id, text}]). An operator can interject into an ATTENDED server turn (#3092); the server queues the text on the ordinary steering queue and the next model call folds it in — but the frame that says so went only to the stream the server itself holds, so the console left the message "queued" under an answer that had already used it, with no way to settle or cancel it. The executor now also hands the boundary to the progress hook (after_flush_text, so bus order matches stream order) and the console splits the live preview there, exactly as it does for its own stream. Being live-only, a missed frame is covered at turn end from the steering queue itself (GET …/steer): whatever left the queue was consumed; whatever the turn never reached is dequeued and sent as the operator's next message.
- Gate on ORIGIN, not on context shape. A turn the browser is streaming itself must never be republished or every tool card renders twice. The gate is
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).