Compare commits
2 Commits
13b0965d63
...
0922a4abb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0922a4abb0 | ||
|
|
fe692844a1 |
@@ -1,3 +1,6 @@
|
||||
<!-- release-notes-config
|
||||
declined: true
|
||||
-->
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
237
docs/superpowers/specs/2026-09-26-docker-deployment-design.md
Normal file
237
docs/superpowers/specs/2026-09-26-docker-deployment-design.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Docker deployment (Coolify) — design
|
||||
|
||||
Date: 2026-09-26. Status: draft.
|
||||
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).
|
||||
|
||||
## Goal
|
||||
|
||||
Run the Telegram host (`hosts/Volcanic.AgenticCore.Telegram`) as a single container on Coolify, deployed from
|
||||
this repo's `docker-compose.yml`. The container carries everything the agent's `bash` tool and the Claude Code
|
||||
provider need, runs as a non-root user, and keeps workspaces, sessions and the Claude login across redeploys.
|
||||
`docker compose up -d --build` on any Docker host runs the same thing locally.
|
||||
|
||||
Reference: VolcanicSecretary (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`), adapted to a .NET
|
||||
build and to the Claude Code provider's subscription-only rules.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` |
|
||||
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
|
||||
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
|
||||
| Entry | `ENTRYPOINT ["dotnet", "Volcanic.AgenticCore.Telegram.dll"]`, so `Environment.ProcessPath` is the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
|
||||
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` |
|
||||
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
|
||||
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
|
||||
| Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` |
|
||||
| User | The runtime image's built-in non-root `app` user (`$APP_UID`, 1654), `HOME=/home/app` |
|
||||
| Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` |
|
||||
| Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` |
|
||||
| Healthcheck | None (see below) |
|
||||
| Coolify | No `ports`, no `expose`, no domain, no `SERVICE_FQDN_*`; `restart: unless-stopped`; `init: true` |
|
||||
| Local script | None. `cp .env.example .env && docker compose up -d --build` is the local run; the `dotnet run` path in `docs/telegram-bot.md` stays for development |
|
||||
|
||||
Out of scope: webhooks/HTTP endpoints, a .NET SDK inside the runtime image for the agent to use, CI image publishing
|
||||
to a registry, multi-arch builds (the image is built on the Coolify server, whatever its arch), running tests in
|
||||
the image build.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Build stage (`sdk:10.0-noble`):
|
||||
|
||||
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
|
||||
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar
|
||||
extensions once the host references them), then
|
||||
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes.
|
||||
2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release
|
||||
--no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
|
||||
3. Guard: `test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll && test -f
|
||||
/out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The bridge reaches the output through
|
||||
a `ReferenceOutputAssembly="false"` project reference; if `publish` drops the runtimeconfig (it does not in
|
||||
`build`), fix the csproj, not the Dockerfile.
|
||||
|
||||
Runtime stage (`runtime:10.0-noble`), as root:
|
||||
|
||||
1. `apt-get install --no-install-recommends` the agent tools above, then NodeSource `nodejs`; clean apt lists.
|
||||
2. `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && claude --version` (fails the build if the
|
||||
CLI does not run). The global prefix `/usr/lib/node_modules` stays root-owned, so the CLI's auto-updater cannot
|
||||
replace the binary at runtime: a Claude Code upgrade changes what `system/init` validation sees and invalidates
|
||||
the prompt cache, so it should only arrive with a rebuild.
|
||||
3. If `INSTALL_PLAYWRIGHT=true`: `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`,
|
||||
`npm install -g @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`, then run the
|
||||
`playwright` CLI **from that package's own dependency** with `install --with-deps chromium`, so browser build and
|
||||
library version match. `chmod -R a+rX /opt/ms-playwright`. Roughly +400 MB. `PLAYWRIGHT_BROWSERS_PATH` is set
|
||||
unconditionally so the MCP spec (`2026-09-26-mcp-client-playwright-design.md`) can rely on it: its server
|
||||
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's
|
||||
`PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two
|
||||
differ, so bumping the version means changing both in one commit. Compose does not override this arg.
|
||||
4. `mkdir -p /data/workspaces /data/agent /data/claude && chown -R app:app /data`. Named volumes copy this
|
||||
ownership on first mount, which is what lets a non-root process write to them.
|
||||
5. `COPY --from=build /out /app`, `WORKDIR /app`, `USER $APP_UID`.
|
||||
6. Environment baked into the image (compose and Coolify may override):
|
||||
|
||||
| Variable | Value | Why |
|
||||
|---|---|---|
|
||||
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
|
||||
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` |
|
||||
| `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it |
|
||||
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
|
||||
| `TZ` | `UTC` | Overridable |
|
||||
| `DOTNET_EnableDiagnostics` | `0` | Turns off the runtime's diagnostic IPC socket, which would hand the bot's environment to the agent's same-uid processes and bypass the host's non-dumpable hardening (process-environment-hook spec §8). Unset it (with `ProcessEnvironment__HardenHostProcess=false`) only to attach `dotnet-dump`/`dotnet-trace` while debugging |
|
||||
|
||||
`VOLUME` is not declared in the Dockerfile; the compose file owns volumes (Coolify treats anonymous volumes badly).
|
||||
|
||||
## docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
INSTALL_PLAYWRIGHT: ${INSTALL_PLAYWRIGHT:-true}
|
||||
CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1}
|
||||
restart: unless-stopped
|
||||
# dotnet does not reap orphans; killed `claude` trees and bash tool children would pile up as zombies.
|
||||
init: true
|
||||
# Chromium crashes with Docker's 64 MB /dev/shm.
|
||||
shm_size: 1gb
|
||||
stop_grace_period: 30s
|
||||
environment:
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS}
|
||||
- Telegram__Model=${TELEGRAM_MODEL:-claude-code/claude-opus-5-5}
|
||||
- TZ=${TZ:-UTC}
|
||||
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
|
||||
# Optional features; empty means off / default.
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
|
||||
- STT_MODEL=${STT_MODEL:-}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
|
||||
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
|
||||
# Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
|
||||
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
|
||||
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
|
||||
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
|
||||
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
|
||||
- PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp}
|
||||
volumes:
|
||||
- workspaces:/data/workspaces
|
||||
- agent:/data/agent
|
||||
- claude:/data/claude
|
||||
|
||||
volumes:
|
||||
workspaces:
|
||||
agent:
|
||||
claude:
|
||||
```
|
||||
|
||||
- No `ports`/`expose`: long polling only makes outbound connections. No domain is configured in Coolify, so no
|
||||
Traefik labels are generated.
|
||||
- **No `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_*` entries**, ever: the provider refuses to run
|
||||
with them. `docs/docker.md` says so next to the Coolify env setup, because shared/team variables in Coolify
|
||||
are the likely way one sneaks in.
|
||||
- Env mapping: the two required settings use the existing shortcuts (`TELEGRAM_BOT_TOKEN`,
|
||||
`TELEGRAM_ALLOWED_USER_IDS`, comma separated) because a list in `Telegram__AllowedUserIds__0..n` form is awkward
|
||||
in Coolify. Everything else in `TelegramOptions` is reachable as `Telegram__<Name>` (e.g.
|
||||
`Telegram__ReconnectMaxDelay=00:02:00`) without a compose change, by adding it in Coolify and to `environment`.
|
||||
The Claude provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `..._TOTAL_TIMEOUT_MS` work the same way, as do
|
||||
the `Scheduler__*`, `GoogleCalendar__*`, `ProcessEnvironment__*` and `Telegram__Speech__*` /
|
||||
`Telegram__InputDebounce` settings of the sibling specs.
|
||||
- Optional features use the env shortcuts their specs define: `OPENAI_API_KEY` (+ `STT_MODEL`) turns on speech to
|
||||
text; `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` turn on the calendar, and each chat then connects its own Google
|
||||
account. Empty values mean off, so they're in `environment` and `.env.example` from the start. `OPENAI_API_KEY` is
|
||||
not on the Claude Code provider's denied list and never reaches `claude`.
|
||||
- Time zones: each chat has its own (`/timezone`). A chat that never set one uses `DEFAULT_TIMEZONE`, else the
|
||||
container's `TZ` (default `UTC`), so a deployment whose users are mostly in one zone should set `DEFAULT_TIMEZONE`.
|
||||
The chat's zone is also passed as `TZ` to the agent's `bash` and watcher scripts (the image has `tzdata`). Calendar
|
||||
times use the calendar's own zone, else the chat's.
|
||||
- Secrets set here (`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` and anything matching
|
||||
`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of
|
||||
the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a
|
||||
credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP
|
||||
server gets one through its own `env` entry instead. On top of that, the bot (PID 1 in the container) makes itself
|
||||
non-dumpable at startup, so the agent can't read `/proc/1/environ` or attach to it, and the configured secrets'
|
||||
values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no
|
||||
core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off
|
||||
for a debugging session. The container stays the real isolation boundary: none of this is a sandbox.
|
||||
- Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first
|
||||
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
|
||||
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
|
||||
so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`.
|
||||
`PLAYWRIGHT_ENABLED` follows `INSTALL_PLAYWRIGHT` (nested default, supported by Compose v2), so an image built
|
||||
without Playwright doesn't advertise tools it can't run.
|
||||
- The owner (first id in `TELEGRAM_ALLOWED_USER_IDS`) gets the `🟢 Online.` message and the Claude Code re-login
|
||||
prompts. The calendar has no owner special case: every chat connects its own account.
|
||||
- Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the
|
||||
resource is deleted with its volumes. Bind mounts (as in VolcanicSecretary) also work but the host directories
|
||||
must then be owned by uid 1654; the doc mentions this in one line.
|
||||
- Deploys: Coolify stops the old container before starting the new one for compose resources. If they ever overlap,
|
||||
the second poller gets `409 Conflict`, which the polling service already backs off from.
|
||||
|
||||
## Healthcheck
|
||||
|
||||
None. The bot serves no HTTP, and the failure modes a probe could detect are already handled in-process: lost
|
||||
connectivity self-heals with backoff (`ReconnectPolicy`), a dead polling loop is restarted, and the unrecoverable
|
||||
cases exit the process (`401` → exit 1, invalid config → exit 2), which `restart: unless-stopped` turns into a
|
||||
visible restart loop in Coolify's logs. A file-heartbeat probe would add a moving part for no new signal. Coolify
|
||||
shows the service as running without health status; that is acceptable.
|
||||
|
||||
## First run and Claude login
|
||||
|
||||
1. In Coolify: new resource from this repo, build pack *Docker Compose*, set `TELEGRAM_BOT_TOKEN` and
|
||||
`TELEGRAM_ALLOWED_USER_IDS` (owner first), deploy.
|
||||
2. The start log says Claude Code was found (image-provided) and lists allowed users.
|
||||
3. The owner opens the bot, sends `/start`, then `/claude-code-login`: link + code prompt arrive as one message, the
|
||||
pasted code is fed to `claude auth login --claudeai` inside the container, and credentials land in
|
||||
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok.
|
||||
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
|
||||
|
||||
Fallback without the bot: `docker exec -it <container> claude auth login --claudeai` (runs as `app`, same
|
||||
`CLAUDE_CONFIG_DIR`). Copying an existing `.credentials.json` into the `claude` volume also works but is not the
|
||||
documented path.
|
||||
|
||||
## Extensions and project context
|
||||
|
||||
- Extra extension assemblies go to `/data/agent/extensions/*.dll` (user directory) or
|
||||
`/data/workspaces/<chatId>/.volcanic/extensions/` (project directory); discovery is unchanged. Getting a DLL there
|
||||
is `docker cp` + restart; they must be built against the same `Volcanic.AgenticCore.Abstractions` version as the image.
|
||||
- `AGENTS.md` / `CLAUDE.md`, `PERSONA.md`, `MEMORY.md` live in each chat's workspace directory in the `workspaces`
|
||||
volume, edited by the agent or by `docker exec`.
|
||||
|
||||
## Docs
|
||||
|
||||
- New `docs/docker.md`: build args, env table (with which variables the agent can't see, and `PROCESS_ENV_ALLOW`), volumes, Coolify steps, first login, no-API-key warning, updating
|
||||
Claude Code (redeploy; set `CLAUDE_CODE_VERSION` to pin), troubleshooting (`[executable_missing]`, `[auth]`,
|
||||
`[mcp_startup]` → bridge files, permission denied on bind mounts → chown 1654, `409` → another instance).
|
||||
- `README.md`: one line under the Telegram quick start linking `docs/docker.md`.
|
||||
- `docs/telegram-bot.md`: "Security" points at the container as the recommended way to run it.
|
||||
- Update the "Out of scope: … Docker/Coolify" line in `2026-09-25-telegram-host-design.md` to reference this spec.
|
||||
|
||||
## Verification
|
||||
|
||||
No unit tests (nothing in C# changes). Manual checklist in the implementation plan:
|
||||
|
||||
1. `docker compose build` succeeds with `INSTALL_PLAYWRIGHT=false` and `true`; the bridge guard and `claude --version` pass.
|
||||
2. `docker compose up` with a real bot: allowed user gets answers; `/claude-code-login` then `/claude-code-doctor`
|
||||
all green; `id` via the agent's bash reports uid 1654; `env` via the agent's bash shows neither
|
||||
`TELEGRAM_BOT_TOKEN` nor `OPENAI_API_KEY`, and `date` shows the chat's zone after `/timezone Europe/Berlin`;
|
||||
`cat /proc/1/environ` via the agent's bash fails with permission denied, and no `dotnet-diagnostic-*` socket
|
||||
exists under `/tmp`.
|
||||
3. Agent can run `git`, `rg`, `python3`, `node`, `npx`; with Playwright, `playwright-mcp --version` prints the pinned version offline, and "Open https://example.com and tell me the H1" works in a chat on the first try (no npx download, no sandbox error).
|
||||
4. `docker compose down && docker compose up -d --build`: previous chat resumes, no re-login needed, workspace files remain.
|
||||
5. Setting `ANTHROPIC_API_KEY` in compose makes the provider refuse with its documented error (then remove it).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Persist `HOME` too? `~/.gitconfig`, `~/.ssh`, `gh` auth and the npm/npx cache are lost on redeploy with this
|
||||
design. A fourth volume at `/home/app` would keep them; left out until there is a need.
|
||||
2. Playwright default: compose builds it in (`true`) on the assumption that the MCP spec ships; flip the compose
|
||||
default to `false` if that spec lands later. (Sandboxing and which binary is launched are settled above.)
|
||||
3. `CLAUDE_CODE_VERSION=2.1` tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead,
|
||||
at the cost of manual bumps?
|
||||
4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET
|
||||
projects in its workspace?
|
||||
338
docs/superpowers/specs/2026-09-26-google-calendar-design.md
Normal file
338
docs/superpowers/specs/2026-09-26-google-calendar-design.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Google Calendar tool and connect flow — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Depends on: `2026-09-26-scheduler-design.md` §7–§8 (the chat's time zone, `IExtensionContext.TimeZone`).
|
||||
|
||||
## Problem
|
||||
|
||||
The agent has no access to the user's calendar. VolcanicSecretary (TypeScript, on pi) already has a working
|
||||
`calendar` tool with recurring-event support and a Google OAuth device flow driven from Telegram
|
||||
(`src/tools/calendar.ts`, `src/tools/google-token-store.ts`, `src/telegram/google-device-flow.ts`, specs
|
||||
`2026-09-08-recurring-calendar-events-design.md` and `2026-09-23-google-calendar-connect-flow-design.md`). We port it,
|
||||
reshaped for this harness: an extension on the public API, one Google account per chat, the connect flow through
|
||||
`IUserInterface`, and automatic polling instead of a `/calendar_done` command.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **An extension, not host code.** New project `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar`,
|
||||
referencing only `Volcanic.AgenticCore.Abstractions` (like `Anthropic` and `ClaudeCode`). The tool, the commands and
|
||||
the flow need nothing a host has that the extension API lacks: dialogs go through `ctx.Ui`, and commands already run
|
||||
outside the agent loop. Any host (Telegram, the headless sample, a future web UI) gets the calendar by registering
|
||||
the extension.
|
||||
- **One Google account per chat.** The extension is loaded in every chat. Each allow-listed chat connects its own
|
||||
Google account with `/calendar_connect`, and the tool only ever uses that chat's token, so nobody sees another chat's
|
||||
calendar through the agent. The OAuth client (`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) stays process-wide: one
|
||||
Google Cloud app for the bot. This is exposure control, not a security boundary between allow-listed users: every
|
||||
one of them runs shell commands as the bot's OS user and could read another chat's token file.
|
||||
- **The chat key is the session scope, from the public API.** `IExtensionContext.SessionManager.Scope` is already in
|
||||
Abstractions. It's stable for a chat (Telegram passes `telegram-<chatId>`) and survives `/new` and resume, unlike
|
||||
`SessionId`. A headless host gets the default scope derived from the cwd, so one account per workspace. **No
|
||||
Abstractions change.** Tokens are only needed from tools and commands, which always have a context, so nothing is
|
||||
keyed during activation.
|
||||
- **Tokens live outside every workspace, one directory per chat:**
|
||||
`<agentDir>/google-calendar/<chat key>/token.json` (agent dir = `VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`), mode
|
||||
0600, directory 0700. The chat key is the scope when it matches `[A-Za-z0-9._-]{1,100}` and isn't `.`/`..`, else
|
||||
`scope-<first 16 hex of SHA-256(scope)>`. The reference kept the token in the workspace, which the agent's
|
||||
`read`/`bash` tools browse routinely. The root is overridable through `GoogleCalendarOptions.TokenDirectory`.
|
||||
- **Scope: `https://www.googleapis.com/auth/calendar`**, the same as the reference. Confirmed working with
|
||||
the device flow in practice, although Google's limited-input scope list doesn't mention it. We ask for one scope
|
||||
and nothing else (no `openid`/`email`); the account shown by `/calendar_status` is the primary calendar's id, which
|
||||
is the account email.
|
||||
- **Raw HTTP, not `Google.Apis.Calendar.v3`.** Reasons:
|
||||
1. `Google.Apis.Auth` has no device-authorization flow for installed apps; we'd hand-write the OAuth part anyway.
|
||||
2. We need seven endpoints (below). The generated client adds `Google.Apis`, `Google.Apis.Core`, `Google.Apis.Auth`
|
||||
and Newtonsoft.Json to an assembly that loads in its own `AssemblyLoadContext`; none of it is shared with the host.
|
||||
3. The existing extensions (`Anthropic`, `OpenAI`) take an optional `HttpClient` and are tested with a
|
||||
`FakeHttpHandler`. Raw HTTP keeps that pattern: tests assert exact URLs, query strings and bodies.
|
||||
4. Serialisation stays on `System.Text.Json`/`VolcanicJson`.
|
||||
- **The device flow polls automatically. There is no `/calendar_done`.** The reference rejected polling because of
|
||||
timers to cancel and a reply colliding with an agent run. Neither applies here: the command handler already runs off
|
||||
the chat's update loop (Telegram's `PromptInBackground`, and `AgentSession.PromptAsync` runs commands immediately even
|
||||
while streaming), so the handler itself can poll until done; the result is one `ui.Notify`, which the Telegram
|
||||
renderer orders with everything else. The only state is one in-flight flow, owned by a single-flight coordinator.
|
||||
- **The tool is always registered when the client is configured**, whether or not an account is connected. Without a
|
||||
token it fails with a message telling the model to ask the user to run `/calendar_connect` in this chat. The tool list stays
|
||||
stable across a connect (as in the reference). The model never starts the flow: auth isn't an agent task.
|
||||
- **The OAuth app is published unverified ("In production").** This is the setup the owner already uses. Publishing
|
||||
needs no Google verification for a handful of users, and it avoids testing status's 7-day refresh-token expiry, so
|
||||
a chat connects once instead of weekly. Each user sees Google's "unverified app" warning once, on approval
|
||||
("Advanced → Go to <app> (unsafe)"); the setup docs show it. Testing status still works (every user added as a test
|
||||
user, otherwise `access_denied`, which the flow reports as a denial), but then each chat has to reconnect about
|
||||
weekly; the `invalid_grant` message covers it.
|
||||
- **Command names use underscores** (`/calendar_connect`, `/calendar_status`, `/calendar_disconnect`). Telegram only
|
||||
links and menus commands matching `[a-z0-9_]`; `/claude-code-login` is not tappable there.
|
||||
|
||||
## Google endpoints used
|
||||
|
||||
| Purpose | Request |
|
||||
|---|---|
|
||||
| Start device flow | `POST https://oauth2.googleapis.com/device/code` form `client_id`, `scope` |
|
||||
| Poll / exchange | `POST https://oauth2.googleapis.com/token` form `client_id`, `client_secret`, `device_code`, `grant_type=urn:ietf:params:oauth:grant-type:device_code` |
|
||||
| Access token | `POST https://oauth2.googleapis.com/token` form `client_id`, `client_secret`, `refresh_token`, `grant_type=refresh_token` |
|
||||
| Revoke | `POST https://oauth2.googleapis.com/revoke` form `token` |
|
||||
| Events | `GET/POST .../calendar/v3/calendars/{cal}/events`, `GET/PATCH/DELETE .../events/{id}`, `GET .../events/{id}/instances` |
|
||||
| Calendars | `GET .../calendar/v3/users/me/calendarList`, `GET .../calendars/primary` (account and time zone after connect) |
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Project and options
|
||||
|
||||
`Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj`: project reference to Abstractions (`Private="false"` as the
|
||||
extension guide says), `InternalsVisibleTo` the extensions test project, no package references.
|
||||
|
||||
```csharp
|
||||
public sealed record GoogleCalendarOptions
|
||||
{
|
||||
public string? ClientId { get; init; } // else GOOGLE_CLIENT_ID
|
||||
public string? ClientSecret { get; init; } // else GOOGLE_CLIENT_SECRET
|
||||
public string? TokenDirectory { get; init; } // else <agentDir>/google-calendar; one <chat key>/token.json per chat
|
||||
public Func<string, string?>? GetEnv { get; init; }
|
||||
public HttpClient? Http { get; init; } // tests pass a FakeHttpHandler client
|
||||
public TimeProvider? Time { get; init; } // tests pass a fake clock for polling and token expiry
|
||||
public GoogleCalendarConnections? Connections { get; init; } // else GoogleCalendarConnections.Shared
|
||||
}
|
||||
```
|
||||
|
||||
`GoogleCalendarExtension(GoogleCalendarOptions? options = null)`, `Name = "google-calendar"`. When the client id or
|
||||
secret is missing it registers nothing and logs one `Information` line (`google-calendar disabled: set GOOGLE_CLIENT_ID
|
||||
and GOOGLE_CLIENT_SECRET`). If only one of the two is set, it logs a `Warning` instead.
|
||||
|
||||
### 2. Token store — `GoogleTokenStore`
|
||||
|
||||
- One store per chat key (`GoogleTokenStore(string path)`). File shape: `{"refreshToken":"1//0e…","scope":"…","account":"user@gmail.com","timeZone":"Europe/Berlin","connectedAt":"2026-09-26T10:00:00Z"}`.
|
||||
- `Read()` returns `StoredToken?`. A missing, empty, unparsable file or one without `refreshToken` gives null. It never
|
||||
throws, so "unreadable" is the same as "not connected".
|
||||
- `Write(StoredToken)`: creates the directory, writes `token.json.tmp` and moves it over the target (atomic). On Unix
|
||||
the file is created with `UnixFileMode.UserRead | UserWrite` and the directory with `0700`.
|
||||
- `Delete()`: removes the file if present.
|
||||
- The refresh token never appears in exceptions, logs, tool results or `Details`.
|
||||
|
||||
### 3. OAuth client — `GoogleOAuthClient`
|
||||
|
||||
- `StartDeviceFlowAsync(ct) → DeviceCode(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`. A non-2xx
|
||||
response throws `GoogleCalendarException` with Google's `error`/`error_description`.
|
||||
- `PollAsync(deviceCode, ct) → PollResult`: `Success(refreshToken, scope)`, `Pending`, `SlowDown`, `Denied`, `Expired`,
|
||||
`Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google documents 428 for
|
||||
`authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
|
||||
A success without `refresh_token` is `Failed("Google returned no refresh token. Remove the app at
|
||||
https://myaccount.google.com/permissions and run /calendar_connect again.")`.
|
||||
- `RefreshAsync(refreshToken, ct) → (accessToken, expiresAt)`. `invalid_grant` throws
|
||||
`GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.")`.
|
||||
- `RevokeAsync(token, ct)`: best effort; failures are reported but don't stop a disconnect.
|
||||
|
||||
### 4. Connections — `GoogleCalendarConnections` (process-wide) and `GoogleCalendarConnection` (per chat)
|
||||
|
||||
`GoogleCalendarConnections.Shared` is explicit process-wide state, like `ClaudeLoginCoordinator.Shared`: extensions are
|
||||
per session, but a chat's account outlives its sessions. `For(chatKey)` returns that chat's `GoogleCalendarConnection`
|
||||
(created on first use, kept for the process lifetime) over its own `GoogleTokenStore`. Connections share nothing but
|
||||
the OAuth client and the `HttpClient`, so chats are independent: separate access-token caches, separate refresh
|
||||
locks, separate pending flows. The tool and the commands call `For(chatKey(ctx.SessionManager.Scope))`.
|
||||
|
||||
Per chat:
|
||||
|
||||
- `Task<string> GetAccessTokenAsync(ct)`: reads the store on every call. It reuses the cached access token while it is
|
||||
valid for more than 60 s **and** was minted from the same refresh token (a reconnect invalidates it). Refreshes are
|
||||
single-flight behind a `SemaphoreSlim`. No token → `GoogleCalendarException(NotConnected: true, "Google Calendar is
|
||||
not connected. Ask the user to run /calendar_connect in this chat.")`.
|
||||
- `void Invalidate()`: drops the cached access token (after a 401 from the Calendar API).
|
||||
- `ConnectFlow? Pending`: this chat's in-flight device flow, if any (§5). At most one per chat; flows in different chats
|
||||
run concurrently and don't know about each other.
|
||||
- `ResolveTimeZone(TimeZoneInfo chatZone)`: the calendar's own zone (the stored `timeZone`, from `calendars/primary`),
|
||||
else the chat's zone (`ctx.TimeZone`, scheduler spec §8). A stored id that doesn't resolve is logged once and falls
|
||||
through to the chat's zone. There's no process-wide override: with per-chat accounts it would be wrong for
|
||||
every chat but one.
|
||||
|
||||
### 5. Connect flow — `/calendar_connect`
|
||||
|
||||
The handler (`ConnectCommand`) with `ctx.Ui`:
|
||||
|
||||
1. No UI (`!ctx.HasUi`): append a `CustomMessage` saying the command needs an interactive UI (as `/claude-code-login`
|
||||
does) and stop.
|
||||
2. A flow is already pending: re-send its link and code (`Notify`) with the remaining minutes, and stop. It does not
|
||||
start a second flow: a new device code would invalidate the one the user may be typing. A flow pending in another
|
||||
chat doesn't matter.
|
||||
3. Otherwise `StartDeviceFlowAsync`, store it as the chat connection's `Pending`, and send **one** message:
|
||||
`ui.Notify("Connect Google Calendar:\n1. Open <verification_url>\n2. Enter the code <USER-CODE>\n3. Approve access. I'll confirm here when it's done (code valid for <N> min).")`.
|
||||
Link and code in one message, for the ordering reason in the re-login spec.
|
||||
4. Poll with `PollAsync` every `Interval` seconds (Google says 5), delays through `TimeProvider`:
|
||||
- `Pending`: keep going. `SlowDown`: add 5 s to the interval permanently (RFC 8628 §3.5).
|
||||
- `Success`: call `GET calendars/primary` with the new token to learn `account` (`id`) and `timeZone`, write the
|
||||
store, `Invalidate()` the cache, then `Notify("✅ Google Calendar connected as <account> (time zone <tz>).")`. If the
|
||||
lookup fails, the token is still saved with those fields empty.
|
||||
- `Denied`: `Notify("Google Calendar access was denied.", Warning)`.
|
||||
- `Expired`, or `ExpiresAt` passed: `Notify("The Google code expired. Run /calendar_connect again.", Warning)`.
|
||||
- `Failed` or a transport error: up to 3 consecutive transport errors are retried at the normal interval, then
|
||||
`Notify("Google Calendar connection failed: <message>", Error)`.
|
||||
- A store write failure: `Notify("Got a Google token but could not save it: <message>", Error)`.
|
||||
5. `Pending` is cleared in a `finally`, whatever the outcome.
|
||||
|
||||
Cancellation: the flow runs on the connection's own `CancellationTokenSource`, not the command token. `/stop` doesn't
|
||||
touch it (it only aborts agent runs). `/calendar_disconnect` cancels it silently. Process shutdown ends it, and the
|
||||
next start has no pending flow; the user runs `/calendar_connect` again. The flow is bounded by Google's `expires_in`
|
||||
(1800 s in practice).
|
||||
|
||||
Connecting while already connected is allowed (switching accounts). The new token replaces the old one only on
|
||||
success; the old one is not revoked automatically.
|
||||
|
||||
### 6. `/calendar_status` and `/calendar_disconnect`
|
||||
|
||||
- `/calendar_status`: `Connected as <account> since <date>, time zone <tz>.`, `Not connected. Run /calendar_connect.`,
|
||||
or `Waiting for approval: open <url> and enter <code> (<N> min left).`
|
||||
- `/calendar_disconnect`: cancels a pending flow, revokes the refresh token (best effort), deletes the file,
|
||||
invalidates the cache, then `Calendar disconnected.` (with ` Revoking at Google failed: <reason>; remove the app at
|
||||
https://myaccount.google.com/permissions.` appended on revoke failure).
|
||||
|
||||
The replies go through `ctx.Ui.Notify`. Without a UI they're appended as a `CustomMessage` with `triggerTurn: false`.
|
||||
|
||||
### 7. The `calendar` tool
|
||||
|
||||
`CalendarTool : Tool<CalendarArgs>`, name `calendar`. Arguments are camelCase (the `VolcanicJson` web defaults), which
|
||||
matches Google's own field names.
|
||||
|
||||
| Field | Used by | Notes |
|
||||
|---|---|---|
|
||||
| `action` | all | `list`, `search`, `get`, `list_instances`, `create`, `update`, `delete`, `calendars` |
|
||||
| `calendarId` | all but `calendars` | default `primary` |
|
||||
| `timeMin`, `timeMax` | list, search, list_instances | see time rules. `list` defaults to now..now+7 d; `search` to now−30 d..now+365 d |
|
||||
| `query` | search (required) | Google's `q` |
|
||||
| `maxResults` | list, search, list_instances | default 25, cap 250 |
|
||||
| `listMode` | list | `occurrences` (default, `singleEvents=true&orderBy=startTime`) or `series` (`singleEvents=false`, masters with RRULE) |
|
||||
| `eventId` | get, list_instances, update, delete | |
|
||||
| `scope` | update, delete | `instance` or `series`; auto-detected when omitted |
|
||||
| `summary`, `description`, `location` | create, update | |
|
||||
| `start`, `end` | create, update | see time rules |
|
||||
| `timeZone` | create, update | IANA id for this event; default the resolved zone (§4) |
|
||||
| `recurrence` | create, update (series only) | RFC 5545 lines, e.g. `["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]`; `[]` on update makes the series a single event |
|
||||
|
||||
Behaviour follows the reference's recurring-events spec:
|
||||
|
||||
- **Scope resolution** for update/delete: fetch the event once. `recurringEventId` set → `instance`; `recurrence` set
|
||||
→ `series`; neither → single (an explicit `scope` is ignored). An explicit `scope=instance` on a master and
|
||||
`scope=series` on an occurrence are rejected with the reference's error texts. `recurrence` together with an
|
||||
instance is rejected. Recurrence lines must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:`; Google is the
|
||||
authority on the rest.
|
||||
- **update** is a `PATCH` carrying only the fields given (empty patch → error). **delete** of an instance cancels one
|
||||
occurrence; of a master deletes the series. The reply texts name which happened.
|
||||
- "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
|
||||
- `calendars` lists `calendarList` entries (`id · summary · accessRole · timeZone`, primary marked) so the model can
|
||||
pass `calendarId`.
|
||||
|
||||
**Time rules** (the reference had none beyond "send dateTime with timeZone"):
|
||||
|
||||
- Zone `Z` = the event's `timeZone` argument, else the resolved zone (§4).
|
||||
- `YYYY-MM-DD` is an all-day date (`{date}`). All-day `end` is **exclusive**, as in Google. A missing `end` on create
|
||||
means one day.
|
||||
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) is sent as-is, as `dateTime`, with `timeZone: Z`.
|
||||
- A date-time without an offset (`2026-09-30T10:00`) is local wall time in `Z`: sent as `dateTime` without an offset
|
||||
plus `timeZone: Z`, which Google interprets in that zone. Recurring events are expanded in `Z`, so a weekly 10:00
|
||||
stays 10:00 across DST.
|
||||
- A missing timed `end` on create means start + 1 hour. `start` and `end` must be the same kind (both dates or both
|
||||
date-times).
|
||||
- `timeMin`/`timeMax` must be RFC 3339 with an offset for Google. A date or local date-time is converted in `Z`
|
||||
(`TimeZoneInfo`) before sending.
|
||||
- Output times are rendered in the resolved zone with the zone in the header line of every result, e.g.
|
||||
`Times in Europe/Berlin.` When the calendar's zone differs from the chat's (the system prompt shows the chat's), the
|
||||
header says both: `Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`, so the model
|
||||
converts deliberately instead of mixing them up. Lines follow the reference format:
|
||||
`<id> · <summary> · Tue 30 Sep 10:00–11:00[ @ <location>][ · recurring (series=<id>)][ · RRULE=…]`.
|
||||
- `Description` and a `PromptGuidelines` bullet state the zone and these rules: times without an offset are in `<Z>`,
|
||||
all-day ends are exclusive, use `list_instances` to get an occurrence id before editing one date.
|
||||
|
||||
**Results and errors**:
|
||||
|
||||
- `Content` is the compact text above. `Details` carries the raw events (id, summary, start, end, recurringEventId,
|
||||
recurrence, htmlLink) for hosts.
|
||||
- A Calendar API 401: `Invalidate()`, refresh and retry once. 404/410: `Event <id> not found in <calendarId>.` Other
|
||||
errors: `Google Calendar <action> failed: <status> <error.message>`. No other retries; the model can retry.
|
||||
- Not connected / `invalid_grant`: the tool throws with the §4 message, so the model tells the user to run
|
||||
`/calendar_connect`.
|
||||
- `ExecutionMode` is `Parallel`: calls are independent HTTP requests and the token refresh is single-flight.
|
||||
|
||||
### 8. Telegram host
|
||||
|
||||
- Configuration: a `GoogleCalendar` section (`ClientId`, `ClientSecret`) with `GOOGLE_CLIENT_ID` and
|
||||
`GOOGLE_CLIENT_SECRET` as environment shortcuts, bound into a `GoogleCalendarOptions` singleton. Empty values count
|
||||
as unset (the Docker compose file passes both through, empty by default). The host adds the client secret to the
|
||||
process environment hook's `SecretValues` (`2026-09-26-process-environment-hook-design.md`), and the default
|
||||
deny-list already removes `GOOGLE_CLIENT_SECRET` from `bash`, scripts and MCP servers.
|
||||
- `ChatRegistry.CreateAsync` adds the extension to `AgentSessionOptions.ExtraExtensions` for **every** chat when the
|
||||
options are configured. It uses a factory registration (`ExtensionRegistration`) so each session gets its own
|
||||
instance over the shared `GoogleCalendarConnections`; the scope `telegram-<chatId>` picks the chat's account.
|
||||
- `BotSetupService` adds `calendar_connect`, `calendar_status` and `calendar_disconnect` to the global command menu when
|
||||
the calendar is configured. Every chat can use them.
|
||||
- `/help` mentions the three commands when the extension is active.
|
||||
- Nothing else changes. The flow uses `Notify` only; the user's replies are never needed, so the pending-input
|
||||
routing is untouched.
|
||||
|
||||
## Error handling summary
|
||||
|
||||
| Situation | Result |
|
||||
|---|---|
|
||||
| client id/secret unset | extension inactive, one info log line |
|
||||
| tool used before connecting | tool error: not connected, ask the user to run `/calendar_connect` in this chat |
|
||||
| user approves | one message in that chat: connected as `<account>`, zone `<tz>` |
|
||||
| user denies / code expires | warning message, no token written |
|
||||
| `/calendar_connect` in two chats at once | two independent flows, each chat gets its own link, code and result |
|
||||
| `/calendar_connect` while a flow is pending | the same link and code are re-sent |
|
||||
| `slow_down` | interval + 5 s, polling continues |
|
||||
| transport errors while polling | 3 consecutive → flow fails with the message |
|
||||
| refresh token revoked (`invalid_grant`) | tool error telling the user to reconnect; file kept until reconnect/disconnect |
|
||||
| bot restarts mid-flow | flow lost; `/calendar_connect` again |
|
||||
| Calendar API 401 | refresh once and retry once |
|
||||
| `/calendar_disconnect` with revoke failure | local token deleted; the reply links Google's permissions page |
|
||||
|
||||
## Testing
|
||||
|
||||
All in `tests/Volcanic.AgenticCore.Extensions.Tests/GoogleCalendar/`, with no network access. A `FakeHttpHandler`
|
||||
(same shape as the Anthropic/OpenAI ones: scripted responses per `method + path`, recorded requests with bodies) and
|
||||
`FakeTimeProvider` (`Microsoft.Extensions.TimeProvider.Testing`, the package the scheduler and message-context specs
|
||||
add to this test project) so polling and expiry run instantly.
|
||||
|
||||
- **OAuth client:** device-code request body; poll mapping for `authorization_pending` at 428 **and** 400, `slow_down`
|
||||
at 403, `access_denied`, `expired_token`, unknown error, 500, success without `refresh_token`; refresh `invalid_grant`.
|
||||
- **Token store:** round trip; null for missing/empty/garbage/keyless; atomic replace; 0600 on Unix (skipped on Windows);
|
||||
delete.
|
||||
- **Connection:** cached token reused, refreshed within 60 s of expiry, dropped when the stored refresh token changes;
|
||||
two concurrent callers cause one refresh; `Invalidate`.
|
||||
- **Connect flow** (through the extension with a recording `IUserInterface`): the link and code arrive in one
|
||||
`Notify`; pending → pending → success writes the store with account and zone and notifies once; `slow_down` widens
|
||||
the interval (asserted from the fake clock); denied and expired paths; a second `/calendar_connect` re-sends the
|
||||
same code without a second `device/code` request; `/calendar_disconnect` cancels a pending flow; no UI → custom
|
||||
message.
|
||||
- **Tool:** the reference's calendar tests ported one-for-one (recurrence in create body, recurrence validation,
|
||||
recurring marker in list, `listMode=series`, scope auto-detection both ways, explicit-scope rejections, PATCH carries
|
||||
only given fields, three delete messages, `list_instances` paging, not connected, `invalid_grant`). Plus: an
|
||||
offset-less date-time is sent without an offset and with `timeZone`; `timeMin` given as a date is converted to
|
||||
RFC 3339 in the zone; all-day create defaults `end` to the next day; mixed date/date-time is rejected; a 401 from
|
||||
the events endpoint refreshes once and succeeds; `search` requires `query`.
|
||||
- **Per chat:** two scopes get two token files under their own directories; a scope with `/` or `..` maps to the
|
||||
hashed key; connecting chat A leaves chat B not connected; concurrent flows in A and B each end with their own
|
||||
store written and their own `Notify`; `/calendar_connect` twice in A reuses A's code while B gets a fresh one;
|
||||
`/calendar_disconnect` in A leaves B's token and cache alone. Resolved zone: the calendar's stored zone, else
|
||||
`ctx.TimeZone`; the two-zone header when they differ.
|
||||
- **Telegram:** with `ChatHost`, every allowed chat lists the `calendar` tool.
|
||||
`/calendar_connect` in a chat, with a fake handler that approves on the second poll, ends with the
|
||||
"connected" message in that chat.
|
||||
|
||||
## Docs
|
||||
|
||||
New `docs/google-calendar.md` (Google Cloud setup: enable the Calendar API, configure the OAuth consent screen and
|
||||
**publish the app** without verification, with a screenshot of the "unverified app" warning users click through,
|
||||
create an OAuth client of type *TVs and Limited Input devices*, set the env vars, the three commands, where the token
|
||||
lives; testing status as an alternative, with its test-user list and 7-day refresh-token expiry). Update `docs/telegram-bot.md`
|
||||
(configuration, one account per chat), the extensions list in `CLAUDE.md` and `docs/architecture.md`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Several Google accounts in one chat, sharing one account between chats, and other Google APIs.
|
||||
- "This and following" edits, attendees and invitations, reminders, free/busy, colours, conferencing.
|
||||
- Push notifications or proactive reminders from calendar changes.
|
||||
- Automatic reconnect prompts on `invalid_grant` (the user gets the tool's message through the model).
|
||||
- Cleaning up token directories of chats removed from the allow-list (`/calendar_disconnect` first, or delete the
|
||||
directory).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should `calendar.events` + `calendar.calendarlist.readonly` replace the full `calendar` scope (least privilege), if
|
||||
the device flow allows them?
|
||||
@@ -0,0 +1,370 @@
|
||||
# MCP client extension and Playwright browser tools — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Depends on: `2026-09-26-telegram-attachments-design.md` (the `send` tool that delivers browser output);
|
||||
`2026-09-26-process-environment-hook-design.md` (the environment every server process gets, and the redaction of
|
||||
known secrets in tool output).
|
||||
Deployed by: `2026-09-26-docker-deployment-design.md` (preinstalled package and Chromium).
|
||||
|
||||
## Problem
|
||||
|
||||
The agent can only use the built-in tools and whatever extensions implement by hand. A large set of useful tools
|
||||
already exists as MCP servers, the most important being [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp),
|
||||
which lets the model drive a real browser (navigate, click, type, snapshot, screenshot). The TypeScript predecessor
|
||||
(VolcanicSecretary, `src/agent/mcp-bridge.ts` + `playwright-mcp.ts`) proved the approach: spawn a stdio MCP server,
|
||||
list its tools, and re-expose each one as an agent tool with its JSON Schema passed through untouched.
|
||||
|
||||
This port needs a generic MCP **client**: launch stdio MCP servers from a declarative list, register their tools as
|
||||
`ITool`s, and ship a Playwright preset whose screenshots land in the chat workspace so the `send` tool
|
||||
(`2026-09-26-telegram-attachments-design.md`) can deliver them.
|
||||
|
||||
Don't confuse this with `ClaudeCode.Bridge`, which is an MCP **server** that exposes core tools to `claude -p`. The
|
||||
two meet only in tool naming (see "Names").
|
||||
|
||||
## Decisions
|
||||
|
||||
- **An extension, public API only.** New project `extensions/Volcanic.AgenticCore.Extensions.Mcp` references only
|
||||
`Volcanic.AgenticCore.Abstractions`, like the provider extensions. It works registered from host code
|
||||
(`o.AddExtension<McpExtension>()`) or dropped into an extensions directory. A small contract gap is closed in
|
||||
Abstractions (see Components §8). No runtime slice changes.
|
||||
- **Official SDK, not hand-rolled JSON-RPC.** Use the `ModelContextProtocol.Core` package (the client/transport
|
||||
core, without the hosting package). It handles the initialize handshake, protocol version negotiation, the
|
||||
`notifications/cancelled` notification on cancellation, pagination of `tools/list`, content-block types and
|
||||
process shutdown (`StdioClientTransport`: `Command`, `Arguments`, `WorkingDirectory`, `EnvironmentVariables`,
|
||||
`StandardErrorLines`, `ShutdownTimeout`). The `ClaudeCode.Bridge` hand-rolls its protocol because it answers only
|
||||
three methods. A general client would have to reimplement much more. SDK types never leave the extension. Its
|
||||
`Microsoft.Extensions.*` dependencies resolve from the host when present and from the extension folder otherwise
|
||||
(`ExtensionLoadContext` already falls back).
|
||||
- **Processes are per session, tool catalogues are per process.** Each `AgentSession` (one Telegram chat) gets its
|
||||
own server process, so browser tabs, cookies, the profile and output files never leak between chats, and a crash
|
||||
only affects one conversation. Spawning one process per chat at startup would be wasteful, though, so:
|
||||
- the **catalogue** (the `tools/list` result) is discovered once per host process per server configuration and
|
||||
cached in `McpCatalogCache.Shared`. It's explicit process-wide state, like `ClaudeLoginCoordinator.Shared`;
|
||||
- the **session process** starts lazily on the first tool call, in the session's working directory, and stops on
|
||||
`SessionShutdownEvent` or after an idle timeout.
|
||||
- **Tools are registered during `ActivateAsync`.** Activation awaits the cached catalogue, which means one discovery
|
||||
spawn per host lifetime, run in a temp directory and disposed right away. It then registers one `ITool` per MCP
|
||||
tool. That keeps registration where the docs require it, honours `AgentSessionOptions.ActiveTools`, and means the
|
||||
cwd (unknown at activation) is needed only when the live process starts, from `ToolInvocation.Context.Cwd`.
|
||||
- **Startup failure never breaks the session.** If discovery fails or times out, the server is logged, recorded as
|
||||
failed for `/mcp`, and contributes no tools. Other servers and the session carry on. A failed discovery is cached
|
||||
for a 60 s cooldown so every new chat doesn't pay the timeout again. A live process that fails to start or dies
|
||||
turns into an error tool result, and the next call respawns it.
|
||||
- **Sequential by default.** MCP servers are usually stateful (Playwright drives one page), and the core runs a
|
||||
batch of tool calls in parallel. MCP tools report `ExecutionMode = Sequential` unless the server config sets
|
||||
`"parallel": true`.
|
||||
- **Server environments go through the process environment hook.** Discovery and session processes get the
|
||||
filtered environment from `IProcessSpawnPolicy` (`2026-09-26-process-environment-hook-design.md`), so host secrets
|
||||
(`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, …) never reach an MCP server by inheritance. A server's own `env` entries are
|
||||
explicit and pass through even when deny-listed: that's how a server that needs `GITHUB_TOKEN` gets it, and the only
|
||||
way. `${env:NAME}` in an `env` value copies a host variable explicitly, so a secret set in Coolify can be handed to
|
||||
one server without writing it into a config file.
|
||||
- **MCP output is redacted like any tool's.** MCP tools are ordinary `ITool`s, so the core loop replaces known secret
|
||||
values (`SecretValues`) with `[REDACTED]` in their results, progress and error messages, including the
|
||||
`<stderr tail>` of the "MCP server exited" error (process-environment-hook spec §9). A server that was handed a
|
||||
secret on purpose and echoes it back doesn't put it in the transcript. `McpTool` does nothing itself.
|
||||
- **`/new` resets the browser.** Every `SessionShutdownEvent` (quit, `/new`, resume) disposes the session's server
|
||||
processes, so tabs and in-memory browser state don't survive a new session. That is the intended behaviour: a new
|
||||
conversation starts with a fresh browser. Logins survive through the persistent profile (next decision).
|
||||
- **The browser profile is persistent and lives in the workspace**, `${workspace}/.volcanic/browser-profile`, not
|
||||
`--isolated`. Chats keep their site logins across idle restarts, `/new` and bot restarts. Cookies and site data in
|
||||
that directory are readable by the agent's `bash` and `read` by design: it's the same trust model as `bash` itself
|
||||
(the agent already acts as the bot's OS user in that workspace), and a profile outside the workspace wouldn't be out
|
||||
of reach anyway. Users who don't want a chat's logins kept can add `--isolated` to `ExtraArgs`.
|
||||
- **Configuration.** Host options (`Mcp` section in appsettings / DI) plus `~/.volcanic/agent/settings.json`
|
||||
`"mcpServers"`. The first source wins on a server name. There's deliberately no `<cwd>/.volcanic/settings.json`
|
||||
source: the cwd isn't known at activation, and a Telegram workspace is writable by the agent itself. The Playwright
|
||||
preset is a typed shortcut that expands into one server entry named `playwright`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Configuration — `McpOptions`, `McpServerConfig`
|
||||
|
||||
```jsonc
|
||||
// appsettings.json ("Mcp" section) or ~/.volcanic/agent/settings.json ("mcpServers" at the root, same entry shape)
|
||||
"Mcp": {
|
||||
"Servers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github@2025.9.1"],
|
||||
"env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" }, // explicit: applied over the filtered environment, never filtered; null removes
|
||||
"toolPrefix": "gh_", // optional; default none
|
||||
"enabled": true,
|
||||
"parallel": false,
|
||||
"startupTimeoutSeconds": 60,
|
||||
"callTimeoutSeconds": 120,
|
||||
"idleTimeoutSeconds": 1800, // 0 = never stop an idle process
|
||||
"stderrLevel": "Warning",
|
||||
"promptSection": null // optional text added to the system prompt while its tools are active
|
||||
}
|
||||
},
|
||||
"Playwright": { "Enabled": false, "Headless": true, "Browser": null, "ExtraArgs": [], "Package": null, "Command": null }
|
||||
}
|
||||
```
|
||||
|
||||
- `args`, `env` values and an optional `cwd` may contain `${workspace}`, which is expanded to the session cwd when
|
||||
the live process starts (and to the temp discovery dir during discovery). The live process's working directory
|
||||
defaults to `${workspace}`.
|
||||
- `env` values may also contain `${env:NAME}`, expanded from the host environment at spawn time (unset → empty). This
|
||||
is the sanctioned way to pass a deny-listed host variable to one server. The expansion happens in the extension, so
|
||||
the value is explicit when it reaches the policy.
|
||||
- The environment a server process gets is `IProcessSpawnPolicy`'s result for a request with `Purpose = McpServer`,
|
||||
`Origin` = the server name, the expanded command, args and cwd, and the expanded `env` entries as the explicit
|
||||
environment. It's passed to `StdioClientTransport.EnvironmentVariables` in full, with a null for every inherited
|
||||
name the policy removed, so the SDK's merge over the host environment can't reintroduce it.
|
||||
- The catalogue cache key is the server name plus a digest of the **unexpanded** command, args and env, so every chat
|
||||
shares one catalogue even though their expanded args differ. Secrets from `${env:…}` never enter the key.
|
||||
- `McpOptions.ApplyEnvironment(getEnv)` works like `TelegramOptions.ApplyEnvironment` and fills in the preset from
|
||||
`PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS` (default true), `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`
|
||||
(shell-style split honouring quotes, so `--device "iPhone 15"` is two argv entries) and `PLAYWRIGHT_COMMAND`.
|
||||
Values from config win over the environment, and an empty variable counts as unset (the Docker compose file passes
|
||||
them all through).
|
||||
- Validation (`McpOptions.Validate()`) rejects empty commands, non-positive timeouts, and prefixes that don't match
|
||||
`[A-Za-z0-9_-]*` or that start with `mcp__`.
|
||||
- The extension has two constructors, `McpExtension(McpOptions)` for DI and a parameterless one that reads only
|
||||
the environment and user settings, so a dropped-in DLL works without host code. The agent dir is
|
||||
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`.
|
||||
|
||||
### 2. Playwright preset — `PlaywrightPreset`
|
||||
|
||||
When enabled, it expands to server `playwright`:
|
||||
|
||||
```
|
||||
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
|
||||
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
|
||||
[--headless] [--browser <b>]
|
||||
--output-dir ${workspace}/browser
|
||||
--user-data-dir ${workspace}/.volcanic/browser-profile
|
||||
<ExtraArgs...>
|
||||
cwd: ${workspace}
|
||||
prefix: none (tools stay browser_*)
|
||||
callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads the package)
|
||||
```
|
||||
|
||||
- The package version is pinned in `PlaywrightPreset.DefaultPackage` so upgrades are deliberate, as with the TS
|
||||
project's pinned dependency. That constant is the **single source of truth** for the version: the Dockerfile's
|
||||
`ARG PLAYWRIGHT_MCP_VERSION` default must equal it, and a unit test reads the Dockerfile and fails if they differ.
|
||||
- **Command.** With `Playwright:Command` (env `PLAYWRIGHT_COMMAND`) set, the preset runs that binary directly and
|
||||
drops `-y <package>` from the args; the rest of the argv is the same. Bare hosts leave it unset and use `npx`
|
||||
(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally
|
||||
and compose sets `PLAYWRIGHT_COMMAND=playwright-mcp`, so a container never downloads anything and the first chat
|
||||
doesn't stall.
|
||||
- **Chromium sandbox.** The preset doesn't add `--no-sandbox` itself. Chromium's sandbox needs user namespaces, which
|
||||
Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets
|
||||
`PLAYWRIGHT_EXTRA_ARGS=--no-sandbox` and the container is the isolation boundary. Bare hosts keep the sandbox.
|
||||
- `--output-dir` inside the workspace makes screenshots, PDFs and downloads reachable by `send` through a
|
||||
workspace-relative path. `@playwright/mcp` resolves relative screenshot `filename`s against its workspace root,
|
||||
which is the process cwd (the chat workspace), and by default refuses file access outside it.
|
||||
- A per-workspace `--user-data-dir` keeps each chat's logins across idle restarts, `/new` and bot restarts, and stops
|
||||
concurrent chats from fighting over one shared profile. Its cookies are agent-readable by design (see Decisions).
|
||||
`--isolated` in `ExtraArgs` opts out.
|
||||
- Browser binaries are not installed at runtime. The deployment has to provide Node and Chromium installed by the
|
||||
`playwright` version that the pinned `@playwright/mcp` depends on (`npx playwright install --with-deps chromium`
|
||||
run from that package, as the Docker spec does). The server process gets the host environment filtered by the
|
||||
process environment hook, which keeps `PLAYWRIGHT_BROWSERS_PATH`, so it applies. In the container that's `/opt/ms-playwright`, set in the image. A missing browser
|
||||
shows up as an error result from the first `browser_*` call (the MCP server itself starts fine), and the model
|
||||
reports it.
|
||||
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any
|
||||
`browser_*` tool is active):
|
||||
|
||||
```
|
||||
## Browser
|
||||
- browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a
|
||||
page and find refs for click/type; it is cheaper and more reliable than screenshots.
|
||||
- Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back
|
||||
to you.
|
||||
- To give the user a screenshot, save it with `filename: "browser/<name>.png"` and then call `send` with that
|
||||
path. Files the browser saves (PDFs, downloads) are under browser/ too.
|
||||
- Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause.
|
||||
- Don't enter passwords or payment details unless the user explicitly asks for that specific site.
|
||||
```
|
||||
|
||||
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
|
||||
`GetAllTools()`), so the preset still reads correctly on hosts without attachments.
|
||||
|
||||
### 3. Catalogue — `McpCatalogCache`
|
||||
|
||||
`Task<McpCatalog> GetAsync(McpServerConfig, ILogger, CancellationToken)`, process-wide (`Shared`):
|
||||
|
||||
- Concurrent activations for the same key share one in-flight task, so ten chats starting at once make one spawn.
|
||||
- Discovery spawns the server in a fresh temp dir, with its environment from `api.Processes.Prepare` (activation has no
|
||||
session, so only host hooks run and `TZ` isn't set), runs `initialize` and a paginated `tools/list` under
|
||||
`startupTimeout`, captures `serverInfo`, then disposes the client and deletes the dir.
|
||||
- Success is cached for the process lifetime. Failure is cached for 60 s, then retried. The failure message
|
||||
includes the last 20 stderr lines, as in the TS bridge, because a server that dies during the handshake explains
|
||||
why only there.
|
||||
- `McpCatalog` = server name, `serverInfo`, and a list of `(mcpName, exposedName, description, inputSchema)`.
|
||||
|
||||
### 4. Names — `ToolNaming`
|
||||
|
||||
The exposed name is `toolPrefix + mcpName`, sanitised so it works for every provider and for the Claude Code
|
||||
provider's aliasing:
|
||||
|
||||
- Characters outside `[A-Za-z0-9_-]` become `_`, and the name is truncated to 48 characters. Anthropic and OpenAI
|
||||
allow 64, but `ToolCatalog` gives up to 48 before switching to an opaque `tool_<digest>` alias, and
|
||||
`mcp__volcanic__browser_navigate` is only readable if the name survives. Duplicates within one server get `_2`,
|
||||
`_3`.
|
||||
- The MCP server is always called with the original `mcpName`.
|
||||
- Collisions with other tools follow the existing `ToolRegistry` rules. An MCP tool named like a built-in
|
||||
(`read`, `bash`, ...) would **replace** it, so `McpExtension` refuses to register an exposed name that
|
||||
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. A clash
|
||||
with another extension's tool is already ignored and recorded by the runner. Between two MCP servers, the first
|
||||
configured wins, and a warning names both.
|
||||
- Through the Claude Code provider these tools reach the model as `mcp__volcanic__<exposedName>`. Nothing else is
|
||||
needed: the Bridge advertises them from the catalogue like any other tool and never executes them. Prefixes that
|
||||
start with `mcp__` are rejected (they'd read as `mcp__volcanic__mcp__x__y`). Because Claude Code runs with
|
||||
`--strict-mcp-config`, a Playwright MCP configured in the user's own Claude Code settings can't shadow ours.
|
||||
|
||||
### 5. The session process — `McpServerConnection`
|
||||
|
||||
One per server per extension instance (that is, per session):
|
||||
|
||||
- `CallAsync(mcpName, JsonElement args, IExtensionContext ctx, CancellationToken)` takes a `SemaphoreSlim`-guarded lazy
|
||||
start: expand `${workspace}` from `ctx.Cwd` and `${env:…}`, build the command, cwd and environment through
|
||||
`ctx.PrepareProcessAsync` (so session-level `ProcessSpawnEvent` handlers run and `TZ` is the chat's zone), create `StdioClientTransport` + `McpClient` under `startupTimeout`, and route
|
||||
`StandardErrorLines` to `api.Logger` at `stderrLevel` with the prefix `"<name> mcp: "`, keeping a 20-line tail.
|
||||
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. The
|
||||
SDK sends `notifications/cancelled` for the request. A timeout becomes
|
||||
`"<tool> timed out after <n>s"`, and an abort propagates as `OperationCanceledException`, which the loop already
|
||||
handles.
|
||||
- If the process exits or the transport faults, the client is disposed and marked dead, and the call fails with
|
||||
`"<name> MCP server exited (<code>): <stderr tail>. It restarts on the next call; browser state was lost."`. The
|
||||
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects.
|
||||
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight.
|
||||
- `DisposeAsync` is idempotent: dispose the client (the SDK closes stdin, waits `ShutdownTimeout`, then kills), then
|
||||
kill the process tree if it's still alive. The extension calls it on every `SessionShutdownEvent` (quit, new,
|
||||
resume). A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
|
||||
- If the live server's `tools/list` differs from the cached catalogue (a package upgrade in place), a warning is
|
||||
logged. Calls go by name either way.
|
||||
|
||||
### 6. The tool — `McpTool : ITool`
|
||||
|
||||
- `Name` = exposed name; `Description` = MCP description, falling back to annotations `title` or the name;
|
||||
`Parameters` = `inputSchema` verbatim, except that a missing or non-object schema becomes `{"type":"object"}`
|
||||
(`RegisterTool` requires an object) and a top-level `$schema` is dropped. The core's light `ArgumentValidator`
|
||||
checks `required` and primitive types, and everything else is left to the server.
|
||||
- `PromptSnippet` is null (hosts get descriptions through the API, and 20–60 browser lines would bloat the prompt).
|
||||
Guidance comes from the server's `promptSection`.
|
||||
- `ExecutionMode` = `Sequential` unless `parallel`.
|
||||
- `ExecuteAsync` passes `call.Arguments` through and maps the result with `McpResultMapper`.
|
||||
|
||||
### 7. Results — `McpResultMapper`
|
||||
|
||||
| MCP content | `ToolResult.Content` |
|
||||
|---|---|
|
||||
| `text` | `TextContent`. The joined text is capped at 2000 lines / 50 KB (head kept, the same limits and note wording as core `OutputTruncation`, reimplemented locally because that type is in the runtime) |
|
||||
| `image` | `ImageContent(data, mimeType)`. Images over 5 MB decoded are replaced by `[image omitted: <mime>, <n> bytes]` |
|
||||
| `audio` | `[audio omitted: <mime>, <n> bytes]` |
|
||||
| `resource` (text) | `TextContent` headed `Resource <uri>:` |
|
||||
| `resource` (blob) | `[resource <uri>: <mime>, <n> bytes omitted]` |
|
||||
| `resource_link` | `Resource link: <uri> (<name>)` |
|
||||
| no content but `structuredContent` | its JSON as text |
|
||||
|
||||
- `Details` = `{ server, tool, isError, truncated, images }`.
|
||||
- `isError: true` throws `ToolExecutionException(result)`, so the model sees the server's own error content,
|
||||
images included, marked as an error, which is the TS behaviour of forwarding it rather than crashing.
|
||||
- Tool-result images already reach the model through the Anthropic encoder and the Claude Code image store. The
|
||||
OpenAI chat-completions encoder turns them into `[image]` (the API has no images in tool messages), so on that
|
||||
provider the model can't see screenshots. That's acceptable, and `send` still works.
|
||||
|
||||
### 8. Abstractions changes (small, contract-only)
|
||||
|
||||
- **Move `ToolExecutionException`** from `Features/Tools/BuiltIn/BashTool.cs` to
|
||||
`Abstractions/Tools/ITool.cs`. `docs/extensions.md` already tells extension authors to use it, but today only the
|
||||
runtime can reach it. The catch in `AgentSession.Tools.cs` gets a namespace change, nothing more.
|
||||
- No other contract change in this spec. Registering tools during activation and reading `ctx.Cwd` from
|
||||
`ToolInvocation` already work. `IExtensionApi.Processes`, `IExtensionContext.PrepareProcessAsync` and
|
||||
`IExtensionContext.TimeZone` come from `2026-09-26-process-environment-hook-design.md` and the scheduler spec (§8).
|
||||
|
||||
### 9. The command — `/mcp`
|
||||
|
||||
Lists each configured server for this session: state (`not started` / `running pid N` / `failed: <reason>` /
|
||||
`disabled`), number of tools, last error, and the stderr tail with `/mcp <name>`. It costs no model call.
|
||||
`/mcp restart <name>` disposes the session process so the next call starts fresh (clears a wedged browser).
|
||||
|
||||
### 10. Host wiring (Telegram)
|
||||
|
||||
```csharp
|
||||
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
|
||||
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
|
||||
// validate alongside TelegramOptions
|
||||
builder.Services.AddSingleton(mcp);
|
||||
o.AddExtension<McpExtension>();
|
||||
```
|
||||
|
||||
At startup the log reads `MCP: playwright enabled (headless, chromium)` or
|
||||
`MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)`.
|
||||
|
||||
### 11. Docs
|
||||
|
||||
- New `docs/mcp.md`: configuration, the preset, env vars, naming and collisions, lifecycle, `/mcp`, and the
|
||||
deployment requirements (Node, `npx playwright install --with-deps chromium`, or the Docker image from
|
||||
`docs/docker.md`, which has everything).
|
||||
- `docs/extensions.md`: a note that `ToolExecutionException` now lives in Abstractions.
|
||||
- `docs/telegram-bot.md`: the `PLAYWRIGHT_*` variables.
|
||||
- `CLAUDE.md`: add `Mcp` to the extensions list.
|
||||
|
||||
## Testing
|
||||
|
||||
No test touches the network, npm or a real browser.
|
||||
|
||||
- **Fake stdio server** `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`: a small console app built
|
||||
with the SDK's server package and launched with `dotnet <dll>`. Its behaviour is chosen by env var
|
||||
`FAKE_MCP_MODE`:
|
||||
- `normal`: `echo`, `image` (1×1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation), `weird.name/x`, and
|
||||
a tool with no `inputSchema`;
|
||||
- `crash-on-start`: writes a traceback to stderr and exits 1;
|
||||
- `hang-on-start`: never answers `initialize`;
|
||||
- `die-after-first-call`: exits after one call;
|
||||
- `env`: adds a tool that returns the server's environment as text;
|
||||
- `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns.
|
||||
- **Tests** (`tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/`):
|
||||
- catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is
|
||||
applied; `weird.name/x` → `weird_name_x`; >48 chars truncated; duplicates suffixed; a missing schema becomes
|
||||
`{"type":"object"}`;
|
||||
- one discovery for N concurrent activations (`count-starts`); cooldown after `crash-on-start`, whose error
|
||||
contains the stderr traceback; `hang-on-start` gives up after `startupTimeout` and the session still activates
|
||||
with its other tools;
|
||||
- lazy start: activation spawns no session process, the first call does, in `${workspace}` with the expanded
|
||||
args;
|
||||
- environment (`FAKE_MCP_MODE=env`, a tool that returns its environment): with `OPENAI_API_KEY` and `GITHUB_TOKEN`
|
||||
in the injected host snapshot, neither reaches the server; a server `env` entry `GITHUB_TOKEN: ${env:GITHUB_TOKEN}`
|
||||
delivers it (explicit beats the deny-list); discovery and the session process agree; `TZ` is the session's zone
|
||||
for the session process;
|
||||
- results: text, image → `ImageContent`, oversized image replaced, `isError` → error tool result with content,
|
||||
output truncation note;
|
||||
- redaction, end to end through the core: the `env` tool's output with a `GITHUB_TOKEN` configured as a secret
|
||||
value shows `[REDACTED]` in the next faux request, and so does a `die-after-first-call` exit error whose stderr
|
||||
tail contains it;
|
||||
- cancellation: aborting during `slow` cancels the call and the server sees `notifications/cancelled`; the call
|
||||
timeout gives an error result naming the timeout;
|
||||
- crash and restart: `die-after-first-call` → the second call's error mentions the exit and stderr, and the third
|
||||
call succeeds on a new process; idle timeout stops the process;
|
||||
- lifecycle: `SessionShutdownEvent` kills the process (checked by pid); dispose is idempotent;
|
||||
- collisions: a server tool named `read` is refused and the built-in stays; two servers with the same tool → the
|
||||
first wins;
|
||||
- config: `ApplyEnvironment` (quoted `PLAYWRIGHT_EXTRA_ARGS`), config over env, `Validate`, user
|
||||
`settings.json` merge and precedence;
|
||||
- Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned
|
||||
package; with `PLAYWRIGHT_COMMAND` the binary is used and `-y <package>` is dropped; the Dockerfile's
|
||||
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version; the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
|
||||
- end to end through the core with `FauxProvider`: the scripted model calls the fake `image` tool, and the tool
|
||||
result message holds the image.
|
||||
- **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest).
|
||||
- **Manual E2E** (documented, not CI): `PLAYWRIGHT_ENABLED=true`, then "Open https://example.com and tell me the H1"
|
||||
→ `Example Domain`; "Screenshot example.com and send it to me" → a photo in Telegram; `/mcp` shows `running`;
|
||||
two chats browse at once without seeing each other's tabs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- HTTP / SSE / streamable-HTTP transports (stdio only; the config shape leaves room for a `url` field later).
|
||||
- MCP resources, prompts, sampling, elicitation and roots (tools only). Server-initiated `tools/list_changed` is
|
||||
logged and ignored.
|
||||
- Per-server tool allow/deny lists and renaming beyond the prefix.
|
||||
- Sharing one server process across sessions (a possible later `"scope": "process"` for stateless servers).
|
||||
- Supervision beyond respawn-on-next-call, and orphan cleanup after a host crash.
|
||||
- Installing Node, the npm package or browser binaries at runtime. The Dockerfile belongs to
|
||||
`2026-09-26-docker-deployment-design.md`.
|
||||
- An approval UX for browser actions.
|
||||
- Fortress (the TS project's `fortress-mcp`) and its `fortress_*` tools.
|
||||
@@ -0,0 +1,428 @@
|
||||
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Used by: `2026-09-26-scheduler-design.md` (watcher scripts), `2026-09-26-mcp-client-playwright-design.md` (MCP
|
||||
server processes), `2026-09-26-telegram-attachments-design.md` (`OPENAI_API_KEY`), `2026-09-26-google-calendar-design.md`
|
||||
(`GOOGLE_CLIENT_SECRET`), `2026-09-26-docker-deployment-design.md` (the secrets set in Coolify).
|
||||
|
||||
## Problem
|
||||
|
||||
Every process the agent starts inherits the host's full environment. `BashTool`
|
||||
(`src/Volcanic.AgenticCore/Features/Tools/BuiltIn/BashTool.cs`) builds a `ProcessStartInfo` and never touches
|
||||
`Environment`, so `env` in any chat prints `TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` and whatever
|
||||
else the deployment sets. The sibling specs add more spawners with the same flaw or their own partial fix: the
|
||||
scheduler's watcher scripts scrub a private list (`SchedulerOptions.ScrubbedEnvironment`, default only
|
||||
`TELEGRAM_BOT_TOKEN`), and MCP server processes (Playwright) inherit everything. `IExtensionApi.ExecAsync` inherits
|
||||
everything too.
|
||||
|
||||
A secret in a child's environment ends up in tool output, in the transcript sent to the model provider, in scripts
|
||||
the agent writes, and in any third-party package (`npx`, `pip`) that reads its environment. A prompt-injected page
|
||||
only has to ask for `env`.
|
||||
|
||||
We want one place that decides what every agent-spawned process sees, reachable from extensions that reference only
|
||||
Abstractions (the scheduler and MCP are extensions), with a safe default.
|
||||
|
||||
Two gaps remain once the children's environment is clean. The agent's `bash` runs as the host's OS user, so it can
|
||||
read the host's own environment from `/proc/<host pid>/environ` (or attach to the host with `ptrace`). And a secret
|
||||
that reaches tool output some other way (read from a file, echoed by a misbehaving MCP server) goes straight into the
|
||||
transcript and to the model provider. §8 closes the first on Linux, §9 the second for the secrets the host knows.
|
||||
|
||||
## Reference: pi's bash spawn hook
|
||||
|
||||
pi (`packages/coding-agent/src/core/tools/bash.ts`) builds a `BashSpawnContext { command, cwd, env }` for every bash
|
||||
call (the shell environment minus its own `PI_*` variables, plus session metadata) and passes it through an optional
|
||||
synchronous `spawnHook: (ctx) => ctx` given to `createBashTool(cwd, { spawnHook })`. The hook may rewrite the command
|
||||
(e.g. `source ~/.profile\n<command>`), the cwd and the env. An extension that wants a hook re-registers `bash` with one
|
||||
(`examples/extensions/bash-spawn-hook.ts`). pi has no deny-list: the default hook is none.
|
||||
|
||||
We keep the shape (a context of command, cwd and env, and a pure function that returns a new one) and generalise it:
|
||||
it covers every process spawned on the agent's behalf, not only `bash`; it is configured once on the host instead of
|
||||
per tool instance; and it has a default deny-list.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **One policy for every agent-requested process.** The `bash` tool, scheduler watcher scripts, MCP server processes
|
||||
(discovery and per-session) and `IExtensionApi.ExecAsync` all build their child environment through
|
||||
`IProcessSpawnPolicy`. Nothing spawns with the inherited environment untouched.
|
||||
- **The deny-list filters what is inherited, never what is passed explicitly.** Inherited variables matching the
|
||||
deny-list are removed. Variables the caller sets on purpose (an MCP server's `env` entry, `ExecOptions.Environment`,
|
||||
`TZ` for the chat's zone) are applied after the filter and are not filtered. So an MCP server that needs
|
||||
`GITHUB_TOKEN` gets it when its config names it, and no other process does. This one rule answers every
|
||||
"but this process legitimately needs X" case.
|
||||
- **A default deny-list, an allow-override, and secret values.** `AgenticCoreOptions.ProcessEnvironment` carries
|
||||
`Deny` (names and globs, pre-filled), `Allow` (names and globs that win over `Deny`) and `SecretValues` (any inherited
|
||||
variable whose value equals one of them is removed, whatever its name). Hosts add their own secrets to both.
|
||||
- **Host hooks, then extension handlers.** Host code adds `ProcessSpawnHook`s (pi's `spawnHook`, host-wide). Inside a
|
||||
session, extensions get a mutable `ProcessSpawnEvent` after the host hooks. Extensions are trusted in-process code
|
||||
that can already read `Environment` directly, so letting them run last gives nothing away, and it matches the
|
||||
existing extension-event model (mutable args, load order).
|
||||
- **The contract lives in Abstractions.** `IProcessSpawnPolicy`, `ProcessSpawnRequest`, `ProcessSpawnContext`,
|
||||
`ProcessSpawnHook`, `ProcessSpawnEvent` and `ISecretRedactor` (§9) are in `Volcanic.AgenticCore.Abstractions`
|
||||
(`Processes/`). The policy is a
|
||||
DI singleton (for process-wide spawners like `JobScheduler`) and is also reachable from extensions through
|
||||
`IExtensionApi.Processes` and, session-bound, `IExtensionContext.PrepareProcessAsync`.
|
||||
- **Fail closed.** A hook or handler that throws fails that spawn. Nothing falls back to the unfiltered environment.
|
||||
- **The Claude Code provider is not covered.** `claude -p`, `claude auth login`, `claude auth status` and the doctor's
|
||||
probes are provider processes, not agent-requested ones, and they already get a replaced environment (see §7).
|
||||
- **The host process is made non-dumpable on Linux (§8).** At startup the host calls `prctl(PR_SET_DUMPABLE, 0)`, so
|
||||
its `/proc/<pid>/*` entries become root-owned and a same-uid child (the agent's `bash`, a watcher, an MCP server)
|
||||
can neither read `/proc/<host pid>/environ` nor `ptrace` the host. The .NET diagnostic IPC socket, which would
|
||||
otherwise hand out the same environment, is disabled with it. On by default, with an opt-out for debugging.
|
||||
- **Known secret values are redacted from tool output (§9).** Every `SecretValues` entry is replaced by `[REDACTED]`
|
||||
in tool results, streamed progress updates and tool error messages before the model, the transcript, hosts or
|
||||
extensions see them. That covers `bash`, MCP tools and every other tool in one place, the agent loop.
|
||||
- **Still not a sandbox.** The agent still runs as the bot's OS user, so it can read config files, everything under
|
||||
the agent dir (session transcripts, `chat-settings.json`, `jobs.json`, the Claude Code login and the calendar token
|
||||
files other specs keep outside the workspace) and anything else that user can read. The hook, the hardening and
|
||||
the redaction stop the accidental and the casually injected leak (`env`, `cat /proc/<pid>/environ`, a script that
|
||||
logs its environment, a package that phones home with it). They don't stop a determined agent that goes after
|
||||
files on disk or encodes a secret it found before printing it. The container is the isolation boundary.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Contract (Abstractions, `Processes/ProcessSpawn.cs`)
|
||||
|
||||
```csharp
|
||||
public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other }
|
||||
|
||||
/// <summary>What a spawner asks for. The policy turns it into the complete child context.</summary>
|
||||
public sealed record ProcessSpawnRequest
|
||||
{
|
||||
public required ProcessPurpose Purpose { get; init; }
|
||||
/// <summary>A shell command line (BashTool, ScheduledScript) or an executable (McpServer, ExtensionExec).</summary>
|
||||
public required string Command { get; init; }
|
||||
public IReadOnlyList<string> Arguments { get; init; } = [];
|
||||
public required string Cwd { get; init; }
|
||||
/// <summary>Set or removed (null) after the deny-list; never filtered.</summary>
|
||||
public IReadOnlyDictionary<string, string?>? Environment { get; init; }
|
||||
/// <summary>IANA id; sets TZ unless <see cref="Environment"/> sets it.</summary>
|
||||
public string? TimeZoneId { get; init; }
|
||||
/// <summary>Session scope or scheduler target, for hooks that differ per chat.</summary>
|
||||
public string? Scope { get; init; }
|
||||
public string? SessionId { get; init; }
|
||||
/// <summary>Who asked: the extension, MCP server or job id. Informational.</summary>
|
||||
public string? Origin { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>pi's BashSpawnContext, generalised: the complete command, cwd and environment the child gets.</summary>
|
||||
public sealed record ProcessSpawnContext(
|
||||
ProcessPurpose Purpose, string Command, IReadOnlyList<string> Arguments, string Cwd,
|
||||
IReadOnlyDictionary<string, string> Environment, string? Scope, string? SessionId, string? Origin);
|
||||
|
||||
public delegate ProcessSpawnContext ProcessSpawnHook(ProcessSpawnContext context);
|
||||
|
||||
public interface IProcessSpawnPolicy
|
||||
{
|
||||
/// <summary>Inherit, filter, apply the explicit variables and TZ, run the host hooks. No extension events.</summary>
|
||||
ProcessSpawnContext Prepare(ProcessSpawnRequest request);
|
||||
}
|
||||
```
|
||||
|
||||
- `ProcessSpawnContext.Environment` is the **complete** environment: spawners clear `ProcessStartInfo.Environment`
|
||||
and copy it in. Names compare ordinally on Unix and case-insensitively on Windows, as the OS does.
|
||||
- Hooks are synchronous and pure, as in pi. Anything needing I/O belongs in the spawner.
|
||||
|
||||
### 2. Configuration — `AgenticCoreOptions.ProcessEnvironment`
|
||||
|
||||
```csharp
|
||||
public sealed class ProcessEnvironmentOptions
|
||||
{
|
||||
/// <summary>Names or globs (<c>*</c>, case-insensitive) removed from the inherited environment.</summary>
|
||||
public List<string> Deny { get; } = [.. DefaultDeny];
|
||||
/// <summary>Names or globs kept even when <see cref="Deny"/> matches.</summary>
|
||||
public List<string> Allow { get; } = [];
|
||||
/// <summary>Inherited variables with one of these values are removed, whatever their name. Values under 8 characters are ignored.</summary>
|
||||
public List<string> SecretValues { get; } = [];
|
||||
/// <summary>Run in order after the filter (pi's spawnHook, host-wide).</summary>
|
||||
public List<ProcessSpawnHook> Hooks { get; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
`DefaultDeny`:
|
||||
|
||||
| Entry | Catches |
|
||||
|---|---|
|
||||
| `*_API_KEY`, `*_APIKEY`, `*__APIKEY` | `OPENAI_API_KEY`, `Telegram__Speech__ApiKey` |
|
||||
| `*_TOKEN`, `*__*TOKEN` | `TELEGRAM_BOT_TOKEN`, `GITHUB_TOKEN`, `Telegram__BotToken` |
|
||||
| `*_SECRET`, `*__*SECRET` | `GOOGLE_CLIENT_SECRET`, `GoogleCalendar__ClientSecret` |
|
||||
| `*_PASSWORD`, `*__*PASSWORD` | database and registry passwords |
|
||||
| `TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` | named explicitly so that an `Allow` glob never re-admits them by accident (an explicit name in `Deny` beats a glob in `Allow`) |
|
||||
| `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN` | the provider credentials (§7) |
|
||||
|
||||
The `__` forms exist because .NET configuration reads `Section__Key` variables, and the Docker spec tells operators to
|
||||
use them (`Telegram__Speech__ApiKey`, `GoogleCalendar__ClientSecret`).
|
||||
|
||||
Precedence: an explicit name in `Deny` removes; else a name or glob in `Allow` keeps; else a glob in `Deny` removes;
|
||||
else a `SecretValues` match removes; else kept. `Allow` of an explicitly denied name is a startup validation error, so
|
||||
the conflict is visible instead of silently lost.
|
||||
|
||||
### 3. The policy — `ProcessSpawnPolicy` (core, new slice `Features/Processes`)
|
||||
|
||||
`Prepare(request)`:
|
||||
|
||||
1. Snapshot the current process environment.
|
||||
2. Remove the entries §2 rejects. Record removed **names** (never values) for the debug log.
|
||||
3. Apply `request.Environment`: set non-null values, remove null ones. Not filtered.
|
||||
4. If `request.TimeZoneId` is set and step 3 didn't set `TZ`, set `TZ` to it. The Docker image has `tzdata`, so an
|
||||
IANA id works for `date`, Python and Node. On Windows `TZ` isn't used by the OS and the step is skipped.
|
||||
5. Run `Hooks` in order. A hook may change `Command`, `Arguments`, `Cwd` and `Environment`; `Purpose`, `Scope`,
|
||||
`SessionId` and `Origin` are copied back from the request whatever it returns.
|
||||
6. Log at Debug: purpose, origin, scope and the removed names.
|
||||
|
||||
`AddAgenticCore` registers it with `TryAddSingleton<IProcessSpawnPolicy, ProcessSpawnPolicy>()`, so a host can replace
|
||||
it wholesale. `AgenticCoreOptions.ProcessEnvironment` is validated when the service is built.
|
||||
|
||||
### 4. Session binding — `IExtensionContext.PrepareProcessAsync`, `IExtensionApi.Processes`, `ProcessSpawnEvent`
|
||||
|
||||
```csharp
|
||||
// IExtensionApi (available during activation, not session-bound)
|
||||
IProcessSpawnPolicy Processes { get; }
|
||||
|
||||
// IExtensionContext (session-bound)
|
||||
ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken);
|
||||
|
||||
public sealed class ProcessSpawnEvent(ProcessSpawnContext context) : ExtensionEvent
|
||||
{
|
||||
public override string Name => "process_spawn";
|
||||
public ProcessSpawnContext Context { get; set; } = context;
|
||||
}
|
||||
```
|
||||
|
||||
`PrepareProcessAsync` fills `Scope`, `SessionId` and (unless given) `TimeZoneId` from the session (the session's time
|
||||
zone comes from `AgentSessionOptions.TimeZone`, scheduler spec §8), calls `Processes.Prepare`, then emits
|
||||
`ProcessSpawnEvent` to extensions in load order. Each handler may replace `Context`. There's no `Block`: refusing a
|
||||
command is `ToolCallEvent.Block`'s job, before any process exists.
|
||||
|
||||
Process-wide spawners without a session (the scheduler, MCP catalogue discovery) call `Processes.Prepare` directly
|
||||
(or the DI singleton), so only host hooks run for them. That is deliberate: a host-wide rule belongs in a host hook.
|
||||
|
||||
### 5. Spawners
|
||||
|
||||
| Spawner | Request | Explicit env | Notes |
|
||||
|---|---|---|---|
|
||||
| `BashTool` | `BashTool`, `Command` = the model's command, `Cwd` = `ctx.Cwd` | none | Through `ctx.PrepareProcessAsync`. Then it runs the shell as today with the returned command and cwd, and a cleared-then-copied environment. |
|
||||
| `ExtensionApi.ExecAsync` | `ExtensionExec`, `Origin` = extension name | `ExecOptions.Environment` | Session-bound. `ExecOptions.Environment` keeps its meaning (set, or null to remove), now on top of the filtered base. |
|
||||
| Scheduler `ScriptRunner` | `ScheduledScript`, `Scope` = target, `Origin` = job id, `TimeZoneId` = the target's zone | none | Through the DI `IProcessSpawnPolicy`: jobs fire for chats with no live session. Replaces `SchedulerOptions.ScrubbedEnvironment`. |
|
||||
| MCP `McpServerConnection` | `McpServer`, `Origin` = server name, `Arguments` = expanded args | the server's `env` entries | Through `ctx.PrepareProcessAsync` from the first tool call. The result's env goes to `StdioClientTransport.EnvironmentVariables`, with a null for every inherited name it doesn't contain. |
|
||||
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
|
||||
|
||||
A spawner whose `Prepare` throws reports it the way it reports a failed start: `bash` returns an error result
|
||||
`process environment hook failed: <message>`, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the
|
||||
message. The message comes from the exception, never from the environment.
|
||||
|
||||
### 6. Telegram host
|
||||
|
||||
- `Program.cs` adds, after reading `TelegramOptions` and the sibling options:
|
||||
- `SecretValues`: the bot token, the speech API key, the Google client secret (whichever are set). This catches a
|
||||
secret that arrives under a name no glob matches (for example a Coolify alias).
|
||||
- No extra `Deny` names: the defaults already cover every secret the host and the sibling specs read.
|
||||
`TELEGRAM_ALLOWED_USER_IDS` isn't secret and stays.
|
||||
- A `ProcessEnvironment` configuration section binds `Deny` and `Allow` (added to the defaults, not replacing them),
|
||||
with the environment shortcut `PROCESS_ENV_ALLOW` (comma separated) for Coolify. A deployment that wants the agent to
|
||||
use `gh` sets `PROCESS_ENV_ALLOW=GH_TOKEN`.
|
||||
- The startup log gains one line: `Process environment: <n> deny rules, allow: GH_TOKEN` (names only).
|
||||
- `Program.cs` calls `HostProcessHardening.Apply` once the options are validated (§8). The `ProcessEnvironment`
|
||||
section also binds `HardenHostProcess`.
|
||||
- The same `SecretValues` feed the tool-output redactor (§9), so the bot token never reaches the model in a tool
|
||||
result, whichever file or process it was read from.
|
||||
|
||||
### 7. The Claude Code provider is out of the hook's reach, on purpose
|
||||
|
||||
The provider spawns `claude -p` per model call, plus `claude auth login` / `auth status` and the doctor's probes.
|
||||
These processes run the model, not something the model asked for, and the provider already replaces their
|
||||
environment completely: `ClaudeArgs.AllowListed` keeps only `HOME`, `PATH`, `USER`, `LANG`, `LC_*`, the temp and proxy
|
||||
variables, `CLAUDE_CONFIG_DIR`, `NODE_EXTRA_CA_CERTS` and the Windows basics, then pins its own flags.
|
||||
`FindDeniedVariable` refuses to run at all when an Anthropic credential is present. The one variable it adds on top
|
||||
is `TZ`, set to the session's zone for `claude -p` (scheduler spec §8), by the provider itself and not through the
|
||||
policy. So none of the deny-listed
|
||||
variables can reach `claude` today, and the hook would add nothing but risk:
|
||||
|
||||
- a host hook adding variables to `claude` could change what `system/init` validation sees or leak an API key into a
|
||||
subscription-only process, which the provider exists to prevent;
|
||||
- the provider references only Abstractions and must stay self-contained, as `docs/claude-code-provider.md` describes.
|
||||
|
||||
The Bridge is started by `claude` and inherits `claude`'s allow-listed environment. The core tools the Bridge
|
||||
advertises run in the core, so a `bash` call made through Claude Code goes through the hook like any other.
|
||||
`docs/claude-code-provider.md` gets one sentence saying this.
|
||||
|
||||
### 8. Host process hardening (Linux) — `HostProcessHardening`
|
||||
|
||||
**Where.** A static core helper, `HostProcessHardening.Apply(ProcessEnvironmentOptions options, ILogger logger)` in
|
||||
`Features/Processes`. It is idempotent (a static flag; the first call wins) and is called from two places:
|
||||
|
||||
- the `ProcessSpawnPolicy` constructor. Every agent-requested spawn resolves the policy first, so any host using
|
||||
`AddAgenticCore` is hardened before the first `bash`, watcher or MCP process exists, without remembering to call
|
||||
anything. `AddAgenticCore` itself can't do it: it only registers services, and the options aren't bound yet;
|
||||
- the Telegram host's `Program.cs`, explicitly, right after the options are validated and before the host starts
|
||||
(so before polling, the scheduler and the Claude Code provider's first `claude` process). The call is the same, so
|
||||
the policy's later call is a no-op. The headless sample relies on the policy constructor.
|
||||
|
||||
Tests that build a `ProcessSpawnPolicy` pass options with `HardenHostProcess = false`, so the test runner is never
|
||||
made non-dumpable (see Testing).
|
||||
|
||||
**What.** `ProcessEnvironmentOptions` gains:
|
||||
|
||||
```csharp
|
||||
/// <summary>Linux: prctl(PR_SET_DUMPABLE, 0) at startup. Turn off only to attach a debugger or dotnet-dump.</summary>
|
||||
public bool HardenHostProcess { get; set; } = true;
|
||||
```
|
||||
|
||||
On Linux with the flag on, `Apply` calls `prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)` through a P/Invoke to libc
|
||||
(`[LibraryImport("libc", SetLastError = true)] static partial int prctl(int option, nuint arg2, nuint arg3, nuint arg4, nuint arg5)`,
|
||||
`PR_SET_DUMPABLE = 4`). A non-zero return is logged as a warning with the errno and startup continues: hardening is
|
||||
defence in depth, and failing to start over it would be worse. On success it logs `Host process hardened: not
|
||||
dumpable` at Information. Everywhere else (`!OperatingSystem.IsLinux()`) it's a no-op with one Debug line; macOS and
|
||||
Windows have no equivalent that matters for a same-user child here. With the flag off it logs a Warning (`Host process
|
||||
hardening disabled: agent processes can read this process's environment`).
|
||||
|
||||
**The diagnostic IPC socket.** The .NET runtime listens on `$TMPDIR/dotnet-diagnostic-<pid>-*-socket`, owned by the
|
||||
host's uid. Its `ProcessEnvironment` command returns the whole environment, and `ApplyStartupHook` loads code into
|
||||
the host, so a same-uid child could bypass `PR_SET_DUMPABLE` entirely. The socket can only be turned off before the
|
||||
runtime starts, through `DOTNET_EnableDiagnostics=0` (or `DOTNET_EnableDiagnostics_IPC=0`). So:
|
||||
|
||||
- the Docker image sets `ENV DOTNET_EnableDiagnostics=0` (Docker spec);
|
||||
- `Apply` checks the variable. When hardening is on and neither is `0`, it logs a Warning naming the variable, so a
|
||||
bare-host operator sees the gap;
|
||||
- the policy doesn't filter the variable from children: an agent's own `dotnet` builds inheriting it only lose their
|
||||
diagnostics.
|
||||
|
||||
**Side effects** (all accepted, documented):
|
||||
|
||||
- no core dumps of the host (the kernel won't write one for a non-dumpable process), including .NET's own
|
||||
`createdump` on a crash;
|
||||
- `/proc/<host pid>/*` is owned by root: `ps` still shows the process, but `environ`, `maps`, `mem`, `fd` and
|
||||
similar are unreadable to the host's own user (tools that read them for that pid, such as `lsof -p`, see less);
|
||||
- `dotnet-dump`, `dotnet-trace`, `dotnet-counters`, `dotnet-gcdump` and a debugger can't attach (ptrace is refused,
|
||||
and the IPC socket is off);
|
||||
- children are unaffected: a forked child inherits the flag, but `execve` resets it, so the agent's processes are
|
||||
normally dumpable.
|
||||
|
||||
**Opt-out for debugging.** `ProcessEnvironment:HardenHostProcess=false` (env `ProcessEnvironment__HardenHostProcess=false`
|
||||
in Coolify), plus unsetting `DOTNET_EnableDiagnostics`, restores attach, dumps and traces. Meant for a debugging
|
||||
session, then turned back on.
|
||||
|
||||
### 9. Secret redaction in tool output — `SecretRedactor`
|
||||
|
||||
The environment filter keeps secrets out of children's environments, but a secret can still reach tool output: read
|
||||
from a file under the agent dir, printed by an MCP server that got it through its explicit `env`, or quoted in an
|
||||
exception message. The agent loop replaces every known secret value before anything downstream sees it.
|
||||
|
||||
**Contract** (Abstractions, `Processes/SecretRedaction.cs`):
|
||||
|
||||
```csharp
|
||||
public interface ISecretRedactor
|
||||
{
|
||||
/// <summary>Replaces every known secret value in <paramref name="text"/> with <c>[REDACTED]</c>.</summary>
|
||||
string Redact(string text);
|
||||
}
|
||||
```
|
||||
|
||||
- **The set** is `ProcessEnvironmentOptions.SecretValues`, the same list §2 filters by value. Values shorter than
|
||||
**8 characters** are ignored here too, so a short or common value (`true`, `admin`, a port) can't redact half the
|
||||
output. Values of deny-listed variables aren't added automatically: a glob can match a non-secret, and the host
|
||||
knows which values are secret. Duplicates are removed; longer values are replaced first, so a secret that contains
|
||||
another is replaced whole.
|
||||
- **Matching** is ordinal substring, the replacement token is `[REDACTED]`. Encoded forms (base64, URL-encoded,
|
||||
split across lines) aren't matched.
|
||||
- **Registration**: `AddAgenticCore` registers `SecretRedactor` (core, `Features/Processes`) as the `ISecretRedactor`
|
||||
singleton with `TryAddSingleton`. It's built once from the validated options; with an empty set, `Redact` returns
|
||||
its input. Extensions reach it through `IExtensionApi.Redactor` (activation, not session-bound) for text they send
|
||||
to the model outside a tool result (the scheduler's watcher output, below).
|
||||
|
||||
**Where it's applied** (core, `AgentSession.RunOneAsync`, so every tool, built-in, extension or MCP, is covered):
|
||||
|
||||
1. **Streamed progress**: each partial `ToolResult` passed to `invocation.Report` is redacted before
|
||||
`AgentSessionEvent.ToolExecutionUpdate` and `ToolExecutionUpdateEvent` are emitted. `bash` reports snapshots of
|
||||
the output tail, not deltas, so every snapshot is redacted whole.
|
||||
2. **The result**, including every error path: a `ToolExecutionException`'s result, the message of any other
|
||||
exception, `Operation aborted`, and preparation errors (argument validation, blocked calls). Redacted before
|
||||
`ToolResultEvent`, so extension handlers only see the redacted text.
|
||||
3. **Again after `ToolResultEvent`**, so a handler can't reintroduce a secret. Then `ToolExecutionEnd` and the
|
||||
`ToolResultMessage` that is persisted and sent to the model.
|
||||
|
||||
What's redacted in a `ToolResult`: the text of every `TextContent` block, and every string value inside `Details`
|
||||
(hosts render it, and it's persisted). Images aren't touched. Tools don't redact themselves, and the tool's own
|
||||
truncation runs first, so a secret cut in half at the start of a truncated tail can leave a fragment; that's
|
||||
accepted.
|
||||
|
||||
**Outside tool results.** The scheduler redacts a watcher's stdout before it becomes a prompt, and the stderr tail of
|
||||
a failure alert (`2026-09-26-scheduler-design.md` §4). User messages and the model's own output aren't redacted: the
|
||||
first comes from an allow-listed user, the second can only contain a secret that already passed through one of the
|
||||
paths above.
|
||||
|
||||
### 10. Docs
|
||||
|
||||
- `docs/extensions.md`: spawning processes from an extension (`ctx.PrepareProcessAsync`, `api.Processes`), the
|
||||
explicit-env rule, `ProcessSpawnEvent`, and `api.Redactor`.
|
||||
- `docs/architecture.md`: the `Processes` slice and the Abstractions contract, host hardening, and where tool output
|
||||
is redacted.
|
||||
- `docs/telegram-bot.md`: Security gains "secrets are removed from the environment of `bash`, scheduled scripts and MCP
|
||||
servers; `PROCESS_ENV_ALLOW` lets one through; the bot's own environment can't be read through `/proc`; the bot
|
||||
token and the other configured secrets are replaced by `[REDACTED]` in tool output", the
|
||||
`ProcessEnvironment:HardenHostProcess` opt-out and its side effects, with the not-a-sandbox caveat.
|
||||
- `docs/claude-code-provider.md`: the §7 sentence.
|
||||
- `CLAUDE.md`: `Processes` in the slice list.
|
||||
|
||||
## Testing
|
||||
|
||||
xunit 2, no network. Tests set variables through an injected environment snapshot (`ProcessSpawnPolicy` takes a
|
||||
`Func<IReadOnlyDictionary<string, string?>>`, defaulting to the process environment), never by mutating the test
|
||||
process's environment.
|
||||
|
||||
- **Policy** (`tests/Volcanic.AgenticCore.Tests/Processes/`):
|
||||
- each default glob removes its examples (`OPENAI_API_KEY`, `Telegram__Speech__ApiKey`, `Telegram__BotToken`,
|
||||
`GoogleCalendar__ClientSecret`, `DB_PASSWORD`) and keeps `PATH`, `HOME`, `LANG`, `PLAYWRIGHT_BROWSERS_PATH`,
|
||||
`TOKENIZERS_PARALLELISM`;
|
||||
- `Allow` of `GITHUB_TOKEN` keeps it; `Allow` of `*_TOKEN` doesn't re-admit `TELEGRAM_BOT_TOKEN`; `Allow` of an
|
||||
explicitly denied name fails validation;
|
||||
- a `SecretValues` match under an innocent name is removed; values under 8 characters are ignored;
|
||||
- explicit env beats the filter (a denied name passed explicitly arrives), null removes;
|
||||
- `TimeZoneId` sets `TZ`, unless explicit env sets it;
|
||||
- hooks run in order, can rewrite command, cwd and env, cannot change `Purpose`/`Scope`; a throwing hook throws out
|
||||
of `Prepare`;
|
||||
- removed names are logged, values never (a recording logger asserts no secret value appears).
|
||||
- **Session binding**: `PrepareProcessAsync` fills scope, session id and the session's zone; two extensions' handlers
|
||||
run in load order after the host hooks; `api.Processes` works during activation.
|
||||
- **`BashTool`** (real processes, Unix-only like the existing script tests): with `OPENAI_API_KEY` in the injected
|
||||
snapshot, `env` output doesn't contain it; a hook's `source`-style command prefix runs; the session's zone shows in
|
||||
`date +%Z`; a throwing hook gives an error result and starts no process.
|
||||
- **`ExecAsync`**: `ExecOptions.Environment` still sets and removes, on the filtered base.
|
||||
- **Scheduler and MCP**: covered in their specs (the `ScriptRunner` test that `TELEGRAM_BOT_TOKEN` is absent; the fake
|
||||
MCP server's `env` mode).
|
||||
- **Claude Code**: a test that `ClaudeArgs.BuildEnvironment` output is unchanged when a host hook is configured (the
|
||||
provider never consults the policy).
|
||||
- **Host hardening** (`HostProcessHardening` takes an injectable `Func<int, int>` prctl seam and an
|
||||
`IsLinux` flag for unit tests):
|
||||
- flag on + Linux → prctl called once with `PR_SET_DUMPABLE, 0`; a second `Apply` doesn't call it again; a failing
|
||||
prctl logs a warning and doesn't throw; non-Linux and flag off → not called (flag off logs the warning);
|
||||
- the `DOTNET_EnableDiagnostics` warning appears when the injected environment lacks it, and not when it's `0`;
|
||||
- Linux-only integration test with the real libc: a helper process (the test runner spawns
|
||||
`dotnet <test helper>.dll`) applies the hardening and prints `ok`; the test then runs `cat /proc/<helper pid>/environ`
|
||||
as the same user and asserts it fails with permission denied, and that the same read succeeds against a helper
|
||||
started with `HardenHostProcess=false`. The test process itself is never hardened, so test debugging keeps working.
|
||||
- **Redaction** (`tests/Volcanic.AgenticCore.Tests/Processes/`, `FauxProvider` + scripted test tools):
|
||||
- `SecretRedactor`: a value inside text becomes `[REDACTED]`; values under 8 characters are ignored; an overlapping
|
||||
longer secret wins; an empty set returns the same string instance;
|
||||
- through the loop: a tool whose result, `Details`, progress reports and thrown exception message contain a secret →
|
||||
the `ToolExecutionUpdate`/`ToolExecutionEnd` events, `ToolResultEvent` handler input, the persisted
|
||||
`ToolResultMessage` and the next faux request all contain `[REDACTED]` and never the value; a `ToolResultEvent`
|
||||
handler that appends the secret is redacted again;
|
||||
- `bash` running `cat secret.txt` (a file containing a configured secret) returns `[REDACTED]`.
|
||||
- **Telegram**: with `ChatHost`, a faux `bash` call that prints the bot token from a file shows `[REDACTED]` in the
|
||||
next model request.
|
||||
- **Telegram**: with `ChatHost`, a faux `bash` call running `env` in a chat doesn't show the bot token, whatever
|
||||
variable name carries it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sandboxing (namespaces, seccomp, a separate OS user per chat), and stopping the agent from reading config files,
|
||||
the agent dir or other files the bot's user can read. (`/proc/<host pid>/environ` is covered by §8.)
|
||||
- `<NAME>_FILE` secrets (the Docker secrets convention). Deployment is Coolify, which passes secrets as environment
|
||||
variables, so reading them from files would add a path nobody uses. The environment filter, §8 and §9 are what
|
||||
keep those variables from the agent.
|
||||
- Redacting encoded or partial secrets, user messages and model output (§9).
|
||||
- Filtering files or arguments; only the environment, command and cwd are in the context.
|
||||
- Blocking spawns by content (`ToolCallEvent.Block` already covers tools).
|
||||
- Applying the hook to the Claude Code provider's processes (§7).
|
||||
- An agent-facing way to see which variables were removed (the log has the names).
|
||||
466
docs/superpowers/specs/2026-09-26-scheduler-design.md
Normal file
466
docs/superpowers/specs/2026-09-26-scheduler-design.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Depends on: `2026-09-26-telegram-attachments-design.md` (`IChatChannel`, `ChatContext.Channel`, the renderer's
|
||||
`RunInOrderAsync`); `2026-09-26-process-environment-hook-design.md` (`IProcessSpawnPolicy`, which builds the watcher
|
||||
scripts' environment, and `ISecretRedactor`, which cleans their output); `2026-09-26-telegram-message-context-design.md`
|
||||
(`MessageContext.TimestampHeader`, the local-time header on every prompt).
|
||||
|
||||
## Problem
|
||||
|
||||
The Telegram bot only speaks when spoken to. The owner wants it to act on a clock: "remind me at 9 tomorrow to call
|
||||
the dentist", "every weekday at 8, summarise what's in `inbox/`", "check this page every 10 minutes and tell me only
|
||||
if the price drops below 50". VolcanicSecretary (`/tmp/VolcanicSecretary`, `src/scheduler/*`, `src/tools/schedule.ts`)
|
||||
already does this for a single-owner pi bot. This spec ports its behaviour to our multi-chat .NET host: which jobs
|
||||
exist, how they fire, where the code lives, and how a scheduled prompt fits with a run that's already active in the
|
||||
same chat.
|
||||
|
||||
## Reference behaviour kept from VolcanicSecretary
|
||||
|
||||
- A job has an `at` (one-off) or `cron` schedule, and it's either fixed text or a shell command.
|
||||
- Watcher convention: exit 0 with empty stdout (after trimming) stays silent. Exit 0 with output delivers the output (here: always to the agent, see Decisions).
|
||||
A non-zero exit or a timeout is a failure. It's reported once, straight to the chat and never through the model,
|
||||
with the last 20 lines of stderr. While later runs fail the same way nothing more is sent. A success resets that.
|
||||
- One-off jobs are removed after they fire, whether that worked or not. On startup overdue one-offs fire right away.
|
||||
Cron jobs never catch up.
|
||||
- At most one instance of a job runs at a time. An occurrence that comes due while the previous one is still running is
|
||||
skipped and logged.
|
||||
- One timer is armed for the soonest due job and capped at 1 h, then re-armed on every tick, create and cancel.
|
||||
- Captured output is capped: the first 48 KiB and the last 16 KiB of each stream are kept. The script environment
|
||||
doesn't contain the bot token (here: nor any other host secret, through the process environment hook).
|
||||
- Short ids of 5 characters from `abcdefghjkmnpqrstuvwxyz23456789`. The store is written atomically (temp file + rename)
|
||||
after every change or run, and the writes are serialised.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Three explicit kinds instead of the source × delivery matrix.** `reminder` sends fixed text to the chat with no
|
||||
model call. `prompt` runs fixed text through the agent. `watcher` runs a script, and when the script reports a match
|
||||
its output always goes to the agent. There's no direct-output mode for watchers: the script itself is the condition
|
||||
(silent until it matches), so matches are rare and the agent turning raw output into a message is worth one model
|
||||
call. The consequence: every match costs one model call, and a match that arrives while a run is active is queued as
|
||||
a follow-up like any scheduled prompt. Reminders stay direct because they are fixed text. Failure reports stay
|
||||
direct (see Firing) because they must arrive even when the model is unavailable.
|
||||
- **A watcher's script runs without the agent. The agent is invoked only when the condition is met.** Every tick runs
|
||||
the script and nothing else: no model call, no session activity (no session is created or woken, nothing is queued),
|
||||
nothing in the transcript. The condition is met only when the script exits 0 with non-empty stdout (after
|
||||
trimming). Then, and only then, the output goes to the agent through `RunPromptAsync`. A silent run (exit 0, empty
|
||||
stdout) costs nothing and is invisible except for `LastRunAt`/`LastResult`. A failure is reported directly, never
|
||||
through the model.
|
||||
- **A new extension project, `extensions/Volcanic.AgenticCore.Extensions.Scheduler`.** It references only
|
||||
Abstractions, plus Cronos. It isn't a core slice: slices are composed per `AgentSession`, while the scheduler is
|
||||
process-wide, has to fire for chats with no live session, and brings a third-party dependency the core shouldn't
|
||||
carry. It follows the Claude Code pattern: one process-wide object (`JobScheduler`), a thin per-session extension
|
||||
that registers the `schedule` tool, and a delivery interface the host implements. The scheduler itself adds nothing
|
||||
to Abstractions. It uses contracts that other specs add there: `IProcessSpawnPolicy` and `ISecretRedactor`
|
||||
(`2026-09-26-process-environment-hook-design.md`) and the session time zone (§8 of this spec).
|
||||
- **Jobs are scoped per chat through a `target` string.** On Telegram the target is the chat id (invariant culture).
|
||||
The per-session extension is bound to its target by the host through `AgentSessionOptions.ExtraExtensions`, so
|
||||
there's no need to add a "scope" to `IExtensionContext`. The tool, `/jobs` and `/cancel` only see the jobs of their
|
||||
own target. Scripts run in that target's workspace, `<WorkspaceRoot>/<chatId>`.
|
||||
- **Scheduled agent prompts are queued as follow-ups, never as steering.** When the chat is idle a prompt job starts a
|
||||
run. When a run is active (a user request, or another job), the prompt goes in with `StreamingBehavior.FollowUp`: the
|
||||
current work finishes first, then the job's prompt is handled in the same run. A clock-driven prompt shouldn't
|
||||
redirect what the user is doing mid-turn. User messages keep steering as they do today.
|
||||
- **Cron parsing uses Cronos** (`Cronos` on NuGet, Hangfire, MIT, no dependencies). It parses standard 5-field
|
||||
expressions and macros (`@daily`, `@hourly`, …). `GetNextOccurrence(DateTimeOffset, TimeZoneInfo)` works in a given
|
||||
zone and has defined DST semantics: a time skipped when clocks go forward fires at the transition, and an ambiguous
|
||||
time when clocks go back fires once. That matches `croner` in the reference. The alternatives:
|
||||
NCrontab has no time zones (we'd have to do the DST arithmetic ourselves). Quartz.NET is a whole scheduler, with its
|
||||
own persistence and a 6/7-field syntax the model gets wrong. We only need "next occurrence after t in zone z".
|
||||
Seconds (`CronFormat.IncludeSeconds`) and the `H` jitter aren't allowed: minute granularity is enough, and it also
|
||||
sets a 1-minute floor for watchers.
|
||||
- **Each chat has its own time zone.** It applies to cron, to displayed times, to the session's system prompt, to the
|
||||
local-time header on each prompt, and to the `TZ` of the chat's `bash`, watcher and `claude -p` processes (§8). An `at` without an offset is read in the chat's zone, because
|
||||
models often leave the offset out. An `at` with an offset is used as given. The zone is a chat setting, not a
|
||||
scheduler setting, because the system prompt and `bash` need it with the scheduler disabled too:
|
||||
- **Stored by the host**, in a small per-chat settings store outside every workspace:
|
||||
`<AgentDir>/telegram/chat-settings.json` (§7). Not in `jobs.json` (the scheduler can be off) and not in the session
|
||||
(a `/new` session must keep it). Like `jobs.json` it's out of the workspace, so the agent doesn't browse or edit
|
||||
it by accident. `bash` can still reach it as the bot's OS user, like every file under the agent dir.
|
||||
- **Default**: `Telegram:DefaultTimeZone` (env `DEFAULT_TIMEZONE`), else `TimeZoneInfo.Local`, which honours `TZ`.
|
||||
A chat without a stored zone follows the default, so changing the default moves every such chat.
|
||||
- **Set** by `/timezone <IANA id>` in the chat, or by the agent through a `set_timezone` tool when the user says
|
||||
where they are. Validated with `TimeZoneInfo.FindSystemTimeZoneById` and stored as an IANA id.
|
||||
- The process-wide `Scheduler:TimeZone` is gone. The scheduler asks the host for a target's zone
|
||||
(`IJobDelivery.TimeZoneFor`).
|
||||
- **Changing a chat's zone re-evaluates cron and leaves one-offs alone.** A cron expression is wall-clock time in the
|
||||
chat's zone ("every weekday at 8" means 8 where the user is now), so on a change every cron job of that chat gets its
|
||||
next occurrence recomputed in the new zone, strictly after now, with no catch-up. A one-off `at` is an absolute
|
||||
instant, fixed when the job was created (an offset-less `at` was resolved in the zone of that moment), so it fires at
|
||||
the same instant and is only displayed in the new zone. The confirmation says both. Per-job zones are out of scope.
|
||||
- **Restart and missed fires:** one-offs fire late with a note, and cron doesn't catch up. On startup an overdue one-off
|
||||
fires immediately. If it's more than 60 s late, direct text gets `(due <time>, delivered late)` and an agent prompt
|
||||
gets `This was due at <time>; the bot was offline.` A cron job's next occurrence is computed strictly after "now".
|
||||
Occurrences missed while the bot was down are dropped, which is logged at Information. A one-off is removed only
|
||||
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once).
|
||||
- **Persistence is one central file:** `<AgentDir>/scheduler/jobs.json` (by default `~/.volcanic/agent/scheduler/`).
|
||||
The workspace is deliberately not used: the agent's `bash` can write anywhere there, and a central file is loaded
|
||||
without scanning every workspace. The format is `{ "version": 1, "jobs": [...] }` in `VolcanicJson` camelCase.
|
||||
- **Direct delivery is text, through `IChatChannel`.** The sibling spec `2026-09-26-telegram-attachments-design.md`
|
||||
adds `IChatChannel` (Abstractions, `Ui/`) with `SendTextAsync`/`SendFileAsync`. `IJobDelivery` stays, because it
|
||||
does what the channel doesn't (target routing, prompt runs, workspaces, deliverability), but the Telegram
|
||||
implementation of `IJobDelivery.SendTextAsync` is a thin call to the chat session's `IChatChannel.SendTextAsync`.
|
||||
A scheduled `prompt` (and a watcher match) runs as a normal agent run, so it can use that spec's `send` tool. Direct
|
||||
reminders and failure alerts stay text.
|
||||
- **Direct deliveries are not added to the transcript.** Reminders and failure alerts go to the chat only;
|
||||
`IChatChannel` never persists and the scheduler appends no `CustomMessage`. As a consequence the agent doesn't see
|
||||
direct reminders (it can't answer "what was that reminder about?" from context). That's accepted: a job that should
|
||||
be in the conversation is a `prompt`, and a watcher match always is.
|
||||
- **Scheduled prompts carry the same local-time header as user messages.** Every prompt a job sends to the agent (a
|
||||
`prompt` job, a watcher match) starts with the message-context spec's timestamp line in the chat's zone, e.g.
|
||||
`[Sat 2026-09-26 08:00 Europe/Berlin]`, taken from `TimeProvider` at fire time. The Telegram delivery adds it
|
||||
(§6), so the scheduler's own format stays host-neutral. The header is the actual fire time, so a late one-off shows
|
||||
both that and its `This was due at …` note.
|
||||
- **Watcher output is redacted before the agent sees it.** Watcher stdout isn't a tool result, so the loop's redaction
|
||||
doesn't reach it; the scheduler applies `ISecretRedactor` itself (§4).
|
||||
- **Time comes only from `TimeProvider`.** No `DateTime.Now` or `Task.Delay`: the timer is `TimeProvider.CreateTimer`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Job model (`Scheduler/Jobs.cs`)
|
||||
|
||||
```csharp
|
||||
public enum JobKind { Reminder, Prompt, Watcher }
|
||||
|
||||
public sealed record JobSchedule(DateTimeOffset? At, string? Cron); // exactly one is set; At is an absolute instant
|
||||
|
||||
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
|
||||
|
||||
public sealed record ScheduledJob
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Target { get; init; } // chat id on Telegram
|
||||
public required string Name { get; init; }
|
||||
public required JobKind Kind { get; init; }
|
||||
public required JobSchedule Schedule { get; init; }
|
||||
public string? Text { get; init; } // reminder, prompt
|
||||
public string? Command { get; init; } // watcher
|
||||
public int? TimeoutSeconds { get; init; } // watcher only; default 300, max 3600
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastRunAt { get; init; }
|
||||
public JobResult? LastResult { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
Validation, shared by the tool and the store loader. The name is required and at most 80 characters.
|
||||
Exactly one of `at`/`cron` is set. The cron expression parses with Cronos `CronFormat.Standard`. A one-off `at` must be
|
||||
in the future when created (with 60 s of grace). Reminders and prompts need `text` (at most 4000 characters) and no
|
||||
`command`. Watchers need `command` and no `text`. `timeoutSeconds` must be between 1 and 3600 and is only allowed on
|
||||
watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error is a sentence the model can act on.
|
||||
|
||||
### 2. `JobStore`
|
||||
|
||||
`LoadAsync()` returns the jobs. A missing file means an empty list. A corrupt file is renamed to
|
||||
`jobs.json.corrupt-<timestamp>`, logged as an error, and loading starts empty: a bad file must not keep the host down.
|
||||
Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
|
||||
`File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
|
||||
|
||||
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests)
|
||||
|
||||
`RunAsync(ScriptRequest(command, cwd, timeout, target, jobId, timeZoneId), ct) → ScriptOutcome` (`Silent` |
|
||||
`Output(text)` | `Failed(exitCode?, stderrTail, timedOut)`).
|
||||
|
||||
- The shell matches the built-in `bash` tool, so a script the agent tested with `bash` behaves the same when it's
|
||||
scheduled: `/bin/sh -c <command>` on Unix and `cmd.exe /d /s /c` on Windows. It's overridable through
|
||||
`SchedulerOptions.Shell`. The command is passed through `ArgumentList`, not quoted by hand.
|
||||
- The working directory is the target's workspace. Stdin is closed.
|
||||
- The command, cwd and environment come from `IProcessSpawnPolicy.Prepare` (DI singleton, registered by
|
||||
`AddAgenticCore`) with `Purpose = ScheduledScript`, `Scope = target`, `Origin = job id` and `TimeZoneId` = the
|
||||
target's zone. So the script sees the same filtered environment as `bash`, with `TZ` set to the chat's zone, and
|
||||
host hooks apply. There's no scheduler-specific scrub list any more (`SchedulerOptions.ScrubbedEnvironment` is
|
||||
removed). A policy that throws is `Failed(null, "process environment hook failed: …")`. Session-level
|
||||
`ProcessSpawnEvent` handlers don't run: a job fires without a live session.
|
||||
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A shell that fails to start is `Failed(null, "failed to start <shell>: …")`,
|
||||
never an exception.
|
||||
- Output capture as in the reference, then `Trim()` on stdout.
|
||||
|
||||
### 4. `JobScheduler` (process-wide, the slice's entry type)
|
||||
|
||||
```csharp
|
||||
public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
|
||||
{
|
||||
Task StartAsync(IJobDelivery delivery, CancellationToken ct); // load, fire overdue one-offs, arm timer
|
||||
Task StopAsync(CancellationToken ct); // disarm, kill running scripts, wait ≤ 10 s
|
||||
IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
|
||||
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException
|
||||
Task<bool> CancelAsync(string target, string id, CancellationToken ct); // false: unknown, or another target's
|
||||
TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
|
||||
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply
|
||||
}
|
||||
|
||||
public interface IJobDelivery
|
||||
{
|
||||
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
|
||||
Task SendTextAsync(string target, string markdown, CancellationToken ct);
|
||||
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
|
||||
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
|
||||
/// <summary>Working directory for the target's scripts.</summary>
|
||||
string WorkspaceFor(string target);
|
||||
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted.</summary>
|
||||
bool IsDeliverable(string target);
|
||||
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached.</summary>
|
||||
TimeZoneInfo TimeZoneFor(string target);
|
||||
}
|
||||
```
|
||||
|
||||
The delivery is passed to `StartAsync` and not to the constructor. That breaks the cycle
|
||||
ChatRegistry → session factory → scheduler extension → scheduler → delivery → ChatRegistry.
|
||||
|
||||
Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the tick):
|
||||
|
||||
| Kind | Action | Message format |
|
||||
|---|---|---|
|
||||
| reminder | `SendTextAsync` | `⏰ <text>` |
|
||||
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>` |
|
||||
| watcher, matched (exit 0, non-empty stdout) | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout>` |
|
||||
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only `LastRunAt`/`LastResult` are updated |
|
||||
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n```` |
|
||||
|
||||
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `WorkspaceFor` and `TimeZoneFor`.
|
||||
It never calls `RunPromptAsync` or `SendTextAsync`, and the Telegram delivery's `WorkspaceFor`/`TimeZoneFor` don't
|
||||
create or look up a chat session. Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
|
||||
`Failed` → at most one `SendTextAsync` per failure streak, `Silent` → nothing.
|
||||
- **Redaction.** A watcher's stdout goes through `redactor.Redact` before it's put into the prompt, and the stderr
|
||||
tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known
|
||||
secret values become `[REDACTED]` (process-environment-hook spec §9). `LastResult.StderrTail` is stored redacted.
|
||||
- A failure repeats the previous one when the previous `LastResult` was also a failure with the same `ExitCode` and
|
||||
`TimedOut`. A repeat isn't sent.
|
||||
- For a prompt (and a watcher match), "in flight" lasts until `RunPromptAsync` completes, i.e. until the run
|
||||
that handled it settles. So a 5-minute job over a 20-minute run skips occurrences instead of piling up follow-ups.
|
||||
- `LastRunAt`/`LastResult` are updated and persisted after each fire. A one-off is removed after its fire completes.
|
||||
- A delivery exception is logged and swallowed. The Telegram channel already retries a send (1 s, 3 s) and then
|
||||
throws `ChatDeliveryException`. The job's state still advances: there's no redelivery queue.
|
||||
- `CancelAsync` removes the job. If that job's watcher script is running it's killed and its outcome thrown away. A
|
||||
prompt run that's in progress isn't aborted: that's what `/stop` is for.
|
||||
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances).
|
||||
- `TimeZoneChangedAsync(target)` recomputes `NextRunAt` for the target's cron jobs in the new zone (strictly after now,
|
||||
no catch-up), leaves one-offs untouched, re-arms the timer and returns
|
||||
`ZoneChange(int CronJobs, int OneOffs)`. A cron occurrence already in flight isn't affected.
|
||||
|
||||
### 5. `SchedulerExtension` and the `schedule` tool (per session)
|
||||
|
||||
`SchedulerExtension(JobScheduler scheduler, string target)` registers `ScheduleTool` (`Tool<ScheduleArgs>`):
|
||||
|
||||
```
|
||||
schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
|
||||
at?, cron?, text?, command?, timeoutSeconds?)
|
||||
```
|
||||
|
||||
- `create` returns the job line (see below), its next fire time in the chat's zone, and `Now: <local time> (<zone>)`,
|
||||
so the model can see a wrong date or zone straight away. `list` returns the target's jobs plus the same "Now" line.
|
||||
`cancel` needs `id`.
|
||||
- `PromptSnippet`: `schedule: reminders, recurring prompts and watcher scripts that message this chat`.
|
||||
- `PromptGuidelines`:
|
||||
- Times are in the chat's zone (shown in the system prompt); `at` is ISO 8601; cron has 5 fields. The zone isn't
|
||||
repeated in the guideline, so the tool's prompt text stays the same when the zone changes.
|
||||
- Use `reminder` for fixed text and `prompt` when the job needs you to think or use tools.
|
||||
- Watcher scripts run with the same shell as `bash`, from the workspace, without you: print nothing and exit 0 while
|
||||
the condition isn't met (that costs nothing). Print the facts only when it is met; the output comes back to you
|
||||
as a prompt, and you write the message. Exit non-zero only on a real error.
|
||||
- Put reusable scripts in `scripts/` and test them with `bash` before scheduling.
|
||||
- It isn't registered when the extension is loaded through discovery without a host-registered `JobScheduler`: the
|
||||
constructor can't be resolved, and that shows up as an ordinary extension load error.
|
||||
|
||||
### 6. Telegram host wiring
|
||||
|
||||
- **Options**: a new `Scheduler` section bound to `SchedulerOptions`: `Enabled` (true),
|
||||
`StorePath` (`<AgentDir>/scheduler/jobs.json`), `DefaultScriptTimeout` (300 s), `MaxJobsPerTarget` (50) and
|
||||
`Shell`. There's no time zone and no scrub list here: zones are per chat (§7), the environment comes from
|
||||
the process environment hook.
|
||||
- **`Program.cs`** registers `SchedulerOptions`, `IScriptRunner` → `ScriptRunner`, `TimeProvider.System` (one
|
||||
`TryAddSingleton`, shared with the message-context and attachments specs), `JobScheduler`,
|
||||
`TelegramJobDelivery`, `ChatSettingsStore`, `ChatTimeZoneService` (§7; registered even when the scheduler is
|
||||
disabled) and `SchedulerHostedService`. The hosted service calls `StartAsync(delivery)` and
|
||||
`StopAsync`, and is registered after `PollingService`.
|
||||
- **`ChatRegistry.CreateAsync`** adds
|
||||
`ExtraExtensions = [ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, chatId.ToString(CultureInfo.InvariantCulture)))]`.
|
||||
- **`TelegramJobDelivery`** is built on `ChatRegistry` and `TelegramOptions`:
|
||||
- `SendTextAsync` → `(await chats.GetAsync(chatId)).Channel.SendTextAsync(markdown, ct)`, i.e. the chat session's
|
||||
`IChatChannel` (`TelegramChatChannel`, attachments spec). No bespoke delivery code: the channel goes through the
|
||||
renderer queue (`ChatRenderer.RunInOrderAsync`) and the reply path (split at 4096, HTML with a plain fallback,
|
||||
retries, status message pushed to the bottom), which keeps a reminder in order with a reply that's being
|
||||
rendered.
|
||||
- `RunPromptAsync` → prefixes the prompt with `MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target))`
|
||||
on its own line (message-context spec), then `session.PromptAsync(prompt, null, StreamingBehavior.FollowUp)`. When it queued rather than
|
||||
ran, it then awaits `session.WaitForIdleAsync()`. Exceptions are reported through `chat.Report` as for user
|
||||
prompts. It doesn't go through `UpdateRouter` or the chat's `ChatInbox` (message-context spec), so a scheduled
|
||||
prompt is never debounced into a user's message and can never answer a pending `InputAsync` question (e.g. the
|
||||
owner's Claude Code login code prompt).
|
||||
- `IsDeliverable` → the chat id is in `AllowedUserIds` (private chat id = user id).
|
||||
- `WorkspaceFor` → `ChatRegistry.WorkspaceFor` (a path computation; no session is created).
|
||||
- `TimeZoneFor` → `ChatSettingsStore.TimeZoneFor(chatId)` (§7).
|
||||
- **Commands** (`ChatCommands`, added to `Menu` and `/help`):
|
||||
- `/jobs` lists this chat's jobs, or `No scheduled jobs.`:
|
||||
```
|
||||
**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03)
|
||||
`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00
|
||||
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00
|
||||
`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)
|
||||
```
|
||||
The header and every time are in the chat's zone.
|
||||
- `/cancel <id>` → `Cancelled k7m2q (Call the dentist).` `No job k7m2q in this chat.` also covers another chat's id.
|
||||
With no id: `Usage: /cancel <id> (see /jobs).`
|
||||
- `/status` gains `**Jobs:** <n>`.
|
||||
- **Security**: scheduled scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that
|
||||
every allow-listed user already has, and the same filtered environment. The existing Security section in
|
||||
`docs/telegram-bot.md` gains that sentence.
|
||||
|
||||
### 7. Per-chat time zones (Telegram host)
|
||||
|
||||
- **`ChatSettingsStore`** (`Chats/ChatSettingsStore.cs`): `<AgentDir>/telegram/chat-settings.json`,
|
||||
`{ "version": 1, "chats": { "<chatId>": { "timeZone": "Asia/Tokyo" } } }` in `VolcanicJson` camelCase. Loaded once
|
||||
and cached in memory; written atomically (temp file + rename) through one `SemaphoreSlim`, like `JobStore`. A
|
||||
corrupt file is set aside and loading starts empty; a stored id that no longer resolves is logged and ignored (the
|
||||
chat follows the default). `TimeZoneFor(chatId)` → the stored zone, else the default. It's a host type, not a
|
||||
scheduler one: it exists with the scheduler disabled, and it's the place for later per-chat settings.
|
||||
- **Default**: `Telegram:DefaultTimeZone` (env shortcut `DEFAULT_TIMEZONE`, empty = unset), else
|
||||
`TimeZoneInfo.Local`. An unknown id is a startup validation error (exit 2, like the rest of `TelegramOptions`). It
|
||||
also sets `AgenticCoreOptions.DefaultTimeZone` (§8), so headless use of the core in the same process agrees.
|
||||
- **Parsing** (`ChatTimeZones.TryParse(string, out TimeZoneInfo, out string error)`), shared by the command and the
|
||||
tool: trim, `TimeZoneInfo.FindSystemTimeZoneById`. A Windows id is accepted and converted to IANA
|
||||
(`TimeZoneInfo.TryConvertWindowsIdToIanaId`); the IANA id is what's stored. `Local` and bare offsets (`+03:00`,
|
||||
`GMT+3`) are rejected with `Unknown time zone "<x>". Use an IANA name such as Europe/Berlin or America/New_York.`
|
||||
- **`ChatTimeZoneService.SetAsync(chatId, zone | null)`** is the one path for a change: write the store (null removes
|
||||
the entry, back to the default), `session.SetTimeZone(zone)` on the chat's live session if there is one (§8), then
|
||||
`scheduler.TimeZoneChangedAsync(target)` when the scheduler is enabled. It returns the text both entry points reply
|
||||
with:
|
||||
`Time zone set to Asia/Tokyo (UTC+09:00). Now 27 Sep 02:15. 2 recurring jobs now follow this zone; 1 one-off job
|
||||
keeps its time (now 27 Sep 16:00 here).` The job clause is left out when the chat has no jobs.
|
||||
- **`/timezone`** (`ChatCommands`, added to `Menu` and `/help`): `/timezone Asia/Tokyo` sets, `/timezone reset`
|
||||
returns to the default, `/timezone` alone shows `Time zone: Europe/Berlin (UTC+02:00, default). Change it with
|
||||
/timezone <IANA name>, e.g. /timezone America/New_York.` It runs as a host command, outside the agent loop.
|
||||
- **`set_timezone` tool**: `ChatRegistry.CreateAsync` adds a small host extension
|
||||
(`ChatSettingsExtension(chatId, service)`, through `ExtraExtensions` next to `SchedulerExtension`) that registers
|
||||
`set_timezone(zone)`. Guideline: `Use set_timezone only when the user tells you their location or time zone
|
||||
changed; confirm the new zone in your reply.` Errors from `TryParse` become error results. It runs whether or not the
|
||||
scheduler is enabled.
|
||||
- `/status` gains `**Time zone:** <id> (<offset>)`.
|
||||
|
||||
### 8. Session time zone (core)
|
||||
|
||||
The session carries its zone, so the system prompt and `bash` agree with the chat:
|
||||
|
||||
- `AgentSessionOptions.TimeZone` (`TimeZoneInfo?`) and `AgenticCoreOptions.DefaultTimeZone` (`TimeZoneInfo?`, null =
|
||||
`TimeZoneInfo.Local`). The session's zone is the first that is set.
|
||||
- `AgentSession.TimeZone` and `AgentSession.SetTimeZone(TimeZoneInfo)`. A change applies from the next run: the system
|
||||
prompt is rendered per run (`BeforeAgentStartEvent`), so a run in progress keeps its prompt. It isn't persisted in
|
||||
session entries: the host owns persistence (§7), and a headless host passes the option each time.
|
||||
- `IExtensionContext.TimeZone` (Abstractions) exposes it to tools and extensions. `schedule` doesn't need it (it asks
|
||||
`JobScheduler.TimeZoneFor(target)`), but the calendar and MCP do (their specs).
|
||||
- `IExtensionContext.PrepareProcessAsync` passes it as `TimeZoneId`, so `bash`'s `TZ` is the chat's zone and `date`
|
||||
prints local time (`2026-09-26-process-environment-hook-design.md` §3–§4).
|
||||
- **System prompt.** `SystemPromptBuilder` takes a `TimeProvider` and a `Func<TimeZoneInfo>` instead of
|
||||
`Func<DateTimeOffset>`, and the environment section becomes:
|
||||
|
||||
```
|
||||
Current date: 2026-09-26 (Saturday)
|
||||
Time zone: Europe/Berlin (UTC+02:00)
|
||||
Working directory: /data/workspaces/111111111
|
||||
```
|
||||
|
||||
The date is the date **in the session's zone**. The time of day is deliberately left out: the prompt is rendered
|
||||
at every run, and a clock time would change the start of the cached prefix on every run (the Claude Code provider
|
||||
replays the whole transcript and relies on the prompt cache). Date and offset change at most daily. The model gets
|
||||
the exact time from the local-time header on every user message and scheduled prompt
|
||||
(`2026-09-26-telegram-message-context-design.md`; host-side, appended so it doesn't touch the cached prefix), from
|
||||
`schedule` (its "Now" line) or from `date` in `bash`.
|
||||
- **Claude Code's `TZ`.** The Claude Code provider passes the session's zone to its `claude -p` child explicitly: the
|
||||
extension gives `ClaudeCodeApiOptions` a `Func<string?>` that reads `IExtensionContext.TimeZone` per request (so
|
||||
`SetTimeZone` applies from the next model call), and `ClaudeArgs.BuildEnvironment` sets `TZ` to that IANA id after
|
||||
the allow-list, like its pinned `CLAUDE_CODE_*` flags. Anything Claude Code itself adds about the date then agrees
|
||||
with our prompt. It isn't set on Windows (the OS ignores `TZ`, as in the process-environment-hook spec §3), nor for
|
||||
`claude auth login`/`auth status` and the doctor's probes, which have no session. `TZ` stays off the inherited
|
||||
allow-list: the host's own `TZ` never leaks through. This goes through the provider, not `IProcessSpawnPolicy`
|
||||
(process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
|
||||
implementation.
|
||||
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.
|
||||
|
||||
### 9. Docs
|
||||
|
||||
The implementation updates `docs/telegram-bot.md` (a Scheduling section: examples, the watcher convention and that
|
||||
a match always goes to the agent, `/jobs`, `/cancel`, `/timezone`, `Scheduler:*` and `Telegram:DefaultTimeZone`
|
||||
settings, the restart behaviour, what a zone change does to jobs), `docs/architecture.md` (the extension list and the
|
||||
session time zone) and the command table in `CLAUDE.md`.
|
||||
|
||||
## Testing
|
||||
|
||||
xunit 2. No test calls a paid API. Time is `FakeTimeProvider` (`Microsoft.Extensions.TimeProvider.Testing`, in
|
||||
`Volcanic.AgenticCore.Extensions.Tests` and `Volcanic.AgenticCore.Telegram.Tests`; shared with the message-context and
|
||||
calendar specs). Zones are fixed (`Europe/Berlin`, `Asia/Tokyo`), never the machine's.
|
||||
|
||||
- **Schedules** (`tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/`): next cron occurrence strictly after `from`,
|
||||
in the zone. DST spring-forward and fall-back days give one fire each. An `at` without an offset is read in the zone.
|
||||
Invalid cron, seconds fields, `H`, and a past `at` are rejected with readable messages.
|
||||
- **Store**: missing file → empty. Round trip leaves no temp file. A corrupt file is set aside and loading starts
|
||||
empty. Invalid entries are dropped. Concurrent saves are serialised.
|
||||
- **`JobScheduler`**, with a fake `IJobDelivery` (records calls, lets a test hold `RunPromptAsync` open) and a fake
|
||||
`IScriptRunner`:
|
||||
- An overdue one-off fires on start, with the late note past 60 s, and is removed only after delivery.
|
||||
- Cron doesn't catch up after a simulated downtime.
|
||||
- Advancing the clock fires, and the 1 h cap re-arms without firing.
|
||||
- A reminder is sent as text and a prompt as a prompt.
|
||||
- **Watcher invariant**: a watcher every minute whose fake runner returns `Silent` for N = 10 ticks → 10 runner
|
||||
calls, zero `RunPromptAsync`, zero `SendTextAsync`, and only `LastRunAt`/`LastResult` changed in the store. The
|
||||
11th tick returns `Output("price 48")` → exactly one `RunPromptAsync` with the "watcher script printed" text,
|
||||
still zero `SendTextAsync`.
|
||||
- A failure is reported once. Same exit code again → silent. A different exit code or a timeout → reported. A
|
||||
success followed by a failure → reported.
|
||||
- Overlapping occurrences are skipped while a watcher runs, and while a prompt run is held open.
|
||||
- Targets are isolated in `List` and `CancelAsync`.
|
||||
- Cancel kills a running watcher and drops its output.
|
||||
- `IsDeliverable == false` skips the fire and keeps the job.
|
||||
- `StopAsync` kills scripts and returns within the grace period.
|
||||
- Zone change: a `0 9 * * *` job and a one-off in `Europe/Berlin`; the fake delivery switches the target to
|
||||
`Asia/Tokyo` and `TimeZoneChangedAsync` runs → the cron job's next run is 09:00 Tokyo, the one-off's instant is
|
||||
unchanged, the timer is re-armed, the counts are (1, 1). Another target's jobs don't move.
|
||||
- Redaction: with a fake redactor for `hunter2-secret`, a watcher whose output contains it → the prompt says
|
||||
`[REDACTED]`; a failure whose stderr contains it → the alert and the stored `StderrTail` say `[REDACTED]`.
|
||||
- **`ScriptRunner`** (real processes, Unix-only like the existing script-based tests, with a real
|
||||
`ProcessSpawnPolicy` over an injected environment snapshot): output, silent, a failure with a 20-line stderr tail, a
|
||||
timeout kills the process tree, the capture is capped, `TELEGRAM_BOT_TOKEN` and `OPENAI_API_KEY` are absent, `TZ`
|
||||
is the target's zone, a throwing host hook gives `Failed` without starting a process, and the cwd is the workspace.
|
||||
- **`schedule` tool**: create, list and cancel through `ToolInvocation`, validation errors as error results,
|
||||
`list`/`cancel` never see another target's jobs, the "Now" line uses the target's zone, and an offset-less `at` is
|
||||
read in the target's zone.
|
||||
- **Session zone and prompt** (`tests/Volcanic.AgenticCore.Tests`): with `FakeTimeProvider` at 2026-09-26T23:30Z, a
|
||||
session in `Asia/Tokyo` renders `Current date: 2026-09-27 (Sunday)` and `Time zone: Asia/Tokyo (UTC+09:00)`, and one
|
||||
in `Europe/Berlin` renders 2026-09-27 too while `America/New_York` renders 2026-09-26. `SetTimeZone` during a run
|
||||
changes the next run's prompt, not the current one. Without options the default is `TimeZoneInfo.Local`.
|
||||
- **Claude Code `TZ`** (`tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/`): `BuildEnvironment` with a zone id
|
||||
sets `TZ`, without one leaves it unset, and a parent `TZ` is never copied; the fake-CLI request test sees the
|
||||
session's zone in the child's environment and a new zone after `SetTimeZone`.
|
||||
- **Telegram** (`ChatHost` + `FakeBotClient` + `FauxProvider`, with a real `JobScheduler` on a temp store path and
|
||||
`FakeTimeProvider`):
|
||||
- A reminder is sent to the right chat, the Faux provider saw no request, and the session transcript has no new
|
||||
entry.
|
||||
- A watcher over a real script that prints nothing, advanced through several ticks: the Faux provider saw no
|
||||
request, no session was created for the chat (`ChatRegistry.Find` is null), no message was sent. The script is
|
||||
then changed to print a line: the next tick produces exactly one Faux request and the agent's reply is rendered.
|
||||
- A prompt job on an idle chat → the agent's scripted reply is rendered, and the faux request's last user message
|
||||
starts with the timestamp header at the fire time in the chat's zone.
|
||||
- A prompt job while a user run is active: the faux turn is held, then the clock advances. The job's prompt is
|
||||
queued as a follow-up (a `QueueUpdate` with one follow-up and no steering). The user's reply is rendered before the
|
||||
job's reply.
|
||||
- The Faux provider scripts a `schedule` tool call that creates a job for its own chat, and `/jobs` in another chat
|
||||
doesn't show it.
|
||||
- `/cancel` with its own id, an unknown id, and another chat's id.
|
||||
- Restart: dispose the host and start a new `ChatHost` on the same store. The overdue one-off is delivered with the
|
||||
late note, and the cron job isn't caught up.
|
||||
- While the owner has a pending login code prompt, a scheduled prompt doesn't answer it.
|
||||
- `/timezone Asia/Tokyo`: the store has the zone, the reply names it and the job counts, `/jobs` shows Tokyo times,
|
||||
and the next faux request's system prompt says `Asia/Tokyo`. `/timezone Mars/Olympus` → the error text, nothing
|
||||
stored. `/timezone reset` → the default. A restart keeps the zone. A scripted `set_timezone` call does the same
|
||||
as the command. Another chat's zone is untouched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Chats other than private ones, and per-job time zones (a cron job always follows its chat's current zone).
|
||||
- Editing a job in place (cancel and create again), pausing, run history beyond `LastResult`, and catch-up policies
|
||||
for cron.
|
||||
- Seconds-level cron, jitter, and a heartbeat that wakes the agent without a job.
|
||||
- A direct (model-free) delivery mode for watcher output (see Decisions).
|
||||
- Sending files from direct jobs (a `prompt` job can use the `send` tool from
|
||||
`2026-09-26-telegram-attachments-design.md`), and a sandbox for scripts.
|
||||
- Adding direct deliveries to the transcript (see Decisions).
|
||||
- Scheduler support in the headless sample. Any host can implement `IJobDelivery`.
|
||||
402
docs/superpowers/specs/2026-09-26-telegram-attachments-design.md
Normal file
402
docs/superpowers/specs/2026-09-26-telegram-attachments-design.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Telegram attachments (files in, voice in, files out) — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Depends on: `2026-09-26-telegram-message-context-design.md` (`ChatInbox`, `InboundComposer` content steps, lease
|
||||
contract); `2026-09-26-process-environment-hook-design.md` (keeps `OPENAI_API_KEY` out of `bash`). Used by:
|
||||
`2026-09-26-scheduler-design.md` (`IChatChannel.SendTextAsync`).
|
||||
|
||||
## Problem
|
||||
|
||||
The Telegram host accepts only text and photos (`UpdateRouter` answers everything else with "I can read text and
|
||||
photos."). The agent can't receive a document, can't hear a voice note, and can only answer with text: a generated
|
||||
PDF, a chart or a screenshot never reaches the user.
|
||||
|
||||
The reference is VolcanicSecretary (`/tmp/VolcanicSecretary`): `src/telegram/inbound.ts` (documents to `inbox/`,
|
||||
voice through `src/stt/openai.ts`), `src/telegram/bridge.ts` (the `🎤` echo, notes to the user) and
|
||||
`src/tools/send.ts` plus `docs/superpowers/specs/2026-09-08-send-attachments-design.md` (the `send` tool). This spec
|
||||
ports that behaviour. The one structural difference is that VolcanicSecretary's `send` tool holds the grammY `Bot` and
|
||||
the owner's chat id directly. Here tools are host-agnostic and the Telegram host has many chats, so the tool needs a
|
||||
contract to reach "this session's chat".
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Outbound contract | New `IChatChannel` in Abstractions (`Ui/`), next to `IUserInterface`. The host supplies one per session through `AgentSessionOptions.Channel`; tools and extensions reach it as `IExtensionContext.Channel`, and host code (the scheduler) as `AgentSession.Channel` |
|
||||
| `send` tool | A core built-in (`Features/Tools/BuiltIn/SendTool.cs`) that is registered and active **only when the session has a channel**. Path handling, kind choice and limit checks live in the tool, once for every host |
|
||||
| Inbound files | Saved to `<cwd>/inbox/<yyyy-MM-dd>-<safe name>` (`-1`, `-2`… on collision). The prompt carries the caption plus a line with the workspace-relative path |
|
||||
| Size cap | Telegram bots download at most 20 MB. Bigger files are refused before download, with a reply, and not sent to the agent |
|
||||
| Image documents | JPEG/PNG/WebP/GIF sent "as file" are saved like any document **and** attached to the prompt as `ImageContent` when ≤ `InlineImageMaxBytes` (3.75 MB, so base64 stays under the 5 MB image cap providers enforce) |
|
||||
| Speech to text | A **host service** (`Speech/ISpeechToText`, `OpenAiSpeechToText`), not an abstraction: it normalises input before a prompt exists, and the core only ever sees text |
|
||||
| STT model | `gpt-4o-transcribe` by default, configurable. Off when no API key is configured; voice then gets a clear "not configured" reply |
|
||||
| Transcript echo | `🎤 <transcript>` goes to the chat before the run starts, cut to Telegram's 4096-character limit |
|
||||
| Ordering | Inbound preparation (download, save, transcribe) runs as content steps inside `InboundComposer.ComposeAsync`, under the message's `ChatInbox` lease (message-context spec). Polling never blocks, and parts merge in Telegram order whatever order they finish in. Notices, the `🎤` echo and outbound files go through the chat renderer's queue (`RunInOrderAsync`) so they stay in order with rendered text |
|
||||
| Commands / answers | A message with an attachment is never a command and never answers a pending `InputAsync` question. Its caption is prompt text. This extends today's photo rule |
|
||||
|
||||
### Why a contract in Abstractions, not a host-registered tool
|
||||
|
||||
The alternative was a Telegram-only `TelegramSendExtension` added per chat through
|
||||
`AgentSessionOptions.ExtraExtensions`, holding `chatId` and `TelegramSender`. It's less code today, but:
|
||||
|
||||
- "Send this file to the user" is host-agnostic. Path confinement, kind choice, limits and the error messages the
|
||||
model reads would be re-implemented by every host (a Slack or web host would copy all of it).
|
||||
- The scheduler (`2026-09-26-scheduler-design.md`) has to deliver text to a chat with no tool call and possibly no
|
||||
run. A host-private tool gives it nothing to reuse. `IChatChannel.SendTextAsync` does.
|
||||
- Extensions loaded from `.volcanic/extensions` reference only Abstractions. With the contract, an extension can
|
||||
deliver a report without knowing the host.
|
||||
|
||||
`IUserInterface` wasn't extended either. It's the dialog surface: request/response with the user, `Notify` for
|
||||
transient notices. Delivering content into the conversation is a separate capability that a host may lack
|
||||
(`NullUserInterface` hosts, the headless sample). Adding methods would force every UI to stub them.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Abstractions — `IChatChannel`
|
||||
|
||||
`src/Volcanic.AgenticCore.Abstractions/Ui/IChatChannel.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Volcanic.AgenticCore.Abstractions.Ui;
|
||||
|
||||
public enum ChatFileKind { Auto, Photo, Document }
|
||||
|
||||
/// <param name="Path">Absolute path of an existing file; the caller has already confined and validated it.</param>
|
||||
/// <param name="Caption">Plain text shown with the file, at most <see cref="ChatChannelLimits.MaxCaptionLength"/>.</param>
|
||||
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
|
||||
|
||||
/// <param name="SentAs">How a file was actually delivered (Photo or Document); null for text.</param>
|
||||
/// <param name="MessageId">Host message id(s), for logs and tool details.</param>
|
||||
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
|
||||
|
||||
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
|
||||
|
||||
/// <summary>Delivers content into the conversation's chat, outside the normal assistant-message rendering.</summary>
|
||||
public interface IChatChannel
|
||||
{
|
||||
ChatChannelLimits Limits { get; }
|
||||
|
||||
/// <summary>Markdown, rendered like assistant text (split when long). Not added to the session transcript.</summary>
|
||||
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deliver a file. <see cref="ChatFileKind.Auto"/> lets the channel choose, and it may fall back from photo to
|
||||
/// document. Throws <see cref="ChatDeliveryException"/> with a message fit for the model or the user.
|
||||
/// </summary>
|
||||
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
```
|
||||
|
||||
- `IExtensionContext` gets `IChatChannel? Channel => null;`, a default interface member, so `StubExtensionContext`
|
||||
and third-party implementations keep compiling. `ExtensionContext` returns the session's channel.
|
||||
- `AgentSessionOptions.Channel` (`IChatChannel?`) and `AgentSession.Channel` (read-only) are new.
|
||||
- Contract rules: channels never throw anything except `ChatDeliveryException` and `OperationCanceledException`, and
|
||||
are safe to call from any thread, including while no run is active (the scheduler case).
|
||||
- Nothing about a delivery is persisted by the core. The `send` tool's result message records it. The scheduler
|
||||
does not append a `CustomMessage` either: direct reminders and watcher output stay out of the transcript (see
|
||||
Scheduler reuse).
|
||||
|
||||
### 2. Core — the `send` tool
|
||||
|
||||
`src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SendTool.cs`, a `Tool<SendArgs>`:
|
||||
|
||||
```
|
||||
name: send
|
||||
args: path string, required workspace-relative (or absolute inside the workspace)
|
||||
kind "auto" | "photo" | "document", optional, default auto
|
||||
caption string, optional
|
||||
snippet: "send: send a workspace file to the user (images inline, other files as documents)"
|
||||
guidelines:
|
||||
- When the user asks for a file, image, screenshot or report, write it into the workspace and call send;
|
||||
your reply arrives after the attachment, so a one-line confirmation is enough.
|
||||
- Files the user sends you are saved under inbox/.
|
||||
ExecutionMode: Sequential (two sends in one turn arrive in call order)
|
||||
```
|
||||
|
||||
Execution:
|
||||
|
||||
1. `ctx.Channel` is null → throw `send is not available: this session has no chat` (defensive; see registration).
|
||||
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` (compare
|
||||
with a trailing separator, case-insensitively on Windows). If the file or any parent is a symlink, resolve it with
|
||||
`ResolveLinkTarget(returnFinalTarget: true)` and apply the same check to the target. Errors are
|
||||
`path escapes the workspace: <path>`, `file not found: <path>` and `path is a directory, not a file: <path>`.
|
||||
3. Caption longer than `Limits.MaxCaptionLength` → `caption is <n> characters; the limit is <m>. Shorten it or put
|
||||
the text in your reply.` The check runs before anything is sent, so a failure never delivers half an attachment.
|
||||
4. Kind:
|
||||
- `photo`: over `MaxPhotoBytes` → `photo too large: <x> MB > <y> MB. Use kind "document" or downscale it.`
|
||||
- `document`: over `MaxDocumentBytes` → `file too large: <x> MB > <y> MB (the chat's upload limit).`
|
||||
- `auto`: `.jpg/.jpeg/.png/.webp` within the photo limit → `Auto` is passed through (the channel tries a photo
|
||||
and falls back). Anything else, including `.gif` (a photo upload loses the animation), is sent as `Document`
|
||||
under the document limit.
|
||||
5. `await channel.SendFileAsync(...)`. A `ChatDeliveryException` is rethrown as-is, which gives an error result
|
||||
the model can read.
|
||||
6. Result: `Sent <path> as <photo|document>.` with details `{ path, kind, bytes, messageId }`.
|
||||
|
||||
Registration in `AgentSessionFactory`: when `options.Channel is not null` and `IncludeBuiltInTools`, add `SendTool`
|
||||
as a built-in, active by default (it's appended to `DefaultActive` for that session). An explicit
|
||||
`options.ActiveTools` still wins. Sessions without a channel never see the tool. The Claude Code provider exposes it
|
||||
as `mcp__volcanic__send` automatically, like every active tool.
|
||||
|
||||
`send` doesn't refuse to send files from `inbox/`. The confinement is about intent, not secrecy: `bash` can
|
||||
already copy any readable file into the workspace. See Security.
|
||||
|
||||
### 3. Telegram — outbound: `TelegramChatChannel`
|
||||
|
||||
`hosts/Volcanic.AgenticCore.Telegram/Chats/TelegramChatChannel.cs`, one per chat, created in
|
||||
`ChatRegistry.CreateAsync` next to `TelegramUi` and passed as `AgentSessionOptions.Channel`. `ChatContext` exposes
|
||||
it (`chat.Channel`).
|
||||
|
||||
- `Limits`: photo 10 MB, document 50 MB, caption 1024 (Bot API limits), in `TelegramLimits` constants.
|
||||
- **Ordering through the renderer.** Session events are rendered asynchronously from `ChatRenderer`'s queue. A file
|
||||
sent directly could overtake an assistant message that ended just before the tool ran. `ChatRenderer` gets
|
||||
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)`. It queues a host-internal work item behind the
|
||||
pending events and completes when the item has run. The renderer marks the status message as buried afterwards, as
|
||||
it does for replies, so the `⚙️ Working...` message moves back to the bottom on the next tool update.
|
||||
`SendTextAsync` and `SendFileAsync` both go through it. The event channel becomes a channel of render items (an
|
||||
event or a work item).
|
||||
- **Files.** `TelegramSender.SendFileAsync(chatId, path, asPhoto, caption, ct) → Task<FileSendResult>`, with
|
||||
`FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`. It follows the sender's never-throw convention.
|
||||
Each attempt opens a fresh `FileStream` and uses `InputFile.FromStream(stream, Path.GetFileName(path))` with
|
||||
`SendPhoto` or `SendDocument`. Captions go as plain text (no parse mode), so the model's caption can't break the
|
||||
HTML.
|
||||
- A network failure (`RequestException` that is not an `ApiRequestException`) is retried after 1 s and 3 s, like
|
||||
replies.
|
||||
- `Auto` whose photo upload is rejected with a 400 (`PHOTO_INVALID_DIMENSIONS`, `IMAGE_PROCESS_FAILED`,
|
||||
`PHOTO_SAVE_FILE_INVALID`, or any 400 on `sendPhoto`) is resent once as a document. `SentAs = Document`.
|
||||
- Anything else becomes `ChatDeliveryException("Telegram refused the file: <description>")`.
|
||||
- **Text.** `SendTextAsync(markdown)` reuses the renderer's reply path (`TelegramFormatter.Split` →
|
||||
`ToHtml` → `SendHtmlAsync` with the plain fallback). It returns the id of the first message, or throws
|
||||
`ChatDeliveryException` when every retry failed.
|
||||
|
||||
### 4. Telegram — inbound: `InboundMedia`
|
||||
|
||||
`hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundMedia.cs` provides the content steps this spec adds to
|
||||
`InboundComposer` (message-context spec). It depends on `TelegramSender` (downloads), `ISpeechToText?`,
|
||||
`TelegramOptions` and `TimeProvider`. Each step adds body lines and images to the message's `InboundPart`, or sends a
|
||||
notice. A step can also **refuse** the message (oversize file, failed download, voice with no usable transcript and
|
||||
no caption): `ComposeAsync` then returns null and nothing is prompted, and no "I can read…" reply is sent because
|
||||
the notice already explains it. The steps run in this order after step 1 (text/caption, largest photo):
|
||||
|
||||
- Step 2, files: `Document`, `Video` and `Audio` saved to the inbox, image documents also attached as
|
||||
`ImageContent`.
|
||||
- Step 3, speech: `Voice` and `Audio` transcription, with the `🎤` echo.
|
||||
|
||||
So a part's body is: caption, then `[File received: …]`, then `[Voice message] …`.
|
||||
|
||||
Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context spec:
|
||||
|
||||
- Accepted: `Text`, `Photo`, `Document`, `Video`, `Audio`, `Voice`. Anything else (stickers, video notes, polls,
|
||||
locations…) gets `I can read text, photos, files and voice messages.` and takes no lease.
|
||||
- The command and pending-answer checks run only for messages with no attachment (as today for photos).
|
||||
- Every accepted message takes its `ChatInbox` lease synchronously and runs `ComposeAsync` in the background, as the
|
||||
message-context spec describes. All slow work (download, save, transcribe) happens inside `ComposeAsync` under the
|
||||
lease, so polling never blocks and commands (`/stop`) act immediately. Ordering comes from the lease slots: a text
|
||||
sent right after a voice note lands in the same batch, after the transcript. There is no separate inbound queue.
|
||||
- Notices and the echo are posted through the renderer (`RunInOrderAsync` → `SendTextAsync`), so they appear in order
|
||||
with rendered output and, on an idle chat, before the run's `⚙️ Working...`.
|
||||
|
||||
**Files** (`Document`, and `Video` with `FileName ?? "video.mp4"`):
|
||||
|
||||
1. `FileSize > 20 MB` (`TelegramLimits.MaxDownloadBytes`) → notice `That file is <x> MB; Telegram lets bots download
|
||||
at most 20 MB. Share a link or a smaller file.` The message isn't prompted, caption included (the caption is
|
||||
about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way.
|
||||
2. Target: `<cwd>/inbox/<date>-<stem><-N><ext>`. `date` is `TimeProvider.GetLocalNow()` as `yyyy-MM-dd`. The name
|
||||
is `FileName ?? "file"`: `Path.GetFileName` drops any directory part, and every character other than a Unicode
|
||||
letter or digit, `.`, `-` or `_` becomes `_`. Leading dots and underscores are trimmed (no hidden files). Stem is
|
||||
capped at 80 characters and extension at 16, with `file` as the fallback. Opened with `FileMode.CreateNew`; an
|
||||
`IOException` for an existing file tries the next suffix.
|
||||
3. Download straight into that stream (`GetInfoAndDownloadFile`). On failure delete the partial file and send the
|
||||
notice `I could not download that file.` The message isn't prompted.
|
||||
4. Prompt text: the caption (if any), a blank line, then
|
||||
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace
|
||||
and uses `/`.
|
||||
5. Image documents: when `MimeType` is `image/jpeg|png|webp|gif` and the size is ≤ `Telegram:InlineImageMaxBytes`
|
||||
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Larger images are
|
||||
path-only, and the line says `(too large to show inline; downscale it with bash if you need to see it)`.
|
||||
|
||||
**Voice and audio** (`Voice`, `Audio`):
|
||||
|
||||
1. No `ISpeechToText` configured → notice `Voice messages need speech-to-text: set OPENAI_API_KEY (or
|
||||
Telegram:Speech:ApiKey) on the bot host.` `Audio` is still saved to the inbox as a file (below); `Voice` isn't
|
||||
prompted unless it has a caption.
|
||||
2. Over 20 MB → the file-size notice, as for files.
|
||||
3. Download into memory. Voice arrives as `.oga`, which OpenAI rejects, so it's renamed to `voice.ogg`
|
||||
(`audio/ogg`). Audio keeps `FileName ?? "audio"` and its `MimeType`.
|
||||
4. `Transcript = await stt.TranscribeAsync(bytes, fileName, mime, ct)`, trimmed. Empty → notice `I couldn't make
|
||||
out any words in that voice message.` An exception → notice `Could not transcribe the voice message: <reason>`.
|
||||
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`.
|
||||
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
|
||||
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
|
||||
processed, not just heard.
|
||||
7. Transcripts never answer a pending `InputAsync` question (see Decisions).
|
||||
|
||||
`UpdateRouter.DownloadPhotoAsync` moves into `InboundComposer`'s step 1 (message-context spec) unchanged in behaviour;
|
||||
`InboundMedia` shares its download helper.
|
||||
|
||||
### 5. Telegram — speech to text
|
||||
|
||||
`hosts/Volcanic.AgenticCore.Telegram/Speech/`:
|
||||
|
||||
```csharp
|
||||
public interface ISpeechToText
|
||||
{
|
||||
Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
`OpenAiSpeechToText(HttpClient http, SpeechOptions options)` is the port of `stt/openai.ts`:
|
||||
|
||||
- `POST {BaseUrl}/audio/transcriptions`, `Authorization: Bearer <key>`, multipart with `file` (name and content type
|
||||
as given), `model`, `response_format=json`, and `language` when configured.
|
||||
- Non-2xx → `SpeechToTextException("OpenAI transcription failed: HTTP <status> <first 200 chars of body>")`.
|
||||
A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs.
|
||||
- `HttpClient` comes from `AddHttpClient<OpenAiSpeechToText>()`, with a 2-minute timeout.
|
||||
|
||||
`SpeechOptions`, the `Telegram:Speech` section:
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| `Telegram:Speech:ApiKey` | `OPENAI_API_KEY` | Empty → STT off (the only way to turn it off) |
|
||||
| `Telegram:Speech:Model` | `gpt-4o-transcribe` | also `STT_MODEL`, as in VolcanicSecretary. `gpt-4o-mini-transcribe` and `whisper-1` also work |
|
||||
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | any OpenAI-compatible transcription endpoint |
|
||||
| `Telegram:Speech:Language` | — | ISO-639-1 hint, optional |
|
||||
|
||||
The environment fallbacks (`OPENAI_API_KEY`, `STT_MODEL`) are read in `TelegramOptions.ApplyEnvironment`, next to
|
||||
the existing `TELEGRAM_*` shortcuts, and only when the `Telegram:Speech` setting is empty. An empty variable counts
|
||||
as unset (the Docker compose file passes both through, empty by default). The OpenAI provider
|
||||
extension, if loaded, reads `OPENAI_API_KEY` on its own; the two don't share state.
|
||||
`Program.cs` registers `ISpeechToText` only when a key is present, and logs `Speech to text: <model>` or
|
||||
`Speech to text: off (no OPENAI_API_KEY)`. `OPENAI_API_KEY` isn't on the Claude Code provider's denied-variable
|
||||
list, and its child environment is allow-listed, so the key never reaches `claude`. The process environment hook's
|
||||
default deny-list (`*_API_KEY`, `*__APIKEY`) removes it, and `Telegram:Speech:ApiKey` under any other name (the host
|
||||
registers the value in `SecretValues`), from `bash`, scheduled scripts and MCP servers.
|
||||
|
||||
### 6. Other options
|
||||
|
||||
`TelegramOptions` gains `InlineImageMaxBytes` (default 3 750 000) and `Speech` (`SpeechOptions`). `Validate()`
|
||||
rejects a non-positive `InlineImageMaxBytes` and a `Speech:BaseUrl` that isn't an absolute http(s) URI.
|
||||
|
||||
## Scheduler reuse
|
||||
|
||||
The scheduler spec delivers text through the same contract. It gets it from `AgentSession.Channel` (or
|
||||
`ChatContext.Channel` for host code) and calls `SendTextAsync(markdown, ct)`. That works whether or not a run is
|
||||
active, and ordering with a running turn's output holds because the Telegram channel goes through the renderer
|
||||
queue. If a scheduled job runs the agent instead, its output renders through the normal session events, and `send`
|
||||
works in that run as in any other. Direct deliveries (reminders, watcher output, failure alerts) are **not** added to
|
||||
the transcript: the channel never persists, and the scheduler doesn't append a `CustomMessage`. The scheduler spec
|
||||
accepts the consequence that the agent doesn't see them.
|
||||
|
||||
## Error handling summary
|
||||
|
||||
| Situation | Result |
|
||||
|---|---|
|
||||
| file > 20 MB sent to the bot | size notice, nothing reaches the agent |
|
||||
| download fails | `I could not download that file.`, partial file deleted, no prompt |
|
||||
| voice with no STT configured | not-configured notice; caption (if any) is still prompted |
|
||||
| STT HTTP/parse error | `Could not transcribe the voice message: …`, no prompt unless there's a caption |
|
||||
| `send` path outside the workspace / missing / directory | error tool result, nothing sent |
|
||||
| `send` over the photo / document limit | error tool result naming the limit |
|
||||
| caption over 1024 | error tool result, nothing sent |
|
||||
| Telegram rejects a photo (dimensions) with kind auto | resent as a document, result says `as document` |
|
||||
| Telegram unreachable during `send` | 2 retries, then an error tool result |
|
||||
| session without a channel (headless sample) | `send` isn't registered |
|
||||
|
||||
## Security
|
||||
|
||||
- Downloaded names are sanitised and confined to `inbox/`. `CreateNew` never overwrites.
|
||||
- `send` confines to the workspace, symlinks included. It doesn't stop exfiltration by a determined agent (`bash cp`
|
||||
works), but a prompt-injected "send ~/.ssh/id_rsa" fails loudly and visibly instead of silently.
|
||||
- `send` can only reach the session's own chat. There's no chat-id parameter.
|
||||
- The OpenAI key lives in the bot process's environment but not in the processes the agent spawns: `env` in `bash`
|
||||
doesn't show it, nor `TELEGRAM_BOT_TOKEN` (`2026-09-26-process-environment-hook-design.md`). On Linux the bot is
|
||||
non-dumpable, so `/proc/<bot pid>/environ` is closed too, and the key's value is replaced by `[REDACTED]` in any
|
||||
tool output (that spec's §8–§9). Its caveat applies: it isn't a sandbox, and a determined agent running as the
|
||||
bot's user can still read files that user can read.
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/telegram-bot.md`: accepted inputs, `inbox/`, the voice setup and settings table, `send`, the size limits.
|
||||
- `docs/ui-integration.md`: a new section "5. Implement `IChatChannel`" (optional, enables `send`), and document /
|
||||
voice rows in the input-mapping table.
|
||||
- `docs/architecture.md` and `CLAUDE.md`: list `IChatChannel` among the Abstractions.
|
||||
|
||||
## Testing
|
||||
|
||||
xunit, no network, no paid API.
|
||||
|
||||
**Core (`tests/Volcanic.AgenticCore.Tests`)**
|
||||
|
||||
- `SendToolTests` with a recording `FakeChatChannel` and a temp workspace:
|
||||
- `auto` `.png` passes `Auto`; `auto` `.pdf` and `.gif` pass `Document`; explicit `photo`/`document` are honoured.
|
||||
- An `auto` image over the photo limit goes as `Document`; an explicit `photo` over the limit fails.
|
||||
- A document over the limit, a caption over the limit, `../x`, an absolute path outside, and a symlink inside the
|
||||
workspace pointing outside all fail, and the channel is never called.
|
||||
- Missing file and directory errors. Absolute paths inside the workspace are accepted.
|
||||
- A `ChatDeliveryException` becomes an error tool result with its message.
|
||||
- Result text and details. Sparse files (`FileStream.SetLength`) stand in for big files.
|
||||
- `AgentSessionFactoryTests`: `send` is registered and active with a channel, absent without one, and an explicit
|
||||
`ActiveTools` wins. `ExtensionContext.Channel` is the session's channel.
|
||||
- End-to-end with `FauxProvider`: a scripted `send` call reaches the fake channel and the run continues to the
|
||||
final text.
|
||||
|
||||
**Telegram (`tests/Volcanic.AgenticCore.Telegram.Tests`)**
|
||||
|
||||
`FakeBotClient` grows:
|
||||
|
||||
- `Files` (file id → bytes and path). `GetFileRequest` answers with a `TGFile` (path and size), and `DownloadFile`
|
||||
writes the scripted bytes.
|
||||
- `SendPhotoRequest` / `SendDocumentRequest` return a `Message`. Their stream is copied inside `SendRequest`, so
|
||||
tests can assert the uploaded bytes, name and caption (`Uploads`).
|
||||
- `OnSend` can throw an `ApiRequestException(400, "PHOTO_INVALID_DIMENSIONS")` to exercise the fallback.
|
||||
|
||||
`Updates` gets `Document(...)`, `Voice(...)`, `Audio(...)` and `Sticker()`. `ChatHost` takes an optional
|
||||
`ISpeechToText` (a `FakeSpeechToText` returning a scripted transcript or throwing) and uses the optional
|
||||
`TimeProvider` the message-context spec adds (`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`).
|
||||
|
||||
- Document with caption: the file lands in `<workspace>/inbox/2026-09-26-report.pdf` with the right bytes. The
|
||||
prompt the faux provider receives contains the caption and the `[File received: …]` line. A second upload of the
|
||||
same name gets `-1`.
|
||||
- A hostile name (`../../evil.sh`, `.bashrc`, a 300-character name) is sanitised and stays in `inbox/`.
|
||||
- Over 20 MB: the notice is sent, no file is written, the faux provider isn't called.
|
||||
- Image document ≤ limit: saved and passed as `ImageContent`. Above the limit: path only, with the "too large"
|
||||
note.
|
||||
- Voice with STT: the `🎤` echo comes before the answer, the prompt contains `[Voice message] …`, and the file name
|
||||
handed to STT ends in `.ogg`. A 5000-character transcript echo is cut to 4096.
|
||||
- Voice without STT: the not-configured notice, and no run. STT throwing: the failure notice, and no run.
|
||||
- Ordering: a voice note followed immediately by text produces one prompt with the transcript before the text (the
|
||||
fake STT is delayed past the debounce window). `/stop` sent during a slow transcription is handled at once.
|
||||
- A document with a `/new` caption doesn't run `/new`. A voice note doesn't answer a pending `InputAsync`.
|
||||
- A sticker gets the "I can read…" reply.
|
||||
- `send` end to end: the faux provider calls `send` for `out/chart.png`, a `sendPhoto` upload with the caption
|
||||
appears **before** the final answer message, and the status message is moved back to the bottom by the next tool
|
||||
line. A `.pdf` → `sendDocument`. A photo rejected with a 400 → a second upload as a document, and the tool result
|
||||
says `as document`.
|
||||
- `TelegramChatChannel.SendTextAsync` while idle (the scheduler case): the text is delivered as HTML, split past
|
||||
4096.
|
||||
|
||||
**Speech (`OpenAiSpeechToTextTests`)** with a stub `HttpMessageHandler`: the multipart form has `file` (name and
|
||||
content type), `model` and `response_format=json`, plus `language` when set; the bearer header is present; the
|
||||
transcript is trimmed; non-2xx, non-JSON and missing `text` each give their own message, which never contains the
|
||||
key.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A local Bot API server (it lifts the 20 MB / 50 MB limits). The limits are constants for now.
|
||||
- Albums and media groups as one prompt, and the debounce of forward + comment: both come from `ChatInbox` in the
|
||||
message-context spec.
|
||||
- Sending video, audio, voice or animations natively (`sendVideo`, `sendVoice`, …): everything that isn't a photo
|
||||
goes as a document. Several files per `send` call. URLs or base64 as `send` input.
|
||||
- Stickers, video notes, locations, contacts and polls as input.
|
||||
- Local STT (whisper.cpp) or other providers behind `ISpeechToText`: the interface allows them, but only OpenAI is
|
||||
built.
|
||||
- Cleaning up `inbox/`: files stay until the user or agent removes them.
|
||||
- Reply and forward context headers (the message-context spec).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should `Audio` files (music, podcasts) be transcribed by default, or only saved? This spec follows
|
||||
VolcanicSecretary and transcribes, which costs money on long files.
|
||||
2. Is 3.75 MB the right inline threshold for image documents, or should the host downscale larger images (with a
|
||||
dependency such as ImageSharp) rather than send only the path?
|
||||
@@ -0,0 +1,333 @@
|
||||
# Telegram message context, grouping and presence — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Depends on: `2026-09-26-telegram-attachments-design.md`, which plugs its content steps (files, speech) into
|
||||
`InboundComposer` and uses the renderer's `RunInOrderAsync` for notices and the `🎤` echo.
|
||||
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; if this spec
|
||||
lands first, the header uses `TimeZoneInfo.Local` until the store exists).
|
||||
|
||||
## Problem
|
||||
|
||||
`UpdateRouter.OnMessageAsync` turns every Telegram message into its own prompt with only `Text ?? Caption` and an
|
||||
optional photo. That drops context the user already gave:
|
||||
|
||||
- **Replies.** When the user replies to one of the bot's older answers (or to their own message), or highlights part of
|
||||
it ("reply with quote"), the agent does not see which message "this" means. `Message.ReplyToMessage`,
|
||||
`Message.Quote` and `Message.ExternalReply` are ignored.
|
||||
- **Forwards.** A forwarded post arrives as bare text. The agent doesn't know it was written by someone else, or who.
|
||||
- **Split turns.** Telegram delivers "forward with a comment" as two messages (the comment first, then the forward
|
||||
a few ms later), and delivers an album as one message per item. Each one currently becomes its own prompt. The first
|
||||
starts a run and the rest steer it, so the agent answers the comment before it has seen what the comment is about.
|
||||
Voice transcription (the sibling spec `2026-09-26-telegram-attachments-design.md`) makes this worse: a transcript
|
||||
finishes seconds after the comment.
|
||||
- **Time of day.** The system prompt carries only the date in the chat's zone (scheduler spec §8: a clock time there
|
||||
would break the prompt cache). The agent can't tell whether "this evening" was said at 9 in the morning or at 11 at
|
||||
night, or that two messages are hours apart.
|
||||
- **Feedback.** A message sent during a run steers it silently, so the user can't tell it arrived. After a restart
|
||||
there is no sign that the bot is back.
|
||||
|
||||
The TypeScript reference (`VolcanicSecretary`, `src/telegram/inbound.ts`, `debouncer.ts`, `bridge.ts`, and its specs
|
||||
`2026-09-22-reply-context-design.md` and `2026-09-23-audio-comment-debounce-race-design.md`) solves all of these. This
|
||||
spec ports that behaviour. Header wording is kept identical so both bots read the same to the model.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Header format | One English line per header in square brackets, before the body: reply header first, then forward header, then the message text. Same wording as the reference. The batch's local timestamp line (next row) comes before all of them. |
|
||||
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone: `[Sat 2026-09-26 14:03 Europe/Berlin]` (invariant culture, 24 h, IANA id). **One per flushed batch**, not per part, before the first part's reply/forward headers. The time is the first part's `Message.Date` (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
|
||||
| Where the timestamp is built | **Host-side** (Telegram), not in the core. The core doesn't know when a user message was sent, and a headless caller may not want a header; the zone it uses is the same one the core session carries (`ChatSettingsStore.TimeZoneFor(chatId)`, which also feeds `AgentSessionOptions.TimeZone`, scheduler spec §7–§8), so prompt date, header and `bash`'s `TZ` always agree. Scheduled prompts and watcher matches get the same header from `TelegramJobDelivery` (scheduler spec §6). |
|
||||
| Reply content | `quote.text` if present (reply with quote), else the replied message's `text` → `caption` → media descriptor. Capped at **500** Unicode scalars (first 499 + `…`). Quote entities are dropped. |
|
||||
| Who was replied to | `ReplyToMessage.From.Id == bot.BotId` → "your message". Any other `From` → "your own earlier message" (private chats only, so it's the user). No `From` → "the earlier message". The bot id comes from `ITelegramBotClient.BotId` (parsed from the token), so no `getMe` is needed. |
|
||||
| External reply | `Message.ExternalReply` (a quote of a message from another chat, which Telegram allows in private chats) → `[Reply to a message from <origin>: "…"]` / `[Reply quoting a message from <origin>: "…"]`, with the origin labelled as for forwards. |
|
||||
| Replied-to media | Described, never re-downloaded. |
|
||||
| Forward label | `[Forwarded from <origin>]` for all four `MessageOrigin` kinds (below). |
|
||||
| Grouping | A per-chat `ChatInbox` debouncer. Default window **600 ms** after the last event (`Telegram:InputDebounce`). Everything that lands inside the window becomes one prompt: comment + forward, album items, text + transcript. |
|
||||
| Plain text is debounced too | The 600 ms window applies to **every** message that reaches the inbox, plain text included, not only to attachments and forwards. Telegram usually delivers a forward's comment first, as an ordinary text message, so at the moment the comment arrives the host can't know a forward follows. Skipping the window for plain text would send the comment alone and reintroduce the split-turn problem. The cost is ~0.6 s of added latency on every message, which is small next to a model call. |
|
||||
| Race handling | The reference's pending counter, as leases. A lease is taken **synchronously in update order** while routing. The window timer is only armed when no lease is open. Parts merge in lease order, which is Telegram order, not completion order. This improves on the reference, which merges in completion order. |
|
||||
| Hung preparation | A lease open longer than **2 min** (`ChatInbox` constructor default, not user config) stops holding the batch. The batch flushes without it, and the late part becomes a prompt of its own. |
|
||||
| Slow work off the poll loop | Downloads (and the sibling's transcription) run in a background task per message, holding the lease. `RouteAsync` returns right after taking the lease, so `/stop` still gets through. Today the photo download is awaited inline. |
|
||||
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending `TelegramUi` input. Unchanged rules, checked before the inbox, and only for messages with no attachment: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec). `/new` and `/resume` discard the buffer (open leases still finish, but their parts are dropped). |
|
||||
| Delivery | A flush calls `ChatContext.PromptInBackground(text, images)`, which is unchanged. While a run is active the merged prompt steers it (`StreamingBehavior.Steer`), as today. |
|
||||
| 👀 reaction | At flush time, if `session.IsStreaming`, every message in the batch gets a 👀 reaction (`setMessageReaction`), best-effort. An album has at most 10 items. It is not sent when the batch starts a new run. |
|
||||
| 🟢 Online | Once per process, after `BotSetupService`'s first successful `getMe` + `setMyCommands`, it sends `🟢 Online.` to the owner (`AllowedUserIds[0]`). Best-effort: a failure (for example 403 because the owner never started the bot) is logged and not retried. |
|
||||
|
||||
### Header wording
|
||||
|
||||
| Situation | Header |
|
||||
|---|---|
|
||||
| Reply to the bot | `[Reply to your message: "<text>"]` |
|
||||
| Reply to the user's own message | `[Reply to your own earlier message: "<text>"]` |
|
||||
| Reply, `From` missing | `[Reply to the earlier message: "<text>"]` |
|
||||
| Reply with quote | `[Reply quoting your message: "<quote>"]` (same "who" variants) |
|
||||
| External reply | `[Reply to a message from <origin>: "<quote or descriptor>"]`, `[Reply quoting a message from <origin>: "<quote>"]` |
|
||||
| Forward | `[Forwarded from <origin>]` |
|
||||
| Local time (once per batch, first line) | `[<ddd> <yyyy-MM-dd> <HH:mm> <IANA zone>]`, e.g. `[Sat 2026-09-26 14:03 Europe/Berlin]` |
|
||||
|
||||
Media descriptors, first match wins: `(photo)`, `(video)`, `(voice message)`, `(video message)` (video note),
|
||||
`(audio: <file name>)` / `(audio)`, `(document: <file name>)` / `(document)`, `(sticker <emoji>)`, otherwise
|
||||
`(message)`. A caption always wins over the descriptor.
|
||||
|
||||
Origin labels (`Telegram.Bot.Types.MessageOrigin*`):
|
||||
|
||||
| Kind | Label |
|
||||
|---|---|
|
||||
| `MessageOriginUser` | `@username`, else `First Last`, else `unknown user` |
|
||||
| `MessageOriginHiddenUser` | `SenderUserName`, else `hidden user` |
|
||||
| `MessageOriginChat` | `SenderChat.Title`, else `@username`, else `chat`; plus ` (<AuthorSignature>)` when set |
|
||||
| `MessageOriginChannel` | `Chat.Title`, else `@username`, else `channel`; plus ` (<AuthorSignature>)` when set |
|
||||
|
||||
The author signature is an addition to the reference, which drops it. It costs nothing and tells the model which
|
||||
author in a channel wrote the post.
|
||||
|
||||
Example: a forwarded channel post, sent as a reply to the bot, with the user's comment sent alongside:
|
||||
|
||||
```
|
||||
[Sat 2026-09-26 14:03 Europe/Berlin]
|
||||
look at this, is it the plant you mentioned?
|
||||
|
||||
[Reply to your message: "Which plant do you mean?"]
|
||||
[Forwarded from Daily News (Anna K.)]
|
||||
Geothermal plant opens in …
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
All new code is in `hosts/Volcanic.AgenticCore.Telegram/Inbound/`. The core and the Abstractions are unchanged.
|
||||
|
||||
### `MessageContext` (static, pure)
|
||||
|
||||
- `string? ReplyHeader(Message message, long botId)`: covers `ReplyToMessage`, `Quote` and `ExternalReply`. Returns
|
||||
null when there's nothing to reply to. It can't throw: an unknown shape falls back to `(message)`.
|
||||
- `string ForwardLabel(MessageOrigin origin)`.
|
||||
- `string Describe(Message replied)` and `string Truncate(string text, int max = 500)`. The truncation counts
|
||||
`Rune`s, so a surrogate pair is never split.
|
||||
- `string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone)`: `[Sat 2026-09-26 14:03 Europe/Berlin]`, formatted
|
||||
with `CultureInfo.InvariantCulture`. Shared with `TelegramJobDelivery` (scheduler spec), so both read the same.
|
||||
|
||||
### `InboundPart` (record)
|
||||
|
||||
```csharp
|
||||
public sealed record InboundPart(
|
||||
int MessageId,
|
||||
DateTimeOffset SentAt, // Message.Date, for the batch's timestamp header
|
||||
string? MediaGroupId,
|
||||
string? ReplyHeader, // from MessageContext
|
||||
string? ForwardLabel, // from MessageContext, without the brackets
|
||||
IReadOnlyList<string> Body, // text/caption, then lines added by content steps ("[File received: …]", "[Voice message] …")
|
||||
IReadOnlyList<ImageContent> Images);
|
||||
```
|
||||
|
||||
Parts stay structured until the flush, so the merge can collapse album headers. Consecutive parts with the same
|
||||
`MediaGroupId` render the reply header and forward label only once (from the first part that has them). An album of
|
||||
five forwarded photos gets one `[Forwarded from …]`, not five. The rendered batch starts with one
|
||||
`TimestampHeader(parts[0].SentAt, zone)` line; parts are joined with a blank line. Inside a part,
|
||||
the order is header lines, then the body lines joined by a blank line. A part with images but no text lines stays
|
||||
image-only, and `UserMessage` accepts empty text with images.
|
||||
|
||||
### `InboundComposer`
|
||||
|
||||
`Task<InboundPart?> ComposeAsync(ChatContext chat, Message message, CancellationToken)`: builds the headers, then
|
||||
runs the **content steps** in a fixed order. Each step can add body lines and images, send a user-facing notice, or
|
||||
refuse the message.
|
||||
|
||||
- Step 1 (this spec): text/caption, and the largest photo, using today's `DownloadPhotoAsync` logic. A download
|
||||
failure still goes through `chat.Report`.
|
||||
- Step 2 (sibling spec): `Document`, `Video` and `Audio` saved to the workspace inbox (`[File received: …]`), image
|
||||
documents also attached as images.
|
||||
- Step 3 (sibling spec): `Voice`/`Audio` transcription (`[Voice message] …`), including its `🎤` echo.
|
||||
|
||||
Notices and the echo are sent through the renderer (`ChatRenderer.RunInOrderAsync`, added by the sibling spec), so
|
||||
they stay in order with rendered output. `ComposeAsync` returns null when the message has nothing usable or a step
|
||||
refused it (with its own notice); the lease then completes empty and nothing else is sent. Headers are attached only
|
||||
if the part has a body or images, as in the reference.
|
||||
|
||||
Unsupported kinds never reach the composer: the router checks the message kind before taking a lease. Anything other
|
||||
than text, photo, document, video, audio or voice gets `I can read text, photos, files and voice messages.` (the
|
||||
sibling spec's text, replacing today's "I can read text and photos.").
|
||||
|
||||
The sibling spec relies on this contract: **any slow work for a message happens inside `ComposeAsync`, while the
|
||||
router holds that message's lease.** A preparer never talks to `ChatInbox` itself.
|
||||
|
||||
### `ChatInbox` (per chat, the debouncer)
|
||||
|
||||
```csharp
|
||||
public sealed class ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window,
|
||||
TimeProvider time, TimeSpan? maxHold = null) : IAsyncDisposable
|
||||
{
|
||||
public InboundLease Begin(int messageId); // reserves the next slot; cancels an armed timer
|
||||
public void Discard(); // /new, /resume: drop buffered and in-flight parts (generation bump)
|
||||
public Task DrainAsync(); // completes when nothing is buffered, no lease is open and the last flush was handed over
|
||||
}
|
||||
|
||||
public sealed class InboundLease : IDisposable
|
||||
{
|
||||
public void Complete(InboundPart? part); // fills the slot (null = the message produced nothing)
|
||||
public void Dispose(); // Complete(null) if not completed; always safe to call twice
|
||||
}
|
||||
```
|
||||
|
||||
Rules (one lock, timer from `TimeProvider.CreateTimer`):
|
||||
|
||||
1. `Begin` appends an open slot and cancels the timer.
|
||||
2. `Complete`/`Dispose` fills the slot. If no slot is open and at least one holds a part, the timer is armed for
|
||||
`window`. If every slot is empty, the slots are cleared.
|
||||
3. When the timer fires, the filled slots are taken in slot order and passed to `onFlush`. A new `Begin` during
|
||||
`onFlush` starts the next batch.
|
||||
4. When a slot has been open for `maxHold` (default 2 min, tracked by a second timer), it is detached. The batch is
|
||||
handled as if that slot were empty. Its later `Complete` is treated as a new `Begin` + `Complete` at the end.
|
||||
5. `Discard` bumps a generation counter and clears the slots. Leases from an older generation complete into nothing.
|
||||
6. `DisposeAsync` (from `ChatContext.DisposeAsync`) discards and logs how many parts were dropped.
|
||||
|
||||
Race walk-through (comment, then a forwarded voice that takes 3 s to transcribe):
|
||||
|
||||
```
|
||||
t=0.000 comment routed → Begin#1 (sync) → compose (fast) → Complete#1 open=0 → timer armed (t+0.6)
|
||||
t=0.050 voice routed → Begin#2 (sync), timer cancelled → compose awaits transcription…
|
||||
t=3.050 transcript done → Complete#2 open=0 → timer armed (t+0.6)
|
||||
t=3.650 flush [#1 comment, #2 forward+transcript] → one prompt
|
||||
```
|
||||
|
||||
If the voice had arrived first, it would still come first in the prompt, because leases are taken in routing order.
|
||||
Polling calls `RouteAsync` sequentially, so routing order is Telegram order.
|
||||
|
||||
### `UpdateRouter` changes
|
||||
|
||||
`OnMessageAsync` keeps the allow-list, private-chat check, pending-input answer and command handling as they are
|
||||
(the last two only for messages with no attachment). Then it rejects unsupported kinds (above), and instead of
|
||||
downloading and prompting inline:
|
||||
|
||||
```csharp
|
||||
var lease = chat.Inbox.Begin(message.Id);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try { lease.Complete(await composer.ComposeAsync(chat, message, ct)); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException) { log; chat.Report("I could not read that message."); }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The pending-input rule adds one condition: a message with `ForwardOrigin` never answers a pending question. A
|
||||
forwarded message is content, not an answer. `ChatCommands` calls `chat.Inbox.Discard()` on `/new` and a successful
|
||||
`/resume`.
|
||||
|
||||
### `ChatContext` / `ChatRegistry` changes
|
||||
|
||||
- `ChatContext` gets an `Inbox` property. `ChatRegistry.CreateAsync` builds the inbox with
|
||||
`options.InputDebounce`, an injected `TimeProvider` (default `TimeProvider.System`, registered in DI), and an
|
||||
`onFlush` that:
|
||||
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
|
||||
(`ChatSettingsStore.TimeZoneFor(chatId)`, read at flush time, so a `/timezone` change applies to the next
|
||||
batch). An image-only batch still gets the header line as its text;
|
||||
2. if `session.IsStreaming`, calls `sender.ReactAsync(chatId, id, "👀")` for each part's message id, fire-and-forget;
|
||||
3. calls `chat.PromptInBackground(text, images)`.
|
||||
- The check in step 2 and the steer in step 3 can race with the end of a run. In that case the message starts a new
|
||||
run and still shows 👀. That's harmless and accepted.
|
||||
|
||||
### `TelegramSender.ReactAsync`
|
||||
|
||||
`Task ReactAsync(long chatId, int messageId, string emoji, CancellationToken)` wraps
|
||||
`bot.SetMessageReaction(chatId, messageId, [new ReactionTypeEmoji { Emoji = emoji }])`. Every failure is logged at
|
||||
Debug and swallowed. A reaction is never worth an error in the chat.
|
||||
|
||||
### `BotSetupService` changes
|
||||
|
||||
After the first successful setup, before `return`, it calls
|
||||
`sender.SendTextAsync(options.AllowedUserIds[0], "🟢 Online.", ct)`. `SendTextAsync` already logs failures and
|
||||
returns null, and this call is outside the retry loop, so a 403 doesn't turn into a retry loop. It does not create
|
||||
the owner's `ChatContext` or session. `BotSetupService` gains a `TelegramSender` dependency.
|
||||
|
||||
### `TelegramOptions`
|
||||
|
||||
`InputDebounce` (`TimeSpan`, default `00:00:00.6`). It must be `>= 0` (validated); zero flushes on the next timer
|
||||
tick. Add it to the configuration table in `docs/telegram-bot.md`.
|
||||
|
||||
### Docs
|
||||
|
||||
`docs/telegram-bot.md`, "Using it":
|
||||
- replies and quotes are passed to the agent;
|
||||
- forwards are labelled;
|
||||
- messages sent within ~0.6 s of each other (a forward with a comment, an album) are one request, and every
|
||||
message waits that long before it's sent to the agent;
|
||||
- each request carries the local time of the user's message, in the chat's zone;
|
||||
- 👀 means the message was added to the running request;
|
||||
- the bot says `🟢 Online.` to the owner after a start.
|
||||
|
||||
The `/help` text in `ChatCommands` gets one line about replies.
|
||||
|
||||
## Testing
|
||||
|
||||
xunit, in `tests/Volcanic.AgenticCore.Telegram.Tests`. Uses `FakeTimeProvider` from
|
||||
`Microsoft.Extensions.TimeProvider.Testing`, added to `Volcanic.AgenticCore.Telegram.Tests` and
|
||||
`Volcanic.AgenticCore.Extensions.Tests` by whichever of the sibling specs lands first (the scheduler and calendar
|
||||
specs use it too).
|
||||
|
||||
**Support changes**
|
||||
- `Updates`: `Reply(text, replyTo: Message)`, `Quote(text, replyTo, quote)`, `ExternalReply(text, origin, quote)`,
|
||||
`Forward(text, MessageOrigin)`, `AlbumPhoto(groupId, caption?)`. All take an optional explicit message id.
|
||||
- `ChatHost`: sets `InputDebounce = 20 ms` (real time, like `EditInterval`). `SettleAsync` first awaits
|
||||
`chat.Inbox.DrainAsync()`, then does what it does today, so existing tests keep their shape. An optional
|
||||
`TimeProvider` parameter is added for the timing tests.
|
||||
- `FakeBotClient` records `SetMessageReactionRequest` already (default branch). Nothing to add apart from a
|
||||
`Reactions` convenience view.
|
||||
|
||||
**`MessageContextTests`** (pure, no host). Follows the reference's 13 cases:
|
||||
- reply to the bot / to self / with no `From`;
|
||||
- quote to the bot and to self;
|
||||
- photo-only, voice-only, named document, sticker;
|
||||
- caption beats the descriptor;
|
||||
- 600-char text truncated to 499 + `…`, with no split surrogate pair at the cut;
|
||||
- reply + forward ordering;
|
||||
- `TimestampHeader` for a fixed instant in `Europe/Berlin` and `Asia/Tokyo` (the day name and date follow the zone,
|
||||
e.g. 2026-09-26T23:30Z is `Sun 2026-09-27 08:30 Asia/Tokyo`), invariant culture under a non-English
|
||||
`CurrentCulture`;
|
||||
- empty reply → `(message)`.
|
||||
|
||||
Also: external reply with and without a quote, each `MessageOrigin` kind including the fallbacks and the author
|
||||
signature, and a bot id taken from `BotId` when `From.IsBot` is false.
|
||||
|
||||
**`ChatInboxTests`** (`FakeTimeProvider`, recording `onFlush`). Mirrors `debouncer.test.ts`:
|
||||
- one part flushes after the window, not before;
|
||||
- two parts inside the window make one flush, and the timer resets on each;
|
||||
- `Begin` cancels an armed timer;
|
||||
- `Complete` while another lease is open does not arm;
|
||||
- two leases need two completions;
|
||||
- begin/complete with a null part never flushes;
|
||||
- the comment-then-slow-voice race gives one flush;
|
||||
- **the voice-then-comment race keeps slot order** (new);
|
||||
- `Discard` with an open lease: the late completion is dropped;
|
||||
- double `Dispose` is a no-op;
|
||||
- `maxHold` detaches a hung lease, and its late part flushes as its own batch;
|
||||
- album header collapsing in the renderer.
|
||||
|
||||
**`MessageContextChatTests`** (through `ChatHost` + `FauxProvider`, asserting on `Faux.Requests[..].Messages.Last()`):
|
||||
- a reply to a bot message reaches the model with the header;
|
||||
- the first line of every user message the model sees is the timestamp header in the chat's zone; forward + comment
|
||||
get one header, not two; after `/timezone Asia/Tokyo` the next message's header says Tokyo;
|
||||
- plain text alone waits for the window: a text followed within it by a forward is one request;
|
||||
- forward + comment sent back to back produce one model request containing both, in order;
|
||||
- a three-photo album with one caption is one request with three images and one forward label;
|
||||
- a message sent during a scripted long run (a `FauxTurn` tool that waits on a gate) gets a 👀
|
||||
`SetMessageReactionRequest` and appears as a steering message. A message sent while idle gets no reaction;
|
||||
- `/new` during the window drops the buffered text;
|
||||
- a forwarded text while `TelegramUi` waits for input goes to the agent, not to the question;
|
||||
- `/stop` routed while a photo download is blocked (an `OnSend` gate on `GetFileRequest`) is handled right away,
|
||||
which proves the poll loop isn't blocked.
|
||||
|
||||
**`BotSetupServiceTests`**:
|
||||
- after `getMe` fails once and then succeeds, exactly one `🟢 Online.` goes to `AllowedUserIds[0]`;
|
||||
- a 403 on that send doesn't stop the service and isn't retried.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Voice, audio, video and documents content. The sibling attachments spec adds them as content steps 2 and 3 under
|
||||
the lease contract above.
|
||||
- Re-downloading media from the replied-to message. Rewinding the session to the replied-to turn.
|
||||
- Quote entities (formatting), and `reply_parameters` on outbound messages.
|
||||
- Group chats, `IsAutomaticForward`, and stories/polls/locations beyond the `(message)` descriptor.
|
||||
- A per-media-group longer window. Telegram delivers album items back to back, well inside 600 ms.
|
||||
- An offline/shutdown message, and a status block in the online message (the reference adds `formatStatus`; here
|
||||
that would force the owner's session to be created at startup).
|
||||
Reference in New Issue
Block a user