- A2AWatchRegistry.Shared was initialised before DefaultPollDelays (static fields run in source order), so the bot's registry had null poll delays and every poll watch crashed on its first wait, then sat in the registry doing nothing until a restart. The delays now come first; a test reads them through Shared. - TaskNotFound from the remote ends the watch at once with a "(lost)" notice instead of retrying for a day. - A crashed watch loop restarts by polling after a pause; the third crash in a row ends it with a "(stopped)" notice.
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 officialA2Aclient 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
a2atool with actionsagents,send,status,cancel. The tool list never changes when the file does. No system prompt section. - Long tasks:
sendwaits up toWaitSeconds(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:PublicUrlis set), elseSubscribeToTask, elseGetTaskpolling; a slow safety poll runs beside push. - Watches survive restarts through
<workspace>/.volcanic/a2a/pending.json. input-requiredis returned to the model with thetaskId; the model answers withsendand thattaskId.- 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(envA2AClient__…),Enabled = false. Independent of the server'sA2A__Enabled. Off: the extension isn't added to any session, no watches are restored,/a2asays the client is off. - Only the owner's own chat sessions get the
a2atool. 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 inagents.json. The hop counter still listens toa2a.inboundfor 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'sA2A:PublicUrlonly when the server is on (the push receiver lives there). Without it, watches subscribe or poll. - Send =
returnImmediately+ wait.sendalways callsSendMessagewithreturnImmediately: true, then waits up toWaitSecondsby subscribing (streaming agents) or pollingGetTask. 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 ignorereturnImmediatelysimply answer late (the call is bounded byWatchTimeout). - Delivery contract:
IA2AResultDelivery { Task<bool> DeliverAsync(target, text, ct); string WorkspaceFor(target); IEnumerable<string> KnownTargets(); }(targets = Telegram chat ids, like the scheduler). The defaultLiveSessionDeliverydelivers through the live session'sIExtensionApi.SendUserMessage(FollowUp)and returns false without one; undelivered results stay inpending.jsonand go out on that target's next session start. - The registry owns one HTTP handler (
A2AWatchRegistry, process-wideShared); 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 asagent.url: base URL; the card is fetched from<url>/.well-known/agent-card.json. A URL ending in.jsonis taken as the card URL itself.key: optional. Sent asX-API-Key, or asAuthorization: Bearerif the card's security schemes declare onlyhttp 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 sayssupportsExtendedAgentCard, the extended card. Returns per agent: name, description (entry override or card), skills (id, name, description, examples), streaming/push capabilities, orunreachable: <reason>.send{agent, message, contextId?, taskId?, files?}— builds a message (text part, file parts read from workspace paths, capMaxFileBytes), metadatavolcanic.hops. CallsSendMessagewithreturnImmediately: true, then waits bySubscribeToTask(card hasstreaming) orGetTaskpolling. Then, withinWaitSeconds:- 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 aMessage, no task) is returned as text with itscontextId.
- terminal (
status{agent, taskId}—GetTask; same result shape as a finishedsend. 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.jsonat each poll/subscribe (an agent removed from the file ends the watch with "agent removed"). - Sources:
- push: card has
capabilities.pushNotificationsandPublicUrlis set →CreateTaskPushNotificationConfigwith URL<PublicUrl>/a2a/push/<watchId>and a random 32-byte token. A safetyGetTaskruns every 10 min. - 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, thenGetTaskand 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. - poll:
GetTaskwith backoff 15 s → 30 s → 1 min → 2 min → 5 min (cap).
- push: card has
- Push receiving: the host wires the server's
A2AServerOptions.OnPushtoA2AWatchRegistry.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 toIA2AResultDelivery, then removes the watch (and deletes the push config remotely, best effort). - Lost / crashed:
TaskNotFoundfrom the remote ends the watch at once with a(lost)notice. A crashed watch loop restarts by polling; after 3 crashes in a row it ends with a(stopped)notice. - 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 everypending.jsonand re-arms the watches (push watches get aGetTaskstraight 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), usesTelegramA2AResultDelivery, and restores watches over the allowed chats at startup. - When the server is on too, wires its
OnPushto the registry and passesA2A:PublicUrlas the push base URL. /a2aadditionally lists remote agents fromagents.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_ReportsFileSend_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_NeverContainsKeyWatch_Push_DeliversFollowUp,Watch_PushBadToken_Rejected,Watch_Subscribe_DeliversFollowUp,Watch_Poll_BacksOff(fakeTimeProvider),Watch_Timeout_DeliversNotice,Watch_Restore_FromPendingFile,Watch_NoLiveSession_DeliveredOnNextSessionStart,Watch_AgentRemoved_EndsWatchCancel_CancelsRemoteAndDropsWatch,Status_ReturnsTaskHops_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 byWatchTimeout:SendMessage,GetTask,CancelTaskand push-config calls each get at most 60 s. An agent that ignoresreturnImmediatelyand needs longer to answerSendMessagefails 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 throughChatRegistry.GetAsync, which returns a chat only after its renderer is subscribed, and opens the session when needed. cancelstops the watch first (StopWatchingAsync), so acanceledoutcome 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
undeliveredinpending.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
StopAsyncthe 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.jsonentry changes. A malformedagents.jsonis 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.PushBaseUrlfromA2A:PublicUrlonly 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) asLinks:and oversized parts asFiles skipped (over N bytes):, instead of oneArtifacts:line; delivered texts carry aContext: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.WorkspaceForreturnsstring?(null: unknown target).- Accepted v1 exposures (like the server's
jobs.jsonones): an inbound A2A caller's isolated session shares the workspace and hasbash, so it can read the keys inagents.json, redirect an agent name there (the owner's nextsend, attachments and key go to the new URL), and forgepending.json(anundeliveredtext 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'sMaxHopsis the only chain guard. /a2ashows agent URLs as scheme, host, port and path only (no query, fragment or user info, which may carry credentials).- Docker: the
Dockerfilerestore layer gained the A2A server and client project files (the server's was missing, which already broke the image build).