docs(a2a): A2A client guide, configuration and Telegram notes

This commit is contained in:
EugeneTes
2026-10-08 14:34:02 +00:00
parent 8eea09baba
commit 5e50d902af
9 changed files with 318 additions and 8 deletions

View File

@@ -1,11 +1,20 @@
# A2A server
# 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. This is the server side only; calling *other* agents
from one of our own sessions is a separate client extension (`2026-10-07-a2a-client-design.md`), not covered here.
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.
@@ -189,3 +198,210 @@ curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a
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 use the keys in `agents.json` to call other agents. 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` |
| `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
optional lines `Files: ...` (saved files), `Files skipped (over N bytes): ...` and `Links: ...`. 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: file parts with inline bytes in a settled task's artifacts are saved to `<workspace>/inbox/a2a/<agent>/`
(name from the part, sanitised; `-2`, `-3`, ... on a clash) and listed under `Files:`; a part over `MaxFileBytes` is
skipped and listed. URL parts are listed under `Links:` and not downloaded.
**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`. 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. A stream is also reopened after a day.
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`. 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/travel-bot/itinerary.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; 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, and on each received file part |
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.
- **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.

View File

@@ -82,6 +82,7 @@ The compose file maps them into the container.
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
| `A2A_ENABLED` | `false` | Serves other agents over A2A on port 8080 ([a2a.md](a2a.md), [telegram-bot.md#a2a](telegram-bot.md#a2a)); passed as `A2A__Enabled`. Needs a domain in Coolify (see below) | No |
| `A2A_PUBLIC_URL` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`; passed as `A2A__PublicUrl`. Required for push notifications to work | No |
| `A2A_CLIENT_ENABLED` | `false` | Lets each chat's assistant call other A2A agents (the `a2a` tool, agents in the chat's `.volcanic/a2a/agents.json`; [a2a.md](a2a.md#client)); passed as `A2AClient__Enabled`. Independent of `A2A_ENABLED`; needs no port or domain, only outbound HTTPS | No |
| `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes |
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes |
| `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes |
@@ -142,7 +143,7 @@ The compose file binds them to directories under `/media/disk1/containers/volcan
| Volume | Path | Contents |
|---|---|---|
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks (`.volcanic/a2a/`, [a2a.md](a2a.md#files)) |
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) |
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`); a `scheduler/jobs.json` from before jobs were per chat is moved into the workspaces on start |
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) |
@@ -176,6 +177,11 @@ back to the inbound request's own host, which is wrong behind a reverse proxy. T
`A2A: listening on ...`; if port 8080 is somehow already taken, the bot logs a critical line and exits with code 3
instead of starting half-working. Leaving `A2A_ENABLED` unset or `false` opens no port at all, same as before.
`A2A_CLIENT_ENABLED=true` turns on the other direction: the bot calls other agents. That needs no domain. With the
server on as well and `A2A_PUBLIC_URL` set, remote agents can push task updates to
`<A2A_PUBLIC_URL>/a2a/push/<watchId>`; without it the bot subscribes or polls. The log shows
`A2A client: on, ...` and, shortly after start, `A2A client: N watch(es) restored`.
## First run and Claude login
1. The owner opens the bot, sends `/start`, then `/claude-code-login`.
@@ -238,6 +244,7 @@ given under [First run and Claude login](#first-run-and-claude-login)).
| `Telegram reports another poller or a webhook for this bot (409)` | Another instance polls with the same token (an old container, a local `dotnet run`), or the bot has a webhook. Stop the other instance; remove a webhook with `https://api.telegram.org/bot<token>/deleteWebhook` |
| `Telegram rejected the bot token (401 Unauthorized)`, restart loop | Wrong or revoked `TELEGRAM_BOT_TOKEN` |
| Browser tools missing or failing | The image was built with `INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED` is off, or `PLAYWRIGHT_EXTRA_ARGS` lost `--no-sandbox`. Chromium crashes also come from a small `/dev/shm`; keep `shm_size: 1gb` |
| Build fails at `dotnet publish` with `NETSDK1004: Assets file '.../obj/project.assets.json' not found` | The `Dockerfile` restores packages in a cached layer that copies an explicit list of project files; a project the host references is missing from it. Add its `.csproj` `COPY` line next to the others (the A2A server and client projects were added there after exactly this failure) |
| The provider refuses to run because of an API key | One of the `ANTHROPIC_*` / `CLAUDE_CODE_*` variables above is set, possibly as a shared Coolify variable. Remove it |
## Security

View File

@@ -208,3 +208,20 @@ reference) so the host's copy is used at runtime.
Activation or load failures are recorded (`AgentSession.ExtensionErrors`) and logged; other extensions still
load.
## Feature extensions in this repository
Besides the providers ([providers](providers.md), [claude-code-provider.md](claude-code-provider.md)), `extensions/`
holds feature extensions built on this API only: `Mcp` ([mcp.md](mcp.md)), `GoogleCalendar`
([google-calendar.md](google-calendar.md)), `Scheduler` ([telegram-bot.md](telegram-bot.md#scheduling)) and `A2A`.
`Volcanic.AgenticCore.Extensions.A2A` is the A2A client ([a2a.md](a2a.md#client)): `A2AClientExtension(settings,
target, registry)` registers the `a2a` tool (`agents`, `send`, `status`, `cancel` over the session's
`.volcanic/a2a/agents.json`), keeps the inbound hop count from the `a2a.inbound` event, and on `SessionStartEvent`
attaches the session for live delivery and flushes results kept for its target. It is meant to be added per session
by the host (`AgentSessionOptions.ExtraExtensions`, as the Telegram host does for each chat), not host-wide. Long
remote tasks go to the process-wide `A2AWatchRegistry`, which follows them by push, subscribe or poll, persists them
in `.volcanic/a2a/pending.json` and hands results to an `IA2AResultDelivery`: by default the target's live session
(`SendUserMessage` as a follow-up), or the host's own (`registry.UseDelivery(...)`, then `RestoreAsync` at startup and
`StopAsync` at shutdown). A host with an A2A server routes `A2AHostOptions.OnPush` to `registry.OnPushAsync` and sets
`A2AClientSettings.PushBaseUrl` to the server's public URL.

View File

@@ -1,7 +1,7 @@
# A2A client extension (design)
Date: 2026-10-07
Status: draft
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).
@@ -197,3 +197,38 @@ Telegram host: `A2aCommand_ListsAgentsAndWatches`, `ResultDelivery_GoesToChatWit
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).
- **Docker**: the `Dockerfile` restore layer gained the A2A server and client project files (the server's was missing,
which already broke the image build).

View File

@@ -67,6 +67,8 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
| `A2A__Enabled` / `A2A:Enabled` | `false` | Serve other agents over A2A (see [A2A](#a2a) and [a2a.md](a2a.md)). Opens Kestrel: port 8080 in the container, `ASPNETCORE_URLS` or Kestrel's default (`http://localhost:5000`) under `dotnet run` |
| `A2A__PublicUrl` / `A2A:PublicUrl` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`. Required for push to work; see [docker.md](docker.md) |
| `A2AClient__Enabled` / `A2AClient:Enabled` | `false` | The A2A client: the `a2a` tool in every allowed chat, for calling other agents listed in the chat's `.volcanic/a2a/agents.json`, and watches for their long tasks (see [A2A](#a2a) and [a2a.md](a2a.md#client)). Independent of `A2A__Enabled` |
| `A2AClient__*` | see [a2a.md](a2a.md#client-configuration) | `WaitSeconds`, `WatchTimeout`, `CardTimeoutSeconds`, `MaxFileBytes` |
| `A2A__*` | see [a2a.md](a2a.md#configuration) | `MaxHops`, `SessionIdleTimeout`, `MaxFileBytes`, `AllowPrivatePushUrls`, `Card:Name`, `Card:Description` |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
@@ -121,7 +123,7 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
| `/calendar_status` | The connected account and time zone, or the pending code |
| `/calendar_disconnect` | Revoke and delete this chat's calendar token, or cancel a pending connect |
| `/a2a` | This chat's A2A callers and recent tasks (see [A2A](#a2a)) |
| `/a2a` | This chat's A2A callers and recent tasks, remote agents and watches (see [A2A](#a2a)) |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work. `/mcp` lists the MCP servers (see [mcp.md](mcp.md#mcp)).
@@ -310,6 +312,17 @@ output to that chat.
`/a2a` shows this chat's callers (name, mode, and the key's last four characters — only once a key is at least 12
characters long) and its ten most recent tasks with their caller, state and age.
With `A2AClient__Enabled=true` (independent of the server) each allowed chat's assistant can also call other agents:
its `a2a` tool reads the chat's `.volcanic/a2a/agents.json` (name, url, key) on every call. A remote task that takes
longer than `A2AClient:WaitSeconds` (60 s) is watched, and its result arrives in the chat later as a follow-up prompt
with the local-time header, like a scheduled prompt (never merged into your messages). Watches survive restarts
(`.volcanic/a2a/pending.json`) and get pushes from the remote only when the server is on with `A2A__PublicUrl`;
otherwise they subscribe or poll. A2A callers' isolated sessions never get the tool. Details in
[a2a.md](a2a.md#client).
`/a2a` then also lists the chat's remote agents (url, key masked the same way, entries skipped as invalid) and its
watches (agent, task, push/subscribe/poll, age). With both halves off it replies `A2A is off` and names both switches.
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids