docs: user documentation and headless sample host

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:36:24 +00:00
parent c220428cb7
commit 018c92af49
7 changed files with 879 additions and 1 deletions

View File

@@ -1,2 +1,93 @@
# VolcanicAgenticCore
A headless, extensible agent harness for .NET, modelled on [earendil-works/pi](https://github.com/earendil-works/pi).
It keeps the parts of pi that make it *extensible* and *provider-agnostic* and leaves out the terminal UI,
so you can put any front end on top (a Telegram bot is the intended first one).
It also ships a C# port of [chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider):
an extension that drives the locally installed `claude` CLI on a Claude subscription, so the core can use
Claude Code as a model provider without an API key.
## What you get
| Piece | Where | Notes |
|---|---|---|
| Contracts for extensions, providers, tools, sessions, UI | `src/Volcanic.AgenticCore.Abstractions` | The only assembly an extension needs to reference |
| Runtime (vertical slices) | `src/Volcanic.AgenticCore` | Agent loop, sessions, models, tools, extensions, commands, compaction, system prompt, hosting |
| Anthropic Messages API provider | `extensions/Volcanic.AgenticCore.Extensions.Anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |
## Quick start
```bash
dotnet build
dotnet test
# run the reference host with the scripted faux provider (no keys needed)
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --faux "Say hello"
# with a real provider
ANTHROPIC_API_KEY=sk-ant-... dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model anthropic/claude-sonnet-4-5 "List the files here"
# with a Claude subscription through the claude CLI
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model claude-code/sonnet "List the files here"
```
Hosting it yourself:
```csharp
var services = new ServiceCollection();
services.AddAgenticCore(o =>
{
o.DefaultModel = "anthropic/claude-sonnet-4-5";
o.AddExtension<AnthropicProviderExtension>();
o.AddExtension<OpenAIProviderExtension>();
o.AddExtension<ClaudeCodeProviderExtension>();
});
var factory = services.BuildServiceProvider().GetRequiredService<IAgentSessionFactory>();
await using var session = await factory.CreateAsync(new AgentSessionOptions { Cwd = "/path/to/project" });
session.Subscribe(e =>
{
if (e is AgentSessionEvent.MessageUpdate { AssistantMessageEvent: AssistantMessageEvent.TextDelta d }) Console.Write(d.Delta);
});
await session.PromptAsync("Explain this repository");
```
## Documentation
- [Architecture](docs/architecture.md): projects, vertical slices, the agent loop and the event model.
- [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery.
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor command.
- Design spec and plan: `docs/superpowers/specs/`, `docs/superpowers/plans/`.
## Layout of the agent directory
Everything the runtime writes lives under `~/.volcanic/agent` (override with `VOLCANIC_AGENT_DIR` or `AgenticCoreOptions.AgentDir`):
```
~/.volcanic/agent/
sessions/<scope>/<session-id>.jsonl one JSONL file per session
extensions/*.dll extension assemblies discovered at start
auth.json { "anthropic": { "apiKey": "sk-..." } }
settings.json { "extensions": ["path/to/Ext.dll"] }
```
A project can add `.volcanic/extensions/*.dll` and `.volcanic/settings.json` in its working directory.
## Status
Ported from pi: agent loop with parallel tool execution, steering and follow-up queues, abort, auto-retry,
overflow recovery and threshold compaction; JSONL session trees with resume and branching; the extension
event model; slash commands; provider registration; API key resolution; built-in `read`, `write`, `edit`,
`bash`, `ls`, `grep`, `find` tools; system prompt assembly with `AGENTS.md`/`CLAUDE.md` project context.
Not ported (by design): the terminal UI, themes, keybindings, renderers, skills and prompt templates,
OAuth logins, image generation models, session tree navigation commands.
License: MIT. Fixtures under `tests/.../ClaudeCode/Fixtures` are from pi-claude-code-provider (MIT).

117
docs/architecture.md Normal file
View File

@@ -0,0 +1,117 @@
# 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.

View File

@@ -0,0 +1,83 @@
# Claude Code provider
`Volcanic.AgenticCore.Extensions.ClaudeCode` lets the core use a **Claude subscription** through the locally
installed `claude` CLI (Claude Code) instead of an Anthropic API key. It is a C# port of
[chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider); read that project's
`DESIGN.md` for the full reasoning behind each decision.
## Requirements
- `claude` (Claude Code) 2.1.x on `PATH` (or `VOLCANIC_CLAUDE_CODE_PATH`), logged in with a claude.ai
subscription (`claude auth status` must report `loggedIn: true`, `authMethod: claude.ai`,
`apiProvider: firstParty`).
- The .NET runtime (`dotnet`) on `PATH` or `Environment.ProcessPath`: `claude` spawns the bridge as
`dotnet Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll`.
- None of `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`,
`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX` in the host environment: the extension refuses to run
with them, because it is designed for subscription auth only.
Register it and pick a model:
```csharp
o.AddExtension<ClaudeCodeProviderExtension>();
o.DefaultModel = "claude-code/sonnet"; // or opus, haiku, fable
```
Run `/claude-code-doctor` in a session to check the installation, auth, required CLI flags and the bridge
handshake. It costs no quota.
## How it works
Every model call spawns a fresh `claude -p` process. Nothing is resumed on Claude's side: the whole
conversation is replayed each time.
1. The active tools are written to a JSON catalogue; each tool becomes an MCP tool `mcp__volcanic__<alias>`.
2. `claude` is started with `--tools ""` (all built-in tools off), `--permission-mode dontAsk`,
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
defeat prompt caching). Only an allow-list of environment variables is passed through, and compaction
inside Claude is disabled: the core owns compaction.
3. The bridge (a tiny console app) is the MCP server. It answers `initialize` and `tools/list` with the
catalogue and touches a "ready" marker file. It **never executes** anything: `tools/call` returns an error
and touches a "violation" marker that fails the request.
4. The transcript is sent as one stream-json user message: a header record, then one JSON record per
message (user, assistant with text/thinking/toolCall, toolResult). `@` characters are escaped because
Claude expands `@path` references. A cache breakpoint is placed on the last record so Claude's prompt
cache keeps hitting as the conversation grows. Images are written to a per-session content-addressed store
and referenced as `@"path"` attachments.
5. Claude's stream-json output is mapped to the core's streaming events. When the model asks for a tool
(`message_delta.stop_reason == "tool_use"`), Claude would deny it itself (dontAsk mode), so the provider
terminates the process immediately and publishes `Done(ToolUse)`. The core executes the tool and the next
call replays the transcript including the result.
6. The `system/init` record is validated strictly: the exposed tools, `permissionMode`, empty slash commands,
`apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else
fails closed.
## Limitations (inherited from the design)
- Full-history replay costs more context than the Messages API and cannot replay thinking signatures.
- Claude adds its own identity line and an environment block to every request; a Claude Code upgrade
invalidates the prompt cache.
- Claude still runs `git ls-files` / `rg --files` in the working directory at startup (slow in huge repos) and
reads `.claude/settings*.json` there.
- Mid-stream API recovery (`api_retry`, message id changes) fails the request as `stream_interrupted`; the
core's retry policy retries it.
- Cost is always reported as 0. Cancelling may still consume quota.
- Windows: process-tree termination is best effort.
## Configuration
| Setting | Where | Default |
|---|---|---|
| CLI path | `VOLCANIC_CLAUDE_CODE_PATH` or `ClaudeCodeProviderOptions.ClaudePath` | `claude` on PATH |
| Idle timeout | `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` | 5 min |
| Total timeout | `VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS` | 30 min |
| Thinking | session thinking level → `--effort` (`low`…`max`), not sent for `haiku` | model default |
## Errors
Provider failures surface as assistant error messages with a code prefix, for example
`[executable_missing] ...`, `[auth] ...`, `[mcp_startup] ...`, `[protocol_init] ...`. Two are special:
`stream_interrupted` messages contain `stream ended before message_stop` (retried automatically), and context
overflow messages start with `context_length_exceeded:` (the core compacts and retries once).

160
docs/extensions.md Normal file
View File

@@ -0,0 +1,160 @@
# Writing extensions
An extension is a class implementing `IExtension` from `Volcanic.AgenticCore.Abstractions`. One instance is
created per `AgentSession`, so instance fields are per conversation. `ActivateAsync` registers things; the
runtime binds actions afterwards.
```csharp
using System.ComponentModel;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
public sealed record HelloArgs([property: Description("Name to greet")] string Name);
public sealed class HelloTool : Tool<HelloArgs>
{
public override string Name => "hello";
public override string Description => "Greets someone";
public override string? PromptSnippet => "hello: greet a person by name";
protected override Task<ToolResult> ExecuteAsync(HelloArgs args, ToolInvocation invocation, CancellationToken ct) =>
Task.FromResult(ToolResult.Text($"Hello, {args.Name}!", new { greeted = args.Name }));
}
public sealed class HelloExtension : IExtension
{
public string Name => "hello";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken ct)
{
api.RegisterTool(new HelloTool());
api.RegisterCommand("hello", new CommandDefinition("Say hello through the model", (args, ctx, ct) =>
{
api.SendUserMessage($"Please greet {args} using the hello tool.");
return Task.CompletedTask;
}));
api.On<ToolCallEvent>(async (e, ctx, ct) =>
{
if (e.ToolName != "bash") return;
var command = e.Input["command"]?.GetValue<string>() ?? "";
if (!command.Contains("rm -rf")) return;
if (!ctx.HasUi) { e.Block("Dangerous command blocked (no UI to confirm)"); return; }
var choice = await ctx.Ui.SelectAsync($"Run `{command}`?", ["Allow", "Block"], null, ct);
if (choice != "Allow") e.Block("Blocked by user");
});
return ValueTask.CompletedTask;
}
}
```
Register it on the host (`o.AddExtension<HelloExtension>()`), pass it per session
(`AgentSessionOptions.ExtraExtensions`), or drop the compiled assembly into an extensions directory.
## The API object
| Member | Purpose |
|---|---|
| `On<TEvent>(handler)` | Subscribe to a lifecycle event. Returns an `IDisposable` to unsubscribe. |
| `RegisterTool(tool)` | Add a tool. First registration of a name wins; an extension tool replaces a built-in of the same name. |
| `RegisterCommand(name, def)` | Add `/name`. Duplicate names get `name:2`, `name:3`. |
| `RegisterProvider(registration)` / `UnregisterProvider(id)` | Add an LLM back end (see [providers](providers.md)). |
| `SendMessage(custom, delivery, triggerTurn)` | Inject a `CustomMessage` (shown to the model as a user message). `Steer`, `FollowUp` or `NextTurn`. |
| `SendUserMessage(text, images, delivery)` | Inject a user message; always triggers a turn. |
| `AppendEntry(customType, data)` | Persist extension state on the session branch; never enters the model context. |
| `SetModelAsync`, `GetThinkingLevel`, `SetThinkingLevel` | Session-scoped model control. |
| `GetActiveTools`, `SetActiveTools`, `GetAllTools`, `GetCommands` | Introspection and tool gating. |
| `GetSessionName`, `SetSessionName` | Display name. |
| `ExecAsync(command, args, options)` | Run a process in the session directory. |
| `Events` | Bus shared by all extensions in the session (`Emit(channel, data)`, `On(channel, handler)`). |
| `Logger` | An `ILogger` named after the extension. |
Action members throw `ExtensionNotBoundException` when called during `ActivateAsync`: do registration there
and start processes or timers in a `SessionStartEvent` handler. After a reload or session replacement the old
API and contexts throw `StaleExtensionContextException`.
## Events
Handlers have the shape `(TEvent e, IExtensionContext ctx, CancellationToken ct) => ValueTask`. Events are
mutable; change them to influence the runtime.
| Event | When | What you can do |
|---|---|---|
| `SessionStartEvent` | After activation, on new/resume/switch | Rebuild state from `ctx.SessionManager.GetBranch()`, start resources |
| `SessionShutdownEvent` | Before dispose or replacement | Clean up (idempotent) |
| `SessionBeforeSwitchEvent` | Before new/switch session | `Cancel()` |
| `SessionInfoChangedEvent` | Name changed | notify |
| `InputEvent` | User text before it becomes a prompt | Change `Text`/`Images`, or `MarkHandled()` to consume it |
| `BeforeAgentStartEvent` | Before a run | Edit `SystemPromptOptions` (base prompt, sections, tools, guidelines), set `SystemPromptOverride`, add `InjectedMessages` |
| `AgentStartEvent` / `AgentEndEvent` / `AgentSettledEvent` | Run boundaries | notify |
| `TurnStartEvent` / `TurnEndEvent` | Turn boundaries | `TurnEndEvent.ContinueRequested = true` forces another model call |
| `ContextEvent` | Before each model call | Edit `Messages` in place (system prompt excluded) |
| `BeforeProviderRequestEvent` | Provider payload built | Replace `Payload` |
| `MessageStartEvent` / `MessageUpdateEvent` / `MessageEndEvent` | Message streaming | `MessageEndEvent.Message` can be replaced (same role) before it is persisted |
| `ToolCallEvent` | Before a tool runs | Patch `Input`, or `Block(reason)`; a throwing handler blocks |
| `ToolResultEvent` | After a tool ran | Edit `Content`, `Details`, `IsError` |
| `ToolExecutionStart/Update/EndEvent` | Tool lifecycle | notify |
| `SessionBeforeCompactEvent` | Compaction about to run | `Cancel()` or set `Override` to supply your own summary |
| `SessionCompactEvent` | Compaction entry written | notify |
| `ModelSelectEvent` / `ThinkingLevelSelectEvent` | Settings changed | notify |
Dispatch rules: handlers run in extension load order, then registration order; exceptions are reported as
`extension_error` session events and dispatch continues; `Cancel`, `Block` and `MarkHandled` stop further
handlers.
## The context object
`IExtensionContext` gives handlers and tools: `Cwd`, `SessionId`, `HasUi`, `Ui` (select/confirm/input/notify/
status), `SessionManager` (read-only entries and branch), `ModelRegistry` (models and provider-neutral
`CompleteAsync` for nested model calls), `Model`, `ThinkingLevel`, `Signal` (cancelled on abort), `IsIdle()`,
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`.
Command handlers get `IExtensionCommandContext`, which adds `WaitForIdleAsync`, `NewSessionAsync` and
`SwitchSessionAsync`.
## Tools
Implement `ITool` directly or derive from `Tool<TArgs>`; the JSON Schema is generated from `TArgs`
(records with `[Description]` attributes work well; nullable properties are optional). `DelegateTool<TArgs>`
builds a tool from a lambda.
- Throw to produce an error result. `ToolExecutionException(result)` returns content and details while
marking the result as an error.
- `ToolResult.Content` is what the model sees; `Details` is structured data for hosts.
- `Terminate = true` on every result of a batch ends the run without another model call.
- `ExecutionMode = Sequential` forces the whole batch to run one at a time (the built-in `edit`/`write` do this).
- `PromptSnippet` puts one line in the system prompt's tools section; `PromptGuidelines` adds bullets.
- Report progress through `invocation.Report(partial)`; hosts see it as `tool_execution_update`.
## Where state should live
| State | Use |
|---|---|
| Something the model should see | `SendMessage` / `SendUserMessage` |
| Durable data outside the model context | `AppendEntry` and rebuild from `GetBranch()` on `SessionStartEvent` |
| Per-run data that follows the branch | tool result `Details` |
| Data spanning sessions | your own storage |
## Discovery and loading
Sources, in precedence order (earlier wins tool/command name collisions):
1. `AgenticCoreOptions.Extensions` (host code) and `AgentSessionOptions.ExtraExtensions`
2. `<cwd>/.volcanic/settings.json` → `{"extensions": ["relative/or/absolute/Ext.dll"]}`
3. `<cwd>/.volcanic/extensions/*.dll` and `<cwd>/.volcanic/extensions/<Name>/<Name>.dll`
4. `~/.volcanic/agent/settings.json` → `extensions`
5. `~/.volcanic/agent/extensions/*.dll`
An assembly is scanned for public, non-abstract `IExtension` types with a usable constructor (constructor
parameters are resolved from the host's `IServiceProvider`). Each assembly loads in its own
`AssemblyLoadContext`; `Volcanic.AgenticCore.*`, `Microsoft.Extensions.*` and `System.*` are shared with the
host so contract types unify. Build an extension with
`<ProjectReference Include="Volcanic.AgenticCore.Abstractions.csproj" Private="false" />` (or a package
reference) so the host's copy is used at runtime.
Activation or load failures are recorded (`AgentSession.ExtensionErrors`) and logged; other extensions still
load.

97
docs/providers.md Normal file
View File

@@ -0,0 +1,97 @@
# Providers and models
## Model keys
Models are addressed as `provider/model-id`, for example `anthropic/claude-sonnet-4-5`, `openai/gpt-5`,
`claude-code/sonnet`, `openrouter/deepseek/deepseek-chat`. `AgentSession.SetModelAsync("provider/id")`,
`AgenticCoreOptions.DefaultModel` and `AgentSessionOptions.Model` all take this form.
`Model.Provider` is the account/auth scope; `Model.Api` is the wire protocol implementation
(`anthropic-messages`, `openai-completions`, `claude-code-headless`, ...). Many providers share one API with a
different base URL.
## Authentication
For each provider the registry resolves an API key in this order:
1. The registration's `ApiKey` spec: a literal, `$ENV_VAR` / `${ENV_VAR}`, or `!command` (stdout is the key,
cached per process). `$$` and `$!` escape a literal `$`.
2. `~/.volcanic/agent/auth.json`: `{ "anthropic": { "apiKey": "sk-..." }, "openai": { "env": "MY_OPENAI_VAR" } }`
3. The `<PROVIDER>_API_KEY` environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`).
Providers with `RequiresApiKey = false` (Claude Code, Ollama) skip this. `IModelRegistry.IsAvailable(model)`
tells a UI whether a model can be selected.
## Built-in provider extensions
| Extension | Provider id | API | Notes |
|---|---|---|---|
| `AnthropicProviderExtension` | `anthropic` | `anthropic-messages` | Thinking budgets from the thinking level, prompt caching on system prompt and last user block, images, tools |
| `OpenAIProviderExtension` | `openai` (configurable) | `openai-completions` | `OpenAIProviderOptions` sets base URL, provider id, models, header names; helpers `OpenAIProviderOptions.OpenRouter(models)` and `Ollama(models)` |
| `ClaudeCodeProviderExtension` | `claude-code` | `claude-code-headless` | Uses the `claude` CLI on a subscription; see [claude-code-provider.md](claude-code-provider.md) |
Add the same extension twice with different options to serve several OpenAI-compatible endpoints:
```csharp
o.AddExtension(new OpenAIProviderExtension()); // openai/*
o.AddExtension(new OpenAIProviderExtension(OpenAIProviderOptions.OpenRouter([
new Model { Id = "deepseek/deepseek-chat", Name = "DeepSeek", Provider = "openrouter", Api = "openai-completions", ContextWindow = 128_000, MaxTokens = 8_192 },
])));
o.AddExtension(new OpenAIProviderExtension(OpenAIProviderOptions.Ollama([
new Model { Id = "qwen2.5-coder", Name = "Qwen Coder", Provider = "ollama", Api = "openai-completions", ContextWindow = 32_000, MaxTokens = 4_096 },
])));
```
## Writing a provider
A provider is a `ProviderRegistration` with a `Stream` delegate:
```csharp
api.RegisterProvider(new ProviderRegistration
{
Id = "my-provider",
Name = "My Provider",
Api = "my-api",
BaseUrl = "https://llm.example.com",
ApiKey = "$MY_PROVIDER_API_KEY",
Models = [new Model { Id = "fast", Name = "Fast", Provider = "my-provider", Api = "my-api", ContextWindow = 128_000, MaxTokens = 8_192 }],
Stream = StreamAsync,
});
static async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(Model model, LlmContext context, StreamOptions options, [EnumeratorCancellation] CancellationToken ct)
{
var builder = new AssistantMessageBuilder(model);
yield return builder.Start();
// ... call your API, then for each delta:
yield return builder.AppendText("Hello");
// tool calls:
yield return builder.StartToolCall("call_1", "bash");
yield return builder.AppendToolCallDelta("{\"command\":\"ls\"}");
yield return builder.EndToolCall();
builder.SetUsage(new Usage(Input: 10, Output: 5, CacheRead: 0, CacheWrite: 0));
yield return builder.Done(StopReason.Stop); // becomes ToolUse automatically when tool calls exist
}
```
Contract:
- Emit `Start`, balanced block events, then exactly one `Done` or `Error`. `AssistantMessageBuilder` does the
bookkeeping and produces the cumulative `Partial` on every event.
- Never throw. Encode failures with `builder.Fail(message)`; cancellation with `builder.Fail("aborted", aborted: true)`.
The registry converts stray exceptions and missing terminal events into `Error` events anyway.
- Honour `StreamOptions`: `ApiKey` (already resolved), `MaxTokens`, `Temperature`, `Reasoning` (a
`ThinkingLevel`; map it through `model.ThinkingLevelMap`), `CacheRetention`, `SessionId` (cache affinity),
`Headers`, and the hooks `OnPayload` (replace the payload), `OnResponse` (status and headers),
`OnProviderStreamEvent` (raw events). The hooks are what power the `before_provider_request` extension event.
- The registry normalises the transcript before calling you: failed assistant messages are dropped, missing
tool results are synthesised, custom messages become user messages, thinking from other models becomes text,
and images are replaced for text-only models. You still receive `UserMessage`, `AssistantMessage` and
`ToolResultMessage` and must map all three.
- Set `Usage` before `Done`; cost is computed from `Model.Cost` when you leave it at zero.
Error message conventions the core reacts to:
- Retryable errors should mention the HTTP status (`429`, `503`, `529`), `overloaded`, `rate limit`, or
`stream ended before`.
- Context overflow should contain `context_length_exceeded` or `prompt is too long`; the core compacts and
retries once.

238
docs/ui-integration.md Normal file
View File

@@ -0,0 +1,238 @@
# Building a UI on the core (Telegram guide)
The core has no user interface. A UI is a process that
1. creates one `AgentSession` per conversation,
2. turns incoming user actions into calls on the session (`PromptAsync`, `AbortAsync`, `SetModelAsync`, ...),
3. subscribes to `AgentSessionEvent`s and renders them,
4. implements `IUserInterface` so extensions can ask the user questions.
This guide uses Telegram as the worked example. Everything transfers to Slack, Discord, a web socket
front end or a desktop app; only the rendering code changes.
## 1. One session per chat
`AgentSession` is single-conversation and not thread-safe across conversations, so keep one per chat and
create it lazily. Give each chat its own working directory (or a shared one per user) and use the chat id
as the store scope so session files group by chat:
```csharp
public sealed class ChatSessions(IAgentSessionFactory factory, string workspaceRoot) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<AgentSession>>> _sessions = new();
public Task<AgentSession> GetAsync(long chatId, IUserInterface ui) =>
_sessions.GetOrAdd(chatId, id => new Lazy<Task<AgentSession>>(() => CreateAsync(id, ui))).Value;
private async Task<AgentSession> CreateAsync(long chatId, IUserInterface ui)
{
var cwd = Path.Combine(workspaceRoot, chatId.ToString());
Directory.CreateDirectory(cwd);
return await factory.CreateAsync(new AgentSessionOptions
{
Cwd = cwd,
Scope = $"telegram-{chatId}",
Ui = ui,
SessionId = await LastSessionIdAsync(chatId), // resume the most recent one, or null
});
}
private async Task<string?> LastSessionIdAsync(long chatId) =>
(await factory.ListSessionsAsync("unused", $"telegram-{chatId}")).FirstOrDefault()?.SessionId;
public async ValueTask DisposeAsync()
{
foreach (var s in _sessions.Values.Where(v => v.IsValueCreated)) await (await s.Value).DisposeAsync();
}
}
```
Persistence is automatic (`FileSessionStore` under `~/.volcanic/agent/sessions/telegram-<chatId>/`).
To store sessions in a database instead, implement `ISessionStore` (four methods: create, append, load,
list) and set `AgenticCoreOptions.SessionStoreFactory`.
## 2. Map chat input to session calls
| Telegram input | Call |
|---|---|
| Text message while idle | `session.PromptAsync(text, images)` |
| Text message while `session.IsStreaming` | `session.PromptAsync(text, images, StreamingBehavior.Steer)` (or `FollowUp`) |
| Photo | download it, wrap as `ImageContent(base64, "image/jpeg")`, pass in `images` |
| `/stop` | `session.AbortAsync()` |
| `/model` | list `session.Models.ListModels()` where `IsAvailable`, then `session.SetModelAsync(key)` |
| `/thinking <level>` | `session.SetThinkingLevelAsync(level)` (`session.AvailableThinkingLevels`) |
| `/new` | `session.NewSessionAsync()` |
| `/sessions`, `/resume <id>` | `session.ListSessionsAsync()`, `session.SwitchSessionAsync(id)` |
| `/compact` | `session.CompactAsync()` |
| `/tools` | `session.Tools`, `session.SetActiveTools(...)` |
| Any other `/command` | just pass it to `PromptAsync`: extension commands are dispatched there |
`PromptAsync` returns when the run has settled, which can take minutes. Do not await it inside the Telegram
update handler; start it with `Task.Run` (or a channel) and return so the bot keeps receiving updates
(`/stop` must arrive while the run is active).
Use `session.State` (`IsStreaming`, `Model`, `MessageCount`, `PendingSteering`, ...) for a `/status` command.
## 3. Render events
Subscribe once per session. Handlers are called synchronously on the agent loop: push into your own channel
and render from a separate task.
```csharp
var channel = Channel.CreateUnbounded<AgentSessionEvent>();
session.Subscribe(e => channel.Writer.TryWrite(e));
_ = Task.Run(() => RenderLoopAsync(chatId, channel.Reader));
```
What to render:
| Event | Rendering |
|---|---|
| `MessageStart` with an `AssistantMessage` | send a placeholder message ("…") and remember its id |
| `MessageUpdate` where `AssistantMessageEvent` is `TextDelta` | append to a buffer; edit the placeholder at most once per second (Telegram rate limits edits) |
| `MessageUpdate` with `ThinkingDelta` | optional: show a "thinking…" status; usually not displayed |
| `MessageEnd` with an `AssistantMessage` | final edit with `message.Text` (split at 4096 chars); if `StopReason` is `Error` show `ErrorMessage` |
| `ToolExecutionStart` | send or update a status line: `▶ bash: ls -la` (`Args` is the JSON arguments) |
| `ToolExecutionUpdate` | optional live output for long commands (`PartialResult.TextContent`) |
| `ToolExecutionEnd` | replace the status line with `✓ bash` or `✗ bash` plus a short excerpt of `Result.TextContent` |
| `MessageEnd` with a `ToolResultMessage` | nothing (already covered by ToolExecutionEnd) or a collapsed quote |
| `QueueUpdate` | show queued steering/follow-up texts, if any |
| `CompactionStart` / `CompactionEnd` | a status line ("compacting context…") |
| `AutoRetryStart` / `AutoRetryEnd` | a status line ("provider overloaded, retrying in 4 s") |
| `AgentSettled` | remove typing indicator, re-enable input |
| `ExtensionError` | log; optionally tell the user |
| `Notification` | send the text (rate limit warnings from Claude Code arrive here) |
| `ModelChanged`, `ThinkingLevelChanged`, `SessionInfoChanged` | confirmation message |
Send Telegram `sendChatAction: typing` on `AgentStart` and every few seconds until `AgentSettled`.
Markdown: the model writes Markdown; Telegram's `MarkdownV2` needs escaping. Simplest is to send plain
text, or convert to Telegram HTML (`<b>`, `<code>`, `<pre>`), which is more forgiving.
## 4. Implement `IUserInterface`
Extensions ask questions through `ctx.Ui`. With Telegram, dialogs become inline keyboards and pending
requests are correlated by callback data:
```csharp
public sealed class TelegramUi(ITelegramBotClient bot, long chatId) : IUserInterface
{
private readonly ConcurrentDictionary<string, TaskCompletionSource<string?>> _pending = new();
public async Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? o, CancellationToken ct)
{
var id = Guid.NewGuid().ToString("N")[..8];
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = tcs;
var keyboard = new InlineKeyboardMarkup(options.Select((opt, i) => InlineKeyboardButton.WithCallbackData(opt, $"ui:{id}:{i}")));
await bot.SendMessage(chatId, title, replyMarkup: keyboard, cancellationToken: ct);
using var timeout = new CancellationTokenSource(o?.Timeout ?? TimeSpan.FromMinutes(10));
using var reg = timeout.Token.Register(() => tcs.TrySetResult(null));
using var reg2 = ct.Register(() => tcs.TrySetResult(null));
var index = await tcs.Task;
_pending.TryRemove(id, out _);
return index is null ? null : options[int.Parse(index)];
}
public async Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? o, CancellationToken ct) =>
await SelectAsync($"{title}\n{message}", ["Yes", "No"], o, ct) == "Yes";
public async Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? o, CancellationToken ct)
{
// Ask with ForceReply and resolve from the next text message in this chat (see OnMessage below).
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending["input"] = tcs;
await bot.SendMessage(chatId, title, replyMarkup: new ForceReplyMarkup { InputFieldPlaceholder = placeholder }, cancellationToken: ct);
using var reg = ct.Register(() => tcs.TrySetResult(null));
return await tcs.Task;
}
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => _ = bot.SendMessage(chatId, $"{Icon(kind)} {message}");
public void SetStatus(string key, string? text) { /* optional: keep a pinned status message per key */ }
// Called by the bot's callback-query handler with data "ui:<id>:<index>".
public bool TryResolve(string id, string value) => _pending.TryGetValue(id, out var tcs) && tcs.TrySetResult(value);
public bool TryResolveInput(string text) => _pending.TryRemove("input", out var tcs) && tcs.TrySetResult(text);
private static string Icon(NotifyKind kind) => kind switch { NotifyKind.Warning => "⚠️", NotifyKind.Error => "❌", _ => "ℹ️" };
}
```
When the user replies to a `ForceReply` prompt, route the text to `TryResolveInput` instead of `PromptAsync`.
Because `Ui` is not `NullUserInterface`, `ctx.HasUi` is true and extensions like a permission gate will ask
instead of blocking.
## 5. Minimal bot skeleton (Telegram.Bot)
```csharp
var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole());
services.AddAgenticCore(o =>
{
o.DefaultModel = "claude-code/sonnet";
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<AnthropicProviderExtension>();
});
var provider = services.BuildServiceProvider();
var sessions = new ChatSessions(provider.GetRequiredService<IAgentSessionFactory>(), "/srv/agent-workspaces");
var bot = new TelegramBotClient(Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!);
var uis = new ConcurrentDictionary<long, TelegramUi>();
bot.OnMessage += async (message, _) =>
{
if (message.Text is null && message.Photo is null) return;
var chatId = message.Chat.Id;
var ui = uis.GetOrAdd(chatId, id => new TelegramUi(bot, id));
if (message.ReplyToMessage is not null && ui.TryResolveInput(message.Text ?? "")) return;
var session = await sessions.GetAsync(chatId, ui);
AttachRenderer(session, chatId); // idempotent: subscribe once per session
switch (message.Text)
{
case "/stop": await session.AbortAsync(); return;
case "/new": await session.NewSessionAsync(); await bot.SendMessage(chatId, "New session."); return;
case "/status": await bot.SendMessage(chatId, Describe(session.State)); return;
case var t when t?.StartsWith("/model ") == true:
var ok = await session.SetModelAsync(t[7..].Trim());
await bot.SendMessage(chatId, ok ? $"Model: {session.Model!.Key}" : "Unknown or unavailable model.");
return;
}
var images = await DownloadPhotosAsync(bot, message);
var behavior = session.IsStreaming ? StreamingBehavior.Steer : (StreamingBehavior?)null;
_ = Task.Run(async () =>
{
try { await session.PromptAsync(message.Text ?? message.Caption ?? "", images, behavior); }
catch (Exception ex) { await bot.SendMessage(chatId, $"Error: {ex.Message}"); }
});
};
bot.OnUpdate += async (update, _) =>
{
if (update.CallbackQuery is { Data: var data } q && data?.StartsWith("ui:") == true)
{
var parts = data.Split(':');
if (uis.TryGetValue(q.Message!.Chat.Id, out var ui)) ui.TryResolve(parts[1], parts[2]);
await bot.AnswerCallbackQuery(q.Id);
}
};
```
`AttachRenderer` is the event loop from section 3. Keep a `HashSet<string>` of session ids you have
subscribed to; `NewSessionAsync`/`SwitchSessionAsync` keep the same `AgentSession` object, so one
subscription per `AgentSession` is enough.
## 6. Operational notes
- **Working directories**: the agent's tools (`bash`, `write`, ...) run with the bot process's permissions in
the session's `Cwd`. Run the bot in a container or with a dedicated user and one workspace per chat.
- **Authorisation**: check `message.From.Id` against an allow-list before creating a session.
- **Long runs**: Telegram delivers updates while your handler is running only if you do not block in it.
- **Restart**: sessions resume from JSONL; extension state rebuilds on `SessionStartEvent`.
- **Multiple bots or workers**: sessions are independent; the store must be safe for concurrent writers to
*different* sessions (the file store is).
- **Claude Code provider**: the `claude` CLI must be installed and logged in on the bot host; see
[claude-code-provider.md](claude-code-provider.md).
- **Headless test double**: `Volcanic.AgenticCore.Testing.FauxProvider` lets you develop the bot without any
model access (`o.AddExtension(faux.Extension)`, `faux.Enqueue(FauxTurn.Say("..."))`).

View File

@@ -1 +1,93 @@
Console.WriteLine("headless sample");
// Reference headless host: prompt in, one JSON line per session event out.
// This is not a UI; it shows the minimum a host needs and doubles as a smoke test.
//
// dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- [--faux] [--model provider/id] [--cwd path] [--quiet] "prompt"
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Anthropic;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.OpenAI;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
string? model = null;
string? cwd = null;
var faux = false;
var quiet = false;
var prompt = new List<string>();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--model": model = args[++i]; break;
case "--cwd": cwd = args[++i]; break;
case "--faux": faux = true; break;
case "--quiet": quiet = true; break;
default: prompt.Add(args[i]); break;
}
}
if (prompt.Count == 0)
{
Console.Error.WriteLine("usage: [--faux] [--model provider/id] [--cwd path] [--quiet] \"prompt\"");
return 2;
}
var fauxProvider = new FauxProvider();
if (faux)
{
fauxProvider.Enqueue(
FauxTurn.Say("Let me look around.").WithTool("ls", new { }),
FauxTurn.Say("Done. That is what the working directory contains."));
model ??= FauxProvider.ModelKey;
}
var services = new ServiceCollection();
services.AddLogging(b => b.AddSimpleConsole(o => o.SingleLine = true).SetMinimumLevel(quiet ? LogLevel.Warning : LogLevel.Information));
services.AddAgenticCore(o =>
{
o.DefaultModel = model ?? "anthropic/claude-sonnet-4-5";
o.AddExtension<AnthropicProviderExtension>();
o.AddExtension<OpenAIProviderExtension>();
o.AddExtension<ClaudeCodeProviderExtension>();
if (faux) o.AddExtension(fauxProvider.Extension);
});
await using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IAgentSessionFactory>();
await using var session = await factory.CreateAsync(new AgentSessionOptions { Cwd = cwd ?? Directory.GetCurrentDirectory() });
if (session.Model is null)
{
Console.Error.WriteLine($"Model '{model}' is not registered. Known models: {string.Join(", ", session.Models.ListModels().Select(m => m.Key))}");
return 1;
}
if (!session.Models.IsAvailable(session.Model))
{
Console.Error.WriteLine($"Model '{session.Model.Key}' has no credentials. Set {session.Model.Provider.ToUpperInvariant().Replace('-', '_')}_API_KEY or add it to auth.json.");
return 1;
}
foreach (var error in session.ExtensionErrors) Console.Error.WriteLine($"extension error: {error.Source}: {error.Error}");
var stdout = Console.Out;
session.Subscribe(e =>
{
// Deltas are noisy; in quiet mode print only the text as it streams.
if (quiet)
{
if (e is AgentSessionEvent.MessageUpdate { AssistantMessageEvent: AssistantMessageEvent.TextDelta d }) stdout.Write(d.Delta);
else if (e is AgentSessionEvent.ToolExecutionStart s) stdout.WriteLine($"\n[tool {s.ToolName} {s.Args.GetRawText()}]");
else if (e is AgentSessionEvent.MessageEnd { Message: AssistantMessage { StopReason: StopReason.Error } a }) stdout.WriteLine($"\n[error] {a.ErrorMessage}");
else if (e is AgentSessionEvent.AgentSettled) stdout.WriteLine();
return;
}
stdout.WriteLine(VolcanicJson.Serialize(e));
});
await session.PromptAsync(string.Join(' ', prompt));
return 0;