diff --git a/.env.example b/.env.example index 50529f2..4e22658 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CLAUDE.md b/CLAUDE.md index 672a7f1..d246c61 100644 --- a/CLAUDE.md +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index d5f4583..fa1de80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/a2a.md b/docs/a2a.md index 9b12bad..022ea26 100644 --- a/docs/a2a.md +++ b/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":""}}' ``` + +## 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 `). + +### agents.json + +`/.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 `/.well-known/agent-card.json`. A URL ending in + `.json` is taken as the card URL itself. +- `key`: optional. Sent as `Authorization: Bearer ` 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 ()` | +| `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 (context ) 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=`; 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 ):` 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 `/inbox/a2a//` +(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: ... ()`, `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 + `/a2a/push/` 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... + +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 `/.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 "" ...]`, 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. diff --git a/docs/docker.md b/docs/docker.md index a10e78c..7d67177 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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/`): `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/`): `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-/`), `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/push/`; 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/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 diff --git a/docs/extensions.md b/docs/extensions.md index 34345cb..e0ca33c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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. diff --git a/docs/superpowers/specs/2026-10-07-a2a-client-design.md b/docs/superpowers/specs/2026-10-07-a2a-client-design.md index 9733d5c..cedbb2e 100644 --- a/docs/superpowers/specs/2026-10-07-a2a-client-design.md +++ b/docs/superpowers/specs/2026-10-07-a2a-client-design.md @@ -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). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 050258e..d41c08e 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -67,6 +67,8 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section | `Mcp:Servers:` | — | 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-/`; 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 diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs index 0a99739..6ea267a 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs @@ -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); + } + /// A file in the repository root, found by walking up from the test output to VolcanicAgenticCore.slnx. private static string RepoFile(string name) {