Files
VolcanicAgenticCore/docs/superpowers/specs/2026-10-07-a2a-client-design.md
EugeneTes f42b00cb1c fix(a2a-client): watches no longer hang on a silently dead task stream
Hour-long remote tasks were never delivered: a subscribe watch held one SSE stream for up to a day, the remote
sent nothing while its agent worked without text, and once the quiet connection died on the way (no FIN/RST) the
watch waited on it with no error and no log line.

- A subscription lasts at most 5 minutes (SubscriptionSlice), then GetTask and a new subscription: a pause, not a
  failure. A slice ended mid-read counts as ours even when it surfaces as an I/O error.
- The shared handler connects with TCP keepalive (60 s, every 15 s, 4 probes) and HTTP/2 pings.
- Log lines when a watch starts or is restored, when a slice ends on a running task, when a result is delivered or
  kept; the Telegram host's console log carries a UTC timestamp.
- action=status on a settled task ends its watch, so the result isn't delivered a second time.
- A "context busy" refusal of send says nothing was sent, names the watched task in that context and that its
  result will arrive by itself; refusals never touch a watch.
2026-10-16 13:48:32 +00:00

16 KiB

A2A client extension (design)

Date: 2026-10-07 Status: implemented (2026-10-08; see "Implementation deviations" at the end). User guide: docs/a2a.md#client. Depends on: 2026-10-07-a2a-server-design.md (push receiver endpoint, a2a.inbound event, end-to-end tests run against that server). Related: 2026-09-26-scheduler-design.md (process-wide registry, restore from workspace files, follow-up delivery).

Problem

Our assistants should be able to use other agents over the A2A protocol (spec 1.0): hand a subtask to a specialist, hold a multi-turn exchange with it (including answering its questions, possibly after asking the user), and get results of long-running tasks back without the user having to ask.

Decisions

  • A feature extension, extensions/Volcanic.AgenticCore.Extensions.A2A, referencing only Abstractions and the official A2A client package.
  • Remote agents live in a plain file in the workspace: <workspace>/.volcanic/a2a/agents.json, read on every call, editable by the agent and the user. No cache.
  • One generic a2a tool with actions agents, send, status, cancel. The tool list never changes when the file does. No system prompt section.
  • Long tasks: send waits up to WaitSeconds (60). If the task is still running, a watch takes over and the result is delivered later as a follow-up prompt into the session that started it.
  • Watch update sources: push (when the remote card supports it and A2A:PublicUrl is set), else SubscribeToTask, else GetTask polling; a slow safety poll runs beside push.
  • Watches survive restarts through <workspace>/.volcanic/a2a/pending.json.
  • input-required is returned to the model with the taskId; the model answers with send and that taskId.
  • Hops: each outgoing message carries metadata["volcanic.hops"] = inbound hops + 1.

Revisions (2026-10-08, before implementation)

Decided with the owner after the server shipped; these override the sections below where they differ.

  • Off by default, its own switch: configuration section A2AClient (env A2AClient__…), Enabled = false. Independent of the server's A2A__Enabled. Off: the extension isn't added to any session, no watches are restored, /a2a says the client is off.
  • Only the owner's own chat sessions get the a2a tool. The Telegram host adds the extension per chat (like the scheduler), never host-wide, so isolated A2A server sessions (driven by untrusted callers) can't call other agents with the keys in agents.json. The hop counter still listens to a2a.inbound for hosts that add it elsewhere.
  • Settings: Enabled, WaitSeconds (60), WatchTimeout (1 day), CardTimeoutSeconds (10), MaxFileBytes (10 MB). The push base URL isn't a client setting: the host passes the server's A2A:PublicUrl only when the server is on (the push receiver lives there). Without it, watches subscribe or poll.
  • Send = returnImmediately + wait. send always calls SendMessage with returnImmediately: true, then waits up to WaitSeconds by subscribing (streaming agents) or polling GetTask. A dropped streaming request would cancel the remote run on servers that tie the run to the request (ours does), so the client never streams the send itself. Agents that ignore returnImmediately simply answer late (the call is bounded by WatchTimeout).
  • Delivery contract: IA2AResultDelivery { Task<bool> DeliverAsync(target, text, ct); string WorkspaceFor(target); IEnumerable<string> KnownTargets(); } (targets = Telegram chat ids, like the scheduler). The default LiveSessionDelivery delivers through the live session's IExtensionApi.SendUserMessage(FollowUp) and returns false without one; undelivered results stay in pending.json and go out on that target's next session start.
  • The registry owns one HTTP handler (A2AWatchRegistry, process-wide Shared); tests inject a handler that reaches an in-process server.

