docs: user documentation and headless sample host
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user