feat(core): tools slice with built-in tools

This commit is contained in:
VolcanicAgenticCore
2026-09-25 12:12:30 +00:00
parent 7e346aff61
commit 9ab663665c
9 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
namespace Volcanic.AgenticCore.Features.Tools;
/// <summary>Light JSON Schema check for tool arguments: required properties and primitive types. Returns null when valid.</summary>
public static class ArgumentValidator
{
public static string? Validate(JsonElement schema, JsonElement args)
{
if (args.ValueKind != JsonValueKind.Object) return "arguments must be a JSON object";
var errors = new List<string>();
if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array)
{
foreach (var r in required.EnumerateArray())
{
var name = r.GetString();
if (name is not null && !args.TryGetProperty(name, out _)) errors.Add($"missing required property '{name}'");
}
}
if (schema.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
if (!args.TryGetProperty(prop.Name, out var value) || value.ValueKind == JsonValueKind.Null) continue;
var expected = ExpectedTypes(prop.Value);
if (expected.Count == 0) continue;
if (!expected.Any(t => Matches(t, value)))
errors.Add($"property '{prop.Name}' should be {string.Join(" or ", expected)} but was {Describe(value)}");
}
}
return errors.Count == 0 ? null : string.Join("; ", errors);
}
private static List<string> ExpectedTypes(JsonElement propertySchema)
{
if (!propertySchema.TryGetProperty("type", out var type)) return [];
if (type.ValueKind == JsonValueKind.String) return [type.GetString()!];
if (type.ValueKind == JsonValueKind.Array) return type.EnumerateArray().Select(t => t.GetString()!).ToList();
return [];
}
private static bool Matches(string type, JsonElement value) => type switch
{
"string" => value.ValueKind == JsonValueKind.String,
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
"number" => value.ValueKind == JsonValueKind.Number,
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
"array" => value.ValueKind == JsonValueKind.Array,
"object" => value.ValueKind == JsonValueKind.Object,
"null" => value.ValueKind == JsonValueKind.Null,
_ => true,
};
private static string Describe(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => "string",
JsonValueKind.Number => "number",
JsonValueKind.True or JsonValueKind.False => "boolean",
JsonValueKind.Array => "array",
JsonValueKind.Object => "object",
_ => value.ValueKind.ToString().ToLowerInvariant(),
};
}

View File