agents.json

[
  { "name": "travel-bot", "url": "https://travel.example/", "key": "k_…", "description": "optional override" }
]
  • name: [a-z0-9-]{1,40}, unique; what the model passes as agent.
  • url: base URL; the card is fetched from <url>/.well-known/agent-card.json. A URL ending in .json is taken as the card URL itself.
  • key: optional. Sent as X-API-Key, or as Authorization: Bearer if the card's security schemes declare only http bearer.
  • Missing file = no agents; malformed = no agents and the tool says which file failed to parse.

The tool never includes keys in its results or errors.

Configuration

Section A2AClient (env A2AClient__…), bound to A2AClientSettings (the SDK already has an A2AClientOptions):

Key Default Meaning
Enabled false add the a2a tool to the owner's chats, restore watches
WaitSeconds 60 how long send blocks before handing over to a watch
WatchTimeout 1.00:00:00 when a watch gives up
CardTimeoutSeconds 10 per-agent card fetch timeout
MaxFileBytes 10485760 cap per file part, in and out

The push base URL comes from the server's A2A:PublicUrl, and only while A2A:Enabled is on.

The a2a tool

Arguments: action (agents | send | status | cancel), agent, message, contextId, taskId, files[].

  • agents — for every entry: fetch the public card and, when a key is set and the card says supportsExtendedAgentCard, the extended card. Returns per agent: name, description (entry override or card), skills (id, name, description, examples), streaming/push capabilities, or unreachable: <reason>.
  • send {agent, message, contextId?, taskId?, files?} — builds a message (text part, file parts read from workspace paths, cap MaxFileBytes), metadata volcanic.hops. Calls SendMessage with returnImmediately: true, then waits by SubscribeToTask (card has streaming) or GetTask polling. Then, within WaitSeconds:
    • terminal (completed, failed, canceled, rejected) → returns state, the agent's final text, the artifact list, contextId, taskId. File artifacts are saved under <workspace>/inbox/a2a/<agent>/ and returned as paths; text artifacts are inlined (long ones saved to a file and returned as a path, by the tool's normal truncation);
    • input-required / auth-required → returns the state, the agent's question, contextId, taskId, and the hint "answer with action=send and this taskId";
    • still running → starts a watch, returns {state: working, taskId, contextId, note: "the result will be delivered to this conversation when it is ready"}. A message-only reply (the agent answered with a Message, no task) is returned as text with its contextId.
  • status {agent, taskId} — GetTask; same result shape as a finished send. A settled task's watch ends, so its outcome isn't delivered a second time.
  • cancel {agent, taskId} — CancelTask, removes any watch for it.

Errors (network, timeout, 401, JSON-RPC error, unknown agent) become error tool results with a readable message. A "context busy" refusal of a send with contextId names the task this chat watches there and says its result will arrive by itself; a refusal never touches a watch. The tool never throws into the loop.

Watches

A2AWatchRegistry is process-wide (A2AWatchRegistry.Shared, like McpCatalogCache.Shared) and built on TimeProvider.

Watch record, persisted in <workspace>/.volcanic/a2a/pending.json:

{ "watchId": "…", "target": "111", "agent": "travel-bot", "taskId": "…", "contextId": "…",
  "pushToken": "…", "startedAt": "…", "source": "push" }
  • The agent's url and key are looked up from agents.json at each poll/subscribe (an agent removed from the file ends the watch with "agent removed").
  • Sources:
    1. push: card has capabilities.pushNotifications and PublicUrl is set → CreateTaskPushNotificationConfig with URL <PublicUrl>/a2a/push/<watchId> and a random 32-byte token. A safety GetTask runs every 10 min.
    2. subscribe: card has streaming → SubscribeToTask; on disconnect, reconnect with backoff, falling back to polling after 3 failures. A stream is held at most 5 minutes, then GetTask and a new subscription (a pause, not a failure): a quiet stream may have died silently on the way (2026-10-16: hour-long tasks never delivered). The shared handler uses TCP keepalive and HTTP/2 pings.
    3. poll: GetTask with backoff 15 s → 30 s → 1 min → 2 min → 5 min (cap).
  • Push receiving: the host wires the server's A2AServerOptions.OnPush to A2AWatchRegistry.Shared.OnPushAsync(watchId, token, body), which checks the token (constant-time) and returns false for unknown watches or bad tokens (→ 404).
  • End: on a terminal state or input-required/auth-required, the registry builds a result text and hands it to IA2AResultDelivery, then removes the watch (and deletes the push config remotely, best effort).
  • Timeout: after WatchTimeout, deliver "travel-bot gave no result for task within 24h; you can check it with action=status" and remove the watch.
  • Restore: at startup the host calls A2AWatchRegistry.Shared.RestoreAsync(workspaces), which reads every pending.json and re-arms the watches (push watches get a GetTask straight away in case a push was missed while down).

Result text delivered as a follow-up prompt:

[A2A result from "travel-bot", task 5c1e… (completed)]
<the agent's final text>
Artifacts: inbox/a2a/travel-bot/itinerary.pdf

For input-required: [A2A question from "travel-bot", task 5c1e… (input-required)] and the hint to answer with send and that taskId.

Delivery

IA2AResultDelivery (in the extension): DeliverAsync(sessionScope, workspace, text). The default implementation holds the live session's IExtensionApi per scope (registered on session_start, removed on session_shutdown) and calls SendUserMessage(text, delivery: FollowUp); no live session → the result stays in pending.json marked undelivered and is delivered on the next session_start for that scope. The Telegram host provides TelegramA2AResultDelivery, which delivers like TelegramJobDelivery (timestamp header, through the chat's session, resuming it if evicted).

Hops

