# 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): 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`. 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//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.