Files
Playa/docs/architecture.md
2026-09-25 12:36:24 +00:00

6.0 KiB

Architecture

Projects

src/Volcanic.AgenticCore.Abstractions   contracts (no runtime code except helpers)
src/Volcanic.AgenticCore                 runtime, organised as vertical slices
extensions/*                             provider extensions 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, and the AgentSessionEvent family a UI subscribes to. Extensions reference only this assembly, which is what allows them to be loaded from separate directories.

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

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.

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

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.