docs: user documentation and headless sample host
This commit is contained in:
91
README.md
91
README.md
@@ -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
117
docs/architecture.md
Normal 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.
|
||||
83
docs/claude-code-provider.md
Normal file
83
docs/claude-code-provider.md
Normal 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
160
docs/extensions.md
Normal 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
97
docs/providers.md
Normal 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
238
docs/ui-integration.md
Normal 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("..."))`).
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user