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

8.7 KiB

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.

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