forked from admin/VolcanicAgenticCore
feat(core): extensions and commands slices
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)..]);
|
||||
}
|
||||
}
|
||||
49
src/Volcanic.AgenticCore/Features/Extensions/EventBus.cs
Normal file
49
src/Volcanic.AgenticCore/Features/Extensions/EventBus.cs
Normal 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();
|
||||
}
|
||||
139
src/Volcanic.AgenticCore/Features/Extensions/ExtensionApi.cs
Normal file
139
src/Volcanic.AgenticCore/Features/Extensions/ExtensionApi.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
130
src/Volcanic.AgenticCore/Features/Extensions/ExtensionLoader.cs
Normal file
130
src/Volcanic.AgenticCore/Features/Extensions/ExtensionLoader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
153
src/Volcanic.AgenticCore/Features/Extensions/ExtensionRunner.cs
Normal file
153
src/Volcanic.AgenticCore/Features/Extensions/ExtensionRunner.cs
Normal 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"); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
304
tests/Volcanic.AgenticCore.Tests/Features/ExtensionsTests.cs
Normal file
304
tests/Volcanic.AgenticCore.Tests/Features/ExtensionsTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user