Files
VolcanicAgenticCore/docs/a2a.md
EugeneTes cb877f697a fix(a2a-client): poll watches crashed on null delays; lost tasks and crashed watches end with a notice
- 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.
2026-10-16 14:46:59 +00:00

457 lines
31 KiB
Markdown

# A2A
Two independent halves, each with its own switch:
- **Server** (`src/Volcanic.AgenticCore.A2A.Server`, `A2A__Enabled`): other agents call a Volcanic assistant. The
sections from [Scenarios](#scenarios) to [curl examples](#curl-examples) describe it.
- **Client** (`extensions/Volcanic.AgenticCore.Extensions.A2A`, `A2AClient__Enabled`): the assistant calls other
agents through its `a2a` tool. See [Client](#client).
## Server
`src/Volcanic.AgenticCore.A2A.Server` lets other agents talk to a Volcanic assistant over
[A2A](https://a2a-protocol.org/latest/specification) (spec 1.0), on the official `A2A` / `A2A.AspNetCore` .NET SDK.
A caller gets a key from the assistant's owner and from then on talks to an isolated session in that owner's
workspace: it can delegate work, hold a multi-turn conversation, send and receive files, ask the assistant to tell
its owner something, and register a webhook for push updates. Calling *other* agents from one of our own sessions
is the [client](#client).
The Telegram host (`docs/telegram-bot.md#a2a`) mounts it with one workspace per allowed chat.
`samples/Volcanic.AgenticCore.Sample.A2A` mounts it over a single folder, for trying it out or scripting against it.
## Scenarios
- **Delegation.** A caller sends a task ("book three slots next week"); the assistant works it like an ordinary
conversation turn and answers with a `response` artifact.
- **Multi-turn.** The assistant calls `request_input` to ask the caller something; the task becomes
`input-required` and the caller's next message, with the same `taskId`, continues it.
- **Files.** A caller can attach bytes or an `https` URL; they land in the workspace's `inbox/`. The assistant's
`send` tool turns into task artifacts in an A2A session, so a file it sends comes back to the caller the same way.
- **Notify the owner.** `notify_owner(text)` is the isolated session's only channel back to its owner — in Telegram,
a message into the owner's chat.
- **Push.** A caller that registers a webhook (`CreateTaskPushNotificationConfig`) gets POSTed on every status
change instead of polling `GetTask`.
## Files
Everything lives under `<workspace>/.volcanic/a2a/`. A missing file means "none". A malformed `callers.json` or
`card.json` means "no callers" / the fallback card, plus a warning log line naming the file — never the keys. A
malformed task file is read as "no such task" (`TaskNotFound`) without a log line.
`callers.json`, read fresh on every request (no cache, so an edit applies to the very next call, no restart needed):
```json
[
{ "name": "travel-bot", "key": "k_3f9c2a...", "mode": "isolated", "note": "Alice's trip planner" }
]
```
- `name`: `[a-z0-9-]{1,40}`, unique in the file; it ends up in the session scope, in logs and in the framing header
the assistant sees.
- `key`: any non-empty string, plaintext. Make one with `openssl rand -hex 32`.
- `mode`: `isolated` is the only one implemented; omit it or set it explicitly. An unknown mode skips the entry with
a warning.
- Unknown entries (bad name, empty key, duplicate name) are skipped with a warning; everything else in the file is
still read.
`card.json` (optional) overrides the caller's extended agent card:
```json
{ "name": "Eugene's assistant", "description": "Schedules meetings and answers for Eugene.",
"skills": [ { "id": "scheduling", "name": "Scheduling", "description": "...", "tags": ["calendar"] } ] }
```
Without it: name from `A2A:Card:Name`, description from the first non-empty line of the workspace's `PERSONA.md`
(else `A2A:Card:Description`), and one generic `chat` skill.
The [client](#client) keeps its own files in the same directory: `agents.json` (the agents this workspace's assistant
may call, with their keys; see [agents.json](#agentsjson)) and `pending.json` (its watches of remote tasks; see
[Watches](#watches)).
`tasks/<taskId>.json`: one task per file (the SDK's `AgentTask` plus which caller owns it and its push configs),
written atomically (temp file, then rename) and read fresh on every request.
## Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
| `GET /.well-known/agent-card.json` | none | The generic public card |
| `POST /a2a` | key | JSON-RPC 2.0: `SendMessage`, `SendStreamingMessage` (SSE), `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, push-config CRUD, `GetExtendedAgentCard` |
| `/a2a/v1/...` | key | The same methods as REST (`HTTP+JSON`) |
| `POST /a2a/push/{watchId}` | none (token in body/header) | Receives pushes for *our own* client's watches; needs `A2AHostOptions.OnPush` wired by the host, otherwise every call is 404 |
Authenticate with the header `X-API-Key: <key>` or `Authorization: Bearer <key>`. Exactly one caller must match the
key across every workspace the host knows about; no match, or the same key matching more than one caller (in two
different workspaces, or two entries of the same `callers.json`), is a 401 (and a warning "matches N callers
(owners: ...)", never the key).
Known REST quirks (the SDK's, not ours): the request body of `/a2a/v1/message:send` and `:stream` is parsed before
the key check runs, so a malformed body on an unauthenticated call still gets parsed; and the card served at
`/a2a/v1/card` is built once at startup (from `PublicUrl`, else `localhost`), so changing `PublicUrl` needs a
restart to reach that one endpoint (`/.well-known/agent-card.json` and the extended card are always built fresh).
## Sessions
One `AgentSession` per `(caller, contextId)`, opened or resumed the first time a message names that `contextId`
(a missing one gets a fresh UUID). A client-chosen `contextId` must match `[A-Za-z0-9_-]{1,100}`, otherwise the
message is refused with `InvalidParams`. Scope: `<ScopePrefix>-a2a-<caller>_<contextId>` (the Telegram host's
`ScopePrefix` is `telegram-<chatId>`); caller names never contain `_`, so two different callers or contexts can't
share a session. The session runs in the caller's workspace, so it loads that workspace's
`AGENTS.md` (or `CLAUDE.md`), `PERSONA.md` and `MEMORY.md` exactly like any other session there — same persona,
same memory, separate conversation. It does **not** get a calendar (Google Calendar connections are keyed by
session scope, and an A2A scope never connects one).
Only one task runs at a time per context: a second message to a context that is still running gets
`UnsupportedOperation` ("context busy, retry when the current task ends"). An idle session (no messages for
`SessionIdleTimeout`, default 30 minutes) is disposed and simply resumes from its JSONL transcript the next time the
context is used — nothing is lost.
`request_input` and `notify_owner` only exist inside these sessions; an owner's own chat never sees them.
## Task lifecycle
| State | Reached when | Notes |
|---|---|---|
| `submitted` → `working` | A new message with no `taskId`, or a continuation, starts a run | |
| `completed` | The run ends normally | The final assistant text becomes the `response` artifact. A reply cut off by the model's length limit (`StopReason.Length`) still counts as completed — it is not flagged as a failure |
| `input-required` | The agent called `request_input(question)` | The question is the task's status message. The caller continues it with the same `taskId` (same `contextId`, or none); a different `contextId` is `InvalidParams`. A continuation of a task that is *not* `input-required` is `UnsupportedOperation` |
| `failed` | A provider error, the hop limit, a bad file, or any unexpected exception | The message is the redacted error text; for an unexpected exception it is only "The run failed unexpectedly." (the redacted detail goes to the log) |
| `canceled` | `CancelTask`, or the request's own cancellation | |
Restart recovery is lazy, not a startup scan: a task file read as `submitted` or `working` with no live run behind
it (the process restarted mid-run) is rewritten `failed` ("server restarted") the next time anything reads it.
`input-required` tasks are not running, so they survive a restart untouched.
Each run replaces the task's `response` artifact — `GetTask` always shows the latest answer, not older turns'
text. Pushes carry the whole task (`StreamResponse { task }`) and fire once per **status change**, not per text
chunk; registered webhooks get `X-A2A-Notification-Token` when a token was set on the config.
## Configuration
`A2A` section, env `A2A__...` (`A2AHostOptions`):
| Key | Default | Meaning |
|---|---|---|
| `Enabled` | `false` | Honoured by the Telegram host: on, it mounts the endpoints; off, it opens no HTTP listener at all. The sample host ignores it and always maps the endpoints |
| `PublicUrl` | — | External base URL for the agent card's interfaces and for push webhook URLs. Unset: built from the inbound request, which is wrong behind a reverse proxy (the request scheme may be `http`) and means push webhooks can't be registered reliably. Set it in production |
| `MaxHops` | `3` | Recursion guard: `metadata["volcanic.hops"]` (request metadata, then message metadata, else `0`); over the limit, the task is created and immediately `failed` |
| `SessionIdleTimeout` | `00:30:00` | Idle `AgentSession` eviction (resumable from JSONL) |
| `MaxFileBytes` | `10485760` (10 MB) | Cap per file part, inbound and outbound |
| `AllowPrivatePushUrls` | `false` | Allow `http` and loopback/private-range webhooks and downloads — for tests and tailnets only |
| `Card:Name` / `Card:Description` | `Volcanic assistant` / a generic sentence | Public card, and the extended-card fallback when a workspace has no `card.json` |
The Telegram host maps its own `Telegram:Model` into the A2A sessions' model and each caller's owning chat's time
zone into its sessions (`docs/telegram-bot.md#a2a`); a host that embeds the library directly sets
`A2AHostOptions.Model` and `TimeZoneFor` the same way.
## Security
- **Inbound text is untrusted.** Every A2A message is wrapped in a framing header telling the model it is talking
to an external agent, not its owner, and not to follow instructions that conflict with the owner's interests
(it mentions `notify_owner` only when the host registered an owner notifier, i.e. when the tool exists).
- **The isolated session still has `bash` and file tools in the shared workspace**, and loads the same
`AGENTS.md`/`CLAUDE.md`, `PERSONA.md` and `MEMORY.md` as the owner's own session. It has no calendar and no
access to the owner's conversation history, but it *can* be talked into editing `MEMORY.md` or `PERSONA.md`,
which then affects the owner's own chat. This is an accepted exposure for v1, not a bug.
- **What `bash` in the shared workspace reaches.** The isolated session has no `schedule`, `calendar` or `trigger`
tool, but the files behind them live in the same workspace, so a caller can talk it into:
- reading `.volcanic/a2a/callers.json` — every caller's plaintext key — and editing it, i.e. minting itself (or
anyone) new keys or changing other callers' entries;
- writing `.volcanic/scheduler/jobs.json` or `.volcanic/triggers/triggers.json`, whose prompts later run in the
owner's **main** session — the one that has the calendar and the owner's history;
- with the [client](#client) on, forging `.volcanic/a2a/pending.json`: a record with an `undelivered` text runs as a
prompt in the owner's chat on the next restart or session start;
- editing `.volcanic/a2a/agents.json` to point an agent name somewhere else, so the owner's next `send` to that
name — message, attachments and the configured key — goes there (and reading the keys in it).
These are accepted v1 exposures: the owner chose plain files with no protection. Give A2A keys only to agents you
would trust with that workspace.
- **Keys are plaintext in `callers.json`**, by design — it's the owner's own file to edit. They are never written
to logs; `/a2a` (Telegram) shows only the last four characters, and only for keys of 12 characters or more.
- **Outbound HTTP is guarded.** Push webhook deliveries and file-part URL downloads both go through the same
handler: `https` only (unless `AllowPrivatePushUrls`), no redirects, no system proxy, and every connection target
is checked against loopback/private/link-local/unique-local/CGNAT (`100.64.0.0/10`)/benchmarking
(`198.18.0.0/15`)/multicast ranges **at connect time**, so a DNS answer that changes between the check and the
TCP connect (DNS rebinding) can't reach an internal host.
- **Two continuations racing the same `input-required` task**: both pass the pre-check before either's run starts;
the loser gets an error back (`InvalidAgentResponse` for a blocking `SendMessage`) and its message may remain in
the task's history even though it was never acted on.
- **Known v1 resource limits.** Each new `contextId` opens another session for its caller, so a caller can hold
many open sessions at once until `SessionIdleTimeout` evicts them; there is no per-caller cap. Session JSONL
directories (one per context), task files and `inbox/` files grow without a total cap — nothing prunes them.
- Push deliveries for one task can arrive out of order (each is an independent fire-and-forget retry loop); use the
task's own `status.timestamp`, not arrival order, to tell which update is newest.
## curl examples
Run the sample host locally (set `ASPNETCORE_URLS` in the environment — a trailing `--urls` can lose to a preset
env var in some containers):
```bash
ASPNETCORE_URLS=http://localhost:5099 dotnet run --project samples/Volcanic.AgenticCore.Sample.A2A -- --faux --workspace /tmp/a2a-ws
```
Create `/tmp/a2a-ws/.volcanic/a2a/callers.json` with a key, then:
```bash
# Public card, no auth
curl -s http://localhost:5099/.well-known/agent-card.json
# SendMessage (JSON-RPC)
curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \
-d '{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"hi"}]}}}'
# → result.task, state TASK_STATE_COMPLETED, artifact "response"
# GetTask, once you have an id from the response above
curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \
-d '{"jsonrpc":"2.0","id":"2","method":"GetTask","params":{"id":"<taskId>"}}'
```
## Client
`extensions/Volcanic.AgenticCore.Extensions.A2A` lets the owner's assistant call other A2A agents: hand a subtask to
a specialist, hold a multi-turn exchange with it (including answering its questions, after asking the user if need
be), and get the results of long tasks back later without anyone asking. It talks to any A2A 1.0 agent over the
official `A2A` client SDK, and to another Volcanic assistant's [server](#server) in particular
([example](#volcanic-to-volcanic)).
### Enabling the client
`A2AClient__Enabled=true` (section `A2AClient`; Docker: `A2A_CLIENT_ENABLED=true`). It is independent of the
server's `A2A__Enabled`: the client works with the server off, it just can't receive pushes (see [Watches](#watches)).
Off (the default), no session gets the tool, no watches are restored, and `/a2a` says so.
Only the owner's own chats get the `a2a` tool: the Telegram host adds `A2AClientExtension` to each allowed chat's
session (target = the chat id), never host-wide. So the isolated sessions the server opens for inbound A2A callers
(untrusted) can't call other agents through the tool (their `bash` can still read the keys in `agents.json`; see
[Client security](#client-security)). The startup log shows
`A2A client: on, agents in each chat's .volcanic/a2a/agents.json, watches subscribe or poll` (or `push at <url>`).
### agents.json
`<workspace>/.volcanic/a2a/agents.json` lists the agents this chat's assistant may call. It is a plain file the user
and the agent edit (the agent with its file tools, when asked to "add the travel bot"), read on **every** call: an
edit applies to the next call, no restart.
```json
[
{ "name": "travel-bot", "url": "https://travel.example/", "key": "k_3f9c2a...", "description": "Plans trips (optional override)" }
]
```
- `name`: `[a-z0-9-]{1,40}`, unique; what the model passes as `agent`.
- `url`: absolute `http(s)` 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 `Authorization: Bearer <key>` when every security scheme on the card is HTTP bearer,
otherwise in the header named by the card's API-key scheme (`X-API-Key` when it names none). With a key, and a card
that offers one, the extended card is fetched and used; if that fails the public card is kept.
- `description`: optional, replaces the card's description in the tool's listing.
- A missing file means no agents. Malformed JSON, or not an array, means no agents and the tool says the file couldn't
be read. A bad entry (bad name or url, duplicate name) is skipped and reported as `skipped: ...`; the others still work.
Keys never appear in tool results, errors or logs: exception texts are scrubbed of the agent's key, and `/a2a` shows
only the last four characters of keys of 12 characters or more.
### The a2a tool
One tool, `a2a`, with `action` and, depending on it, `agent`, `message`, `contextId`, `taskId`, `files`.
| Action | Does |
|---|---|
| `agents` | For every entry, fetches the card (`CardTimeoutSeconds`) and lists name, description, card name, `streaming`/`push` capabilities and skills (id, name, description, up to three examples), or `unreachable (<reason>)` |
| `send` | Sends `message` and/or `files` to `agent`; `contextId` continues an earlier conversation with it, `taskId` answers its question on that task |
| `status` | `GetTask` for `taskId`: same result shape as `send`; a settled task's watch ends (its outcome is in the conversation now, so it isn't delivered again) |
| `cancel` | Stops this chat's watch of `taskId`, then `CancelTask` |
**send** calls `SendMessage` with `returnImmediately: true` and `metadata["volcanic.hops"]` (see [Hops](#hops)), then
waits up to `WaitSeconds` (60) for the task to settle: it subscribes when the card advertises streaming (a stream that
fails or ends early falls back to polling) and otherwise polls `GetTask` every second. The send itself is never
streamed, because a dropped streaming request cancels the remote run on servers that tie the run to the request (ours
does). Then:
- **Settled** (`completed`, `failed`, `canceled`, `rejected`, or waiting for us: `input-required`, `auth-required`):
the result is `Agent "travel-bot": task <taskId> (context <contextId>) is completed.`, then the answer, then
an optional `Files:` list (see "Files out" below). The answer is
the `response` artifact, else every text artifact, else the status message. For `input-required`/`auth-required`
the question (status message) comes first, followed by the hint to reply with `action=send`, the agent and
`taskId=<id>`; the model asks the user first when it doesn't know the answer.
- **Still running**: a [watch](#watches) takes over and the result says so (`It is still running: the result will be
delivered to this conversation when it is ready.`). The same happens when the run is aborted (`/stop`) or the
remote stops answering while `send` waits: once the remote task exists it is never left unwatched.
- **A plain message reply** (no task): `Agent "travel-bot" replied (context <contextId>):` and its text.
Files in: `files` are workspace paths (relative to the workspace). Each is confined to the workspace lexically and
after resolving every symlink; directories and missing files are refused, and all attachments together may be at most
`MaxFileBytes`. The media type comes from the extension (`.png`, `.jpg`, `.pdf`, `.txt`, `.md`, `.json`, `.csv`,
else `application/octet-stream`).
Files out: every file part of the answer is saved to `<workspace>/inbox/a2a/<taskId>/` (for a plain message reply,
`<messageId>`), whether it sits in an artifact, in the status message or in an agent message after our last one in
the task history. Inline bytes are written; URL parts are downloaded, streamed, with the agent's key only when the URL
is on the agent's own origin (its `agents.json` URL or a card interface), and otherwise only over `https` and without
the key. Each file may be at most `MaxReceivedFileBytes` (200 MB); a failed or partial download leaves nothing behind.
Names come from the part (else the URL's last segment, else the artifact), sanitised, with `-2`, `-3`, ... when two
parts of one answer share a name; the same part found twice (say, artifact and status message) is saved once. The
result lists each file with its size and media type, or why it was not saved:
```
Files:
- inbox/a2a/eff0e973.../SKILL.md (5222 bytes, text/markdown)
- crm.tar.gz (320000000 bytes, application/gzip): not saved, over 209715200 bytes
```
The SDK speaks protocol 1.0 and would silently drop file parts in other shapes, so every A2A response (JSON and
SSE) first passes `A2AWireCompat`: the 0.3 shape `{"kind":"file","file":{"bytes"|"uri","name","mimeType"}}` (or those
fields directly on the part) becomes `{"raw"|"url","filename","mediaType"}`, and a part it still can't read is listed
as `- part: not saved, not understood (fields: ...)` and logged with its field names (never values).
Saving a task again overwrites its folder's files, so `action=status` with the `taskId` of a finished task fetches
its files again (as long as the remote agent still returns them). Watch deliveries (`[A2A result from …]`) carry the
same list.
**cancel** stops the watch first, so a `canceled` outcome isn't delivered again later. If the remote refuses (typically
because the task already finished), the result says so and carries the task's outcome. If the remote can't be reached
or doesn't answer in time, the watch is re-armed and the error ends with `The task is still watched.`
Errors are error tool results with a readable message: unknown agent (with the configured names), `rejected the key
(401)`, `Could not reach agent ...`, `Agent ... refused: ... (<code>)`, `did not answer in time`. A `send` with a
`contextId` that the remote refuses as busy (`UnsupportedOperation` mentioning "busy", as ours and VolcanicAgents do)
says that nothing was sent, names the task this chat watches in that context (`its result will arrive here by
itself`) and suggests sending again after it or without `contextId`. A refusal never touches a watch. Every single remote
call is bounded by 60 seconds; an agent that ignores `returnImmediately` and needs longer than that to answer
`SendMessage` gets `did not answer in time`, and since its task id never arrived, nothing watches that task.
### Watches
A watch follows one remote task of one chat until it settles, then delivers the result into that chat. All watches live
in one process-wide `A2AWatchRegistry` (one HTTP handler, built on `TimeProvider`). A chat watches a task at most once:
handing over a task that is already watched keeps the existing watch.
How it learns about updates, chosen when the watch starts:
1. **push**: only when the server is on with `A2A__PublicUrl` set (the push receiver lives there) and the remote card
advertises push notifications. The watch registers a push config with id = the watch id, URL
`<PublicUrl>/a2a/push/<watchId>` and a random 32-byte token. A push only means "check now": the receiver checks the
token (constant time; unknown watch or bad token → 404) and wakes the watch, which reads the task with `GetTask`; the
body is never trusted. A safety `GetTask` runs every 10 minutes. If the remote refuses the push config, the watch
subscribes or polls instead.
2. **subscribe**: the card advertises streaming. A stream that drops or ends before the task settles is reopened after
1 s, 5 s, then 15 s; after 3 failures in a row the watch polls instead. No stream is held for more than 5 minutes:
the watch then reads the task with `GetTask` and subscribes again (a pause, not a failure). Remotes send nothing
while their agent works without writing text, and such a quiet stream can die on the way (a proxy or NAT dropping
it) without either side noticing; before this slice a watch could wait on a dead stream until `WatchTimeout`.
3. **poll**: `GetTask` after 15 s, 30 s, 1 min, 2 min, then every 5 min.
Each check reads `agents.json` again: an agent removed from it ends the watch with a `(stopped)` notice; a malformed
file is treated as a passing failure (the user may be editing it) and the watch keeps going. A watch keeps its
connection (card and client) between checks and reconnects after a failure or when the agent's entry changes. After
`WatchTimeout` (1 day by default) from its start the watch gives up with a `(no result)` notice suggesting
`action=status`. A remote that answers `TaskNotFound` has forgotten the task (typically it restarted): the watch ends
at once with a `(lost)` notice suggesting to send the request again. A crash of a watch's loop (a bug, not a remote
failure) restarts it by polling after 1 s, 5 s, 15 s; the third crash in a row ends it with a `(stopped)` notice
suggesting `action=status`, so no watch stays listed while doing nothing. The shared handler turns on TCP keepalive (probes after 60 s of silence, every 15 s, 4 tries) and
HTTP/2 pings, so a dead connection fails instead of hanging. The log has one line when a watch starts or is restored
(task, agent, chat, source, watch id), when a stream slice ends on a running task (`subscribing again`), when a
result is delivered or kept, and for every failed check. On a settled task it saves files like `send` does and, for push watches, deletes the push config
(best effort).
The delivered text is a follow-up prompt:
```
[A2A result from "travel-bot", task 5c1e... (completed)]
Context: 7f20...
<the agent's answer>
Files:
- inbox/a2a/5c1e.../itinerary.pdf (48213 bytes, application/pdf)
```
`[A2A question from ...]` for `input-required`/`auth-required`, with the hint to answer with `send` and the `taskId`.
In Telegram it runs in the chat's session like a scheduled prompt: with the local-time header, as a follow-up (queued
behind a running request, never steering it), never through the chat's inbox, and the session is opened if it isn't
yet. Other hosts get the default delivery into the target's live session; with no live session the result is kept.
**pending.json.** Every watch is a record in `<workspace>/.volcanic/a2a/pending.json`:
```json
[ { "watchId": "...", "target": "111111111", "agent": "travel-bot", "taskId": "...", "contextId": "...",
"pushToken": null, "startedAt": "2026-10-08T12:00:00+00:00", "source": "poll", "undelivered": null } ]
```
A finished watch first stores its text as `undelivered`, then delivers it, then removes the record. A result that
couldn't be delivered (no live session, a chat no longer allowed, a failed session start) stays there and goes out on
the chat's next session start (a second after it) or the next restart. Delivery is **at least once**: a crash between
delivering and removing the record, or a rare race between a finishing watch and a new `send` of the same task, can
deliver a result twice.
**Restarts.** At startup, in the background (startup never waits for it), the host re-arms the watches in every
allowed chat's `pending.json` and then delivers the results kept there; a push watch re-armed while no push URL is
configured subscribes instead. On shutdown the watches stop and their records stay for the next start.
### Hops
Every outgoing message carries `metadata["volcanic.hops"]` = the session's inbound hop count + 1. The count comes from
the `a2a.inbound` event the server's per-session extension emits before each inbound prompt; a session with no inbound
A2A counts 0. A Volcanic server refuses a task over its `MaxHops` (3), which stops two assistants delegating to each
other forever. In Telegram only the owner's chats have the tool, so their messages always carry 1 and the listener is
inactive: the remote side's `MaxHops` (or whatever guard a non-Volcanic remote has) is the only thing that bounds a
chain of agents delegating onwards. The listener matters for hosts that add the extension to
sessions that also receive A2A.
### Client configuration
`A2AClient` section, env `A2AClient__...` (`A2AClientSettings`):
| Key | Default | Meaning |
|---|---|---|
| `Enabled` | `false` | Adds the `a2a` tool to the owner's chats and restores watches. Independent of `A2A:Enabled` |
| `WaitSeconds` | `60` | How long `send` waits for a result before a watch takes over. `0` hands over at once |
| `WatchTimeout` | `1.00:00:00` | When a watch gives up, counted from its start |
| `CardTimeoutSeconds` | `10` | Card (and extended card) fetch timeout per agent |
| `MaxFileBytes` | `10485760` (10 MB) | Cap on the files attached to one `send` together |
| `MaxReceivedFileBytes` | `209715200` (200 MB) | Cap per file the remote agent sends back (inline or downloaded) |
The push base URL isn't a client setting: the Telegram host sets it from `A2A:PublicUrl` while the server is on and
ignores any configured value otherwise.
### Client security
- **`agents.json` keys are plaintext in the workspace**, like `callers.json`: anything that reads the workspace (the
agent's `bash`, an inbound A2A caller's isolated session, a backup) can read them.
- **An inbound A2A caller's isolated session can tamper with the client's files** through `bash` in the shared
workspace: forge `pending.json` (an `undelivered` text runs as a prompt in the owner's chat on restart or session
start) or redirect an agent name in `agents.json` (the owner's next `send`, its attachments and the key go to the new
URL). Accepted v1 exposures, like `jobs.json` (see [Security](#security)).
- **The agent chooses where data goes.** It can edit `agents.json` like any workspace file, so it can add an agent with
any URL (including internal addresses; the client's HTTP has no private-range guard) and send it workspace files.
That is the same trust it already has through `bash` (`curl`), not a new boundary; treat the `a2a` tool accordingly.
- **Remote answers are untrusted text.** Results and questions arrive as prompts in the owner's chat; the model sees
them labelled `[A2A result from "<agent>" ...]`, but a remote agent can still try to talk it into things.
- **Special files aren't detected**: attaching a FIFO blocks the tool until the run is aborted.
- Every single remote call is bounded by 60 seconds (see [The a2a tool](#the-a2a-tool)).
### Volcanic to Volcanic
Two assistants, A (the remote) and B (the caller). On A, the server is on and A's workspace has a caller entry:
```bash
# A: the sample host over one folder (or a Telegram bot with A2A__Enabled=true)
mkdir -p /tmp/a-ws/.volcanic/a2a
echo '[ { "name": "assistant-b", "key": "k_b_0123456789abcdef" } ]' > /tmp/a-ws/.volcanic/a2a/callers.json
ASPNETCORE_URLS=http://localhost:5099 dotnet run --project samples/Volcanic.AgenticCore.Sample.A2A -- --faux --workspace /tmp/a-ws
```
On B, the client is on and the chat's workspace lists A under the same key:
```bash
mkdir -p ~/.volcanic/telegram-workspaces/111111111/.volcanic/a2a
echo '[ { "name": "assistant-a", "url": "http://localhost:5099/", "key": "k_b_0123456789abcdef" } ]' \
> ~/.volcanic/telegram-workspaces/111111111/.volcanic/a2a/agents.json
A2AClient__Enabled=true TELEGRAM_BOT_TOKEN=... TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
Ask B "ask assistant-a what it can do": B's model calls `a2a` with `action=agents`, then `send`. On A the message
runs in an isolated session for caller `assistant-b`; its answer comes back as B's tool result, or later as a
follow-up if it takes longer than `WaitSeconds`. `/a2a` on B lists `assistant-a` and any running watches; on A (in
Telegram) it lists caller `assistant-b` and its tasks. With both bots public, give B's server a `PublicUrl` too, and
B's watches get pushes from A instead of polling.