@@ -0,0 +1,99 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
public sealed record BashArgs(
[property: Description("Shell command to run")] string Command,
[property: Description("Timeout in seconds (optional)")] int? Timeout = null);
/// <summary>Runs a shell command in the session's working directory, streaming output through <see cref="ToolInvocation.Progress"/>.</summary>
public sealed class BashTool : Tool<BashArgs>
{
public override string Name => "bash";
public override string Description => "Run a shell command in the working directory. Output (stdout and stderr) is returned; long output is truncated to the tail.";
public override string? PromptSnippet => "bash: run shell commands";
public override IReadOnlyList<string> PromptGuidelines => ["Use bash for git, builds, tests and other commands; prefer the file tools for reading and editing files."];
protected override async Task<ToolResult> ExecuteAsync(BashArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var (file, arguments) = OperatingSystem.IsWindows()
? ("cmd.exe", $"/d /s /c \"{args.Command}\"")
: ("/bin/sh", $"-c \"{args.Command.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"");
var psi = new ProcessStartInfo(file, arguments)
{
WorkingDirectory = invocation.Context.Cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = psi };
var output = new StringBuilder();
var outputLock = new Lock();
var lastReport = Stopwatch.StartNew();
void Append(string? line)
{
if (line is null) return;
lock (outputLock) output.Append(line).Append('\n');
if (invocation.Progress is not null && lastReport.ElapsedMilliseconds > 250)
{
lastReport.Restart();
string snapshot;
lock (outputLock) snapshot = output.ToString();
invocation.Report(ToolResult.Text(OutputTruncation.Tail(snapshot).Text));
}
}
process.OutputDataReceived += (_, e) => Append(e.Data);
process.ErrorDataReceived += (_, e) => Append(e.Data);
process.Start();
process.StandardInput.Close();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var timeoutCts = new CancellationTokenSource();
if (args.Timeout is > 0) timeoutCts.CancelAfter(TimeSpan.FromSeconds(args.Timeout.Value));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var timedOut = false;
var cancelled = false;
try
{
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
timedOut = timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested;
cancelled = cancellationToken.IsCancellationRequested;
try { process.Kill(entireProcessTree: true); } catch (Exception) { /* already gone */ }
try { await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception) { }
}
string all;
lock (outputLock) all = output.ToString().TrimEnd('\n');
var (text, info) = OutputTruncation.Tail(all);
var sb = new StringBuilder(text);
if (info.Truncated) sb.Append('\n').Append(info.Note);
if (timedOut) sb.Append($"\n[Command timed out after {args.Timeout}s and was killed]");
if (cancelled) sb.Append("\n[Command aborted]");
var exitCode = timedOut || cancelled ? -1 : process.ExitCode;
if (!timedOut && !cancelled && exitCode != 0) sb.Append($"\n[exit code {exitCode}]");
var result = ToolResult.Text(sb.Length == 0 ? "(no output)" : sb.ToString(), new { exitCode, timedOut, cancelled, truncation = info });
if (!timedOut && !cancelled && exitCode != 0) throw new ToolExecutionException(result);
return result;
}
}
/// <summary>Thrown by tools that want to return content and details while still marking the result as an error.</summary>
public sealed class ToolExecutionException(ToolResult result) : Exception(result.TextContent)
{
public ToolResult Result { get; } = result;
}

View File

@@ -0,0 +1,156 @@
using System.ComponentModel;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
internal static class Paths
{
public static string Resolve(string cwd, string path) =>
Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(cwd, path));
public static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".webp" };
public static string MimeFor(string extension) => extension.ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "application/octet-stream",
};
}
public sealed record ReadArgs(
[property: Description("Path to the file (relative to the working directory or absolute)")] string Path,
[property: Description("1-based line to start from")] int? Offset = null,
[property: Description("Maximum number of lines to return")] int? Limit = null);
public sealed class ReadTool : Tool<ReadArgs>
{
public override string Name => "read";
public override string Description => "Read a text file (with line numbers) or an image. Use offset and limit for large files.";
public override string? PromptSnippet => "read: read files and images";
protected override async Task<ToolResult> ExecuteAsync(ReadArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var path = Paths.Resolve(invocation.Context.Cwd, args.Path);
if (!File.Exists(path)) throw new FileNotFoundException($"File not found: {args.Path}");
var extension = System.IO.Path.GetExtension(path);
if (Paths.ImageExtensions.Contains(extension))
{
var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
return new ToolResult([new ImageContent(Convert.ToBase64String(bytes), Paths.MimeFor(extension))]);
}
var lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
var start = Math.Max(1, args.Offset ?? 1);
var count = args.Limit ?? int.MaxValue;
var selected = lines.Skip(start - 1).Take(count).ToArray();
var sb = new StringBuilder();
for (var i = 0; i < selected.Length; i++) sb.Append(start + i).Append(": ").Append(selected[i]).Append('\n');
var (text, info) = OutputTruncation.Head(sb.ToString().TrimEnd('\n'));
if (info.Truncated) text += "\n" + info.Note;
if (start > lines.Length && lines.Length > 0) text = $"[offset {start} is past the end of the file ({lines.Length} lines)]";
return ToolResult.Text(text, new { truncation = info, totalLines = lines.Length });
}
}
public sealed record WriteArgs(
[property: Description("Path to the file")] string Path,
[property: Description("Full file content")] string Content);
public sealed class WriteTool : Tool<WriteArgs>
{
public override string Name => "write";
public override string Description => "Create or overwrite a file with the given content. Parent directories are created.";
public override string? PromptSnippet => "write: create or overwrite files";
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Sequential;
protected override async Task<ToolResult> ExecuteAsync(WriteArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var path = Paths.Resolve(invocation.Context.Cwd, args.Path);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, args.Content, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
return ToolResult.Text($"Wrote {Encoding.UTF8.GetByteCount(args.Content)} bytes to {args.Path}", new { path });
}
}
public sealed record EditOperation(
[property: Description("Exact text to find; must occur exactly once")] string OldText,
[property: Description("Replacement text")] string NewText);
public sealed record EditArgs(
[property: Description("Path to the file")] string Path,
[property: Description("Edits applied in order against the original file")] EditOperation[] Edits);
public sealed class EditTool : Tool<EditArgs>
{
public override string Name => "edit";
public override string Description => "Replace exact text in a file. Each oldText must match exactly once. Use read first to see the current content.";
public override string? PromptSnippet => "edit: exact string replacement in files";
public override IReadOnlyList<string> PromptGuidelines => ["Prefer edit over write for existing files; keep oldText small but unique."];
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Sequential;
protected override async Task<ToolResult> ExecuteAsync(EditArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var path = Paths.Resolve(invocation.Context.Cwd, args.Path);
if (!File.Exists(path)) throw new FileNotFoundException($"File not found: {args.Path}");
if (args.Edits is null || args.Edits.Length == 0) throw new ArgumentException("edits must contain at least one edit");
var original = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var content = original;
foreach (var edit in args.Edits)
{
if (string.IsNullOrEmpty(edit.OldText)) throw new ArgumentException("oldText must not be empty");
var first = original.IndexOf(edit.OldText, StringComparison.Ordinal);
if (first < 0) throw new InvalidOperationException($"oldText not found in {args.Path}: {Preview(edit.OldText)}");
if (original.IndexOf(edit.OldText, first + 1, StringComparison.Ordinal) >= 0)
throw new InvalidOperationException($"oldText matches more than once in {args.Path}; add more context: {Preview(edit.OldText)}");
var pos = content.IndexOf(edit.OldText, StringComparison.Ordinal);
if (pos < 0) throw new InvalidOperationException($"oldText overlaps an earlier edit: {Preview(edit.OldText)}");
content = string.Concat(content.AsSpan(0, pos), edit.NewText, content.AsSpan(pos + edit.OldText.Length));
}
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
var firstChangedLine = FirstChangedLine(original, content);
return ToolResult.Text($"Applied {args.Edits.Length} edit(s) to {args.Path}", new { path, firstChangedLine });
}
private static string Preview(string text) => text.Length <= 60 ? text.Replace("\n", "\\n") : text[..60].Replace("\n", "\\n") + "…";
private static int FirstChangedLine(string a, string b)
{
var la = a.Split('\n');
var lb = b.Split('\n');
for (var i = 0; i < Math.Min(la.Length, lb.Length); i++) if (la[i] != lb[i]) return i + 1;
return Math.Min(la.Length, lb.Length) + 1;
}
}
public sealed record LsArgs(
[property: Description("Directory to list (defaults to the working directory)")] string? Path = null,
[property: Description("Maximum entries")] int? Limit = null);
public sealed class LsTool : Tool<LsArgs>
{
public override string Name => "ls";
public override string Description => "List a directory. Directories are suffixed with '/'.";
public override string? PromptSnippet => "ls: list directory contents";
protected override Task<ToolResult> ExecuteAsync(LsArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var path = Paths.Resolve(invocation.Context.Cwd, args.Path ?? ".");
if (!Directory.Exists(path)) throw new DirectoryNotFoundException($"Directory not found: {args.Path ?? "."}");
var limit = args.Limit ?? 500;
var entries = Directory.EnumerateFileSystemEntries(path)
.Select(p => Directory.Exists(p) ? System.IO.Path.GetFileName(p) + "/" : System.IO.Path.GetFileName(p))
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
var shown = entries.Take(limit).ToList();
var text = string.Join('\n', shown);
if (entries.Count > limit) text += $"\n[{entries.Count - limit} more entries not shown]";
return Task.FromResult(ToolResult.Text(text.Length == 0 ? "(empty directory)" : text, new { count = entries.Count, truncated = entries.Count > limit }));
}
}

