Files
Playa/docs/architecture.md
EugeneTes 28bbfbc759 feat(scheduler): store jobs per chat and import VolcanicSecretary job files
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the
scheduler loads every chat's file, moves the retired shared
<agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>.
A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted
in place for that chat; the original is kept as .secretary-<ts>.

Also adds the VolcanicSecretary data migration plan.
2026-10-02 07:29:48 +00:00

169 lines
9.9 KiB
Markdown

# Architecture
## Projects
```
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, Scheduler), built on the public API only
samples/* reference host
tests/* xunit
```
`Abstractions` is deliberately small and stable: messages and content blocks, `Model` and `IProvider`,
the streaming event protocol, `ITool`, the extension API and its events, session entries and stores,
`IUserInterface`, `IChatChannel` (delivering files and texts into the conversation's chat), the `AgentSessionEvent`
family a UI subscribes to, and the process contract in `Processes/`
(`IProcessSpawnPolicy`, `ProcessSpawnRequest`/`ProcessSpawnContext`, `ProcessSpawnHook`, `ProcessSpawnEvent`,
`ISecretRedactor`). Extensions reference only this assembly, which is what allows them to be loaded from separate
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` (scheduled prompts and watcher scripts
on a `TimeProvider` timer, jobs in each target's workspace, `.volcanic/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`
owns everything for that feature and exposes one entry type:
| Slice | Entry type | Responsibility |
|---|---|---|
| `Models` | `ModelRegistry` | Provider registrations, `provider/id` lookup, API key resolution, transcript normalisation before a call |
| `Sessions` | `SessionManager` | The entry tree of one session, branch/context building, `ISessionStore` implementations |
| `Tools` | `ToolRegistry` | Known and active tools, argument validation, output truncation, the built-in tools (`send` only for sessions with a channel) |
| `Extensions` | `ExtensionRunner` | Activating extensions (two-phase), dispatching events, contexts, the event bus, assembly loading |
| `Commands` | `CommandRegistry` | Slash commands |
| `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment |
| `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt |
| `Agent` | `AgentSession`, `AgentSessionFactory` | The conversation facade and the loop |
| `Processes` | `ProcessSpawnPolicy` | The environment, command and cwd of every agent-requested process (deny-list, allow-list, secret values, host hooks), host hardening, secret redaction |
Slices talk to each other only through their entry types and the Abstractions contracts. `AgentSession`
is the composition root of one conversation; a host never touches the other slices directly.
## One conversation = one `AgentSession`
`IAgentSessionFactory.CreateAsync(options)` builds a session:
1. open or create the `SessionManager` in the store (scope derived from the working directory, or given);
2. create the tool, command and model registries;
3. activate every extension (host-wide list, per-session extras, discovered assemblies), each getting its
own `IExtensionApi`; registrations are committed only when activation succeeds;
4. bind the runtime so extension actions work, restore model/thinking level from the branch on resume;
5. emit `session_start`.
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)`:
1. `/command args` → the command handler runs immediately, even while streaming.
2. `input` event: extensions can transform or swallow the text.
3. Streaming? Queue as steer or follow-up (the caller must say which).
4. Idle? Start a run.
One run (`RunLoopAsync`):
```
threshold compaction check
before_agent_start → system prompt options, injected custom messages
agent_start
loop:
turn_start
append pending messages (prompt, injected, steering, follow-up)
compaction between turns if requested/needed
context → extensions may rewrite the message list
stream the provider → message_start / message_update* / message_end (with retry + overflow recovery)
tool calls? → tool_call hook, execute (parallel by default), tool_result hook, tool result messages
(every result, progress update and error is redacted before tool_result handlers,
and again after them, before tool_execution_end and the persisted message)
turn_end
more tool calls or steering messages or continue requested? → next turn
follow-up queued? → next turn
agent_end
agent_settled
```
Invariants carried over from pi:
- Providers never throw; failures are `Error` events. The registry also guards against a stream that ends
without a terminal event.
- An error or abort ends the run immediately (after `turn_end`/`agent_end`).
- If the assistant stopped with `Length`, every tool call in that message fails with an error result.
- Tool preparation is sequential in source order; execution is parallel unless the session or any called
tool asks for sequential. `tool_execution_end` fires in completion order, tool result messages are
appended in source order.
- Failed assistant messages are persisted (so the transcript is honest) but the transcript normaliser drops
them from the model context, so retries do not see them.
## Event model
Two independent event systems exist:
- **Session events** (`AgentSessionEvent`) go to the host through `AgentSession.Subscribe`. Names match pi's
JSON event stream (`agent_start`, `message_update`, `tool_execution_end`, `agent_settled`, ...). Handlers are
invoked synchronously on the loop; keep them fast.
- **Extension events** (`ExtensionEvent` subclasses) go to extensions through `IExtensionApi.On<TEvent>`. They
are mutable "event args": a handler influences the runtime by calling methods on the event
(`Block`, `Cancel`, `MarkHandled`) or setting properties (`Text`, `Payload`, `Message`, `Override`).
Handlers run sequentially in load order and see each other's changes; some events short-circuit.
## 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
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)
→ TZ from TimeZoneId → host hooks → ProcessSpawnEvent to extensions (only in a session, via PrepareProcessAsync)
```
The result is the child's complete environment; spawners clear `ProcessStartInfo.Environment` and copy it in. A
throwing hook or handler fails the spawn instead of falling back to the inherited environment.
`HostProcessHardening` makes the host non-dumpable on Linux (`prctl(PR_SET_DUMPABLE, 0)`), so a same-user child
can't read `/proc/<host pid>/environ` or `ptrace` the host. It runs from the `ProcessSpawnPolicy` constructor, or
explicitly from a host at startup (the Telegram host does), and only once. It warns when `DOTNET_EnableDiagnostics=0`
is missing, since the diagnostic socket would hand out the same environment. `HardenHostProcess = false` turns it off
for debugging. Test projects make the one call with the flag off from a module initializer, so the test runner stays
dumpable.
`ISecretRedactor` replaces every `SecretValues` entry with `[REDACTED]`. `AgentSession` applies it to tool
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 (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
A session is a tree of entries (`message`, `model_change`, `thinking_level_change`, `compaction`, `custom`,
`label`, `session_info`) linked by `id`/`parentId`, with a leaf pointer. `Branch(entryId)` moves the leaf so the
next append forks. Context building walks the branch and, when a compaction entry exists, replaces older
messages with the summary. `FileSessionStore` writes JSONL; implement `ISessionStore` for a database.
## Error isolation
- Extension handler exceptions are logged, reported as `extension_error` session events, and dispatch
continues. A failing `tool_call` handler blocks the tool.
- Tool exceptions become error tool results the model can read.
- Provider exceptions become assistant error messages; transient ones are retried with backoff, context
overflow triggers one compaction and retry.