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.
This commit is contained in:
12
CLAUDE.md
12
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 <user_id>` (`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
|
||||
|
||||
|
||||
174
docs/a2a.md
Normal file
174
docs/a2a.md
Normal file
@@ -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 `<workspace>/.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/<taskId>.json`: one task per file (the SDK's `AgentTask` plus which caller owns it and its push configs),
|
||||
written atomically (temp file, then rename) and read fresh on every request.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /.well-known/agent-card.json` | none | The generic public card |
|
||||
| `POST /a2a` | key | JSON-RPC 2.0: `SendMessage`, `SendStreamingMessage` (SSE), `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, push-config CRUD, `GetExtendedAgentCard` |
|
||||
| `/a2a/v1/...` | key | The same methods as REST (`HTTP+JSON`) |
|
||||
| `POST /a2a/push/{watchId}` | none (token in body/header) | Receives pushes for *our own* client's watches; needs `A2AHostOptions.OnPush` wired by the host, otherwise every call is 404 |
|
||||
|
||||
Authenticate with the header `X-API-Key: <key>` or `Authorization: Bearer <key>`. Exactly one caller must match the
|
||||
key across every workspace the host knows about; no match, or the same key matching 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: `<ScopePrefix>-a2a-<caller>-<contextId>` (the Telegram host's
|
||||
`ScopePrefix` is `telegram-<chatId>`). 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":"<taskId>"}}'
|
||||
```
|
||||
@@ -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`
|
||||
|
||||
@@ -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/<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`) |
|
||||
| `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)) |
|
||||
| `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) |
|
||||
|
||||
@@ -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: <ids>. 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<A2ACaller>`, 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<IEnumerable<A2AWorkspace>>` 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=<chatId>`: `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.
|
||||
|
||||
@@ -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:<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 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-<chatId>/`; after a restart each chat resumes
|
||||
its most recent conversation. Each chat's time zone is kept in `<agent dir>/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 · <caller>: <text>` 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
|
||||
|
||||
Reference in New Issue
Block a user