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.Contentis what the model sees;Detailsis structured data for hosts.Terminate = trueon every result of a batch ends the run without another model call.ExecutionMode = Sequentialforces the whole batch to run one at a time (the built-inedit/writedo this).PromptSnippetputs one line in the system prompt's tools section;PromptGuidelinesadds bullets.- Report progress through
invocation.Report(partial); hosts see it astool_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):
AgenticCoreOptions.Extensions(host code) andAgentSessionOptions.ExtraExtensions<cwd>/.volcanic/settings.json→{"extensions": ["relative/or/absolute/Ext.dll"]}<cwd>/.volcanic/extensions/*.dlland<cwd>/.volcanic/extensions/<Name>/<Name>.dll~/.volcanic/agent/settings.json→extensions~/.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.