Files
VolcanicAgenticCore/docs/architecture.md
EugeneTes d542fcfe42 docs(a2a): A2A server guide, architecture, Telegram and Docker notes
Add docs/a2a.md (concepts, file formats, endpoints, sessions, task
lifecycle, configuration, security notes, curl examples) describing
what src/Volcanic.AgenticCore.A2A.Server actually does. Add an A2A
project paragraph to architecture.md, an "A2A" section to
telegram-bot.md (/a2a, notify_owner, isolated sessions, port 8080),
A2A_ENABLED/A2A_PUBLIC_URL and the aspnet image to docker.md, and
project-map/test-command updates to CLAUDE.md.

Mark the A2A server design spec implemented and record the deviations
found while building it (A2AHostOptions, synchronous
IA2ACallerDirectory.Find, generic WorkspaceCallerDirectory, per-status
push payload, split file-part error handling, the outbound guarded
handler, the per-caller server/router architecture, the restart grace
period, the owner= log field, the aspnet image, and the actual push
retry schedule). Note the aspnet image in the docker-deployment spec
too, without rewriting its historical decisions.
2026-10-07 14:20:19 +00:00

11 KiB

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), 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).

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).

Volcanic.AgenticCore.A2A.Server exposes assistants over A2A 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.

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.