forked from admin/VolcanicAgenticCore
docs(scheduler): scheduling, per-chat time zones and the session zone
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -56,6 +56,8 @@ Read `docs/architecture.md` first. Key points:
|
||||
- **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are
|
||||
**instantiated per session** and each session has its own `ModelRegistry`, so provider registrations and extension
|
||||
fields are per conversation. Process-wide state has to be explicit (e.g. `ClaudeLoginCoordinator.Shared`).
|
||||
`AgentSession.SetTimeZone` changes the session's zone (system prompt from the next run); see "Time zone" in
|
||||
`docs/architecture.md`.
|
||||
- **Two event systems.** `AgentSessionEvent`s go to hosts via `session.Subscribe` and are invoked synchronously on the
|
||||
loop. `ExtensionEvent`s go to extensions via `api.On<T>`: they are mutable event args (`Block`, `Cancel`,
|
||||
`MarkHandled`, set `Text`/`Message`/...), run sequentially in load order, and some short-circuit.
|
||||
@@ -80,9 +82,11 @@ Read `docs/architecture.md` first. Key points:
|
||||
|
||||
Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
|
||||
`ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
|
||||
preset; `docs/mcp.md`); and the `GoogleCalendar` feature extension (`calendar` tool,
|
||||
preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
|
||||
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens under
|
||||
`<agentDir>/google-calendar/`; `docs/google-calendar.md`). The Claude Code provider is the most intricate piece
|
||||
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
|
||||
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
|
||||
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
|
||||
(`docs/claude-code-provider.md`):
|
||||
|
||||
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
|
||||
@@ -108,6 +112,11 @@ with a local-time header. Files, audio and voice are saved to `<workspace>/inbox
|
||||
(`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through
|
||||
`ChatRenderer.RunInOrderAsync`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
|
||||
prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry.
|
||||
Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/telegram/chat-settings.json`, default
|
||||
`Telegram:DefaultTimeZone`) and is changed through `ChatTimeZoneService` (`/timezone`, the `set_timezone` tool); it
|
||||
feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts
|
||||
through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox)
|
||||
and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs.
|
||||
Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
```
|
||||
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
|
||||
src/Volcanic.AgenticCore runtime, organised as vertical slices
|
||||
extensions/* provider and feature extensions (MCP client, Google Calendar), built on the public API only
|
||||
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only
|
||||
samples/* reference host
|
||||
tests/* xunit
|
||||
```
|
||||
@@ -21,6 +21,10 @@ directories.
|
||||
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
|
||||
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
|
||||
|
||||
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher
|
||||
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
|
||||
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
|
||||
|
||||
## Vertical slices
|
||||
|
||||
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`
|
||||
@@ -55,6 +59,15 @@ is the composition root of one conversation; a host never touches the other slic
|
||||
Extensions are instantiated per session, so extension fields are per conversation. Provider registrations
|
||||
also live per session (a `ModelRegistry` per session), which keeps sessions independent.
|
||||
|
||||
## Time zone
|
||||
|
||||
A session's zone is the first that is set of `SetTimeZone`, `AgentSessionOptions.TimeZone`,
|
||||
`AgenticCoreOptions.DefaultTimeZone` and `TimeZoneInfo.Local`. It is exposed as `IExtensionContext.TimeZone`, becomes
|
||||
`TZ` for the session's processes (`PrepareProcessAsync`) and for Claude Code's `claude -p`, and fills the system
|
||||
prompt's `Current date` / `Time zone` lines. The prompt has the date only and is rendered per run, so a change applies
|
||||
from the next run and the cached prompt prefix changes at most daily. The session doesn't persist its zone; a host
|
||||
that lets users change it keeps it (the Telegram host in `chat-settings.json`).
|
||||
|
||||
## The agent loop
|
||||
|
||||
`PromptAsync(text)`:
|
||||
@@ -113,9 +126,9 @@ Two independent event systems exist:
|
||||
## Processes and secrets
|
||||
|
||||
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
|
||||
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and, with
|
||||
its spec, scheduler scripts) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
|
||||
`AgenticCoreOptions.ProcessEnvironment`:
|
||||
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and
|
||||
scheduler watcher scripts, through the DI `IProcessSpawnPolicy` since jobs fire without a session) gets its command,
|
||||
cwd and environment from one `IProcessSpawnPolicy`, configured through `AgenticCoreOptions.ProcessEnvironment`:
|
||||
|
||||
```
|
||||
inherit the host environment → filter (Deny, Allow, SecretValues) → explicit request.Environment (never filtered)
|
||||
@@ -136,7 +149,7 @@ dumpable.
|
||||
results, progress updates and error messages (see the loop above), so every tool is covered in one place.
|
||||
|
||||
The Claude Code provider's own `claude` processes are outside the policy on purpose: they run the model and already
|
||||
get an allow-listed environment. None of this is a sandbox: the agent still runs as the host's OS user and can read
|
||||
get an allow-listed environment (plus the session's `TZ`, set by the provider). None of this is a sandbox: the agent still runs as the host's OS user and can read
|
||||
the files that user can read.
|
||||
|
||||
## Persistence
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md; the scheduler `ScriptRunner` comes with its own spec)
|
||||
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md, the scheduler `ScriptRunner` with 2026-09-26-scheduler-design.md)
|
||||
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).
|
||||
@@ -231,6 +231,7 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery)
|
||||
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
|
||||
|
||||
*The two MCP rows are implemented in `extensions/Volcanic.AgenticCore.Extensions.Mcp` (`McpServerConnection`, `McpCatalogCache`).*
|
||||
*The scheduler row is implemented in `extensions/Volcanic.AgenticCore.Extensions.Scheduler` (`ScriptRunner`).*
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
|
||||
|
||||
Date: 2026-09-26
|
||||
Status: draft
|
||||
Status: implemented
|
||||
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`
|
||||
@@ -346,6 +346,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
|
||||
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>)`.
|
||||
- `ChatTimeZoneService` finds the live session through a function (`ChatRegistry.Find`) so DI has no ChatRegistry ↔
|
||||
service cycle; with no scheduler, `/timezone` replies without the job clause.
|
||||
|
||||
### 8. Session time zone (core)
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ Date: 2026-09-26
|
||||
Status: implemented
|
||||
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 the session's zone (`AgentSession.TimeZone`, which falls back to `TimeZoneInfo.Local`)
|
||||
through `ChatRegistry.TimeZoneFor` until the store exists).
|
||||
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; the
|
||||
header's zone comes from `ChatRegistry.TimeZoneFor`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -224,8 +223,7 @@ on `/new`, `/stop` and a `/resume` that names exactly one conversation, before i
|
||||
`TimeProvider.System`; `Program.cs` registers `TimeProvider.System` with `TryAddSingleton`), and an
|
||||
`onFlush` that:
|
||||
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
|
||||
(`ChatRegistry.TimeZoneFor(chatId)`, which returns the chat session's `TimeZone` until the scheduler spec's
|
||||
`ChatSettingsStore` exists and then delegates to `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
|
||||
(`ChatRegistry.TimeZoneFor(chatId)`, which returns `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)`.
|
||||
|
||||
@@ -23,7 +23,7 @@ Open the bot in Telegram and say hello.
|
||||
## Configuration
|
||||
|
||||
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google
|
||||
settings use the `GoogleCalendar` section):
|
||||
settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section):
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
@@ -34,6 +34,12 @@ settings use the `GoogleCalendar` section):
|
||||
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
|
||||
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
|
||||
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
|
||||
| `DEFAULT_TIMEZONE` / `Telegram:DefaultTimeZone` | — (the machine's zone) | IANA zone for chats that haven't set one with `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup. Also the default for the core (`AgenticCoreOptions.DefaultTimeZone`) |
|
||||
| `Scheduler:Enabled` | `true` | Reminders, scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
|
||||
| `Scheduler:StorePath` | `<agent dir>/scheduler/jobs.json` | One file for every chat's jobs, outside the workspaces |
|
||||
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
|
||||
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
|
||||
| `Scheduler:Shell` | `/bin/sh -c` (`cmd.exe /d /s /c` on Windows) | The watcher shell as a list, command appended; same as `bash` by default |
|
||||
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription on. Hidden from `bash` and redacted from tool output |
|
||||
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
|
||||
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
|
||||
@@ -49,7 +55,8 @@ settings use the `GoogleCalendar` section):
|
||||
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
|
||||
|
||||
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
|
||||
its most recent conversation.
|
||||
its most recent conversation. Each chat's time zone is kept in `<agent dir>/telegram/chat-settings.json`, so it
|
||||
survives `/new` and restarts.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -73,7 +80,7 @@ Along with your text, the agent sees:
|
||||
- **Replies and quotes.** Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets
|
||||
a line such as `[Reply to your message: "Which plant do you mean?"]` before your text.
|
||||
- **Forwards**, labelled with their source: `[Forwarded from Daily News (Anna K.)]`.
|
||||
- **The local time** you sent the message, in the chat's time zone (for now, the zone of the machine running the bot):
|
||||
- **The local time** you sent the message, in the chat's time zone (`/timezone`; by default `Telegram:DefaultTimeZone`, else the zone of the machine running the bot):
|
||||
`[Sat 2026-09-26 14:03 Europe/Berlin]`, once per request.
|
||||
|
||||
Messages sent within about 0.6 s of each other (`Telegram:InputDebounce`), such as a forward with your comment or
|
||||
@@ -88,10 +95,13 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
|
||||
|---|---|
|
||||
| `/new` | Start a new conversation |
|
||||
| `/stop` | Stop the current run |
|
||||
| `/status` | Model, thinking level, session, context usage, workspace |
|
||||
| `/status` | Model, thinking level, session, context usage, time zone, number of scheduled jobs, workspace |
|
||||
| `/sessions`, `/resume <id>` | List and resume earlier conversations (an id prefix is enough) |
|
||||
| `/compact` | Summarise the conversation to free context |
|
||||
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
|
||||
| `/timezone [name\|reset]` | Show or set this chat's time zone (IANA name such as `Asia/Tokyo`; `reset` returns to the default) |
|
||||
| `/jobs` | This chat's scheduled jobs with their next run |
|
||||
| `/cancel <id>` | Cancel one of them |
|
||||
| `/help` | Short help |
|
||||
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
|
||||
| `/calendar_status` | The connected account and time zone, or the pending code |
|
||||
@@ -135,6 +145,44 @@ as photos (up to 10 MB), other files as documents (up to 50 MB), with an optiona
|
||||
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
|
||||
chat's workspace and delivers only to that chat.
|
||||
|
||||
### Scheduling
|
||||
|
||||
Ask the agent in plain words. It has a `schedule` tool with three kinds of job:
|
||||
|
||||
- "Remind me at 9 tomorrow to call the dentist" is a **reminder**: at 9 the bot sends the text as is,
|
||||
`⏰ Call the dentist`, with no model call.
|
||||
- "Every weekday at 8, summarise what's in `inbox/`" is a **prompt**: when it fires, the agent handles it like a
|
||||
message from you, with the same time header.
|
||||
- "Check this page every 10 minutes and tell me only if the price drops below 50" is a **watcher**.
|
||||
|
||||
For a watcher the agent writes a script (usually in `scripts/`, tested with `bash` first). Every run the bot runs the
|
||||
script itself, **without the agent**:
|
||||
|
||||
- exit 0 with no output means "nothing yet" and costs nothing: no model call, nothing in the conversation;
|
||||
- exit 0 with output means the condition is met: the output always goes to the agent, which writes you the message
|
||||
(one model call per match);
|
||||
- a non-zero exit or a timeout is reported once, straight to the chat, with the last 20 lines of the script's errors.
|
||||
Later identical failures stay quiet until the script succeeds again.
|
||||
|
||||
Scripts run in the chat's workspace with the same filtered environment as `bash` and `TZ` set to the chat's zone.
|
||||
Known secrets are redacted from their output.
|
||||
|
||||
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never
|
||||
interrupts. Occurrences that come due while the same job is still running are skipped.
|
||||
|
||||
Times are in the chat's zone. `/timezone Asia/Tokyo` (or telling the agent where you are; it has a `set_timezone`
|
||||
tool) changes it for the date in the agent's instructions, `bash`, scheduled scripts, message timestamps and Claude
|
||||
Code. Recurring jobs follow the new zone ("every day at 9" means 9 where you are now); one-off jobs keep their moment
|
||||
and are just shown in the new zone. The reply says how many of each.
|
||||
|
||||
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, marked
|
||||
`(due …, delivered late)` (for prompts: "This was due at …; the bot was offline."). Recurring jobs don't catch up:
|
||||
missed occurrences are skipped.
|
||||
|
||||
Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Direct reminders and failure alerts aren't part
|
||||
of the conversation, so the agent can't see them later; use a prompt job for things that should be. With
|
||||
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
|
||||
|
||||
### Google Calendar
|
||||
|
||||
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
|
||||
@@ -148,6 +196,9 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O
|
||||
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
|
||||
container, and treat the workspace root as writable by anyone on the allow-list.
|
||||
|
||||
Scheduled watcher 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.
|
||||
|
||||
`send` refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails
|
||||
visibly (it is no barrier against `bash cp`). The speech API key is kept out of the agent's processes like the bot
|
||||
token.
|
||||
|
||||
Reference in New Issue
Block a user