# 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](https://a2a-protocol.org/latest/specification) (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**: `/.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 `/.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 DeliverAsync(target, text, ct); string WorkspaceFor(target); IEnumerable 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` ```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 `/.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: `. - **`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 `/inbox/a2a//` 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 `/.volcanic/a2a/pending.json`: ```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 `/a2a/push/` 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)] 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).