summaryrefslogtreecommitdiff
path: root/src/Agent.Core
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Core
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
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まで生成可能にした。
Diffstat (limited to 'src/Agent.Core')
-rw-r--r--src/Agent.Core/Agent.Core.csproj8
-rw-r--r--src/Agent.Core/AssemblyMarker.cs3
-rw-r--r--src/Agent.Core/Localization/LanguageContext.cs3
-rw-r--r--src/Agent.Core/Localization/LocaleCatalog.cs97
-rw-r--r--src/Agent.Core/Localization/LocaleException.cs4
-rw-r--r--src/Agent.Core/Ollama/IOllamaClient.cs11
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs92
-rw-r--r--src/Agent.Core/Ollama/OllamaException.cs7
-rw-r--r--src/Agent.Core/Ollama/OllamaModels.cs46
-rw-r--r--src/Agent.Core/Ollama/OllamaSettings.cs10
-rw-r--r--src/Agent.Core/Prompts/FixedPrompt.cs16
-rw-r--r--src/Agent.Core/Prompts/FixedSystemPrompt.md4
-rw-r--r--src/Agent.Core/Prompts/PromptComposer.cs36
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs165
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs10
-rw-r--r--src/Agent.Core/Runtime/IAgentRuntimeObserver.cs19
-rw-r--r--src/Agent.Core/Sessions/AgentSession.cs26
-rw-r--r--src/Agent.Core/Sessions/ConversationMessage.cs3
-rw-r--r--src/Agent.Core/Sessions/SessionStore.cs19
-rw-r--r--src/Agent.Core/Tasks/AgentTaskState.cs11
-rw-r--r--src/Agent.Core/Tasks/TaskRegistry.cs115
-rw-r--r--src/Agent.Core/Tasks/TaskSnapshot.cs16
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs32
-rw-r--r--src/Agent.Core/Tools/InternalToolDefinition.cs5
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs29
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs86
-rw-r--r--src/Agent.Core/Tools/TaskGraphToolHandler.cs123
-rw-r--r--src/Agent.Core/Tools/ToolPolicy.cs31
-rw-r--r--src/Agent.Core/Tools/ToolUseGuard.cs22
-rw-r--r--src/Agent.Core/Tools/ToolValidationException.cs3
30 files changed, 1052 insertions, 0 deletions
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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <ItemGroup>
+ <ProjectReference Include="..\Agent.Planner\Agent.Planner.csproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <EmbeddedResource Include="Prompts\FixedSystemPrompt.md" />
+ </ItemGroup>
+</Project>
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<string, IReadOnlyDictionary<string, string>> _locales;
+ private readonly IReadOnlyDictionary<string, string> _fallback;
+
+ private LocaleCatalog(string selectedLocale, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> locales, IReadOnlyDictionary<string, string> fallback)
+ {
+ SelectedLocale = selectedLocale;
+ _locales = locales;
+ _fallback = fallback;
+ }
+
+ public string SelectedLocale { get; }
+ public IReadOnlyCollection<string> 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<string, IReadOnlyDictionary<string, string>>(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<string, string>? fallback))
+ throw new LocaleException($"Fallback locale '{fallbackLocale}' is missing.");
+
+ HashSet<string> baselineKeys = fallback.Keys.ToHashSet(StringComparer.Ordinal);
+ Dictionary<string, HashSet<string>> baselinePlaceholders = fallback.ToDictionary(static p => p.Key, static p => Placeholders(p.Value), StringComparer.Ordinal);
+ foreach ((string locale, IReadOnlyDictionary<string, string> 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<string, object?> 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<string, string> 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<string, string>(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<string> 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<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken);
+ Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) =>
+ ChatAsync(messages, temperatureOverride, cancellationToken);
+ Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(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<TimeSpan, CancellationToken, Task> _delay;
+ private bool? _visionCapability;
+
+ public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func<TimeSpan, CancellationToken, Task>? delay = null)
+ {
+ _httpClient = httpClient;
+ _httpClient.BaseAddress = settings.Endpoint;
+ _settings = settings;
+ _delay = delay ?? Task.Delay;
+ }
+
+ public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, null, cancellationToken).ConfigureAwait(false);
+
+ public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, tools, cancellationToken).ConfigureAwait(false);
+
+ public async Task<bool?> 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<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, CancellationToken cancellationToken)
+ {
+ Dictionary<string, JsonElement> 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<OllamaChatResponse>(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<string>? Images = null,
+ [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null,
+ [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? 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<OllamaMessage> 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<string, System.Text.Json.JsonElement> Options { get; init; }
+ [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList<OllamaToolDefinition>? 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<OllamaToolCall>? 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<string, JsonElement> 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<string> Skills,
+ string SandboxCapabilitySummary,
+ string TaskGraphSummary,
+ string PreviousSummary,
+ IReadOnlyList<ConversationMessage> Conversation,
+ string Request,
+ IReadOnlyList<string>? Images = null);
+
+public static class PromptComposer
+{
+ public static IReadOnlyList<OllamaMessage> Compose(PromptContext context)
+ {
+ var messages = new List<OllamaMessage>
+ {
+ 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<string, string>(StringComparer.Ordinal);
+ IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request);
+ var successfulToolCalls = new Dictionary<string, int>(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<OllamaToolCall> 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<string?> 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<string, int> RequiredToolCalls(string request)
+ {
+ var result = new Dictionary<string, int>(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<string> 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<OllamaMessage> 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<OllamaMessage> 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<ConversationMessage> _conversation = [];
+
+ public Guid SessionId { get; } = sessionId;
+ public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow;
+
+ public IReadOnlyList<ConversationMessage> 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<Guid, AgentSession> _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<Guid, MutableTask> _tasks = new();
+ private readonly SessionStore _sessions;
+ private readonly Channel<Guid> _queue = Channel.CreateUnbounded<Guid>(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<string>? 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<Guid> 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<string> 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<string> 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<string, InternalToolDefinition> Definitions = CreateDefinitions();
+
+ public static IReadOnlyList<InternalToolDefinition> 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<string, InternalToolDefinition> 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<ToolExecutionResult> 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<ToolExecutionResult> 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<string> 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<Guid, TaskGraph> _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<Guid>();
+ 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<ToolExecutionResult> 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<string>? allowedTools = null) : IToolPolicyEngine
+{
+ private readonly IReadOnlySet<string> _allowedTools = allowedTools ?? new HashSet<string>(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<string, int> _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);