docs(a2a): A2A client guide, configuration and Telegram notes
This commit is contained in:
@@ -29,6 +29,12 @@ GOOGLE_CLIENT_SECRET=
|
||||
# Default time zone for chats that never ran /timezone (IANA name, e.g. Europe/Berlin; empty = TZ).
|
||||
DEFAULT_TIMEZONE=
|
||||
|
||||
# A2A (docs/a2a.md). Server: other agents call this bot on port 8080 (needs a domain in Coolify); the public URL is
|
||||
# also where remote agents push updates for our own watches. Client: the a2a tool in each chat. Both default to false.
|
||||
A2A_ENABLED=
|
||||
A2A_PUBLIC_URL=
|
||||
A2A_CLIENT_ENABLED=
|
||||
|
||||
# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN).
|
||||
PROCESS_ENV_ALLOW=
|
||||
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -91,7 +91,10 @@ preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
|
||||
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens encrypted in the chat
|
||||
workspace under `.volcanic/google-calendar/`, watchers get a short-lived `GOOGLE_ACCESS_TOKEN`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
|
||||
`JobScheduler` on `TimeProvider`, `jobs.json` per chat in the workspace under `.volcanic/scheduler/`, the per-chat `schedule` tool, watcher
|
||||
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
|
||||
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`); and the `A2A` client extension (the per-session
|
||||
`a2a` tool over `.volcanic/a2a/agents.json` read on every call; a process-wide `A2AWatchRegistry` follows long remote
|
||||
tasks by push, subscribe or poll, persists them in `.volcanic/a2a/pending.json` and delivers results through
|
||||
`IA2AResultDelivery` as follow-ups, at least once; `docs/a2a.md#client`). The Claude Code provider is the most intricate piece
|
||||
(`docs/claude-code-provider.md`):
|
||||
|
||||
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
|
||||
@@ -137,7 +140,12 @@ router then handles the admin's updates as the target's, and the target chat's r
|
||||
`ASPNETCORE_HTTP_PORTS`; under `dotnet run`, `ASPNETCORE_URLS` or Kestrel's default):
|
||||
`TelegramA2ASetup` wires a `WorkspaceCallerDirectory` over every allowed chat's workspace and a
|
||||
`TelegramOwnerNotifier` (`notify_owner` → the owner's chat, or the admin's during `/impersonate`); off, `NoHttpServer`
|
||||
replaces Kestrel so no port opens. `/a2a` lists a chat's callers and recent tasks (`docs/a2a.md`,
|
||||
replaces Kestrel so no port opens. The A2A client is separate (on only with `A2AClient__Enabled=true`):
|
||||
`TelegramA2AClientSetup` (registered before polling and before `TelegramA2ASetup`) adds `A2AClientExtension` to chat
|
||||
sessions only (`ChatRegistry`, never A2A server sessions), routes the server's `OnPush` into the registry and uses the
|
||||
server's `PublicUrl` as push base only while the server is on; `TelegramA2AResultDelivery` runs results as follow-ups
|
||||
through `ChatFollowUps` (shared with the scheduler) and `A2AWatchHostedService` restores watches in the background.
|
||||
`/a2a` lists a chat's callers and recent tasks, and with the client its remote agents and watches (`docs/a2a.md`,
|
||||
`docs/telegram-bot.md#a2a`).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
|
||||
- A2A__Enabled=${A2A_ENABLED:-false} # A2A server (a2a-server spec); needs a domain in Coolify
|
||||
- A2A__PublicUrl=${A2A_PUBLIC_URL:-} # e.g. https://bot.example.com; required for push webhooks
|
||||
- A2AClient__Enabled=${A2A_CLIENT_ENABLED:-false} # A2A client: the a2a tool and watches (a2a-client spec)
|
||||
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
|
||||
# image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself).
|
||||
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true}
|
||||
|
||||
222
docs/a2a.md
222
docs/a2a.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,13 @@ public sealed class DockerComposeTests
|
||||
Assert.Equal(TelegramOptions.DefaultModel, match.Groups["model"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_passes_the_A2A_client_switch_off_by_default()
|
||||
{
|
||||
var compose = File.ReadAllText(RepoFile("docker-compose.yml"));
|
||||
Assert.Contains("- A2AClient__Enabled=${A2A_CLIENT_ENABLED:-false}", compose, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
|
||||
private static string RepoFile(string name)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user