feat(core): tools slice with built-in tools
This commit is contained in:
66
src/Volcanic.AgenticCore/Features/Tools/ArgumentValidator.cs
Normal file
66
src/Volcanic.AgenticCore/Features/Tools/ArgumentValidator.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
99
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/BashTool.cs
Normal file
99
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/BashTool.cs
Normal 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;
|
||||
}
|
||||
156
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/FileTools.cs
Normal file
156
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/FileTools.cs
Normal 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 }));
|
||||
}
|
||||
}
|
||||
178
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SearchTools.cs
Normal file
178
src/Volcanic.AgenticCore/Features/Tools/BuiltIn/SearchTools.cs
Normal 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 }));
|
||||
}
|
||||
}
|
||||
12
src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs
Normal file
12
src/Volcanic.AgenticCore/Features/Tools/BuiltInTools.cs
Normal 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()];
|
||||
}
|
||||
56
src/Volcanic.AgenticCore/Features/Tools/OutputTruncation.cs
Normal file
56
src/Volcanic.AgenticCore/Features/Tools/OutputTruncation.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
78
src/Volcanic.AgenticCore/Features/Tools/ToolRegistry.cs
Normal file
78
src/Volcanic.AgenticCore/Features/Tools/ToolRegistry.cs
Normal 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();
|
||||
}
|
||||
190
tests/Volcanic.AgenticCore.Tests/Features/ToolsTests.cs
Normal file
190
tests/Volcanic.AgenticCore.Tests/Features/ToolsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
Reference in New Issue
Block a user