From d542fcfe42f7e3569b9b1238e9de52a2a083e51d Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 7 Oct 2026 14:20:19 +0000 Subject: [PATCH] docs(a2a): A2A server guide, architecture, Telegram and Docker notes Add docs/a2a.md (concepts, file formats, endpoints, sessions, task lifecycle, configuration, security notes, curl examples) describing what src/Volcanic.AgenticCore.A2A.Server actually does. Add an A2A project paragraph to architecture.md, an "A2A" section to telegram-bot.md (/a2a, notify_owner, isolated sessions, port 8080), A2A_ENABLED/A2A_PUBLIC_URL and the aspnet image to docker.md, and project-map/test-command updates to CLAUDE.md. Mark the A2A server design spec implemented and record the deviations found while building it (A2AHostOptions, synchronous IA2ACallerDirectory.Find, generic WorkspaceCallerDirectory, per-status push payload, split file-part error handling, the outbound guarded handler, the per-caller server/router architecture, the restart grace period, the owner= log field, the aspnet image, and the actual push retry schedule). Note the aspnet image in the docker-deployment spec too, without rewriting its historical decisions. --- CLAUDE.md | 12 +- docs/a2a.md | 174 ++++++++++++++++++ docs/architecture.md | 12 +- docs/docker.md | 24 ++- .../2026-09-26-docker-deployment-design.md | 3 + .../specs/2026-10-07-a2a-server-design.md | 50 ++++- docs/telegram-bot.md | 24 +++ 7 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 docs/a2a.md diff --git a/CLAUDE.md b/CLAUDE.md index 2e186d6..b3bad8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,9 @@ deliberately match them. ```bash dotnet build # whole solution (VolcanicAgenticCore.slnx) -dotnet test # all three test projects; no test calls a paid API +dotnet test # all four test projects; no test calls a paid API dotnet test tests/Volcanic.AgenticCore.Tests # one project +dotnet test tests/Volcanic.AgenticCore.A2A.Server.Tests # A2A server only dotnet test --filter "FullyQualifiedName~AgentLoopTests" # one class dotnet test --filter "FullyQualifiedName~AgentLoopTests.SomeTest" # one test @@ -53,6 +54,10 @@ Read `docs/architecture.md` first. Key points: `SystemPromptBuilder`, `Compactor`, `ProcessSpawnPolicy`). Slices talk only through those entry types and the Abstractions. `Hosting/` has `AddAgenticCore(...)` and `AgenticCoreOptions`; `Testing/FauxProvider.cs` is the scripted provider that tests and `--faux` use. +- **`src/Volcanic.AgenticCore.A2A.Server`** exposes assistants over A2A (`docs/a2a.md`) on the official SDK, built + directly on the core (`IAgentSessionFactory`), not the extension API: one `VolcanicA2AServer` per authenticated + caller (`A2AServerCache`), routed by `A2ARequestRouter`, one `AgentSession` per `(caller, contextId)` + (`A2ASessionPool`). `samples/Volcanic.AgenticCore.Sample.A2A` mounts it over one workspace for local use. - **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are **instantiated per session** and each session has its own `ModelRegistry`, so provider registrations and extension fields are per conversation. Process-wide state has to be explicit (e.g. `ClaudeLoginCoordinator.Shared`). @@ -128,6 +133,11 @@ by `TriggerDispatcher` and delivered as follow-up prompts (`TelegramTriggerDeliv `ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate ` (`Chats/Impersonation`, in memory, `0` stops): the router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat (`ChatRenderer.DeliveryChatId`); the target's own updates get `Impersonation.BlockedNotice` instead. Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`. +`A2A/` (on only with `A2A__Enabled=true`) mounts `Volcanic.AgenticCore.A2A.Server` on Kestrel, port 8080: +`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`, +`docs/telegram-bot.md#a2a`). ## Deployment diff --git a/docs/a2a.md b/docs/a2a.md new file mode 100644 index 0000000..356abab --- /dev/null +++ b/docs/a2a.md @@ -0,0 +1,174 @@ +# A2A 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. + +The Telegram host (`docs/telegram-bot.md#a2a`) mounts it with one workspace per allowed chat. +`samples/Volcanic.AgenticCore.Sample.A2A` mounts it over a single folder, for trying it out or scripting against it. + +## Scenarios + +- **Delegation.** A caller sends a task ("book three slots next week"); the assistant works it like an ordinary + conversation turn and answers with a `response` artifact. +- **Multi-turn.** The assistant calls `request_input` to ask the caller something; the task becomes + `input-required` and the caller's next message, with the same `taskId`, continues it. +- **Files.** A caller can attach bytes or an `https` URL; they land in the workspace's `inbox/`. The assistant's + `send` tool turns into task artifacts in an A2A session, so a file it sends comes back to the caller the same way. +- **Notify the owner.** `notify_owner(text)` is the isolated session's only channel back to its owner — in Telegram, + a message into the owner's chat. +- **Push.** A caller that registers a webhook (`CreateTaskPushNotificationConfig`) gets POSTed on every status + change instead of polling `GetTask`. + +## Files + +Everything lives under `/.volcanic/a2a/`. A missing file means "none"; a malformed one means "none" plus +a warning log line naming the file — never the keys. + +`callers.json`, read fresh on every request (no cache, so an edit applies to the very next call, no restart needed): + +```json +[ + { "name": "travel-bot", "key": "k_3f9c2a...", "mode": "isolated", "note": "Alice's trip planner" } +] +``` + +- `name`: `[a-z0-9-]{1,40}`, unique in the file; it ends up in the session scope, in logs and in the framing header + the assistant sees. +- `key`: any non-empty string, plaintext. Make one with `openssl rand -hex 32`. +- `mode`: `isolated` is the only one implemented; omit it or set it explicitly. An unknown mode skips the entry with + a warning. +- Unknown entries (bad name, empty key, duplicate name) are skipped with a warning; everything else in the file is + still read. + +`card.json` (optional) overrides the caller's extended agent card: + +```json +{ "name": "Eugene's assistant", "description": "Schedules meetings and answers for Eugene.", + "skills": [ { "id": "scheduling", "name": "Scheduling", "description": "...", "tags": ["calendar"] } ] } +``` + +Without it: name from `A2A:Card:Name`, description from the first non-empty line of the workspace's `PERSONA.md` +(else `A2A:Card:Description`), and one generic `chat` skill. + +`tasks/.json`: one task per file (the SDK's `AgentTask` plus which caller owns it and its push configs), +written atomically (temp file, then rename) and read fresh on every request. + +## Endpoints + +| Endpoint | Auth | Purpose | +|---|---|---| +| `GET /.well-known/agent-card.json` | none | The generic public card | +| `POST /a2a` | key | JSON-RPC 2.0: `SendMessage`, `SendStreamingMessage` (SSE), `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, push-config CRUD, `GetExtendedAgentCard` | +| `/a2a/v1/...` | key | The same methods as REST (`HTTP+JSON`) | +| `POST /a2a/push/{watchId}` | none (token in body/header) | Receives pushes for *our own* client's watches; needs `A2AHostOptions.OnPush` wired by the host, otherwise every call is 404 | + +Authenticate with the header `X-API-Key: ` or `Authorization: Bearer `. Exactly one caller must match the +key across every workspace the host knows about; no match, or the same key matching callers in two different +workspaces, is a 401 (and a warning naming the owners, never the key). + +Known REST quirks (the SDK's, not ours): the request body of `/a2a/v1/message:send` and `:stream` is parsed before +the key check runs, so a malformed body on an unauthenticated call still gets parsed; and the card served at +`/a2a/v1/card` is built once at startup (from `PublicUrl`, else `localhost`), so changing `PublicUrl` needs a +restart to reach that one endpoint (`/.well-known/agent-card.json` and the extended card are always built fresh). + +## Sessions + +One `AgentSession` per `(caller, contextId)`, opened or resumed the first time a message names that `contextId` +(a missing one gets a fresh UUID). Scope: `-a2a--` (the Telegram host's +`ScopePrefix` is `telegram-`). The session runs in the caller's workspace, so it loads that workspace's +`AGENTS.md` (or `CLAUDE.md`), `PERSONA.md` and `MEMORY.md` exactly like any other session there — same persona, +same memory, separate conversation. It does **not** get a calendar (Google Calendar connections are keyed by +session scope, and an A2A scope never connects one). + +Only one task runs at a time per context: a second message to a context that is still running gets +`UnsupportedOperation` ("context busy, retry when the current task ends"). An idle session (no messages for +`SessionIdleTimeout`, default 30 minutes) is disposed and simply resumes from its JSONL transcript the next time the +context is used — nothing is lost. + +`request_input` and `notify_owner` only exist inside these sessions; an owner's own chat never sees them. + +## Task lifecycle + +| State | Reached when | Notes | +|---|---|---| +| `submitted` → `working` | A new message with no `taskId`, or a continuation, starts a run | | +| `completed` | The run ends normally | The final assistant text becomes the `response` artifact. A reply cut off by the model's length limit (`StopReason.Length`) still counts as completed — it is not flagged as a failure | +| `input-required` | The agent called `request_input(question)` | The question is the task's status message. The caller continues it with the same `taskId` (same `contextId`, or none); a different `contextId` is `InvalidParams`. A continuation of a task that is *not* `input-required` is `UnsupportedOperation` | +| `failed` | A provider error, the hop limit, a bad file, or any unexpected exception | The message is the redacted error text | +| `canceled` | `CancelTask`, or the request's own cancellation | | + +Restart recovery is lazy, not a startup scan: a task file read as `submitted` or `working` with no live run behind +it (the process restarted mid-run) is rewritten `failed` ("server restarted") the next time anything reads it. +`input-required` tasks are not running, so they survive a restart untouched. + +Each run replaces the task's `response` artifact — `GetTask` always shows the latest answer, not older turns' +text. Pushes carry the whole task (`StreamResponse { task }`) and fire once per **status change**, not per text +chunk; registered webhooks get `X-A2A-Notification-Token` when a token was set on the config. + +## Configuration + +`A2A` section, env `A2A__...` (`A2AHostOptions`): + +| Key | Default | Meaning | +|---|---|---| +| `Enabled` | `false` | Mount the endpoints. Off: the host opens no HTTP listener at all | +| `PublicUrl` | — | External base URL for the agent card's interfaces and for push webhook URLs. Unset: built from the inbound request, which is wrong behind a reverse proxy (the request scheme may be `http`) and means push webhooks can't be registered reliably. Set it in production | +| `MaxHops` | `3` | Recursion guard: `metadata["volcanic.hops"]` (request metadata, then message metadata, else `0`); over the limit, the task is created and immediately `failed` | +| `SessionIdleTimeout` | `00:30:00` | Idle `AgentSession` eviction (resumable from JSONL) | +| `MaxFileBytes` | `10485760` (10 MB) | Cap per file part, inbound and outbound | +| `AllowPrivatePushUrls` | `false` | Allow `http` and loopback/private-range webhooks and downloads — for tests and tailnets only | +| `Card:Name` / `Card:Description` | `Volcanic assistant` / a generic sentence | Public card, and the extended-card fallback when a workspace has no `card.json` | + +The Telegram host maps its own `Telegram:Model` into the A2A sessions' model and each caller's owning chat's time +zone into its sessions (`docs/telegram-bot.md#a2a`); a host that embeds the library directly sets +`A2AHostOptions.Model` and `TimeZoneFor` the same way. + +## Security + +- **Inbound text is untrusted.** Every A2A message is wrapped in a framing header telling the model it is talking + to an external agent, not its owner, and not to follow instructions that conflict with the owner's interests. +- **The isolated session still has `bash` and file tools in the shared workspace**, and loads the same + `AGENTS.md`/`CLAUDE.md`, `PERSONA.md` and `MEMORY.md` as the owner's own session. It has no calendar and no + access to the owner's conversation history, but it *can* be talked into editing `MEMORY.md` or `PERSONA.md`, + which then affects the owner's own chat. This is an accepted exposure for v1, not a bug. +- **Keys are plaintext in `callers.json`**, by design — it's the owner's own file to edit. They are never written + to logs; `/a2a` (Telegram) shows only the last four characters, and only for keys of 12 characters or more. +- **Outbound HTTP is guarded.** Push webhook deliveries and file-part URL downloads both go through the same + handler: `https` only (unless `AllowPrivatePushUrls`), no redirects, no system proxy, and every connection target + is checked against loopback/private/link-local/unique-local/CGNAT (`100.64.0.0/10`)/benchmarking + (`198.18.0.0/15`)/multicast ranges **at connect time**, so a DNS answer that changes between the check and the + TCP connect (DNS rebinding) can't reach an internal host. +- **Two continuations racing the same `input-required` task**: both pass the pre-check before either's run starts; + the loser gets an error back (`InvalidAgentResponse` for a blocking `SendMessage`) and its message may remain in + the task's history even though it was never acted on. +- Push deliveries for one task can arrive out of order (each is an independent fire-and-forget retry loop); use the + task's own `status.timestamp`, not arrival order, to tell which update is newest. + +## curl examples + +Run the sample host locally (set `ASPNETCORE_URLS` in the environment — a trailing `--urls` can lose to a preset +env var in some containers): + +```bash +ASPNETCORE_URLS=http://localhost:5099 dotnet run --project samples/Volcanic.AgenticCore.Sample.A2A -- --faux --workspace /tmp/a2a-ws +``` + +Create `/tmp/a2a-ws/.volcanic/a2a/callers.json` with a key, then: + +```bash +# Public card, no auth +curl -s http://localhost:5099/.well-known/agent-card.json + +# SendMessage (JSON-RPC) +curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \ + -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"hi"}]}}}' +# → result.task, state TASK_STATE_COMPLETED, artifact "response" + +# GetTask, once you have an id from the response above +curl -s -H 'X-API-Key: k' -H 'Content-Type: application/json' localhost:5099/a2a \ + -d '{"jsonrpc":"2.0","id":"2","method":"GetTask","params":{"id":""}}' +``` diff --git a/docs/architecture.md b/docs/architecture.md index d5a1384..4d3dd43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,9 @@ ``` src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers) src/Volcanic.AgenticCore runtime, organised as vertical slices +src/Volcanic.AgenticCore.A2A.Server A2A protocol server on the official SDK, built on the core extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only -samples/* reference host +samples/* reference hosts tests/* xunit ``` @@ -25,6 +26,15 @@ directories. on a `TimeProvider` timer, jobs in each target's workspace, `.volcanic/scheduler/jobs.json`), a per-session `schedule` tool bound to a host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)). +`Volcanic.AgenticCore.A2A.Server` exposes assistants over [A2A](a2a.md) on the official `A2A`/`A2A.AspNetCore` SDK. +It depends on the core (`IAgentSessionFactory`, `AgentSessionOptions`) directly, not through the extension API, +since it needs ASP.NET Core hosting the extension contract doesn't carry. Tenancy is structural: the SDK's +`A2AServer` owns background runs and cancellation state per instance, so `A2AServerCache` keeps one +`VolcanicA2AServer` subclass per authenticated caller, each bound to that caller's own `WorkspaceTaskStore`; a +single `A2ARequestRouter` (the SDK's `IA2ARequestHandler`) reads the caller an endpoint filter already resolved +onto `HttpContext.Items` and forwards every call to that caller's server. `A2ASessionPool` maps one `AgentSession` per +`(caller, contextId)`, one task running at a time, with idle eviction. See [a2a.md](a2a.md). + ## Vertical slices The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features` diff --git a/docs/docker.md b/docs/docker.md index 38486e0..a10e78c 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -15,7 +15,9 @@ docker compose logs -f bot ## What's in the image -- The .NET 10 runtime and the published host in `/app`. No .NET SDK, so the agent can't build .NET projects. +- The ASP.NET Core 10 runtime (`mcr.microsoft.com/dotnet/aspnet:10.0-noble`) and the published host in `/app`. No + .NET SDK, so the agent can't build .NET projects. The image needs the ASP.NET Core runtime, not just the plain + .NET runtime, because the [A2A server](a2a.md) hosts Kestrel when it's turned on. - Tools for the agent's `bash`: `bash`, `git`, `ripgrep`, `python3` (with `python-is-python3` and `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client`. - Node.js 24 from NodeSource, with `npm` and `npx`. @@ -78,6 +80,8 @@ The compose file maps them into the container. | `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes | | `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes | | `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 | | `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 | @@ -138,7 +142,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`) | +| `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)) | | `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) | @@ -154,14 +158,24 @@ stored: everything shows as root with mode 777, `chown` is a silent no-op, and ` 1. Create a resource from this repository with the *Docker Compose* build pack. 2. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) and any optional variables from - [Configuration](#configuration). Leave the domain empty. + [Configuration](#configuration). Leave the domain empty, unless you're turning A2A on (next). 3. Deploy. The log shows lines such as `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5` and `Allowed users: . Workspaces under /data/workspaces`, and the owner gets `🟢 Online.`. -There is no healthcheck, because the bot serves no HTTP. Lost connectivity is retried with backoff inside the -process. A rejected token (`401`) exits with code 1 and invalid configuration exits with code 2; +There is no healthcheck, because by default the bot serves no HTTP. Lost connectivity is retried with backoff +inside the process. A rejected token (`401`) exits with code 1 and invalid configuration exits with code 2; `restart: unless-stopped` turns either into a restart loop you can see in the logs. +### A2A + +With `A2A_ENABLED=true` the bot also listens on port 8080 for [A2A](a2a.md) requests. The compose file already +`expose`s it to the Coolify network; give the resource a **domain** so Coolify's proxy routes it there (without a +domain, nothing outside the container can reach the port). Set `A2A_PUBLIC_URL` to that domain +(`https://bot.example.com`) so the agent card and push webhooks advertise the right address — without it they fall +back to the inbound request's own host, which is wrong behind a reverse proxy. The log gains +`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. + ## First run and Claude login 1. The owner opens the bot, sends `/start`, then `/claude-code-login`. diff --git a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md index 9a7ff43..47baa5a 100644 --- a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md @@ -4,6 +4,9 @@ Date: 2026-09-26. Status: implemented (plan: docs/superpowers/plans/2026-09-26-d Depends on: `2026-09-26-process-environment-hook-design.md` (the secrets below stay out of agent-spawned processes), `2026-09-26-scheduler-design.md` §7 (the default time zone). +**Update (2026-10-07):** the runtime stage is now `mcr.microsoft.com/dotnet/aspnet:10.0-noble`, not +`dotnet/runtime:10.0-noble` — the A2A server (`2026-10-07-a2a-server-design.md`) needs ASP.NET Core hosting. + ## Goal Run the Telegram host (`hosts/Volcanic.AgenticCore.Telegram`) as a single container on Coolify, deployed from diff --git a/docs/superpowers/specs/2026-10-07-a2a-server-design.md b/docs/superpowers/specs/2026-10-07-a2a-server-design.md index 5c06508..485363d 100644 --- a/docs/superpowers/specs/2026-10-07-a2a-server-design.md +++ b/docs/superpowers/specs/2026-10-07-a2a-server-design.md @@ -1,7 +1,7 @@ # A2A server (design) Date: 2026-10-07 -Status: draft +Status: implemented (plan: docs/superpowers/plans/2026-10-07-a2a-server.md) Related: `2026-10-07-a2a-client-design.md` (the client extension; it uses this server's push receiver and runs its end-to-end tests against it); `2026-09-26-telegram-attachments-design.md` (`IChatChannel`, `inbox/`); `2026-09-26-scheduler-design.md` (follow-up delivery pattern). @@ -271,3 +271,51 @@ New `docs/a2a.md` (concepts, file formats, config, scenarios, security notes, cu `owner` mode, gRPC, OIDC/OAuth auth, rate limits, per-caller tool allow-lists, A2A protocol extensions, signed agent cards, queueing messages for a busy context. + +## Deviations from this design + +- **Options class is `A2AHostOptions`**, not `A2AServerOptions` — the SDK itself already defines + `A2A.A2AServerOptions`, so the Volcanic options type needed a different name. It still binds from the `A2A` section. +- **`IA2ACallerDirectory.Find(key) → IReadOnlyList`, synchronous**, not `ResolveAsync(key) → A2ACaller?`. + `callers.json` is read from disk on every call, which doesn't need to be async, and returning every match (rather + than just the first) is what lets the auth filter detect an ambiguous key across workspaces and refuse it. +- **No `TelegramCallerDirectory` class.** `WorkspaceCallerDirectory` (`src/.../Callers/IA2ACallerDirectory.cs`) is + generic: it takes a `Func>` and reads every workspace's `callers.json` on each `Find`. + `TelegramA2ASetup.Workspaces` supplies that function from `TelegramOptions.AllowedUserIds`; the sample host supplies + one fixed workspace. Same for the Telegram host as any other caller of the library. +- **Push payload and trigger**: the server POSTs `StreamResponse { Task = task }` (the whole task, not an + artifact-chunk event) to every registered webhook **on each status change** (`WorkspaceTaskStore.SaveTaskAsync` + compares the previous and new `TaskState`), not per artifact chunk as the request flow table implied. +- **File-part errors are split by cause.** Inline bytes (`Part.Raw`) over `MaxFileBytes`, or a file-part URL that + isn't `https`, are rejected up front as `InvalidParams` (checked in `VolcanicA2AServer.PreCheck`, before a task is + even created). A URL whose *download* fails or exceeds the cap fails the task instead (`A2APromptException` inside + `A2AAgentHandler.RunAsync`, caught and turned into `failed (file)`), since by then the task already exists. +- **Outbound HTTP is a guarded handler**, not a plain `HttpClient`: `PushUrlGuard.CreateGuardedHandler` disables + redirects and the system proxy and checks every connect-time address with `PushUrlGuard.IsPrivate` (loopback, + private, link-local, unique-local, CGNAT `100.64.0.0/10`, benchmarking `198.18.0.0/15`, multicast/reserved), so a + DNS answer that changes between the pre-check and the connection (DNS rebinding) can't reach an internal host. It's + shared by push sending (`HttpA2APushSender`) and file-part URL downloads (`A2AFileDownloader`), both pinned by + `AllowPrivatePushUrls` for tests and tailnets. +- **Per-caller SDK server + router**, not one server with a tenancy parameter: `A2AServerCache` keeps one + `VolcanicA2AServer` per caller identity (workspace + name), lazily created, because the SDK's `A2AServer` owns + background runs and cancellation state that must outlive a single request. `A2ARequestRouter` is the single + `IA2ARequestHandler` the SDK endpoints call; it reads the authenticated caller from `HttpContext.Items` (set by the + `A2AAuth` endpoint filter) and forwards to that caller's server. +- **A finished-run grace period**, not just a live/not-live flag: `A2ASessionPool.FinishedGrace` (1 minute) keeps a + just-finished task's run counted as "live" after its handler returns, because the SDK itself still needs to read + the task through the store and apply the final status event after the handler completes. Without the grace window, + lazy restart recovery could race that and mark a task that only just finished as "server restarted". +- **The task-end log line carries `owner=`**, not `chat=`: `a2a task {TaskId} owner={Owner} caller={Caller} + state={State} in {Duration}` (`A2AAgentHandler.Log`). The server library has no concept of a Telegram chat id; it + only knows the caller's `OwnerId` (whatever the host's `A2AWorkspace` set it to — the chat id as a string, for the + Telegram host). +- **Docker runtime image is `mcr.microsoft.com/dotnet/aspnet:10.0-noble`**, not the bare `dotnet/runtime` image the + docker-deployment spec describes: the bot now hosts Kestrel for the A2A endpoints, which needs the ASP.NET Core + runtime. See `docs/docker.md` and the note added to `2026-09-26-docker-deployment-design.md`. +- **`Hops_EmittedOnEventBus` is tested from the client side**, in the A2A client extension's test suite, not here: + the server only emits `a2a.inbound` on the session's event bus (`A2ASessionExtension.BeginRun`); nothing in this + server's own test project subscribes to it, so the assertion that a hop count actually arrives on the bus belongs + to whichever extension reads it. +- **Push retries wait 1 s then 5 s (3 attempts total)**, not "1 s / 5 s / 30 s" as the request-flow table said: + `HttpA2APushSender.Waits = [1s, 5s]`, with one more attempt after the second wait (`attempt >= Waits.Length` stops + it), each attempt bounded by a 10 s timeout. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index f34d2bd..92a14d7 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -65,6 +65,9 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section | `PLAYWRIGHT_ENABLED` / `Mcp:Playwright:Enabled` | `false` | Browser tools through `@playwright/mcp` ([mcp.md](mcp.md)) | | `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` | `true`, `chromium`, —, `npx` | See [mcp.md](mcp.md#the-playwright-preset) | | `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 on port 8080 | +| `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) | +| `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 its most recent conversation. Each chat's time zone is kept in `/telegram/chat-settings.json`, so it @@ -118,6 +121,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)) | 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)). @@ -284,6 +288,26 @@ chat's account, so nobody sees another chat's calendar through the agent. The bo sending the link and code and confirms in the chat once access is approved. Setup of the Google Cloud app, token storage and time zones are in [google-calendar.md](google-calendar.md). +### A2A + +With `A2A__Enabled=true` the bot also serves [A2A](a2a.md): other agents can call it over HTTP once given a key. +It opens Kestrel on port 8080 (Coolify needs a domain pointed at that port — see [docker.md](docker.md)) and logs +`A2A: listening on ...`. With it left off (the default) no port is opened at all. If the port is already taken, +the bot logs a critical line and exits with code 3 instead of coming up half-working. + +Callers live only in allowed chats' workspaces: each chat's own `.volcanic/a2a/callers.json` and +`.volcanic/a2a/tasks/` (see [a2a.md](a2a.md#files)). A caller's session is isolated from the chat's own +conversation — same workspace, same `AGENTS.md`/`CLAUDE.md`, `PERSONA.md` and `MEMORY.md`, separate history — and +opens with that chat's current time zone and `Telegram:Model`; a later `/timezone` only reaches a caller's session +once it's been idle long enough to be evicted and reopened (`SessionIdleTimeout`, 30 minutes by default). + +`notify_owner` delivers as `🤖 A2A · : ` in the owner's chat, through the same channel as everything +else the bot sends there — including during `/impersonate`, when it reaches the admin instead, like any other +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. + ## Security The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids