feat(core): extensions and commands slices

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:16:33 +00:00
parent 9ab663665c
commit a49a2ea375
11 changed files with 968 additions and 6 deletions

View File

@@ -35,12 +35,11 @@ public static class JsonSchema
TransformSchemaNode = Transform,
};
var node = SchemaOptions.GetJsonSchemaAsNode(type, exporterOptions);
if (node is JsonObject root)
{
root.Remove("$schema");
if (!root.ContainsKey("type")) root["type"] = "object";
}
var element = VolcanicJson.Parse(node.ToJsonString());
// `object`/`JsonElement` arguments produce a boolean schema (`true`); tools always need an object schema.
if (node is not JsonObject root) root = new JsonObject();
root.Remove("$schema");
if (!root.ContainsKey("type")) root["type"] = "object";
var element = VolcanicJson.Parse(root.ToJsonString());
lock (CacheLock) Cache[type] = element;
return element;
}

View File

@@ -0,0 +1,47 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Features.Commands;
/// <summary>Slash commands registered by extensions. A second registration of the same name gets an <c>name:2</c> invocation name.</summary>
public sealed class CommandRegistry
{
private readonly Dictionary<string, (CommandDefinition Definition, string Source)> _commands = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
/// <summary>Registers and returns the invocation name actually assigned.</summary>
public string Register(string name, CommandDefinition definition, string source)
{
if (string.IsNullOrWhiteSpace(name) || name.Contains(' ')) throw new ArgumentException("Command names must be non-empty and contain no spaces.", nameof(name));
var trimmed = name.TrimStart('/');
lock (_lock)
{
var invocation = trimmed;
var n = 2;
while (_commands.ContainsKey(invocation)) invocation = $"{trimmed}:{n++}";
_commands[invocation] = (definition, source);
return invocation;
}
}
public CommandDefinition? Get(string name)
{
lock (_lock) return _commands.TryGetValue(name.TrimStart('/'), out var c) ? c.Definition : null;
}
public IReadOnlyList<CommandInfo> All
{
get
{
lock (_lock)
return _commands.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => new CommandInfo(kv.Key, kv.Value.Definition.Description, kv.Value.Source)).ToList();
}
}
/// <summary>Splits <c>/name rest of line</c>. Returns null when the text is not a slash command.</summary>
public static (string Name, string Args)? Parse(string text)
{
if (string.IsNullOrEmpty(text) || text[0] != '/' || text.Length < 2 || char.IsWhiteSpace(text[1])) return null;
var space = text.IndexOf(' ');
return space < 0 ? (text[1..], "") : (text[1..space], text[(space + 1)..]);
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Features.Extensions;
/// <summary>One bus per session shared by all extensions. Handler exceptions are logged and swallowed.</summary>
public sealed class EventBus(ILogger logger) : IEventBus
{
private readonly Dictionary<string, List<Action<object?>>> _handlers = new(StringComparer.Ordinal);
private readonly Lock _lock = new();
public void Emit(string channel, object? data)
{
Action<object?>[] snapshot;
lock (_lock) snapshot = _handlers.TryGetValue(channel, out var list) ? list.ToArray() : [];
foreach (var handler in snapshot)
{
try { handler(data); }
catch (Exception ex) { logger.LogError(ex, "Event bus handler for '{Channel}' failed", channel); }
}
}
public IDisposable On(string channel, Action<object?> handler)
{
lock (_lock)
{
if (!_handlers.TryGetValue(channel, out var list)) _handlers[channel] = list = [];
list.Add(handler);
}
return new Subscription(() =>
{
lock (_lock)
{
if (_handlers.TryGetValue(channel, out var list)) list.Remove(handler);
}
});
}
public void Clear()
{
lock (_lock) _handlers.Clear();
}
}
internal sealed class Subscription(Action dispose) : IDisposable
{
private Action? _dispose = dispose;
public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke();
}

View File

@@ -0,0 +1,139 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.Extensions;
/// <summary>Per-extension implementation of <see cref="IExtensionApi"/>. Registrations are queued until the runner commits them.</summary>
internal sealed class ExtensionApi : IExtensionApi
{
private readonly ExtensionRunner _runner;
private readonly List<Action> _pendingCommits = [];
private bool _committed;
public ExtensionApi(ExtensionRunner runner, string extensionName, ILogger logger)
{
_runner = runner;
ExtensionName = extensionName;
Logger = logger;
}
public string ExtensionName { get; }
public ILogger Logger { get; }
public IEventBus Events => _runner.EventBus;
internal void Commit()
{
_committed = true;
foreach (var commit in _pendingCommits) commit();
_pendingCommits.Clear();
}
internal void Discard() => _pendingCommits.Clear();
private void Queue(Action commit)
{
_runner.AssertActive();
if (_committed) commit();
else _pendingCommits.Add(commit);
}
private IExtensionHost Host(string member)
{
_runner.AssertActive();
return _runner.Host ?? throw new ExtensionNotBoundException(member);
}
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
{
var registration = new ExtensionRunner.HandlerRegistration(ExtensionName, typeof(TEvent),
(e, ctx, ct) => handler((TEvent)e, ctx, ct));
Queue(() => _runner.AddHandler(registration));
return new Subscription(() => _runner.RemoveHandler(registration));
}
public void RegisterTool(ITool tool)
{
if (tool.Parameters.ValueKind != JsonValueKind.Object)
throw new ArgumentException($"Tool '{tool.Name}' parameters must be a JSON Schema object.", nameof(tool));
Queue(() => _runner.RegisterTool(tool, ExtensionName));
}
public void RegisterCommand(string name, CommandDefinition command) => Queue(() => _runner.Commands.Register(name, command, ExtensionName));
public void RegisterProvider(ProviderRegistration provider) => Queue(() => _runner.Models.Register(provider));
public void UnregisterProvider(string providerId) => Queue(() => _runner.Models.Unregister(providerId));
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) =>
Host(nameof(SendMessage)).SendMessage(message, delivery, triggerTurn);
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) =>
Host(nameof(SendUserMessage)).SendUserMessage(text, images, delivery);
public void AppendEntry(string customType, JsonElement? data = null) => Host(nameof(AppendEntry)).AppendEntry(customType, data);
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => Host(nameof(SetModelAsync)).SetModelAsync(model, cancellationToken);
public ThinkingLevel GetThinkingLevel() => Host(nameof(GetThinkingLevel)).ThinkingLevel;
public void SetThinkingLevel(ThinkingLevel level) => Host(nameof(SetThinkingLevel)).SetThinkingLevel(level);
public IReadOnlyList<string> GetActiveTools() => _runner.Tools.ActiveNames;
public void SetActiveTools(IEnumerable<string> names) => _runner.Tools.SetActive(names);
public IReadOnlyList<ToolInfo> GetAllTools() => _runner.Tools.All;
public IReadOnlyList<CommandInfo> GetCommands() => _runner.Commands.All;
public string? GetSessionName() => Host(nameof(GetSessionName)).GetSessionName();
public void SetSessionName(string? name) => Host(nameof(SetSessionName)).SetSessionName(name);
public async Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default)
{
var host = Host(nameof(ExecAsync));
var psi = new ProcessStartInfo(command)
{
WorkingDirectory = options?.WorkingDirectory ?? host.Cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
if (options?.Environment is not null)
foreach (var (k, v) in options.Environment) { if (v is null) psi.Environment.Remove(k); else psi.Environment[k] = v; }
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (stdout) stdout.Append(e.Data).Append('\n'); };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (stderr) stderr.Append(e.Data).Append('\n'); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (options?.Timeout is { } timeout) cts.CancelAfter(timeout);
var killed = false;
try { await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException)
{
killed = true;
try { process.Kill(entireProcessTree: true); } catch (Exception) { }
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception) { }
}
string outText, errText;
lock (stdout) outText = stdout.ToString();
lock (stderr) errText = stderr.ToString();
return new ExecResult(outText, errText, killed ? -1 : process.ExitCode, killed);
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Features.Extensions;
internal class ExtensionContext(IExtensionHost host, Func<bool> isStale, ILogger logger) : IExtensionContext
{
protected IExtensionHost Host => isStale() ? throw new StaleExtensionContextException() : host;
public string Cwd => Host.Cwd;
public string SessionId => Host.SessionId;
public bool HasUi => Host.Ui is not NullUserInterface;
public IUserInterface Ui => Host.Ui;
public IReadOnlySessionManager SessionManager => Host.SessionManager;
public IModelRegistry ModelRegistry => Host.ModelRegistry;
public Model? Model => Host.Model;
public ThinkingLevel ThinkingLevel => Host.ThinkingLevel;
public CancellationToken Signal => Host.Signal;
public ILogger Logger => logger;
public bool IsIdle() => Host.IsIdle();
public void Abort() => Host.Abort();
public bool HasPendingMessages() => Host.HasPendingMessages();
public ContextUsage? GetContextUsage() => Host.GetContextUsage();
public string GetSystemPrompt() => Host.GetSystemPrompt();
public void Compact(CompactOptions? options = null) => Host.RequestCompaction(options);
}
internal sealed class ExtensionCommandContext(IExtensionHost host, Func<bool> isStale, ILogger logger)
: ExtensionContext(host, isStale, logger), IExtensionCommandContext
{
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Host.WaitForIdleAsync(cancellationToken);
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Host.NewSessionAsync(options, cancellationToken);
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Host.SwitchSessionAsync(sessionId, cancellationToken);
}

View File

@@ -0,0 +1,130 @@
using System.Reflection;
using System.Runtime.Loader;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Features.Extensions;
/// <summary>
/// Finds extension assemblies. Sources in precedence order: inline registrations, project settings, project directory,
/// user settings, user directory. Each assembly loads in its own <see cref="ExtensionLoadContext"/>.
/// </summary>
public sealed class ExtensionLoader(ILogger<ExtensionLoader> logger)
{
public sealed record DiscoveryOptions(string Cwd, string UserAgentDir, bool ScanDirectories = true);
/// <summary>Discover extension registrations from settings files and conventional directories.</summary>
public (IReadOnlyList<ExtensionRegistration> Registrations, IReadOnlyList<ExtensionLoadError> Errors) Discover(DiscoveryOptions options)
{
var registrations = new List<ExtensionRegistration>();
var errors = new List<ExtensionLoadError>();
var seenAssemblies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var projectDir = Path.Combine(options.Cwd, ".volcanic");
var sources = new List<(string Path, string Source)>();
sources.AddRange(FromSettings(Path.Combine(projectDir, "settings.json"), projectDir, errors).Select(p => (p, "project-settings")));
if (options.ScanDirectories) sources.AddRange(FromDirectory(Path.Combine(projectDir, "extensions")).Select(p => (p, "project-directory")));
sources.AddRange(FromSettings(Path.Combine(options.UserAgentDir, "settings.json"), options.UserAgentDir, errors).Select(p => (p, "user-settings")));
if (options.ScanDirectories) sources.AddRange(FromDirectory(Path.Combine(options.UserAgentDir, "extensions")).Select(p => (p, "user-directory")));
foreach (var (path, source) in sources)
{
var full = Path.GetFullPath(path);
if (!seenAssemblies.Add(full)) continue;
if (!File.Exists(full))
{
errors.Add(new ExtensionLoadError(source, $"Extension assembly not found: {full}"));
continue;
}
try
{
var assembly = new ExtensionLoadContext(full).LoadFromAssemblyPath(full);
var found = FromAssembly(assembly, $"{source}:{Path.GetFileName(full)}");
if (found.Count == 0) errors.Add(new ExtensionLoadError(source, $"No IExtension types in {full}"));
registrations.AddRange(found);
}
catch (Exception ex)
{
errors.Add(new ExtensionLoadError(source, $"Failed to load {full}: {ex.Message}"));
logger.LogError(ex, "Failed to load extension assembly {Path}", full);
}
}
return (registrations, errors);
}
/// <summary>All public, non-abstract <see cref="IExtension"/> types with a usable constructor.</summary>
public static IReadOnlyList<ExtensionRegistration> FromAssembly(Assembly assembly, string source)
{
Type[] types;
try { types = assembly.GetExportedTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
return types
.Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IExtension).IsAssignableFrom(t))
.Select(t => ExtensionRegistration.FromType(t, source))
.ToList();
}
private static IEnumerable<string> FromDirectory(string dir)
{
if (!Directory.Exists(dir)) yield break;
foreach (var dll in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.Ordinal)) yield return dll;
foreach (var sub in Directory.EnumerateDirectories(dir).OrderBy(p => p, StringComparer.Ordinal))
{
var candidate = Path.Combine(sub, Path.GetFileName(sub) + ".dll");
if (File.Exists(candidate)) yield return candidate;
}
}
private static IEnumerable<string> FromSettings(string settingsPath, string baseDir, List<ExtensionLoadError> errors)
{
if (!File.Exists(settingsPath)) return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(settingsPath));
if (!doc.RootElement.TryGetProperty("extensions", out var list) || list.ValueKind != JsonValueKind.Array) return [];
return list.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!)
.Select(p => Path.IsPathRooted(p) ? p : Path.Combine(baseDir, p))
.ToList();
}
catch (JsonException ex)
{
errors.Add(new ExtensionLoadError(settingsPath, $"Invalid settings.json: {ex.Message}"));
return [];
}
}
}
/// <summary>Isolated load context that shares the core contracts with the host so <see cref="IExtension"/> types unify.</summary>
public sealed class ExtensionLoadContext : AssemblyLoadContext
{
private static readonly string[] SharedPrefixes = ["Volcanic.AgenticCore", "Microsoft.Extensions.", "System."];
private readonly AssemblyDependencyResolver _resolver;
public ExtensionLoadContext(string mainAssemblyPath) : base(name: Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible: false)
{
_resolver = new AssemblyDependencyResolver(mainAssemblyPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var name = assemblyName.Name ?? "";
if (SharedPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
{
// Let the default context serve these so types are identical across host and extension.
try { return Default.LoadFromAssemblyName(assemblyName); }
catch (FileNotFoundException) { /* fall through to the extension's own copy */ }
}
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is null ? null : LoadFromAssemblyPath(path);
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
}
}

View File

@@ -0,0 +1,24 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Features.Extensions;
/// <summary>How to create an extension instance for a session. Instances are per session.</summary>
public sealed record ExtensionRegistration(string Name, Func<IServiceProvider, IExtension> Factory, string Source)
{
public static ExtensionRegistration FromType<T>() where T : IExtension =>
new(typeof(T).Name, sp => (IExtension)(Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(sp, typeof(T))), "inline");
public static ExtensionRegistration FromType(Type type, string source) =>
new(type.Name, sp => (IExtension)Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(sp, type), source);
public static ExtensionRegistration FromFactory(string name, Func<IExtension> factory) =>
new(name, _ => factory(), "inline");
/// <summary>Shares one instance across sessions. Only for stateless extensions.</summary>
public static ExtensionRegistration FromInstance(IExtension instance) =>
new(instance.Name, _ => instance, "inline");
}
public sealed record ExtensionLoadError(string Source, string Error);
public sealed record ExtensionLoadResult(IReadOnlyList<string> Loaded, IReadOnlyList<ExtensionLoadError> Errors);

View File

@@ -0,0 +1,153 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Tools;
namespace Volcanic.AgenticCore.Features.Extensions;
public sealed record ExtensionError(string ExtensionName, string Event, Exception Exception);
/// <summary>
/// Owns the extensions of one session: activates them (two-phase: queued registrations, then commit),
/// dispatches events sequentially in load order with per-handler error isolation, and hands out contexts.
/// </summary>
public sealed class ExtensionRunner
{
internal sealed record HandlerRegistration(string ExtensionName, Type EventType, Func<ExtensionEvent, IExtensionContext, CancellationToken, ValueTask> Invoke);
private readonly List<HandlerRegistration> _handlers = [];
private readonly List<(string Name, IExtension Extension)> _extensions = [];
private readonly List<ExtensionLoadError> _loadErrors = [];
private readonly Lock _lock = new();
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private bool _stale;
public ExtensionRunner(ToolRegistry tools, CommandRegistry commands, ModelRegistry models, ILoggerFactory loggerFactory)
{
Tools = tools;
Commands = commands;
Models = models;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ExtensionRunner>();
EventBus = new EventBus(_logger);
}
public ToolRegistry Tools { get; }
public CommandRegistry Commands { get; }
public ModelRegistry Models { get; }
public EventBus EventBus { get; }
internal IExtensionHost? Host { get; private set; }
public IReadOnlyList<ExtensionLoadError> LoadErrors => _loadErrors;
public IReadOnlyList<string> LoadedExtensions => _extensions.Select(e => e.Name).ToList();
public bool IsStale => _stale;
/// <summary>Raised when a handler throws. The session forwards it to UI subscribers as an <c>extension_error</c> event.</summary>
public event Action<ExtensionError>? OnError;
/// <summary>Activate an extension. Registrations made during activation are committed only when activation succeeds.</summary>
public async Task<bool> ActivateAsync(IExtension extension, string source, CancellationToken cancellationToken)
{
var name = string.IsNullOrWhiteSpace(extension.Name) ? extension.GetType().Name : extension.Name;
var api = new ExtensionApi(this, name, _loggerFactory.CreateLogger($"Extension.{name}"));
try
{
await extension.ActivateAsync(api, cancellationToken).ConfigureAwait(false);
api.Commit();
lock (_lock) _extensions.Add((name, extension));
_logger.LogDebug("Activated extension {Name} from {Source}", name, source);
return true;
}
catch (Exception ex)
{
api.Discard();
_loadErrors.Add(new ExtensionLoadError(source, $"{name}: {ex.Message}"));
_logger.LogError(ex, "Extension {Name} from {Source} failed to activate", name, source);
return false;
}
}
/// <summary>Makes API actions available. Call once the session is constructed.</summary>
public void Bind(IExtensionHost host) => Host = host;
/// <summary>Marks every API and context created by this runner as stale (reload or session replacement).</summary>
public void Invalidate()
{
_stale = true;
EventBus.Clear();
}
internal void AssertActive()
{
if (_stale) throw new StaleExtensionContextException();
}
internal void AddHandler(HandlerRegistration registration)
{
lock (_lock) _handlers.Add(registration);
}
internal void RemoveHandler(HandlerRegistration registration)
{
lock (_lock) _handlers.Remove(registration);
}
internal void RegisterTool(ITool tool, string extensionName)
{
if (!Tools.AddExtensionTool(tool, extensionName))
_loadErrors.Add(new ExtensionLoadError(extensionName, $"Tool '{tool.Name}' is already registered by another extension; ignored."));
}
public bool HasHandlers<TEvent>() where TEvent : ExtensionEvent
{
lock (_lock) return _handlers.Any(h => h.EventType == typeof(TEvent));
}
public IExtensionContext CreateContext() =>
new ExtensionContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
public IExtensionCommandContext CreateCommandContext() =>
new ExtensionCommandContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
/// <summary>
/// Dispatch an event to every handler registered for its type, in load order. Handler exceptions are reported through
/// <see cref="OnError"/> and dispatch continues, except for <see cref="ToolCallEvent"/> where an exception blocks the tool.
/// Dispatch stops early when the event reports <see cref="ExtensionEvent.ShortCircuit"/>.
/// </summary>
public async ValueTask<TEvent> EmitAsync<TEvent>(TEvent e, CancellationToken cancellationToken = default) where TEvent : ExtensionEvent
{
HandlerRegistration[] snapshot;
lock (_lock) snapshot = _handlers.Where(h => h.EventType == typeof(TEvent)).ToArray();
if (snapshot.Length == 0) return e;
var ctx = CreateContext();
foreach (var handler in snapshot)
{
try
{
await handler.Invoke(e, ctx, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Report(new ExtensionError(handler.ExtensionName, e.Name, ex));
if (e is ToolCallEvent toolCall) toolCall.Block($"Extension '{handler.ExtensionName}' failed: {ex.Message}");
}
if (e.ShortCircuit) break;
}
return e;
}
internal void Report(ExtensionError error)
{
_logger.LogError(error.Exception, "Extension {Name} failed in {Event}", error.ExtensionName, error.Event);
try { OnError?.Invoke(error); }
catch (Exception ex) { _logger.LogError(ex, "OnError listener failed"); }
}
}

View File

@@ -0,0 +1,40 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Features.Extensions;
/// <summary>What the runner needs from the owning session to serve extension contexts and API actions.</summary>
public interface IExtensionHost
{
string Cwd { get; }
string SessionId { get; }
IUserInterface Ui { get; }
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }
Model? Model { get; }
ThinkingLevel ThinkingLevel { get; }
CancellationToken Signal { get; }
bool IsIdle();
void Abort();
bool HasPendingMessages();
ContextUsage? GetContextUsage();
string GetSystemPrompt();
void RequestCompaction(CompactOptions? options);
Task WaitForIdleAsync(CancellationToken cancellationToken);
Task<SwitchResult> NewSessionAsync(NewSessionOptions? options, CancellationToken cancellationToken);
Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken);
void SendMessage(CustomMessage message, MessageDelivery delivery, bool triggerTurn);
void SendUserMessage(string text, IReadOnlyList<ImageContent>? images, MessageDelivery delivery);
void AppendEntry(string customType, JsonElement? data);
Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken);
void SetThinkingLevel(ThinkingLevel level);
string? GetSessionName();
void SetSessionName(string? name);
}

View File

@@ -0,0 +1,304 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
/// <summary>An extension built from a lambda, for tests.</summary>
public sealed class LambdaExtension(string name, Func<IExtensionApi, ValueTask> activate) : IExtension
{
public string Name => name;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) => activate(api);
}
/// <summary>Public extension type discovered by the loader test through this test assembly.</summary>
public sealed class DiscoverableExtension : IExtension
{
public string Name => "discoverable";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
public class ExtensionRunnerTests
{
private static ExtensionRunner NewRunner() =>
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance);
private static ITool Tool(string name) => new DelegateTool<object>(name, "d", (_, _, _) => Task.FromResult(ToolResult.Text("ok")));
[Fact]
public async Task Activation_commits_registrations_only_on_success()
{
var runner = NewRunner();
var ok = new LambdaExtension("ok", api =>
{
api.RegisterTool(Tool("hello"));
api.RegisterCommand("hi", new CommandDefinition("says hi", (_, _, _) => Task.CompletedTask));
api.RegisterProvider(new ProviderRegistration { Id = "prov", Stream = (_, _, _, _) => Empty(), RequiresApiKey = false, Models = [new Model { Id = "m", Name = "m", Provider = "", Api = "x" }] });
return ValueTask.CompletedTask;
});
var failing = new LambdaExtension("bad", api =>
{
api.RegisterTool(Tool("never"));
throw new InvalidOperationException("boom");
});
Assert.True(await runner.ActivateAsync(ok, "inline", CancellationToken.None));
Assert.False(await runner.ActivateAsync(failing, "inline", CancellationToken.None));
Assert.NotNull(runner.Tools.Get("hello"));
Assert.Null(runner.Tools.Get("never"));
Assert.NotNull(runner.Commands.Get("hi"));
Assert.NotNull(runner.Models.Find("prov/m"));
Assert.Equal(["ok"], runner.LoadedExtensions);
var error = Assert.Single(runner.LoadErrors);
Assert.Contains("boom", error.Error);
}
[Fact]
public async Task Actions_throw_until_bound_then_delegate_to_host()
{
var runner = NewRunner();
IExtensionApi? captured = null;
await runner.ActivateAsync(new LambdaExtension("x", api =>
{
captured = api;
Assert.Throws<ExtensionNotBoundException>(() => api.AppendEntry("t"));
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
var host = new StubExtensionHost();
runner.Bind(host);
captured!.AppendEntry("bookmark");
captured.SendMessage(CustomMessage.FromText("note", "hi"), MessageDelivery.FollowUp, triggerTurn: false);
captured.SetSessionName("renamed");
Assert.Equal(["entry:bookmark", "send:note:FollowUp:False", "name:renamed"], host.Calls);
runner.Invalidate();
Assert.Throws<StaleExtensionContextException>(() => captured.AppendEntry("late"));
}
[Fact]
public async Task ToolCall_first_block_wins_and_handler_exception_blocks()
{
var runner = NewRunner();
var order = new List<string>();
await runner.ActivateAsync(new LambdaExtension("a", api =>
{
api.On<ToolCallEvent>((e, _, _) => { order.Add("a"); e.Input["patched"] = true; return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
await runner.ActivateAsync(new LambdaExtension("b", api =>
{
api.On<ToolCallEvent>((e, _, _) => { order.Add("b"); e.Block("nope"); return ValueTask.CompletedTask; });
api.On<ToolCallEvent>((_, _, _) => { order.Add("never"); return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var e = await runner.EmitAsync(new ToolCallEvent("c1", "bash", new JsonObject()));
Assert.True(e.IsBlocked);
Assert.Equal("nope", e.BlockReason);
Assert.True(e.Input["patched"]!.GetValue<bool>());
Assert.Equal(["a", "b"], order);
var throwing = NewRunner();
var errors = new List<ExtensionError>();
throwing.OnError += errors.Add;
await throwing.ActivateAsync(new LambdaExtension("t", api =>
{
api.On<ToolCallEvent>((_, _, _) => throw new InvalidOperationException("handler broke"));
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
throwing.Bind(new StubExtensionHost());
var blocked = await throwing.EmitAsync(new ToolCallEvent("c1", "bash", new JsonObject()));
Assert.True(blocked.IsBlocked);
Assert.Contains("handler broke", blocked.BlockReason);
Assert.Single(errors);
Assert.Equal("tool_call", errors[0].Event);
}
[Fact]
public async Task Input_transform_chains_and_handled_short_circuits()
{
var runner = NewRunner();
await runner.ActivateAsync(new LambdaExtension("a", api =>
{
api.On<InputEvent>((e, _, _) => { e.Text = e.Text.ToUpperInvariant(); return ValueTask.CompletedTask; });
api.On<InputEvent>((e, _, _) => { if (e.Text.StartsWith("STOP")) e.MarkHandled(); return ValueTask.CompletedTask; });
api.On<InputEvent>((e, _, _) => { e.Text += "!"; return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var normal = await runner.EmitAsync(new InputEvent("hello", null, InputSource.User, null));
Assert.Equal("HELLO!", normal.Text);
Assert.False(normal.Handled);
var handled = await runner.EmitAsync(new InputEvent("stop now", null, InputSource.User, null));
Assert.True(handled.Handled);
Assert.Equal("STOP NOW", handled.Text);
}
[Fact]
public async Task Notify_events_isolate_exceptions_and_context_events_chain()
{
var runner = NewRunner();
var errors = new List<ExtensionError>();
runner.OnError += errors.Add;
await runner.ActivateAsync(new LambdaExtension("a", api =>
{
api.On<TurnStartEvent>((_, _, _) => throw new Exception("turn boom"));
api.On<ContextEvent>((e, _, _) => { e.Messages.Add(UserMessage.FromText("from-a")); return ValueTask.CompletedTask; });
api.On<ContextEvent>((e, _, _) => { e.Messages.Insert(0, UserMessage.FromText("first")); return ValueTask.CompletedTask; });
api.On<BeforeAgentStartEvent>((e, _, _) => { e.InjectedMessages.Add(CustomMessage.FromText("ctx", "x")); e.SystemPromptOverride = "custom"; return ValueTask.CompletedTask; });
api.On<SessionBeforeSwitchEvent>((e, _, _) => { e.Cancel(); return ValueTask.CompletedTask; });
api.On<SessionBeforeSwitchEvent>((_, _, _) => throw new Exception("should not run"));
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
await runner.EmitAsync(new TurnStartEvent(0));
Assert.Single(errors);
var ctx = await runner.EmitAsync(new ContextEvent([UserMessage.FromText("orig")]));
Assert.Equal(["first", "orig", "from-a"], ctx.Messages.Select(m => m.Text));
var before = await runner.EmitAsync(new BeforeAgentStartEvent("p", null, new SystemPromptOptions(), _ => "rendered"));
Assert.Single(before.InjectedMessages);
Assert.Equal("custom", before.CurrentSystemPrompt);
var sw = await runner.EmitAsync(new SessionBeforeSwitchEvent(SessionStartReason.New, null));
Assert.True(sw.Cancelled);
Assert.Single(errors);
}
[Fact]
public async Task Event_bus_connects_extensions_and_context_reaches_host()
{
var runner = NewRunner();
object? received = null;
IExtensionApi? apiB = null;
await runner.ActivateAsync(new LambdaExtension("a", api => { api.Events.On("ping", d => received = d); return ValueTask.CompletedTask; }), "inline", CancellationToken.None);
await runner.ActivateAsync(new LambdaExtension("b", api => { apiB = api; return ValueTask.CompletedTask; }), "inline", CancellationToken.None);
var host = new StubExtensionHost { Cwd = "/work" };
runner.Bind(host);
apiB!.Events.Emit("ping", 42);
Assert.Equal(42, received);
var ctx = runner.CreateContext();
Assert.Equal("/work", ctx.Cwd);
Assert.False(ctx.HasUi);
ctx.Compact();
Assert.Contains("compact", host.Calls);
Assert.Equal("prompt", ctx.GetSystemPrompt());
runner.Invalidate();
Assert.Throws<StaleExtensionContextException>(() => ctx.Cwd);
}
[Fact]
public async Task Exec_runs_a_process()
{
if (OperatingSystem.IsWindows()) return;
var runner = NewRunner();
IExtensionApi? captured = null;
await runner.ActivateAsync(new LambdaExtension("x", api => { captured = api; return ValueTask.CompletedTask; }), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var result = await captured!.ExecAsync("sh", ["-c", "echo out; echo err 1>&2; exit 2"]);
Assert.Equal("out\n", result.Stdout);
Assert.Equal("err\n", result.Stderr);
Assert.Equal(2, result.ExitCode);
Assert.False(result.Killed);
}
private static async IAsyncEnumerable<AssistantMessageEvent> Empty() { await Task.CompletedTask; yield break; }
}
public class ExtensionLoaderTests
{
[Fact]
public void FromAssembly_finds_public_extension_types()
{
var found = ExtensionLoader.FromAssembly(typeof(DiscoverableExtension).Assembly, "tests");
Assert.Contains(found, r => r.Name == nameof(DiscoverableExtension));
Assert.DoesNotContain(found, r => r.Name == nameof(LambdaExtension) && false);
}
[Fact]
public void Discover_reports_missing_assemblies_and_ignores_missing_dirs()
{
var root = Path.Combine(Path.GetTempPath(), "volcanic-loader", Guid.NewGuid().ToString("N"));
var cwd = Path.Combine(root, "project");
var user = Path.Combine(root, "user");
Directory.CreateDirectory(Path.Combine(cwd, ".volcanic"));
Directory.CreateDirectory(user);
File.WriteAllText(Path.Combine(cwd, ".volcanic", "settings.json"), """{"extensions":["missing/Ext.dll"]}""");
File.WriteAllText(Path.Combine(user, "settings.json"), "{not json");
try
{
var loader = new ExtensionLoader(NullLogger<ExtensionLoader>.Instance);
var (registrations, errors) = loader.Discover(new ExtensionLoader.DiscoveryOptions(cwd, user));
Assert.Empty(registrations);
Assert.Equal(2, errors.Count);
Assert.Contains(errors, e => e.Error.Contains("not found"));
Assert.Contains(errors, e => e.Error.Contains("Invalid settings.json"));
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void Discover_loads_a_real_assembly_from_the_user_directory()
{
var root = Path.Combine(Path.GetTempPath(), "volcanic-loader", Guid.NewGuid().ToString("N"));
var extDir = Path.Combine(root, "user", "extensions");
Directory.CreateDirectory(extDir);
var testAssembly = typeof(DiscoverableExtension).Assembly.Location;
var target = Path.Combine(extDir, Path.GetFileName(testAssembly));
File.Copy(testAssembly, target);
try
{
var loader = new ExtensionLoader(NullLogger<ExtensionLoader>.Instance);
var (registrations, errors) = loader.Discover(new ExtensionLoader.DiscoveryOptions(Path.Combine(root, "project"), Path.Combine(root, "user")));
Assert.Empty(errors);
Assert.Contains(registrations, r => r.Name == nameof(DiscoverableExtension) && r.Source.StartsWith("user-directory"));
}
finally { Directory.Delete(root, true); }
}
}
public class CommandRegistryTests
{
[Fact]
public void Parse_splits_name_and_raw_args()
{
Assert.Equal(("model", "claude fast"), CommandRegistry.Parse("/model claude fast"));
Assert.Equal(("help", ""), CommandRegistry.Parse("/help"));
Assert.Null(CommandRegistry.Parse("no slash"));
Assert.Null(CommandRegistry.Parse("/ leading space"));
Assert.Null(CommandRegistry.Parse("/"));
}
[Fact]
public void Duplicate_names_get_suffixes()
{
var registry = new CommandRegistry();
var def = new CommandDefinition("d", (_, _, _) => Task.CompletedTask);
Assert.Equal("x", registry.Register("x", def, "a"));
Assert.Equal("x:2", registry.Register("/x", def, "b"));
Assert.Equal("x:3", registry.Register("x", def, "c"));
Assert.NotNull(registry.Get("/x:2"));
Assert.Equal(["x", "x:2", "x:3"], registry.All.Select(c => c.Name));
Assert.Throws<ArgumentException>(() => registry.Register("has space", def, "a"));
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models;
namespace Volcanic.AgenticCore.Tests.Support;
public sealed class StubExtensionHost : IExtensionHost
{
public List<string> Calls { get; } = [];
public string Cwd { get; init; } = Path.GetTempPath();
public string SessionId => "session-1";
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry { get; init; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));
public Model? Model { get; set; }
public ThinkingLevel ThinkingLevel { get; set; } = ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public bool IsIdle() => true;
public void Abort() => Calls.Add("abort");
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "prompt";
public void RequestCompaction(CompactOptions? options) => Calls.Add("compact");
public Task WaitForIdleAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options, CancellationToken cancellationToken) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken) => Task.FromResult(new SwitchResult(false));
public void SendMessage(CustomMessage message, MessageDelivery delivery, bool triggerTurn) => Calls.Add($"send:{message.CustomType}:{delivery}:{triggerTurn}");
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images, MessageDelivery delivery) => Calls.Add($"user:{text}:{delivery}");
public void AppendEntry(string customType, JsonElement? data) => Calls.Add($"entry:{customType}");
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken) { Model = model; return Task.FromResult(true); }
public void SetThinkingLevel(ThinkingLevel level) => ThinkingLevel = level;
public string? GetSessionName() => "name";
public void SetSessionName(string? name) => Calls.Add($"name:{name}");
}