A skill's SKILL.md frontmatter may declare session-start (output into <skill_session_context>, at session start, /new, resume and after compaction), turn-end (after every run, in the background; output or a failure reaches the next run as a hidden notice, repeats suppressed until a clean run) and protected-paths (write/edit refused inside them). Hooks run from the skill directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits are bounded by each hook's timeout and failures never break a run. /skills shows declared hooks and broken fields.
199 lines
13 KiB
Markdown
199 lines
13 KiB
Markdown
# Architecture
|
|
|
|
## Projects
|
|
|
|
```
|
|
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
|
|
src/Volcanic.AgenticCore runtime, organised as vertical slices
|
|
src/Volcanic.AgenticCore.A2A.Server A2A protocol server on the official SDK, built on the core
|
|
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler, A2A client), built on the public API only
|
|
samples/* reference hosts
|
|
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)).
|
|
|
|
`A2A` is the A2A client, a feature extension: a per-session `a2a` tool bound to a host target over the workspace's
|
|
`.volcanic/a2a/agents.json`, and a process-wide `A2AWatchRegistry` that follows long remote tasks (push, subscribe or
|
|
poll), persists them in `.volcanic/a2a/pending.json` and hands results to an `IA2AResultDelivery` the host may
|
|
implement (default: the target's live session). Hosts add it per session, never host-wide, so A2A server sessions
|
|
don't get it (see [a2a.md](a2a.md#client)).
|
|
|
|
`Volcanic.AgenticCore.A2A.Server` exposes assistants over [A2A](a2a.md) on the official `A2A`/`A2A.AspNetCore` SDK.
|
|
It depends on the core (`IAgentSessionFactory`, `AgentSessionOptions`) directly, not through the extension API,
|
|
since it needs ASP.NET Core hosting the extension contract doesn't carry. Tenancy is structural: the SDK's
|
|
`A2AServer` owns background runs and cancellation state per instance, so `A2AServerCache` keeps one
|
|
`VolcanicA2AServer` subclass per authenticated caller, each bound to that caller's own `WorkspaceTaskStore`; a
|
|
single `A2ARequestRouter` (the SDK's `IA2ARequestHandler`) reads the caller an endpoint filter already resolved
|
|
onto `HttpContext.Items` and forwards every call to that caller's server. `A2ASessionPool` maps one `AgentSession` per
|
|
`(caller, contextId)`, one task running at a time, with idle eviction. See [a2a.md](a2a.md).
|
|
|
|
## Vertical slices
|
|
|
|
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`
|
|
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 |
|
|
| `SkillHooks` | `SkillHookRunner` | The hooks skills declare in SKILL.md (`session-start`, `turn-end`) and their `protected-paths` guard on `write`/`edit`; owned by `AgentSession` |
|
|
| `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`, then start the skills' `session-start` hooks in the background (also after `/new`, a
|
|
resume and every compaction).
|
|
|
|
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
|
|
skill hooks → wait for session-start hooks and the previous turn-end (each until its deadline at most):
|
|
<skill_session_context> for the prompt, a turn-end notice after the prompt
|
|
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, protected-paths check (write/edit), 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 → the skills' turn-end hooks start in the background (never awaited here)
|
|
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.
|
|
|
|
## Skill hooks
|
|
|
|
A skill's SKILL.md may declare `session-start` and `turn-end` commands and `protected-paths`
|
|
([telegram-bot.md](telegram-bot.md#skill-hooks-and-protected-paths) has the format). `SkillFiles`'s companion
|
|
`SkillHookFiles` (Abstractions) parses them; `SkillHookRunner` (one per `AgentSession`, off with
|
|
`AgenticCoreOptions.SkillHooks = false`) runs them through `PrepareProcessAsync` with `ProcessPurpose.SkillHook`, from
|
|
the skill directory, with `VOLCANIC_WORKSPACE`/`VOLCANIC_SKILL_DIR` and the session's `TZ`, and redacts their output.
|
|
Nothing a hook does can fail a run: errors and timeouts become text for the model or a log line. Waits are bounded
|
|
by each hook's deadline (start + timeout), so a run never waits longer than a hook's timeout.
|
|
|
|
## 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.
|