View File

@@ -0,0 +1,178 @@
using System.ComponentModel;
using System.Text;
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.Tools.BuiltIn;
internal static class FileWalker
{
private static readonly HashSet<string> SkippedDirectories = new(StringComparer.OrdinalIgnoreCase)
{ ".git", "node_modules", "bin", "obj", ".vs", ".idea", "__pycache__", ".volcanic" };
/// <summary>Files under <paramref name="root"/>, depth-first, skipping VCS and build folders.</summary>
public static IEnumerable<string> Files(string root, CancellationToken cancellationToken)
{
var stack = new Stack<string>();
stack.Push(root);
while (stack.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var dir = stack.Pop();
IEnumerable<string> subdirs;
IEnumerable<string> files;
try
{
subdirs = Directory.EnumerateDirectories(dir).Where(d => !SkippedDirectories.Contains(Path.GetFileName(d))).OrderByDescending(d => d, StringComparer.Ordinal);
files = Directory.EnumerateFiles(dir).OrderBy(f => f, StringComparer.Ordinal);
}
catch (UnauthorizedAccessException) { continue; }
catch (IOException) { continue; }
foreach (var file in files) yield return file;
foreach (var sub in subdirs) stack.Push(sub);
}
}
public static Regex GlobToRegex(string glob)
{
var sb = new StringBuilder("^");
for (var i = 0; i < glob.Length; i++)
{
var c = glob[i];
switch (c)
{
case '*':
if (i + 1 < glob.Length && glob[i + 1] == '*')
{
sb.Append(".*");
i++;
if (i + 1 < glob.Length && glob[i + 1] == '/') i++;
}
else sb.Append("[^/]*");
break;
case '?': sb.Append("[^/]"); break;
case '.': sb.Append("\\."); break;
case '{':
var close = glob.IndexOf('}', i);
if (close > i)
{
var alternatives = glob[(i + 1)..close].Split(',').Select(Regex.Escape);
sb.Append('(').Append(string.Join('|', alternatives)).Append(')');
i = close;
}
else sb.Append("\\{");
break;
default: sb.Append(Regex.Escape(c.ToString())); break;
}
}
sb.Append('$');
return new Regex(sb.ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
public static bool LooksBinary(ReadOnlySpan<byte> bytes)
{
var sample = bytes.Length > 8000 ? bytes[..8000] : bytes;
foreach (var b in sample) if (b == 0) return true;
return false;
}
}
public sealed record GrepArgs(
[property: Description("Regular expression to search for")] string Pattern,
[property: Description("Directory or file to search (defaults to the working directory)")] string? Path = null,
[property: Description("Glob filter on file names, for example *.cs")] string? Glob = null,
[property: Description("Case-insensitive match")] bool? IgnoreCase = null,
[property: Description("Treat the pattern as a literal string")] bool? Literal = null,
[property: Description("Lines of context around each match")] int? Context = null,
[property: Description("Maximum matches (default 100)")] int? Limit = null);
public sealed class GrepTool : Tool<GrepArgs>
{
public override string Name => "grep";
public override string Description => "Search file contents with a regular expression. Output is file:line:text.";
public override string? PromptSnippet => "grep: search file contents by regex";
protected override async Task<ToolResult> ExecuteAsync(GrepArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var root = Paths.Resolve(invocation.Context.Cwd, args.Path ?? ".");
var options = RegexOptions.CultureInvariant | (args.IgnoreCase == true ? RegexOptions.IgnoreCase : RegexOptions.None);
var pattern = args.Literal == true ? Regex.Escape(args.Pattern) : args.Pattern;
var regex = new Regex(pattern, options, TimeSpan.FromSeconds(2));
var glob = args.Glob is null ? null : FileWalker.GlobToRegex(args.Glob);
var limit = args.Limit ?? 100;
var context = Math.Max(0, args.Context ?? 0);
var files = File.Exists(root) ? [root] : FileWalker.Files(root, cancellationToken);
var sb = new StringBuilder();
var matches = 0;
var truncated = false;
foreach (var file in files)
{
if (glob is not null && !glob.IsMatch(Path.GetFileName(file))) continue;
string[] lines;
try
{
var bytes = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false);
if (FileWalker.LooksBinary(bytes)) continue;
lines = Encoding.UTF8.GetString(bytes).Split('\n');
}
catch (IOException) { continue; }
catch (UnauthorizedAccessException) { continue; }
var relative = Path.GetRelativePath(invocation.Context.Cwd, file);
for (var i = 0; i < lines.Length; i++)
{
if (!regex.IsMatch(lines[i])) continue;
if (matches >= limit) { truncated = true; break; }
matches++;
var from = Math.Max(0, i - context);
var to = Math.Min(lines.Length - 1, i + context);
for (var j = from; j <= to; j++)
{
var line = lines[j].Length > 500 ? lines[j][..500] + "…" : lines[j];
sb.Append(relative).Append(j == i ? ':' : '-').Append(j + 1).Append(j == i ? ':' : '-').Append(line).Append('\n');
}
if (context > 0) sb.Append("--\n");
}
if (truncated) break;
}
var text = sb.Length == 0 ? "No matches." : sb.ToString().TrimEnd('\n');
if (truncated) text += $"\n[Stopped after {limit} matches; refine the pattern or raise limit.]";
var (capped, info) = OutputTruncation.Head(text);
if (info.Truncated) capped += "\n" + info.Note;
return ToolResult.Text(capped, new { matches, truncated = truncated || info.Truncated });
}
}
public sealed record FindArgs(
[property: Description("Glob pattern, for example **/*.cs or src/*.json")] string Pattern,
[property: Description("Directory to search (defaults to the working directory)")] string? Path = null,
[property: Description("Maximum results (default 1000)")] int? Limit = null);
public sealed class FindTool : Tool<FindArgs>
{
public override string Name => "find";
public override string Description => "Find files by glob pattern (relative to the search directory).";
public override string? PromptSnippet => "find: find files by glob";
protected override Task<ToolResult> ExecuteAsync(FindArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var root = Paths.Resolve(invocation.Context.Cwd, args.Path ?? ".");
if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Directory not found: {args.Path ?? "."}");
var regex = FileWalker.GlobToRegex(args.Pattern.Replace('\\', '/'));
var limit = args.Limit ?? 1000;
var results = new List<string>();
var truncated = false;
foreach (var file in FileWalker.Files(root, cancellationToken))
{
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
if (!regex.IsMatch(relative) && !regex.IsMatch(Path.GetFileName(file))) continue;
if (results.Count >= limit) { truncated = true; break; }
results.Add(relative);
}
var text = results.Count == 0 ? "No files matched." : string.Join('\n', results);
if (truncated) text += $"\n[Stopped after {limit} results.]";
return Task.FromResult(ToolResult.Text(text, new { count = results.Count, truncated }));
}
}

View File

@@ -0,0 +1,12 @@
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
namespace Volcanic.AgenticCore.Features.Tools;
public static class BuiltInTools
{
public static readonly IReadOnlyList<string> DefaultActive = ["read", "bash", "edit", "write"];
public static readonly IReadOnlyList<string> ReadOnlySet = ["read", "grep", "find", "ls"];
public static IReadOnlyList<ITool> All() => [new ReadTool(), new BashTool(), new EditTool(), new WriteTool(), new LsTool(), new GrepTool(), new FindTool()];
}

View File

@@ -0,0 +1,56 @@
using System.Text;
namespace Volcanic.AgenticCore.Features.Tools;
public sealed record TruncationInfo(bool Truncated, int TotalLines, int TotalBytes, int ShownLines, string? Note);
/// <summary>Caps tool output at a line and byte budget, keeping the head (or the tail for command output).</summary>
public static class OutputTruncation
{
public const int DefaultMaxLines = 2000;
public const int DefaultMaxBytes = 50 * 1024;
public static (string Text, TruncationInfo Info) Head(string text, int maxLines = DefaultMaxLines, int maxBytes = DefaultMaxBytes)
{
var lines = text.Split('\n');
var totalBytes = Encoding.UTF8.GetByteCount(text);
if (lines.Length <= maxLines && totalBytes <= maxBytes)
return (text, new TruncationInfo(false, lines.Length, totalBytes, lines.Length, null));
var sb = new StringBuilder();
var bytes = 0;
var shown = 0;
foreach (var line in lines)
{
var lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (shown >= maxLines || bytes + lineBytes > maxBytes) break;
if (shown > 0) sb.Append('\n');
sb.Append(line);
bytes += lineBytes;
shown++;
}
var note = $"[Output truncated: showing {shown} of {lines.Length} lines ({totalBytes} bytes). Use offset/limit or a narrower query to see more.]";
return (sb.ToString(), new TruncationInfo(true, lines.Length, totalBytes, shown, note));
}
public static (string Text, TruncationInfo Info) Tail(string text, int maxLines = DefaultMaxLines, int maxBytes = DefaultMaxBytes)
{
var lines = text.Split('\n');
var totalBytes = Encoding.UTF8.GetByteCount(text);
if (lines.Length <= maxLines && totalBytes <= maxBytes)
return (text, new TruncationInfo(false, lines.Length, totalBytes, lines.Length, null));
var kept = new List<string>();
var bytes = 0;
for (var i = lines.Length - 1; i >= 0; i--)
{
var lineBytes = Encoding.UTF8.GetByteCount(lines[i]) + 1;
if (kept.Count >= maxLines || bytes + lineBytes > maxBytes) break;
kept.Add(lines[i]);
bytes += lineBytes;
}
kept.Reverse();
var note = $"[Output truncated: showing last {kept.Count} of {lines.Length} lines ({totalBytes} bytes).]";
return (string.Join('\n', kept), new TruncationInfo(true, lines.Length, totalBytes, kept.Count, note));
}
}

View File

@@ -0,0 +1,78 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Features.Tools;
/// <summary>
/// All tools known to a session and the active subset the model may call.
/// Extension tools registered first win name collisions; an extension tool replaces a built-in of the same name.
/// </summary>
public sealed class ToolRegistry
{
private readonly Dictionary<string, (ITool Tool, string Source)> _tools = new(StringComparer.Ordinal);
private readonly List<string> _active = [];
private readonly Lock _lock = new();
public IReadOnlyList<string> ActiveNames
{
get { lock (_lock) return _active.ToList(); }
}
public IReadOnlyList<ITool> ActiveTools
{
get { lock (_lock) return _active.Where(_tools.ContainsKey).Select(n => _tools[n].Tool).ToList(); }
}
public IReadOnlyList<ToolInfo> All
{
get
{
lock (_lock)
return _tools.Values.Select(t => new ToolInfo(t.Tool.Name, t.Tool.Description, t.Tool.Parameters, t.Tool.PromptGuidelines, t.Source)).ToList();
}
}
/// <summary>Register a built-in. Does not override an existing registration.</summary>
public void AddBuiltIn(ITool tool, bool active)
{
lock (_lock)
{
if (_tools.ContainsKey(tool.Name)) return;
_tools[tool.Name] = (tool, "built-in");
if (active && !_active.Contains(tool.Name)) _active.Add(tool.Name);
}
}
/// <summary>Register an extension tool. Replaces a built-in of the same name; a second extension registration is ignored and reported.</summary>
public bool AddExtensionTool(ITool tool, string source)
{
lock (_lock)
{
if (_tools.TryGetValue(tool.Name, out var existing) && existing.Source != "built-in") return false;
var wasActive = !_tools.ContainsKey(tool.Name) || _active.Contains(tool.Name);
_tools[tool.Name] = (tool, source);
if (wasActive && !_active.Contains(tool.Name)) _active.Add(tool.Name);
return true;
}
}
public ITool? Get(string name)
{
lock (_lock) return _tools.TryGetValue(name, out var t) ? t.Tool : null;
}
public void SetActive(IEnumerable<string> names)
{
lock (_lock)
{
_active.Clear();
foreach (var name in names)
{
if (_tools.ContainsKey(name) && !_active.Contains(name)) _active.Add(name);
}
}
}
public IReadOnlyList<ToolDescriptor> ActiveDescriptors() => ActiveTools.Select(t => t.ToDescriptor()).ToList();
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Features.Tools.BuiltIn;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class ToolRegistryTests
{
private static ITool Named(string name) => new DelegateTool<object>(name, "d", (_, _, _) => Task.FromResult(ToolResult.Text("x")));
[Fact]
public void Extension_tool_replaces_builtin_but_not_another_extension()
{
var registry = new ToolRegistry();
registry.AddBuiltIn(Named("read"), active: true);
registry.AddBuiltIn(Named("grep"), active: false);
Assert.Equal(["read"], registry.ActiveNames);
var replacement = Named("read");
Assert.True(registry.AddExtensionTool(replacement, "ext-a"));
Assert.Same(replacement, registry.Get("read"));
Assert.Equal(["read"], registry.ActiveNames);
Assert.False(registry.AddExtensionTool(Named("read"), "ext-b"));
Assert.Same(replacement, registry.Get("read"));
Assert.True(registry.AddExtensionTool(Named("hello"), "ext-b"));
Assert.Equal(["read", "hello"], registry.ActiveNames);
registry.SetActive(["grep", "unknown", "hello"]);
Assert.Equal(["grep", "hello"], registry.ActiveNames);
Assert.Equal(3, registry.All.Count);
Assert.Equal("ext-b", registry.All.Single(t => t.Name == "hello").Source);
}
}
public class ArgumentValidatorTests
{
private static readonly JsonElement Schema = VolcanicJson.Parse("""
{"type":"object","properties":{"path":{"type":"string"},"limit":{"type":"integer"},"flag":{"type":"boolean"}},"required":["path"]}
""");
[Fact]
public void Valid_arguments_pass()
{
Assert.Null(ArgumentValidator.Validate(Schema, VolcanicJson.Parse("""{"path":"a","limit":3,"flag":true}""")));
Assert.Null(ArgumentValidator.Validate(Schema, VolcanicJson.Parse("""{"path":"a","limit":null}""")));
}
[Fact]
public void Missing_required_and_wrong_types_are_reported()
{
var error = ArgumentValidator.Validate(Schema, VolcanicJson.Parse("""{"limit":"three","flag":1}"""));
Assert.NotNull(error);
Assert.Contains("missing required property 'path'", error);
Assert.Contains("'limit' should be integer but was string", error);
Assert.Contains("'flag' should be boolean but was number", error);
Assert.Equal("arguments must be a JSON object", ArgumentValidator.Validate(Schema, VolcanicJson.Parse("[]")));
}
}
public class OutputTruncationTests
{
[Fact]
public void Head_and_tail_respect_line_limits()
{
var text = string.Join('\n', Enumerable.Range(1, 10).Select(i => $"line{i}"));
var (head, headInfo) = OutputTruncation.Head(text, maxLines: 3);
Assert.Equal("line1\nline2\nline3", head);
Assert.True(headInfo.Truncated);
Assert.Equal(10, headInfo.TotalLines);
var (tail, _) = OutputTruncation.Tail(text, maxLines: 2);
Assert.Equal("line9\nline10", tail);
var (same, info) = OutputTruncation.Head(text);
Assert.Equal(text, same);
Assert.False(info.Truncated);
}
}
public class BuiltInToolTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "volcanic-tools", Guid.NewGuid().ToString("N"));
private readonly StubExtensionContext _ctx;
public BuiltInToolTests()
{
Directory.CreateDirectory(_dir);
_ctx = new StubExtensionContext(_dir);
}
public void Dispose() => Directory.Delete(_dir, true);
private Task<ToolResult> Run<TArgs>(Tool<TArgs> tool, object args, IProgress<ToolResult>? progress = null) =>
((ITool)tool).ExecuteAsync(new ToolCallContent("c1", tool.Name, VolcanicJson.ToElement(args)), new ToolInvocation("c1", _ctx, progress), CancellationToken.None);
[Fact]
public async Task Write_read_edit_round_trip()
{
await Run(new WriteTool(), new { path = "sub/a.txt", content = "alpha\nbeta\ngamma" });
Assert.True(File.Exists(Path.Combine(_dir, "sub", "a.txt")));
var read = await Run(new ReadTool(), new { path = "sub/a.txt", offset = 2, limit = 1 });
Assert.Equal("2: beta", read.TextContent);
var edit = await Run(new EditTool(), new { path = "sub/a.txt", edits = new[] { new { oldText = "beta", newText = "BETA" } } });
Assert.Contains("1 edit", edit.TextContent);
Assert.Equal(2, edit.Details!.Value.GetProperty("firstChangedLine").GetInt32());
Assert.Equal("alpha\nBETA\ngamma", await File.ReadAllTextAsync(Path.Combine(_dir, "sub", "a.txt")));
var notFound = await Assert.ThrowsAsync<InvalidOperationException>(() => Run(new EditTool(), new { path = "sub/a.txt", edits = new[] { new { oldText = "zzz", newText = "y" } } }));
Assert.Contains("not found", notFound.Message);
await File.WriteAllTextAsync(Path.Combine(_dir, "sub", "a.txt"), "x x");
var ambiguous = await Assert.ThrowsAsync<InvalidOperationException>(() => Run(new EditTool(), new { path = "sub/a.txt", edits = new[] { new { oldText = "x", newText = "y" } } }));
Assert.Contains("more than once", ambiguous.Message);
await Assert.ThrowsAsync<FileNotFoundException>(() => Run(new ReadTool(), new { path = "missing.txt" }));
}
[Fact]
public async Task Read_returns_images_as_image_content()
{
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47 };
await File.WriteAllBytesAsync(Path.Combine(_dir, "pic.png"), png);
var result = await Run(new ReadTool(), new { path = "pic.png" });
var image = Assert.IsType<ImageContent>(result.Content.Single());
Assert.Equal("image/png", image.MimeType);
Assert.Equal(Convert.ToBase64String(png), image.Data);
}
[Fact]
public async Task Ls_find_and_grep()
{
Directory.CreateDirectory(Path.Combine(_dir, "src"));
Directory.CreateDirectory(Path.Combine(_dir, ".git"));
await File.WriteAllTextAsync(Path.Combine(_dir, "src", "a.cs"), "class A { }\nvoid Foo() {}\n");
await File.WriteAllTextAsync(Path.Combine(_dir, "src", "b.txt"), "foo bar\nFOO\n");
await File.WriteAllTextAsync(Path.Combine(_dir, ".git", "config"), "foo");
var ls = await Run(new LsTool(), new { });
Assert.Equal(".git/\nsrc/", ls.TextContent);
var find = await Run(new FindTool(), new { pattern = "**/*.cs" });
Assert.Equal("src/a.cs", find.TextContent);
var findAll = await Run(new FindTool(), new { pattern = "*", path = "src" });
Assert.Equal("a.cs\nb.txt", findAll.TextContent);
var grep = await Run(new GrepTool(), new { pattern = "foo", ignoreCase = true });
Assert.Contains("src/b.txt:1:foo bar", grep.TextContent);
Assert.Contains("src/b.txt:2:FOO", grep.TextContent);
Assert.Contains("src/a.cs:2:void Foo() {}", grep.TextContent);
Assert.DoesNotContain(".git", grep.TextContent);
var grepGlob = await Run(new GrepTool(), new { pattern = "foo", glob = "*.txt", ignoreCase = true, limit = 1 });
Assert.Equal(1, grepGlob.Details!.Value.GetProperty("matches").GetInt32());
Assert.Contains("Stopped after 1", grepGlob.TextContent);
var none = await Run(new GrepTool(), new { pattern = "nothing-here" });
Assert.Equal("No matches.", none.TextContent);
}
[Fact]
public async Task Bash_runs_streams_and_reports_exit_code()
{
if (OperatingSystem.IsWindows()) return;
var updates = new List<ToolResult>();
var progress = new SynchronousProgress(updates.Add);
var ok = await Run(new BashTool(), new { command = "echo hello; echo err 1>&2" }, progress);
Assert.Contains("hello", ok.TextContent);
Assert.Contains("err", ok.TextContent);
Assert.Equal(0, ok.Details!.Value.GetProperty("exitCode").GetInt32());
var failure = await Assert.ThrowsAsync<ToolExecutionException>(() => Run(new BashTool(), new { command = "echo bad; exit 3" }));
Assert.Contains("bad", failure.Result.TextContent);
Assert.Contains("exit code 3", failure.Result.TextContent);
var timeout = await Run(new BashTool(), new { command = "sleep 5", timeout = 1 });
Assert.True(timeout.Details!.Value.GetProperty("timedOut").GetBoolean());
Assert.Contains("timed out", timeout.TextContent);
}
private sealed class SynchronousProgress(Action<ToolResult> handler) : IProgress<ToolResult>
{
public void Report(ToolResult value) => handler(value);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Features.Models;
namespace Volcanic.AgenticCore.Tests.Support;
/// <summary>Minimal context for exercising tools outside a session.</summary>
public sealed class StubExtensionContext(string cwd) : IExtensionContext
{
public string Cwd { get; } = cwd;
public string SessionId => "test";
public bool HasUi => Ui is not NullUserInterface;
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry { get; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
}