A2AExtension listens on the event bus channel a2a.inbound (emitted by the server's per-session extension before each inbound prompt) and keeps the last hops value for the session; sessions with no inbound A2A use 0. Each outgoing message sends hops + 1. The server refuses above MaxHops.

Telegram host

  • With A2AClient:Enabled, adds the extension to each chat session (target = chat id), uses TelegramA2AResultDelivery, and restores watches over the allowed chats at startup.
  • When the server is on too, wires its OnPush to the registry and passes A2A:PublicUrl as the push base URL.
  • /a2a additionally lists remote agents from agents.json (name, url, key masked) and active watches (agent, task, source, age).

Testing

In tests/Volcanic.AgenticCore.Extensions.Tests/A2A/, against the A2A server library hosted in-process (WebApplicationFactory, FauxProvider), which is also the Volcanic↔Volcanic test:

  • Agents_ListsCardsAndSkills, Agents_UnreachableAgent_Listed, Agents_FileEdited_AppliesToNextCall, Agents_MalformedFile_ReportsFile
  • Send_CompletesWithinWait_ReturnsAnswerAndIds, Send_ContinuesContext, Send_InputRequired_ReturnsQuestion, Send_AnswerWithTaskId_ContinuesTask, Send_SlowTask_StartsWatch, Send_FileArtifact_SavedToInbox, Send_FilesArgument_SentAsParts, Send_UnknownAgent_Error, Send_401_ReadableError, Result_NeverContainsKey
  • Watch_Push_DeliversFollowUp, Watch_PushBadToken_Rejected, Watch_Subscribe_DeliversFollowUp, Watch_Poll_BacksOff (fake TimeProvider), Watch_Timeout_DeliversNotice, Watch_Restore_FromPendingFile, Watch_NoLiveSession_DeliveredOnNextSessionStart, Watch_AgentRemoved_EndsWatch
  • Cancel_CancelsRemoteAndDropsWatch, Status_ReturnsTask
  • Hops_Incremented, Hops_FromInboundEvent

Telegram host: A2aCommand_ListsAgentsAndWatches, ResultDelivery_GoesToChatWithTimestampHeader.

Docs

docs/a2a.md (client half: agents.json, the tool, watches, push, scenarios), docs/extensions.md (the extension), docs/telegram-bot.md (/a2a additions), CLAUDE.md (extensions list).

Out of scope

Per-agent tools, a system-prompt list of agents, OAuth flows for remote agents, gRPC, A2A protocol extensions, signed card verification.

Implementation deviations

What the code does where it differs from the sections above (the user guide, docs/a2a.md#client, follows the code):

  • Remote calls are bounded by a fixed 60 s call timeout (A2AWatchRegistry.CallTimeout), not by WatchTimeout: SendMessage, GetTask, CancelTask and push-config calls each get at most 60 s. An agent that ignores returnImmediately and needs longer to answer SendMessage fails with "did not answer in time"; its task id never arrived, so nothing watches it.
  • Kept results flush a second after session start (SessionStartFlushDelay), not inline: delivering starts a run, which mustn't happen inside session creation. In Telegram delivery goes through ChatRegistry.GetAsync, which returns a chat only after its renderer is subscribed, and opens the session when needed.
  • cancel stops the watch first (StopWatchingAsync), so a canceled outcome is never delivered later, and re-arms it (RearmAsync, same watch id, token and start) when the cancel doesn't go through (unreachable, too slow, aborted). A refused cancel (task already finished) returns the task's outcome in the tool result.
  • Finishing a watch is claim / deliver / settle: under the workspace lock the watch leaves the map and its text is stored as undelivered in pending.json; delivery runs outside any lock (it may run a whole turn that calls back into the registry); the record is removed once delivered. Delivery is at least once (a crash between delivering and removing, or a start racing a finishing watch of the same task, can deliver twice). A chat watches a task at most once.
  • Restore runs in the background at host startup, re-arms every watch first and then flushes kept results; a push watch restored without a push URL subscribes instead. After StopAsync the registry starts no loops and flushes nothing; records stay for the next start.
  • Each watch keeps its connection (card and client) between checks and reconnects after a failure or when its agents.json entry changes. A malformed agents.json is a passing failure for a watch, not the end of it. A subscription is reopened after a day; a stream that ends before the task settles counts as a failure.
  • Push base URL: the Telegram host sets A2AClientSettings.PushBaseUrl from A2A:PublicUrl only while the server (and the client) is on and overwrites any configured value otherwise.
  • Result texts list saved files as Files:, URL parts (not downloaded) as Links: and oversized parts as Files skipped (over N bytes):, instead of one Artifacts: line; delivered texts carry a Context: line.
  • Attachments: paths are confined to the workspace after resolving symlinks and capped together at MaxFileBytes; special files (FIFOs, devices) aren't detected, so a FIFO blocks the call until the run is aborted.
  • IA2AResultDelivery.WorkspaceFor returns string? (null: unknown target).
  • Accepted v1 exposures (like the server's jobs.json ones): an inbound A2A caller's isolated session shares the workspace and has bash, so it can read the keys in agents.json, redirect an agent name there (the owner's next send, attachments and key go to the new URL), and forge pending.json (an undelivered text runs as a prompt in the owner's chat on restart or session start). It can't call other agents through the tool. In Telegram the hop listener is inactive (only the owner's chats have the tool, so every message carries 1); the remote's MaxHops is the only chain guard.
  • /a2a shows agent URLs as scheme, host, port and path only (no query, fragment or user info, which may carry credentials).
  • Docker: the Dockerfile restore layer gained the A2A server and client project files (the server's was missing, which already broke the image build).