From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。 --- src/Agent.Core/Agent.Core.csproj | 8 ++ src/Agent.Core/AssemblyMarker.cs | 3 + src/Agent.Core/Localization/LanguageContext.cs | 3 + src/Agent.Core/Localization/LocaleCatalog.cs | 97 ++++++++++++++ src/Agent.Core/Localization/LocaleException.cs | 4 + src/Agent.Core/Ollama/IOllamaClient.cs | 11 ++ src/Agent.Core/Ollama/OllamaClient.cs | 92 +++++++++++++ src/Agent.Core/Ollama/OllamaException.cs | 7 + src/Agent.Core/Ollama/OllamaModels.cs | 46 +++++++ src/Agent.Core/Ollama/OllamaSettings.cs | 10 ++ src/Agent.Core/Prompts/FixedPrompt.cs | 16 +++ src/Agent.Core/Prompts/FixedSystemPrompt.md | 4 + src/Agent.Core/Prompts/PromptComposer.cs | 36 ++++++ src/Agent.Core/Runtime/AgentRuntime.cs | 165 ++++++++++++++++++++++++ src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 10 ++ src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 19 +++ src/Agent.Core/Sessions/AgentSession.cs | 26 ++++ src/Agent.Core/Sessions/ConversationMessage.cs | 3 + src/Agent.Core/Sessions/SessionStore.cs | 19 +++ src/Agent.Core/Tasks/AgentTaskState.cs | 11 ++ src/Agent.Core/Tasks/TaskRegistry.cs | 115 +++++++++++++++++ src/Agent.Core/Tasks/TaskSnapshot.cs | 16 +++ src/Agent.Core/Tools/InternalToolCatalog.cs | 32 +++++ src/Agent.Core/Tools/InternalToolDefinition.cs | 5 + src/Agent.Core/Tools/InternalToolDispatcher.cs | 29 +++++ src/Agent.Core/Tools/InternalToolValidator.cs | 86 ++++++++++++ src/Agent.Core/Tools/TaskGraphToolHandler.cs | 123 ++++++++++++++++++ src/Agent.Core/Tools/ToolPolicy.cs | 31 +++++ src/Agent.Core/Tools/ToolUseGuard.cs | 22 ++++ src/Agent.Core/Tools/ToolValidationException.cs | 3 + 30 files changed, 1052 insertions(+) create mode 100644 src/Agent.Core/Agent.Core.csproj create mode 100644 src/Agent.Core/AssemblyMarker.cs create mode 100644 src/Agent.Core/Localization/LanguageContext.cs create mode 100644 src/Agent.Core/Localization/LocaleCatalog.cs create mode 100644 src/Agent.Core/Localization/LocaleException.cs create mode 100644 src/Agent.Core/Ollama/IOllamaClient.cs create mode 100644 src/Agent.Core/Ollama/OllamaClient.cs create mode 100644 src/Agent.Core/Ollama/OllamaException.cs create mode 100644 src/Agent.Core/Ollama/OllamaModels.cs create mode 100644 src/Agent.Core/Ollama/OllamaSettings.cs create mode 100644 src/Agent.Core/Prompts/FixedPrompt.cs create mode 100644 src/Agent.Core/Prompts/FixedSystemPrompt.md create mode 100644 src/Agent.Core/Prompts/PromptComposer.cs create mode 100644 src/Agent.Core/Runtime/AgentRuntime.cs create mode 100644 src/Agent.Core/Runtime/AgentRuntimeSettings.cs create mode 100644 src/Agent.Core/Runtime/IAgentRuntimeObserver.cs create mode 100644 src/Agent.Core/Sessions/AgentSession.cs create mode 100644 src/Agent.Core/Sessions/ConversationMessage.cs create mode 100644 src/Agent.Core/Sessions/SessionStore.cs create mode 100644 src/Agent.Core/Tasks/AgentTaskState.cs create mode 100644 src/Agent.Core/Tasks/TaskRegistry.cs create mode 100644 src/Agent.Core/Tasks/TaskSnapshot.cs create mode 100644 src/Agent.Core/Tools/InternalToolCatalog.cs create mode 100644 src/Agent.Core/Tools/InternalToolDefinition.cs create mode 100644 src/Agent.Core/Tools/InternalToolDispatcher.cs create mode 100644 src/Agent.Core/Tools/InternalToolValidator.cs create mode 100644 src/Agent.Core/Tools/TaskGraphToolHandler.cs create mode 100644 src/Agent.Core/Tools/ToolPolicy.cs create mode 100644 src/Agent.Core/Tools/ToolUseGuard.cs create mode 100644 src/Agent.Core/Tools/ToolValidationException.cs (limited to 'src/Agent.Core') diff --git a/src/Agent.Core/Agent.Core.csproj b/src/Agent.Core/Agent.Core.csproj new file mode 100644 index 0000000..9816d8a --- /dev/null +++ b/src/Agent.Core/Agent.Core.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Agent.Core/AssemblyMarker.cs b/src/Agent.Core/AssemblyMarker.cs new file mode 100644 index 0000000..2efa480 --- /dev/null +++ b/src/Agent.Core/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Agent.Core; + +public static class AssemblyMarker; diff --git a/src/Agent.Core/Localization/LanguageContext.cs b/src/Agent.Core/Localization/LanguageContext.cs new file mode 100644 index 0000000..f8878b5 --- /dev/null +++ b/src/Agent.Core/Localization/LanguageContext.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Localization; + +public sealed record LanguageContext(string Locale); diff --git a/src/Agent.Core/Localization/LocaleCatalog.cs b/src/Agent.Core/Localization/LocaleCatalog.cs new file mode 100644 index 0000000..3c49283 --- /dev/null +++ b/src/Agent.Core/Localization/LocaleCatalog.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Agent.Core.Localization; + +public sealed partial class LocaleCatalog +{ + private readonly IReadOnlyDictionary> _locales; + private readonly IReadOnlyDictionary _fallback; + + private LocaleCatalog(string selectedLocale, IReadOnlyDictionary> locales, IReadOnlyDictionary fallback) + { + SelectedLocale = selectedLocale; + _locales = locales; + _fallback = fallback; + } + + public string SelectedLocale { get; } + public IReadOnlyCollection Keys => _fallback.Keys.ToArray(); + + public static LocaleCatalog Load(string directory, string selectedLocale, string fallbackLocale = "en") + { + if (!Directory.Exists(directory)) throw new LocaleException($"Locale directory does not exist: {directory}"); + + var locales = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (string file in Directory.EnumerateFiles(directory, "*.json", SearchOption.TopDirectoryOnly)) + { + string locale = Path.GetFileNameWithoutExtension(file); + locales.Add(locale, ReadFlatObject(file)); + } + + if (!locales.TryGetValue(fallbackLocale, out IReadOnlyDictionary? fallback)) + throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing."); + + HashSet baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal); + Dictionary> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal); + foreach ((string locale, IReadOnlyDictionary values) in locales) + { + string? unknown = values.Keys.FirstOrDefault(key => !baselineKeys.Contains(key)); + if (unknown is not null) throw new LocaleException($"Locale '{locale}' contains unknown key '{unknown}'."); + foreach ((string key, string message) in values) + { + if (!baselinePlaceholders[key].SetEquals(Placeholders(message))) + throw new LocaleException($"Locale '{locale}' has mismatched placeholders for key '{key}'."); + } + } + + if (!locales.ContainsKey(selectedLocale)) throw new LocaleException($"Selected locale '{selectedLocale}' is missing."); + return new LocaleCatalog(selectedLocale, locales, fallback); + } + + public string Get(string key) + { + if (_locales[SelectedLocale].TryGetValue(key, out string? selected)) return selected; + return _fallback.TryGetValue(key, out string? fallback) ? fallback : key; + } + + public string Format(string key, IReadOnlyDictionary values) + { + string template = Get(key); + return PlaceholderRegex().Replace(template, match => + { + string name = match.Groups[1].Value; + if (!values.TryGetValue(name, out object? value)) throw new LocaleException($"Missing placeholder value '{name}' for key '{key}'."); + return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + }); + } + + private static Dictionary ReadFlatObject(string file) + { + try + { + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(file)); + if (document.RootElement.ValueKind != JsonValueKind.Object) throw new LocaleException($"Locale file must contain a JSON object: {file}"); + var result = new Dictionary(StringComparer.Ordinal); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) throw new LocaleException($"Locale value '{property.Name}' must be a string."); + if (!result.TryAdd(property.Name, property.Value.GetString()!)) throw new LocaleException($"Duplicate locale key '{property.Name}'."); + } + return result; + } + catch (LocaleException) + { + throw; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + throw new LocaleException($"Could not load locale file '{file}': {ex.Message}", ex); + } + } + + private static HashSet Placeholders(string text) => PlaceholderRegex().Matches(text).Select(static m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + + [GeneratedRegex("\\{([A-Za-z][A-Za-z0-9_]*)\\}", RegexOptions.CultureInvariant)] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/Agent.Core/Localization/LocaleException.cs b/src/Agent.Core/Localization/LocaleException.cs new file mode 100644 index 0000000..788a45c --- /dev/null +++ b/src/Agent.Core/Localization/LocaleException.cs @@ -0,0 +1,4 @@ +namespace Agent.Core.Localization; + +public sealed class LocaleException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs new file mode 100644 index 0000000..a03a353 --- /dev/null +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Ollama; + +using Agent.Core.Tools; + +public interface IOllamaClient +{ + Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken); + Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) => + ChatAsync(messages, temperatureOverride, cancellationToken); + Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(null); +} diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs new file mode 100644 index 0000000..bfd2210 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed class OllamaClient : IOllamaClient +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1)]; + private readonly HttpClient _httpClient; + private readonly OllamaSettings _settings; + private readonly Func _delay; + private bool? _visionCapability; + + public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func? delay = null) + { + _httpClient = httpClient; + _httpClient.BaseAddress = settings.Endpoint; + _settings = settings; + _delay = delay ?? Task.Delay; + } + + public async Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false); + + public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false); + + public async Task SupportsVisionAsync(CancellationToken cancellationToken) + { + if (_visionCapability is not null) return _visionCapability; + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/show", new { model = _settings.Model }, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return null; + using JsonDocument document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("capabilities", out JsonElement capabilities) || capabilities.ValueKind != JsonValueKind.Array) return null; + _visionCapability = capabilities.EnumerateArray().Any(static item => item.GetString()?.Equals("vision", StringComparison.OrdinalIgnoreCase) == true); + return _visionCapability; + } + catch (HttpRequestException) { return null; } + } + + private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, CancellationToken cancellationToken) + { + Dictionary options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); + var request = new OllamaChatRequest + { + Model = _settings.Model, + Messages = messages, + Stream = false, + Think = _settings.Think, + KeepAlive = _settings.KeepAlive, + Options = options, + Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray() + }; + + for (int attempt = 0; ; attempt++) + { + try + { + using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/chat", request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + bool transient = IsTransient(response.StatusCode); + if (transient && attempt < RetryDelays.Length) + { + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + continue; + } + throw new OllamaException($"Ollama returned HTTP {(int)response.StatusCode}: {body}", transient); + } + + OllamaChatResponse result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new OllamaException("Ollama returned an empty response.", false); + if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); + if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + if (attempt >= RetryDelays.Length) throw new OllamaException("Ollama is unavailable after three retries.", true, ex); + await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; +} diff --git a/src/Agent.Core/Ollama/OllamaException.cs b/src/Agent.Core/Ollama/OllamaException.cs new file mode 100644 index 0000000..6b0c158 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaException.cs @@ -0,0 +1,7 @@ +namespace Agent.Core.Ollama; + +public sealed class OllamaException(string message, bool transient, Exception? innerException = null) + : Exception(message, innerException) +{ + public bool IsTransient { get; } = transient; +} diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs new file mode 100644 index 0000000..823a178 --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using System.Text.Json; +using Agent.Core.Tools; + +namespace Agent.Core.Ollama; + +public sealed record OllamaMessage( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? Images = null, + [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null, + [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null); + +public sealed record OllamaToolCall([property: JsonPropertyName("function")] OllamaFunctionCall Function); +public sealed record OllamaFunctionCall( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("arguments")] JsonElement Arguments); + +public sealed record OllamaToolDefinition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("function")] OllamaFunctionDefinition Function); +public sealed record OllamaFunctionDefinition( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("parameters")] JsonElement Parameters); + +public sealed class OllamaChatRequest +{ + [JsonPropertyName("model")] public required string Model { get; init; } + [JsonPropertyName("messages")] public required IReadOnlyList Messages { get; init; } + [JsonPropertyName("stream")] public bool Stream { get; init; } + [JsonPropertyName("think")] public bool Think { get; init; } + [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } + [JsonPropertyName("options")] public required IReadOnlyDictionary Options { get; init; } + [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Tools { get; init; } +} + +public sealed class OllamaChatResponse +{ + [JsonPropertyName("message")] public OllamaMessage? Message { get; init; } + [JsonPropertyName("prompt_eval_count")] public long PromptEvalCount { get; init; } + [JsonPropertyName("eval_count")] public long EvalCount { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } +} + +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList? ToolCalls = null); diff --git a/src/Agent.Core/Ollama/OllamaSettings.cs b/src/Agent.Core/Ollama/OllamaSettings.cs new file mode 100644 index 0000000..94ab3ef --- /dev/null +++ b/src/Agent.Core/Ollama/OllamaSettings.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Agent.Core.Ollama; + +public sealed record OllamaSettings( + Uri Endpoint, + string Model, + bool Think, + string KeepAlive, + IReadOnlyDictionary Options); diff --git a/src/Agent.Core/Prompts/FixedPrompt.cs b/src/Agent.Core/Prompts/FixedPrompt.cs new file mode 100644 index 0000000..6995bd4 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedPrompt.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Agent.Core.Prompts; + +public static class FixedPrompt +{ + private const string ResourceName = "Agent.Core.Prompts.FixedSystemPrompt.md"; + + public static string Load() + { + using Stream stream = typeof(FixedPrompt).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' was not found."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Agent.Core/Prompts/FixedSystemPrompt.md b/src/Agent.Core/Prompts/FixedSystemPrompt.md new file mode 100644 index 0000000..3cbc6e7 --- /dev/null +++ b/src/Agent.Core/Prompts/FixedSystemPrompt.md @@ -0,0 +1,4 @@ +You are the execution agent inside AI Agent Sandbox MCP Server. +Follow the active SandboxPolicy and never claim an external effect unless an Internal Tool result proves it occurred. +Never reveal hidden reasoning, policy internals, credentials, or capabilities not present in the supplied capability summary. +Complete a request only when every TaskGraph node is completed and all required effects are verified. diff --git a/src/Agent.Core/Prompts/PromptComposer.cs b/src/Agent.Core/Prompts/PromptComposer.cs new file mode 100644 index 0000000..e07c7e8 --- /dev/null +++ b/src/Agent.Core/Prompts/PromptComposer.cs @@ -0,0 +1,36 @@ +using Agent.Core.Ollama; +using Agent.Core.Sessions; + +namespace Agent.Core.Prompts; + +public sealed record PromptContext( + string FixedSystemPrompt, + string UserSystemPrompt, + string Locale, + IReadOnlyList Skills, + string SandboxCapabilitySummary, + string TaskGraphSummary, + string PreviousSummary, + IReadOnlyList Conversation, + string Request, + IReadOnlyList? Images = null); + +public static class PromptComposer +{ + public static IReadOnlyList Compose(PromptContext context) + { + var messages = new List + { + new("system", context.FixedSystemPrompt), + new("system", context.UserSystemPrompt), + new("system", $"Respond in locale: {context.Locale}."), + new("system", string.Join("\n\n", context.Skills)), + new("system", context.SandboxCapabilitySummary), + new("system", context.TaskGraphSummary), + new("system", context.PreviousSummary) + }; + messages.AddRange(context.Conversation.Select(static item => new OllamaMessage(item.Role, item.Content))); + messages.Add(new OllamaMessage("user", context.Request, context.Images)); + return messages; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs new file mode 100644 index 0000000..c2fa568 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -0,0 +1,165 @@ +using Agent.Core.Ollama; +using Agent.Core.Prompts; +using Agent.Core.Sessions; +using Agent.Core.Tasks; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) +{ + private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( + new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), + new FakeInternalToolBackend()); + private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance; + + public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken) + { + if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; + if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) + { + registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found."); + return; + } + + try + { + await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) + throw new InvalidOperationException(settings.VisionUnsupportedMessage); + var messages = PromptComposer.Compose(new PromptContext( + FixedPrompt.Load(), + settings.UserSystemPrompt, + settings.Locale, + settings.Skills, + settings.SandboxCapabilitySummary, + "Current task: " + task.Request, + string.Empty, + session.Conversation, + task.Request, + task.Images)).ToList(); + var guard = new ToolUseGuard(settings.SameFailureLimit); + bool requiresExternalEffect = RequiresExternalEffect(task.Request); + bool externalEffectEvidence = false; + var unresolvedToolFailures = new Dictionary(StringComparer.Ordinal); + IReadOnlyDictionary requiredToolCalls = RequiredToolCalls(task.Request); + var successfulToolCalls = new Dictionary(StringComparer.Ordinal); + string? finalContent = null; + for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + { + _observer.LlmRequest(taskId, iteration, messages); + OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(taskId, iteration, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + IReadOnlyList calls = result.ToolCalls ?? []; + if (calls.Count > 0) + { + messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); + string? failureFingerprint = null; + foreach (OllamaToolCall call in calls) + { + ToolExecutionResult toolResult; + try + { + toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is ToolValidationException or ArgumentException or UnauthorizedAccessException) + { + toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); + } + externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect; + if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); + else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; + if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1; + _observer.ToolResult(taskId, call.Function.Name, toolResult); + failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; + messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); + } + ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); + if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); + continue; + } + + if (unresolvedToolFailures.Count > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + + string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); + continue; + } + string[] missingCalls = requiredToolCalls + .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value) + .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}") + .ToArray(); + if (missingCalls.Length > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls))); + continue; + } + ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); + if (completion.Decision == ToolUseGuardDecision.Retry) + { + messages.Add(new OllamaMessage("system", completion.Observation)); + continue; + } + if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); + finalContent = result.Content; + break; + } + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); + if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); + session.Append(new ConversationMessage("user", task.Request)); + session.Append(new ConversationMessage("assistant", finalContent)); + registry.Transition(taskId, AgentTaskState.Completed, result: finalContent); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Cancelled, result: result); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); + string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; + registry.Transition(taskId, AgentTaskState.Failed, result: result); + } + } + + private async Task EndTaskSafelyAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + try + { + await _tools.EndTaskAsync(taskId, success, cancellationToken).ConfigureAwait(false); + return null; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + return $"Task resource cleanup failed: {ex.Message}"; + } + } + + private static bool RequiresExternalEffect(string request) + { + string normalized = request.ToLowerInvariant(); + string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"]; + return indicators.Any(normalized.Contains); + } + + private static IReadOnlyDictionary RequiredToolCalls(string request) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (InternalToolDefinition tool in InternalToolCatalog.All) + { + int count = 0; + int offset = 0; + while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + offset += tool.Name.Length; + } + if (count > 0) result[tool.Name] = count; + } + return result; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs new file mode 100644 index 0000000..9bc04b9 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -0,0 +1,10 @@ +namespace Agent.Core.Runtime; + +public sealed record AgentRuntimeSettings( + string UserSystemPrompt, + string Locale, + IReadOnlyList Skills, + string SandboxCapabilitySummary, + int MaxIterations = 128, + int SameFailureLimit = 5, + string VisionUnsupportedMessage = "The selected model does not support image input."); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs new file mode 100644 index 0000000..e4e26e3 --- /dev/null +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -0,0 +1,19 @@ +using Agent.Core.Ollama; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public interface IAgentRuntimeObserver +{ + void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages); + void LlmResponse(Guid taskId, int iteration, OllamaResult result); + void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); +} + +public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver +{ + public static NullAgentRuntimeObserver Instance { get; } = new(); + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages) { } + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } +} diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs new file mode 100644 index 0000000..d561c8d --- /dev/null +++ b/src/Agent.Core/Sessions/AgentSession.cs @@ -0,0 +1,26 @@ +namespace Agent.Core.Sessions; + +public sealed class AgentSession(Guid sessionId) +{ + private readonly object _sync = new(); + private readonly List _conversation = []; + + public Guid SessionId { get; } = sessionId; + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow; + + public IReadOnlyList Conversation + { + get { lock (_sync) return _conversation.ToArray(); } + } + + public void Append(ConversationMessage message) + { + ArgumentNullException.ThrowIfNull(message); + lock (_sync) + { + _conversation.Add(message); + LastAccessAt = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/Agent.Core/Sessions/ConversationMessage.cs b/src/Agent.Core/Sessions/ConversationMessage.cs new file mode 100644 index 0000000..9d48747 --- /dev/null +++ b/src/Agent.Core/Sessions/ConversationMessage.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Sessions; + +public sealed record ConversationMessage(string Role, string Content); diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs new file mode 100644 index 0000000..59ea02c --- /dev/null +++ b/src/Agent.Core/Sessions/SessionStore.cs @@ -0,0 +1,19 @@ +using System.Collections.Concurrent; + +namespace Agent.Core.Sessions; + +public sealed class SessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + + public AgentSession Create() + { + while (true) + { + var session = new AgentSession(Guid.NewGuid()); + if (_sessions.TryAdd(session.SessionId, session)) return session; + } + } + + public bool TryGet(Guid sessionId, out AgentSession? session) => _sessions.TryGetValue(sessionId, out session); +} diff --git a/src/Agent.Core/Tasks/AgentTaskState.cs b/src/Agent.Core/Tasks/AgentTaskState.cs new file mode 100644 index 0000000..927e8d7 --- /dev/null +++ b/src/Agent.Core/Tasks/AgentTaskState.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Tasks; + +public enum AgentTaskState +{ + Queued, + Planning, + Running, + Completed, + Failed, + Cancelled +} diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs new file mode 100644 index 0000000..b61e525 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Agent.Core.Sessions; + +namespace Agent.Core.Tasks; + +public sealed class TaskRegistry +{ + private readonly ConcurrentDictionary _tasks = new(); + private readonly SessionStore _sessions; + private readonly Channel _queue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + public TaskRegistry(SessionStore? sessions = null) => _sessions = sessions ?? new SessionStore(); + + public SessionStore Sessions => _sessions; + + public TaskSnapshot Enqueue(string request, Guid? requestedSessionId, double? temperature, IReadOnlyList? images = null) + { + if (string.IsNullOrWhiteSpace(request)) throw new ArgumentException("Request cannot be empty.", nameof(request)); + if (temperature is < 0 or > 2) throw new ArgumentOutOfRangeException(nameof(temperature)); + Guid sessionId; + if (requestedSessionId is { } existing) + { + if (!_sessions.TryGet(existing, out _)) throw new KeyNotFoundException($"Session '{existing}' was not found."); + sessionId = existing; + } + else + { + sessionId = _sessions.Create().SessionId; + } + + var task = new MutableTask(Guid.NewGuid(), sessionId, request, temperature, images ?? []); + if (!_tasks.TryAdd(task.TaskId, task)) throw new InvalidOperationException("Could not register task."); + if (!_queue.Writer.TryWrite(task.TaskId)) + { + _tasks.TryRemove(task.TaskId, out _); + throw new InvalidOperationException("Task queue is not accepting work."); + } + return task.Snapshot(); + } + + public bool TryGet(Guid taskId, Guid? sessionId, out TaskSnapshot? snapshot) + { + if (_tasks.TryGetValue(taskId, out MutableTask? task) && (sessionId is null || task.SessionId == sessionId)) + { + snapshot = task.Snapshot(); + return true; + } + snapshot = null; + return false; + } + + public IAsyncEnumerable ReadQueuedAsync(CancellationToken cancellationToken) => _queue.Reader.ReadAllAsync(cancellationToken); + + public bool Transition(Guid taskId, AgentTaskState state, string? currentTask = null, string? result = null) + { + return _tasks.TryGetValue(taskId, out MutableTask? task) && task.Transition(state, currentTask, result); + } + + public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + + private sealed class MutableTask(Guid taskId, Guid sessionId, string request, double? temperature, IReadOnlyList images) + { + private readonly object _sync = new(); + private AgentTaskState _state = AgentTaskState.Queued; + private string? _currentTask; + private string? _result; + private long _inputTokens; + private long _outputTokens; + + public Guid TaskId { get; } = taskId; + public Guid SessionId { get; } = sessionId; + + public TaskSnapshot Snapshot() + { + lock (_sync) + { + int completed = _state == AgentTaskState.Completed ? 1 : 0; + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + } + } + + public bool AddUsage(long inputTokens, long outputTokens) + { + if (inputTokens < 0 || outputTokens < 0) return false; + lock (_sync) + { + _inputTokens += inputTokens; + _outputTokens += outputTokens; + return true; + } + } + + public bool Transition(AgentTaskState next, string? currentTask, string? result) + { + lock (_sync) + { + if (!Allowed(_state, next)) return false; + _state = next; + _currentTask = currentTask; + _result = result; + return true; + } + } + + private static bool Allowed(AgentTaskState current, AgentTaskState next) => current switch + { + AgentTaskState.Queued => next is AgentTaskState.Planning or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Planning => next is AgentTaskState.Running or AgentTaskState.Failed or AgentTaskState.Cancelled, + AgentTaskState.Running => next is AgentTaskState.Planning or AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled, + _ => false + }; + } +} diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs new file mode 100644 index 0000000..0a15de3 --- /dev/null +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -0,0 +1,16 @@ +namespace Agent.Core.Tasks; + +public sealed record TaskSnapshot( + Guid TaskId, + Guid SessionId, + AgentTaskState State, + string Request, + double? Temperature, + string? CurrentTask, + string? Result, + int CompletedTasks, + int TotalTasks, + long InputTokens, + long OutputTokens, + double CriticalPathTokens, + IReadOnlyList Images); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs new file mode 100644 index 0000000..1563c16 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolCatalog +{ + private static readonly IReadOnlyDictionary Definitions = CreateDefinitions(); + + public static IReadOnlyList All { get; } = Definitions.Values.ToArray(); + public static InternalToolDefinition Get(string name) => Definitions.TryGetValue(name, out InternalToolDefinition? definition) + ? definition + : throw new ToolValidationException($"Unknown internal tool '{name}'."); + + private static IReadOnlyDictionary CreateDefinitions() + { + InternalToolDefinition[] definitions = + [ + Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""), + Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), + Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""), + Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), + Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}""") + ]; + return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); + } + + private static InternalToolDefinition Define(string name, string description, string schema) + { + using JsonDocument document = JsonDocument.Parse(schema); + return new(name, description, document.RootElement.Clone()); + } +} diff --git a/src/Agent.Core/Tools/InternalToolDefinition.cs b/src/Agent.Core/Tools/InternalToolDefinition.cs new file mode 100644 index 0000000..8f06764 --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDefinition.cs @@ -0,0 +1,5 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record InternalToolDefinition(string Name, string Description, JsonElement Parameters); diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs new file mode 100644 index 0000000..7302c1f --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend) +{ + public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken); + + public async Task DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + InternalToolValidator.Validate(toolName, arguments); + ToolPolicyDecision decision = policy.Evaluate(toolName, arguments); + if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}"); + return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class FakeInternalToolBackend : IInternalToolBackend +{ + private readonly List<(string Name, JsonElement Arguments)> _calls = []; + public IReadOnlyList<(string Name, JsonElement Arguments)> Calls => _calls; + public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + _calls.Add((toolName, arguments.Clone())); + bool effect = toolName is "file_edit" or "process_run"; + return Task.FromResult(new ToolExecutionResult(true, "fake-ok", effect)); + } +} diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs new file mode 100644 index 0000000..c48719e --- /dev/null +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public static class InternalToolValidator +{ + public static void Validate(string name, JsonElement arguments) + { + InternalToolDefinition definition = InternalToolCatalog.Get(name); + if (arguments.ValueKind != JsonValueKind.Object) throw new ToolValidationException("Tool arguments must be an object."); + JsonElement properties = definition.Parameters.GetProperty("properties"); + var known = properties.EnumerateObject().Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); + foreach (JsonProperty argument in arguments.EnumerateObject()) + { + if (!known.Contains(argument.Name)) throw new ToolValidationException($"Unknown argument '{argument.Name}'."); + ValidateValue(argument.Name, argument.Value, properties.GetProperty(argument.Name)); + } + foreach (JsonElement required in definition.Parameters.GetProperty("required").EnumerateArray()) + { + string requiredName = required.GetString()!; + if (!arguments.TryGetProperty(requiredName, out _)) throw new ToolValidationException($"Missing required argument '{requiredName}'."); + } + ValidateOperationSpecific(name, arguments); + } + + private static void ValidateValue(string name, JsonElement value, JsonElement schema) + { + if (value.ValueKind == JsonValueKind.Null && AllowsNull(schema)) return; + bool validType = GetTypes(schema).Any(type => type switch + { + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _), + "array" => value.ValueKind == JsonValueKind.Array, + _ => false + }); + if (!validType) throw new ToolValidationException($"Argument '{name}' has an invalid type."); + if (schema.TryGetProperty("enum", out JsonElement enumValues) && !enumValues.EnumerateArray().Any(item => JsonElement.DeepEquals(item, value))) + throw new ToolValidationException($"Argument '{name}' has an unsupported value."); + if (value.ValueKind == JsonValueKind.Number) + { + long number = value.GetInt64(); + if (schema.TryGetProperty("minimum", out JsonElement minimum) && number < minimum.GetInt64()) throw new ToolValidationException($"Argument '{name}' is below its minimum."); + if (schema.TryGetProperty("maximum", out JsonElement maximum) && number > maximum.GetInt64()) throw new ToolValidationException($"Argument '{name}' exceeds its maximum."); + } + if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out JsonElement items)) + foreach (JsonElement item in value.EnumerateArray()) ValidateValue(name, item, items); + } + + private static IEnumerable GetTypes(JsonElement schema) + { + JsonElement type = schema.GetProperty("type"); + return type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Select(static item => item.GetString()!).ToArray() + : [type.GetString()!]; + } + private static bool AllowsNull(JsonElement schema) => GetTypes(schema).Contains("null", StringComparer.Ordinal); + + private static void ValidateOperationSpecific(string name, JsonElement arguments) + { + if (name == "file_read" && arguments.GetProperty("mode").GetString() == "range" && !arguments.TryGetProperty("length", out _)) + throw new ToolValidationException("Range reads require length."); + if (name == "file_edit") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation is "overwrite" or "atomic_replace" && (!arguments.TryGetProperty("content", out JsonElement content) || content.ValueKind != JsonValueKind.String)) + throw new ToolValidationException($"{operation} requires content."); + if (operation == "patch" && (!arguments.TryGetProperty("patch", out JsonElement patch) || patch.ValueKind != JsonValueKind.String)) + throw new ToolValidationException("patch requires patch text."); + } + if (name == "task_update") + { + string operation = arguments.GetProperty("operation").GetString()!; + if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); + if (operation == "set_state") Require(arguments, "task_id", "state"); + if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); + } + + static void Require(JsonElement value, params string[] properties) + { + foreach (string property in properties) + if (!value.TryGetProperty(property, out JsonElement item) || item.ValueKind == JsonValueKind.Null) + throw new ToolValidationException($"{property} is required for this operation."); + } + } +} diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs new file mode 100644 index 0000000..03daa56 --- /dev/null +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Agent.Planner; + +namespace Agent.Core.Tools; + +public interface ITaskUpdateHandler +{ + void BeginTask(Guid taskId); + ToolExecutionResult Execute(Guid taskId, JsonElement arguments); + void EndTask(Guid taskId); +} + +public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler +{ + private readonly ConcurrentDictionary _graphs = new(); + + public void BeginTask(Guid taskId) + { + if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks))) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + } + + public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) + { + if (!_graphs.TryGetValue(taskId, out TaskGraph? graph)) + throw new InvalidOperationException("TaskGraph scope is not active."); + + string operation = arguments.GetProperty("operation").GetString()!; + switch (operation) + { + case "add": + graph.AddTask( + RequiredString(arguments, "description"), + Budget(arguments), + Estimate(arguments), + Ids(arguments, "depends_on"), + id: OptionalId(arguments, "task_id")); + break; + case "add_dependency": + Guid taskIdValue = RequiredId(arguments, "task_id"); + Guid[] dependencies = Ids(arguments, "depends_on"); + if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); + foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); + break; + case "set_state": + graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); + break; + case "set_estimate": + graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + break; + default: + throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); + } + + string output = JsonSerializer.Serialize(new + { + tasks = graph.Tasks.Select(static task => new + { + task_id = task.Id, + description = task.Description, + depends_on = task.Dependencies, + state = task.State.ToString().ToLowerInvariant(), + expected_tokens = task.ExpectedTokens + }), + critical_path = graph.CriticalPath.TaskIds, + critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, + total_token_budget = graph.TotalTokenBudget + }); + return new ToolExecutionResult(true, output, false); + } + + public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _); + + private static TokenBudgetClass Budget(JsonElement arguments) => + TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); + + private static TokenEstimate Estimate(JsonElement arguments) => new( + RequiredInt64(arguments, "optimistic_tokens"), + RequiredInt64(arguments, "most_likely_tokens"), + RequiredInt64(arguments, "pessimistic_tokens")); + + private static string RequiredString(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()! + : throw new ToolValidationException($"{property} is required for this operation."); + + private static long RequiredInt64(JsonElement arguments, string property) => + arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt64() + : throw new ToolValidationException($"{property} is required for this operation."); + + private static Guid RequiredId(JsonElement arguments, string property) => + OptionalId(arguments, property) ?? throw new ToolValidationException($"{property} must be a UUID for this operation."); + + private static Guid? OptionalId(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) return null; + return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out Guid id) + ? id + : throw new ToolValidationException($"{property} must be a UUID."); + } + + private static Guid[] Ids(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value)) return []; + var ids = new List(); + foreach (JsonElement item in value.EnumerateArray()) + ids.Add(Guid.TryParse(item.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must contain only UUIDs.")); + return [.. ids]; + } + + private static PlannerTaskState ParseState(string state) => state switch + { + "queued" => PlannerTaskState.Queued, + "planning" => PlannerTaskState.Planning, + "running" => PlannerTaskState.Running, + "completed" => PlannerTaskState.Completed, + "failed" => PlannerTaskState.Failed, + "cancelled" => PlannerTaskState.Cancelled, + _ => throw new ToolValidationException($"Unknown task state '{state}'.") + }; +} diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs new file mode 100644 index 0000000..c9f216a --- /dev/null +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace Agent.Core.Tools; + +public sealed record ToolPolicyDecision(bool Allowed, string Reason) +{ + public static ToolPolicyDecision Allow() => new(true, "allowed"); + public static ToolPolicyDecision Deny(string reason) => new(false, reason); +} + +public interface IToolPolicyEngine +{ + ToolPolicyDecision Evaluate(string toolName, JsonElement arguments); +} + +public interface IInternalToolBackend +{ + Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask; + Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken); + Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; +} + +public sealed record ToolExecutionResult(bool Success, string Output, bool ExternalEffect, string? FailureFingerprint = null); + +public sealed class DenyByDefaultPolicy(IReadOnlySet? allowedTools = null) : IToolPolicyEngine +{ + private readonly IReadOnlySet _allowedTools = allowedTools ?? new HashSet(StringComparer.Ordinal); + public ToolPolicyDecision Evaluate(string toolName, JsonElement arguments) => _allowedTools.Contains(toolName) + ? ToolPolicyDecision.Allow() + : ToolPolicyDecision.Deny("Capability is not allowed by policy."); +} diff --git a/src/Agent.Core/Tools/ToolUseGuard.cs b/src/Agent.Core/Tools/ToolUseGuard.cs new file mode 100644 index 0000000..633d34c --- /dev/null +++ b/src/Agent.Core/Tools/ToolUseGuard.cs @@ -0,0 +1,22 @@ +namespace Agent.Core.Tools; + +public enum ToolUseGuardDecision { Complete, Retry, Fail } + +public sealed record ToolUseGuardResult(ToolUseGuardDecision Decision, string Observation, int IdenticalFailureCount); + +public sealed class ToolUseGuard(int sameFailureLimit = 5) +{ + private readonly Dictionary _failures = new(StringComparer.Ordinal); + public int SameFailureLimit { get; } = sameFailureLimit > 0 ? sameFailureLimit : throw new ArgumentOutOfRangeException(nameof(sameFailureLimit)); + + public ToolUseGuardResult Evaluate(bool requiresExternalEffect, bool hadToolCall, bool hasExternalEffectEvidence, string? failureFingerprint = null) + { + if (!requiresExternalEffect) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + if (hasExternalEffectEvidence) return new(ToolUseGuardDecision.Complete, string.Empty, 0); + string fingerprint = string.IsNullOrWhiteSpace(failureFingerprint) ? "missing-external-effect" : failureFingerprint; + int count = _failures.TryGetValue(fingerprint, out int prior) ? prior + 1 : 1; + _failures[fingerprint] = count; + if (count >= SameFailureLimit) return new(ToolUseGuardDecision.Fail, "Required external effect was not completed after repeated identical failures.", count); + return new(ToolUseGuardDecision.Retry, "No verified external effect occurred; perform the required operation using an internal tool.", count); + } +} diff --git a/src/Agent.Core/Tools/ToolValidationException.cs b/src/Agent.Core/Tools/ToolValidationException.cs new file mode 100644 index 0000000..a99472c --- /dev/null +++ b/src/Agent.Core/Tools/ToolValidationException.cs @@ -0,0 +1,3 @@ +namespace Agent.Core.Tools; + +public sealed class ToolValidationException(string message) : InvalidOperationException(message); -- cgit v1.2.3