From 6c3458a5ec37a5b69957ad17c84de58984698f25 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 11 Aug 2026 21:44:53 +0900 Subject: Audit and align v1 runtime design --- src/Agent.App/Cli/CommandLine.cs | 6 +- src/Agent.App/Configuration/ConfigurationLoader.cs | 69 ++++++- src/Agent.App/Logging/JsonLinesRuntimeObserver.cs | 46 +++-- src/Agent.App/Program.cs | 23 ++- src/Agent.Core/Ollama/IOllamaClient.cs | 2 + src/Agent.Core/Ollama/OllamaClient.cs | 13 +- src/Agent.Core/Runtime/AgentRuntime.cs | 228 ++++++++++++++++----- src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 4 +- src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 17 +- src/Agent.Core/Runtime/PlannerService.cs | 177 +++++++--------- src/Agent.Core/Tasks/TaskRegistry.cs | 55 ++++- src/Agent.Core/Tasks/TaskSnapshot.cs | 6 +- src/Agent.Core/Tools/InternalToolCatalog.cs | 2 +- src/Agent.Core/Tools/InternalToolValidator.cs | 1 - src/Agent.Core/Tools/TaskGraphToolHandler.cs | 25 +-- src/Agent.Mcp/AgentTools.cs | 6 +- src/Agent.Mcp/McpHost.cs | 4 +- src/Agent.Windows/Network/TaskNetworkPolicy.cs | 18 +- src/Agent.Windows/Recovery/RecoveryManager.cs | 3 +- src/Agent.Windows/Security/ExecutablePolicy.cs | 4 +- src/Agent.Windows/Security/PathPolicy.cs | 9 +- .../Security/RestrictedProcessLauncher.cs | 15 +- src/Agent.Windows/Security/SecurityAudit.cs | 6 +- src/Agent.Windows/Tools/WindowsToolBackend.cs | 46 ++++- 24 files changed, 545 insertions(+), 240 deletions(-) (limited to 'src') diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs index df65080..3fdac9f 100644 --- a/src/Agent.App/Cli/CommandLine.cs +++ b/src/Agent.App/Cli/CommandLine.cs @@ -6,8 +6,8 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? { public static CommandLine Parse(string[] args) { - if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'."); - AppCommand command = args[0] switch + bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal); + AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch { "serve" => AppCommand.Serve, "check" => AppCommand.Check, @@ -17,7 +17,7 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? string? config = null; string? language = null; string? transport = null; - for (int i = 1; i < args.Length; i++) + for (int i = implicitServe ? 0 : 1; i < args.Length; i++) { string option = args[i]; if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'."); diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index 298a143..efb3db1 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.RegularExpressions; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.RepresentationModel; namespace Agent.App.Configuration; @@ -17,10 +18,14 @@ public static partial class ConfigurationLoader try { + string yaml = File.ReadAllText(absolutePath); + string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml"); + if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath)) + yaml = MergeYaml(yaml, File.ReadAllText(localPath)); var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); - AgentConfiguration configuration = deserializer.Deserialize(File.ReadAllText(absolutePath)) + AgentConfiguration configuration = deserializer.Deserialize(yaml) ?? throw new ConfigurationException("Configuration document is empty."); Validate(configuration, Path.GetDirectoryName(absolutePath)!); return configuration; @@ -60,6 +65,16 @@ public static partial class ConfigurationLoader foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals) { if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters."); + string approvalPath = Path.GetFullPath(approval.Path); + bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath)); + if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute."); + } + foreach (string executeEntry in value.Sandbox.Filesystem.Execute) + { + string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry); + if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval => + string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase))) + Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals."); } if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); @@ -81,6 +96,58 @@ public static partial class ConfigurationLoader } public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); + + private static bool ContainsPath(string baseDirectory, string configuredPath, string target) + { + string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath); + if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true; + string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static string MergeYaml(string baseYaml, string localYaml) + { + YamlNode baseRoot = LoadRoot(baseYaml, "base configuration"); + YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml"); + YamlNode merged = MergeNode(baseRoot, localRoot); + var stream = new YamlStream(new YamlDocument(merged)); + using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + stream.Save(writer, assignAnchors: false); + return writer.ToString(); + } + + private static YamlNode LoadRoot(string yaml, string source) + { + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document."); + return stream.Documents[0].RootNode; + } + + private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode) + { + if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap) + return Clone(localNode); + + var result = (YamlMappingNode)Clone(baseMap); + foreach ((YamlNode key, YamlNode localValue) in localMap.Children) + { + YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal)); + if (existingKey is null) result.Add(Clone(key), Clone(localValue)); + else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue); + } + return result; + } + + private static YamlNode Clone(YamlNode node) + { + var stream = new YamlStream(new YamlDocument(node)); + using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + stream.Save(writer, assignAnchors: false); + return LoadRoot(writer.ToString(), "merged configuration"); + } + private static void Throw(string message) => throw new ConfigurationException(message); [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] private static partial Regex Sha256Regex(); diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs index 20ef88d..78ad47b 100644 --- a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs +++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs @@ -5,33 +5,51 @@ using Agent.Core.Tools; namespace Agent.App.Logging; -public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver +public sealed class JsonLinesRuntimeObserver : IAgentRuntimeObserver, IDisposable { - private readonly object _sync = new(); - private readonly string _path = Path.Combine(logDirectory, "llm.jsonl"); + private readonly RollingFileWriter _writer; - public void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages) => Write(new + public JsonLinesRuntimeObserver(string logDirectory, long maxBytes = 10 * 1024 * 1024, int retainedFiles = 5) => + _writer = new RollingFileWriter(Path.Combine(logDirectory, "llm.jsonl"), maxBytes, retainedFiles); + + public void LlmRequest(AgentLlmCallContext context, IReadOnlyList messages) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration, - messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName }) + timestamp = DateTimeOffset.UtcNow, event_name = "llm.request", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + messages = messages.Select(static message => new { role = message.Role, content = message.Content, image_count = message.Images?.Count ?? 0, tool_name = message.ToolName }) }); - public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new + public void LlmResponse(AgentLlmCallContext context, OllamaResult result) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration, - result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, + timestamp = DateTimeOffset.UtcNow, event_name = "llm.response", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + content = result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? [] }); - public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new + public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName, - result.Success, result.ExternalEffect, result.RetryAllowed, result.Output, result.FailureFingerprint + timestamp = DateTimeOffset.UtcNow, event_name = "llm.failure", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + error_code = errorCode, message + }); + + public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, event_name = "llm.tool_result", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, tool_name = toolName, + success = result.Success, external_effect = result.ExternalEffect, retry_allowed = result.RetryAllowed, + output = result.Output, failure_fingerprint = result.FailureFingerprint }); private void Write(object value) { - string line = JsonSerializer.Serialize(value); - lock (_sync) File.AppendAllText(_path, line + Environment.NewLine); + _writer.WriteLine(JsonSerializer.Serialize(value)); } + + public void Dispose() => _writer.Dispose(); } diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index 1c29ec3..6f3485f 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -76,7 +76,7 @@ internal static class Program var sessionStore = new SessionStore(); InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore); - var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory); + using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles); string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks"); UserOnlyDirectory.Ensure(taskStateDirectory); var taskStoreSettings = new McpTaskStoreSettings(taskStateDirectory, @@ -88,7 +88,7 @@ internal static class Program string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); var skills = SkillLoader.Load(skillDirectory); - var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale); var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false); @@ -99,7 +99,7 @@ internal static class Program string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); var skills = SkillLoader.Load(skillDirectory); - var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); + AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale); var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver, @@ -143,7 +143,8 @@ internal static class Program configuration.Sandbox.Network.Mode, configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList)item.Ports)).ToArray(), journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), - configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines); + configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines, + configuration.Sandbox.Resources.ProcessTimeoutSeconds); var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore)); var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend); @@ -153,4 +154,18 @@ internal static class Program foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability)); } } + + private static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language, + IReadOnlyList skills, LocaleCatalog locale) => new( + userPrompt, + language, + skills, + "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", + configuration.Agent.MaxIterations, + configuration.Agent.SameFailureLimit, + locale.Get("ollama.vision_unsupported"), + configuration.Ollama.Options.NumCtx ?? 32768, + configuration.Limits.ContextSummaryRatio, + MaxTasks: configuration.Planner.MaxTasks, + Model: configuration.Ollama.Model); } diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs index d1cb99f..88a2bec 100644 --- a/src/Agent.Core/Ollama/IOllamaClient.cs +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -10,5 +10,7 @@ public interface IOllamaClient ChatAsync(messages, temperatureOverride, cancellationToken); Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => ChatAsync(messages, temperatureOverride, cancellationToken); + Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) => + ChatStructuredAsync(messages, temperatureOverride, format, cancellationToken); Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(null); } diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index dde8478..428bca9 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -22,13 +22,16 @@ public sealed class OllamaClient : IOllamaClient } public async Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, null, null, cancellationToken).ConfigureAwait(false); public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, tools, null, null, cancellationToken).ConfigureAwait(false); public async Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, format, null, cancellationToken).ConfigureAwait(false); + + public async Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, format, thinkOverride, cancellationToken).ConfigureAwait(false); public async Task SupportsVisionAsync(CancellationToken cancellationToken) { @@ -45,7 +48,7 @@ public sealed class OllamaClient : IOllamaClient catch (HttpRequestException) { return null; } } - private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, JsonElement? format, CancellationToken cancellationToken) + private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, JsonElement? format, bool? thinkOverride, 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); @@ -54,7 +57,7 @@ public sealed class OllamaClient : IOllamaClient Model = _settings.Model, Messages = messages, Stream = false, - Think = _settings.Think, + Think = thinkOverride ?? _settings.Think, KeepAlive = _settings.KeepAlive, Options = options, Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(), diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 0b261da..8cf1ddb 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Agent.Core.Ollama; +using Agent.Core.Localization; using Agent.Core.Prompts; using Agent.Core.Sessions; using Agent.Core.Skills; @@ -9,7 +10,7 @@ using Agent.Planner; namespace Agent.Core.Runtime; -public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null) +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null) { private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), @@ -28,12 +29,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag 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."); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found.")); return; } try { + var counters = new ExecutionCounters(); // Vision capability check before any Ollama call. if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) throw new InvalidOperationException(settings.VisionUnsupportedMessage); @@ -42,29 +44,42 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag TaskGraph graph; try { - graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false); + graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message)); return; } // Phase 2: Running — execute ready Tasks sequentially. registry.Transition(taskId, AgentTaskState.Running, "running"); - await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false); + await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false); } 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); + if (cleanupFailure is null) + { + registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled.")); + } + else + { + registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure); + registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure); + } } 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.TryGet(taskId, null, out TaskSnapshot? failedSnapshot); + string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed"; + string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result; + registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage); registry.Transition(taskId, AgentTaskState.Failed, result: result); } } @@ -74,48 +89,61 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop. /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase. /// - private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken) + private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken) { // Apply Sandbox Task Resource at RunAsync time. await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false); var finalContents = new List(); - int replanCount = 0; const int maxReplans = 3; + var denyFingerprints = new HashSet(StringComparer.Ordinal); try { + UpdateTaskProgress(taskId, graph, null, counters); // Execute tasks in ready order: dependencies satisfied → critical path → creation order. while (true) { PlannerTask? next = SelectNextReadyTask(graph); - if (next is null) break; + if (next is null) + { + if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break; + throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task."); + } graph.SetState(next.Id, PlannerTaskState.Running); + UpdateTaskProgress(taskId, graph, next.Description, counters); try { - string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false); + string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false); graph.SetState(next.Id, PlannerTaskState.Completed); finalContents.Add(content); + UpdateTaskProgress(taskId, graph, null, counters); } - catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans) + catch (PolicyDeniedException ex) { - // Policy deny → replan possible (Design §8.4). - replanCount++; graph.SetState(next.Id, PlannerTaskState.Failed); - graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false); - // Transition back to Running for the replan iteration. - registry.Transition(taskId, AgentTaskState.Running, "replan"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message); + if (!denyFingerprints.Add(ex.Fingerprint)) + throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}"); + if (counters.Replans >= maxReplans) + throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}"); + + string previousSignature = GraphSignature(graph); + counters.Replans++; + registry.Transition(taskId, AgentTaskState.Planning, "replan"); + graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); + if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal)) + throw new InvalidOperationException("Replan did not change the incomplete TaskGraph."); + if (!registry.Transition(taskId, AgentTaskState.Running, "replan")) + throw new InvalidOperationException("Task state could not return to running after replan."); + UpdateTaskProgress(taskId, graph, null, counters); continue; } } - // All tasks completed — assemble final result. - // If replan was attempted but no tasks could execute, provide a meaningful message. - string finalContent = finalContents.Count > 0 - ? string.Join("\n\n", finalContents) - : replanCount > 0 - ? "The request could not be completed because the sandbox policy denied required operations." - : "No tasks were executed."; + if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result."); + string finalContent = string.Join("\n\n", finalContents); 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)); @@ -136,7 +164,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag private static PlannerTask? SelectNextReadyTask(TaskGraph graph) { var completedIds = graph.Tasks - .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed) + .Where(static t => t.State == PlannerTaskState.Completed) .Select(static t => t.Id) .ToHashSet(); @@ -159,10 +187,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Executes a single PlannerTask: LLM chat-with-tools iteration loop. /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard. /// - private async Task ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken) + private async Task ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, + ExecutionCounters counters, CancellationToken cancellationToken) { // Progressive Disclosure Stage 2: LLM Skill Selection. - IReadOnlyList selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); + IReadOnlyList selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false); var messages = PromptComposer.Compose(new PromptContext( FixedPrompt.Load(), @@ -183,11 +212,24 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var unresolvedToolFailures = new Dictionary(StringComparer.Ordinal); string? finalContent = null; - for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + while (counters.Iterations < settings.MaxIterations) { - _observer.LlmRequest(taskId, iteration, messages); - OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); - _observer.LlmResponse(taskId, iteration, result); + int iteration = counters.Iterations++; + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id, + "running", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result; + try + { + result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message); + throw; + } + _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); // Record usage in TaskGraph for CPM tracking. @@ -195,7 +237,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Context Summary: compact old conversation/results when context utilization exceeds threshold. // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens). - TryContextSummary(session, result.PromptEvalCount); + await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false); IReadOnlyList calls = result.ToolCalls ?? []; if (calls.Count > 0) @@ -227,9 +269,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Record ExecutionResult in session for context summary. session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output)); - _observer.ToolResult(taskId, call.Function.Name, toolResult); + _observer.ToolResult(callContext, call.Function.Name, toolResult); if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool) - throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}"); + throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output); failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } @@ -256,7 +298,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag break; } - if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}."); return finalContent; } @@ -283,20 +325,53 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens. /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials. /// - private void TryContextSummary(AgentSession session, long promptEvalCount) + private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount, + ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.NumCtx <= 0) return; double utilization = (double)promptEvalCount / settings.NumCtx; if (utilization < settings.ContextSummaryThreshold) return; - // Compact old conversation entries, keeping only the most recent N. - // The summary itself is generated by the LLM in a separate call (future enhancement); - // for now, we compact the active context set to stay within num_ctx. - session.CompactConversation(settings.ConversationKeepLastN); - session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + string source = JsonSerializer.Serialize(new + { + previous_summary = session.Summary, + conversation = session.Conversation, + execution_results = session.ExecutionResults + }); + var messages = new List + { + new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."), + new("user", source) + }; + using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}"""); + AgentLlmCallContext? callContext = null; + try + { + if (counters.Iterations >= settings.MaxIterations) return; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null, + "context_summary", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + using JsonDocument response = JsonDocument.Parse(result.Content); + string? summary = response.RootElement.GetProperty("summary").GetString(); + if (string.IsNullOrWhiteSpace(summary)) return; + session.SetSummary(summary); + session.CompactConversation(settings.ConversationKeepLastN); + session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message); + } } - private async Task> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.Skills.Count == 0) return []; @@ -304,25 +379,86 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var selectionMessages = new List { new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."), - new("user", request) + new("user", task.Request) }; string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); OllamaResult result; + AgentLlmCallContext? callContext = null; try { - result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + if (counters.Iterations >= settings.MaxIterations) return []; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + "skill_selection", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, selectionMessages); + result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message); return []; } return ParseSelectedSkills(result.Content); } + private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase, + IReadOnlyList messages, OllamaResult? result, Exception? error) + { + if (counters.Iterations >= settings.MaxIterations) + throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}."); + counters.Iterations++; + int iteration = counters.Iterations - 1; + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + phase, iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + if (result is not null) + { + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + } + if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); + } + + private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters) + { + int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed); + registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + } + + private static string GraphSignature(TaskGraph graph) + { + IReadOnlyDictionary descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description); + return string.Join("\n", graph.Tasks + .Where(static item => item.State != PlannerTaskState.Completed) + .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}")); + } + + private sealed class ExecutionCounters + { + public int Iterations { get; set; } + public int Replans { get; set; } + } + + private string Message(string key, string fallback, string? detail = null) + { + if (locale is null) return fallback; + return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary { ["message"] = detail }); + } + + private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message) + { + public string Fingerprint { get; } = fingerprint; + } + private IReadOnlyList ParseSelectedSkills(string content) { try @@ -364,4 +500,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag return $"Task resource cleanup failed: {ex.Message}"; } } -} \ No newline at end of file +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs index 873c571..b6d8504 100644 --- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -13,4 +13,6 @@ public sealed record AgentRuntimeSettings( long NumCtx = 32768, double ContextSummaryThreshold = 0.80, int ConversationKeepLastN = 6, - int ExecutionResultsKeepLastN = 10); + int ExecutionResultsKeepLastN = 10, + int MaxTasks = 32, + string Model = ""); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs index e4e26e3..5098a92 100644 --- a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -5,15 +5,20 @@ 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); + void LlmRequest(AgentLlmCallContext context, IReadOnlyList messages); + void LlmResponse(AgentLlmCallContext context, OllamaResult result); + void LlmFailure(AgentLlmCallContext context, string errorCode, string message); + void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result); } +public sealed record AgentLlmCallContext(Guid CallId, Guid SessionId, Guid TaskId, Guid? PlannerTaskId, + string Phase, int Iteration, int Replan, string Model); + 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) { } + public void LlmRequest(AgentLlmCallContext context, IReadOnlyList messages) { } + public void LlmResponse(AgentLlmCallContext context, OllamaResult result) { } + public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) { } + public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) { } } diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index bf896f0..99569e4 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -23,10 +23,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// Generates an initial TaskGraph for the given request via Ollama structured output. /// Returns a TaskGraph stored in the session, keyed by the MCP Task ID. /// - public async Task PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList? images, CancellationToken cancellationToken) + public async Task PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList? images, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { // Build a Planning prompt with skill catalog and sandbox capability summary. - IReadOnlyList selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false); + IReadOnlyList selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { @@ -39,39 +40,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se new("user", request, images) }; - // Use structured output to get a plan. - OllamaResult result; - try - { - result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false); - } - catch - { - // Fallback: single-task plan with conservative estimates. - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - PlanResult? plan = ParsePlan(result.Content); - if (plan is null || plan.Tasks.Count == 0) - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - TaskGraph graph; - try - { - graph = BuildTaskGraph(plan, request); - } - catch - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } + TaskGraph graph = await RequestValidGraphAsync(messages, temperature, "planning", observeAttempt, + plan => BuildTaskGraph(plan, request), cancellationToken).ConfigureAwait(false); session.TaskGraphs[taskId] = graph; return graph; } @@ -80,10 +50,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph. /// Completed tasks are immutable; only incomplete tasks are modified. /// - public async Task ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken) + public async Task ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing)) - return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false); + return await PlanAsync(taskId, sessionId, session, request, temperature, null, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { @@ -95,26 +66,15 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se new("user", request) }; - OllamaResult result; - try - { - result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false); - } - catch - { - return existing; - } - - PlanResult? plan = ParsePlan(result.Content); - if (plan is null || plan.Tasks.Count == 0) return existing; - - // Build a new graph, preserving completed tasks from the existing graph. - return RebuildTaskGraph(existing, plan, request); + TaskGraph rebuilt = await RequestValidGraphAsync(messages, temperature, "replanning", observeAttempt, + plan => RebuildTaskGraph(existing, plan, request), cancellationToken).ConfigureAwait(false); + session.TaskGraphs[taskId] = rebuilt; + return rebuilt; } private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest) { - var graph = new TaskGraph(32); + var graph = new TaskGraph(_settings.MaxTasks); var idMap = new List(); for (int i = 0; i < plan.Tasks.Count; i++) @@ -127,81 +87,65 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se var dependencies = new HashSet(); foreach (int depIdx in pt.Dependencies) { - if (depIdx >= 0 && depIdx < idMap.Count) - dependencies.Add(idMap[depIdx]); + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + dependencies.Add(idMap[depIdx]); } string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description; - try - { - PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); - idMap.Add(task.Id); - } - catch (PlannerValidationException) - { - // If a task fails validation (e.g., bad estimate, cycle), fall back to single-task graph. - return CreateFallbackGraph(originalRequest); - } + PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); + idMap.Add(task.Id); } - // If all tasks were filtered out, fall back. - if (graph.Count == 0) return CreateFallbackGraph(originalRequest); + if (graph.Count == 0) throw new PlannerValidationException("Structured plan did not contain an executable Task."); return graph; } private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest) { - // Collect completed task descriptions to skip them in the new graph. - var completedDescriptions = new HashSet( - existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description), - StringComparer.Ordinal); - - var graph = new TaskGraph(32); + PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray(); + var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + var graph = new TaskGraph(_settings.MaxTasks); + foreach (PlannerTask item in completed) + { + Guid[] dependencies = item.Dependencies.Where(id => completed.Any(candidate => candidate.Id == id)).ToArray(); + PlannerTask copy = graph.AddTask(item.Description, item.BudgetClass, item.Estimate, dependencies, item.RequiresExternalEffect, item.Id); + graph.AddUsage(copy.Id, item.ActualInputTokens, item.ActualOutputTokens); + graph.SetState(copy.Id, PlannerTaskState.Completed); + } var idMap = new List(); + var reusedCompleted = new HashSet(); for (int i = 0; i < plan.Tasks.Count; i++) { PlannedTask pt = plan.Tasks[i]; // Skip tasks that are already completed in the existing graph. - if (completedDescriptions.Contains(pt.Description)) + if (completedByDescription.TryGetValue(pt.Description, out PlannerTask? completedTask) && reusedCompleted.Add(completedTask.Id)) { - // Find the completed task and add it as completed to the new graph. - PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description); - if (completedTask is not null) - { - TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass); - TokenEstimate estimate = CreateEstimate(pt, budgetClass); - var deps = new HashSet(); - foreach (int depIdx in pt.Dependencies) - if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]); - PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect); - graph.SetState(added.Id, PlannerTaskState.Completed); - idMap.Add(added.Id); - continue; - } + idMap.Add(completedTask.Id); + continue; } TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass); TokenEstimate est = CreateEstimate(pt, bc); var dependencies = new HashSet(); foreach (int depIdx in pt.Dependencies) - if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]); + { + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + dependencies.Add(idMap[depIdx]); + } string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description; PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect); idMap.Add(task.Id); } - return graph; - } - - private TaskGraph CreateFallbackGraph(string request) - { - var graph = new TaskGraph(32); - TokenEstimate estimate = new(512, 2048, 8192); - graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false); + if (graph.Tasks.All(static task => task.State == PlannerTaskState.Completed)) + throw new PlannerValidationException("Replan did not produce an incomplete executable Task."); return graph; } @@ -238,7 +182,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se } } - private async Task> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task> SelectSkillsAsync(string request, double? temperature, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (_settings.Skills.Count == 0) return []; @@ -254,15 +199,45 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se try { - OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + observeAttempt?.Invoke("skill_selection", selectionMessages, result, null); return ParseSelectedSkills(result.Content); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + observeAttempt?.Invoke("skill_selection", selectionMessages, null, ex); return []; } } + private async Task RequestValidGraphAsync(IReadOnlyList messages, double? temperature, string phase, + Action, OllamaResult?, Exception?>? observeAttempt, Func buildGraph, CancellationToken cancellationToken) + { + Exception? firstFailure = null; + for (int attempt = 0; attempt < 2; attempt++) + { + OllamaResult? result = null; + try + { + bool? thinkOverride = attempt == 0 ? null : false; + result = await _ollama.ChatStructuredAsync(messages, temperature, PlanSchema.Element, thinkOverride, cancellationToken).ConfigureAwait(false); + PlanResult? plan = ParsePlan(result.Content); + if (plan is null || plan.Tasks.Count == 0) + throw new PlannerValidationException("Ollama returned an empty or invalid structured plan."); + TaskGraph graph = buildGraph(plan); + observeAttempt?.Invoke(phase, messages, result, null); + return graph; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + observeAttempt?.Invoke(phase, messages, result, ex); + firstFailure ??= ex; + } + } + throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {firstFailure?.Message}"); + } + private IReadOnlyList ParseSelectedSkills(string content) { try @@ -331,4 +306,4 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se sb.AppendLine("Return a complete revised plan including completed tasks."); return sb.ToString(); } -} \ No newline at end of file +} diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs index b7a4afb..a89be6b 100644 --- a/src/Agent.Core/Tasks/TaskRegistry.cs +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -60,6 +60,12 @@ public sealed class TaskRegistry public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + public bool UpdateProgress(Guid taskId, int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateProgress(completedTasks, totalTasks, currentTask, criticalPathTokens); + + public bool UpdateDiagnostics(Guid taskId, int? iterations, int? replans, string? errorCode = null, string? error = null) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateDiagnostics(iterations, replans, errorCode, error); + public CancellationToken GetCancellationToken(Guid taskId) => _tasks.TryGetValue(taskId, out MutableTask? task) ? task.CancellationToken : CancellationToken.None; @@ -81,6 +87,13 @@ public sealed class TaskRegistry private string? _result; private long _inputTokens; private long _outputTokens; + private int _completedTasks; + private int _totalTasks = 1; + private double _criticalPathTokens; + private int _iterations; + private int _replans; + private string? _lastErrorCode; + private string? _lastError; private readonly CancellationTokenSource _cancellation = new(); private readonly TaskCompletionSource _terminal = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -93,8 +106,9 @@ public sealed class TaskRegistry { lock (_sync) { - int completed = _state == AgentTaskState.Completed ? 1 : 0; - return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, + _completedTasks, _totalTasks, _inputTokens, _outputTokens, _criticalPathTokens, images, + _iterations, _replans, _lastErrorCode, _lastError); } } @@ -122,16 +136,45 @@ public sealed class TaskRegistry } } + public bool UpdateProgress(int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) + { + if (completedTasks < 0 || totalTasks < 0 || completedTasks > totalTasks || criticalPathTokens < 0) return false; + lock (_sync) + { + _completedTasks = completedTasks; + _totalTasks = totalTasks; + _currentTask = currentTask; + _criticalPathTokens = criticalPathTokens; + return true; + } + } + + public bool UpdateDiagnostics(int? iterations, int? replans, string? errorCode, string? error) + { + if (iterations < 0 || replans < 0) return false; + lock (_sync) + { + if (iterations.HasValue) _iterations = iterations.Value; + if (replans.HasValue) _replans = replans.Value; + if (errorCode is not null) _lastErrorCode = errorCode; + if (error is not null) _lastError = error; + return true; + } + } + public bool Cancel() { lock (_sync) { if (_state is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) return false; _cancellation.Cancel(); - _state = AgentTaskState.Cancelled; - _currentTask = null; - _result = "cancelled"; - _terminal.TrySetResult(); + if (_state == AgentTaskState.Queued) + { + _state = AgentTaskState.Cancelled; + _currentTask = null; + _result = "cancelled"; + _terminal.TrySetResult(); + } return true; } } diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs index 0a15de3..64844a8 100644 --- a/src/Agent.Core/Tasks/TaskSnapshot.cs +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -13,4 +13,8 @@ public sealed record TaskSnapshot( long InputTokens, long OutputTokens, double CriticalPathTokens, - IReadOnlyList Images); + IReadOnlyList Images, + int Iterations = 0, + int Replans = 0, + string? LastErrorCode = null, + string? LastError = null); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs index a92a829..d261269 100644 --- a/src/Agent.Core/Tools/InternalToolCatalog.cs +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -19,7 +19,7 @@ public static class InternalToolCatalog 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","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"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]},"requires_external_effect":{"type":["boolean","null"]}}}"""), + Define("task_update", "Update the deterministic task graph structure or estimates.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","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},"requires_external_effect":{"type":["boolean","null"]}}}"""), Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""") ]; return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs index d789dbf..57f58b2 100644 --- a/src/Agent.Core/Tools/InternalToolValidator.cs +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -74,7 +74,6 @@ public static class InternalToolValidator 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"); } // dir: path and operation are already required by the schema; no operation-specific diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs index 10094d3..26d5a27 100644 --- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -56,15 +56,15 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio break; case "add_dependency": Guid taskIdValue = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, taskIdValue); 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)); + Guid estimateTaskId = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, estimateTaskId); + graph.SetEstimate(estimateTaskId, Budget(arguments), Estimate(arguments)); break; default: throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); @@ -114,6 +114,13 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio return value.ValueKind == JsonValueKind.True; } + private static void DemandStructuralMutation(TaskGraph graph, Guid taskId) + { + PlannerTaskState state = graph.Get(taskId).State; + if (state is not (PlannerTaskState.Queued or PlannerTaskState.Planning)) + throw new PlannerValidationException($"TaskGraph structure or estimate cannot be changed while Task '{taskId}' is {state}."); + } + private static TokenBudgetClass Budget(JsonElement arguments) => TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); @@ -152,14 +159,4 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio 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.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs index 6a5759f..623c6a4 100644 --- a/src/Agent.Mcp/AgentTools.cs +++ b/src/Agent.Mcp/AgentTools.cs @@ -40,7 +40,7 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi if (mcpTaskId is null) return queued; using CancellationTokenRegistration registration = cancellationToken.Register(() => registry.Cancel(task.TaskId)); - TaskSnapshot terminal = await registry.WaitForTerminalAsync(task.TaskId, cancellationToken).ConfigureAwait(false); + TaskSnapshot terminal = await registry.WaitForTerminalAsync(task.TaskId, CancellationToken.None).ConfigureAwait(false); if (terminal.State == AgentTaskState.Failed) throw new InvalidOperationException(terminal.Result ?? "Agent task failed."); if (terminal.State == AgentTaskState.Cancelled) throw new OperationCanceledException("Agent task was cancelled.", cancellationToken); return FormatStatus(terminal); @@ -94,6 +94,10 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi $"current_task: {task.CurrentTask ?? string.Empty}", $"token_usage: input={task.InputTokens} output={task.OutputTokens} total={task.InputTokens + task.OutputTokens}", $"critical_path_tokens: {task.CriticalPathTokens.ToString(System.Globalization.CultureInfo.InvariantCulture)}", + $"iterations: {task.Iterations}", + $"replans: {task.Replans}", + $"last_error_code: {task.LastErrorCode ?? string.Empty}", + $"last_error: {task.LastError ?? string.Empty}", $"message: {locale.Get("mcp.status." + state)}", terminal ? $"result: {task.Result}" : "result:"); } diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs index 8ae25d5..bfb6d66 100644 --- a/src/Agent.Mcp/McpHost.cs +++ b/src/Agent.Mcp/McpHost.cs @@ -36,7 +36,7 @@ public static class McpHost var registry = new TaskRegistry(sessionStore); using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; var ollama = new OllamaClient(httpClient, ollamaSettings); - var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer); + var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer, locale: locale); builder.Services.AddSingleton(locale); builder.Services.AddSingleton(registry); builder.Services.AddSingleton(runtime); @@ -69,7 +69,7 @@ public static class McpHost builder.WebHost.UseUrls($"http://{listenHost}:{port}"); var registry = new TaskRegistry(sessionStore); var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; - var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer); + var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer, locale: locale); builder.Services.AddSingleton(locale); builder.Services.AddSingleton(registry); builder.Services.AddSingleton(runtime); diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs index 59b2602..bc4d291 100644 --- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -13,16 +13,18 @@ public sealed class TaskNetworkPolicy : IDisposable private readonly SecurityIdentifier _appContainerSid; private readonly Guid _loopbackEntryId; private readonly bool _loopbackAdded; + private readonly Guid? _sessionId; private bool _disposed; private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, - SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded) + SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId) { _session = session; _journal = journal; _audit = audit; _entryId = entryId; _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; + _sessionId = sessionId; } - public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList allowances, ISecurityAuditSink audit) + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList allowances, ISecurityAuditSink audit, Guid? sessionId = null) { bool originallyExempt = LoopbackExemption.Contains(appContainerSid); Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, @@ -39,13 +41,13 @@ public sealed class TaskNetworkPolicy : IDisposable journal.MarkApplied(loopbackEntryId, new Dictionary { ["added"] = loopbackAdded.ToString() }); IReadOnlyList ids = session.ApplyDefaultBlock(appContainerSid, allowances); journal.MarkApplied(entryId, new Dictionary { ["filter_ids"] = string.Join(',', ids) }); - audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary { ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" - })); + }, SessionId: sessionId)); audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", - new Dictionary { ["app_container_sid"] = appContainerSid.Value })); - return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded); + new Dictionary { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId)); + return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId); } catch { @@ -65,9 +67,9 @@ public sealed class TaskNetworkPolicy : IDisposable _journal.MarkCleaned(_entryId); if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); _journal.MarkCleaned(_loopbackEntryId); - _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary { ["residual_filters"] = "0" }, SessionId: _sessionId)); _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", - new Dictionary { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" })); + new Dictionary { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId)); _disposed = true; } } diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs index c9b28bf..9dfeef9 100644 --- a/src/Agent.Windows/Recovery/RecoveryManager.cs +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -24,6 +24,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink try { TaskResourceJournal journal = TaskResourceJournal.Open(path); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.start", "started", new Dictionary())); foreach (JournalEntry entry in journal.Entries.Reverse()) { if (entry.State == JournalEntryState.Cleaned) continue; @@ -33,7 +34,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink resources++; } journal.CompleteAndDelete(); - audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary())); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.end", "clean", new Dictionary())); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 3d20bfa..1f36164 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -14,11 +14,11 @@ public sealed class ExecutablePolicy(IEnumerable approvals, { string canonical = CanonicalPath.Resolve(executable); if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled."); - if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved."); + if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals."); using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read); string actualHash = Convert.ToHexString(SHA256.HashData(stream)); if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash))) - throw new SandboxPolicyException("process.hash_denied", "Executable SHA-256 does not match its approval."); + throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals."); return new(canonical, actualHash); } diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 32a949c..4f6eec4 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -17,7 +17,8 @@ public sealed class PathPolicy(IEnumerable entries) public void Demand(string path, FileCapability required) { - if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}."); + if (!IsAllowed(path, required)) + throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server."); } private static bool IsAtOrBelow(string candidate, string root) => @@ -39,7 +40,11 @@ public static class CanonicalPath current = Path.Combine(current, segment); if (!File.Exists(current) && !Directory.Exists(current)) continue; if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'."); + { + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget; + throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead."); + } } if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); return Path.TrimEndingDirectorySeparator(fullPath); diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs index 0786150..687af94 100644 --- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -19,7 +19,8 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable private const uint ProcessCreationChildProcessRestricted = 0x1; private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); - public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList arguments, string workingDirectory, TimeSpan timeout, + string? standardInput = null, Guid? sessionId = null) { VerifiedExecutable verified; VerifiedExecutable? managedEntry = null; @@ -31,7 +32,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable } catch (SandboxPolicyException ex) { - audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } @@ -142,7 +143,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary { ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" - })); + }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); @@ -157,23 +158,23 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable processAudit["managed_entry_path"] = managedEntry.CanonicalPath; processAudit["managed_entry_sha256"] = managedEntry.Sha256; } - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId)); WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); if (wait == WAIT_EVENT.WAIT_TIMEOUT) { _ = PInvoke.TerminateJobObject(job, 1460); - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary { ["pid"] = process.dwProcessId.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId)); throw new TimeoutException("Sandbox process exceeded its timeout."); } if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess"); - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }, SessionId: sessionId)); if (exitCode != 0) { audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary { ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() - })); + }, SessionId: sessionId, ErrorCode: "process.child_denied")); } return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); } diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs index 8858c2b..616b08f 100644 --- a/src/Agent.Windows/Security/SecurityAudit.cs +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -2,7 +2,8 @@ using System.Text.Json; namespace Agent.Windows.Security; -public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary Data); +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, + IReadOnlyDictionary Data, Guid? SessionId = null, string? ErrorCode = null); public interface ISecurityAuditSink { @@ -12,11 +13,12 @@ public interface ISecurityAuditSink public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink { private readonly object _sync = new(); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public void Write(SecurityAuditEvent auditEvent) { string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (directory is not null) Directory.CreateDirectory(directory); - string line = JsonSerializer.Serialize(auditEvent); + string line = JsonSerializer.Serialize(auditEvent, Json); lock (_sync) File.AppendAllText(path, line + Environment.NewLine); } } diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index 6e62d3d..5c1fe49 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -22,7 +22,8 @@ public sealed record WindowsSandboxSettings( long MemoryBackupLimitBytes, long WholeReadLimitBytes, int SearchResultLimit, - int TailLineLimit); + int TailLineLimit, + int ProcessTimeoutSeconds = 300); public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend { @@ -37,7 +38,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit AppContainerProfile profile = AppContainerProfile.EnsureFixed(); TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId); var acl = new TaskAclManager(profile.Sid, audit, journal); - var context = new TaskContext(journal, acl); + var context = new TaskContext(journal, acl, sessionId); _taskUpdates.BeginTask(taskId, sessionId); try { @@ -57,8 +58,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit IReadOnlyList allowances = settings.NetworkMode == "allow" ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) : []; - context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit); + context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId); if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary(), SessionId: sessionId)); } catch { @@ -71,6 +73,8 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { TaskContext context = Get(taskId); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted", + new Dictionary { ["tool"] = toolName }, SessionId: context.SessionId)); try { return toolName switch @@ -78,7 +82,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit "file_search" => Task.FromResult(Search(arguments)), "file_read" => Task.FromResult(Read(arguments)), "file_edit" => Task.FromResult(Edit(context, arguments)), - "process_run" => Task.FromResult(RunProcess(taskId, arguments)), + "process_run" => Task.FromResult(RunProcess(context, arguments)), "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), "dir" => Task.FromResult(DirectoryOp(context, arguments)), _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") @@ -86,14 +90,28 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit } catch (SandboxPolicyException ex) { - return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:SandboxPolicyException:{ex.Message}", RetryAllowed: false)); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", + new Dictionary { ["tool"] = toolName, ["message"] = ex.Message }, + SessionId: context.SessionId, ErrorCode: ex.Code)); + return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false)); } } public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) { if (!_contexts.TryRemove(taskId, out TaskContext? context)) return; - try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + try + { + await CleanupAsync(taskId, context).ConfigureAwait(false); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", success ? "completed" : "stopped", + new Dictionary(), SessionId: context.SessionId)); + } + catch + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", "cleanup_failed", + new Dictionary(), SessionId: context.SessionId)); + throw; + } finally { _taskUpdates.EndTask(taskId); } } @@ -188,7 +206,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit string auditPath = operation == "rename" ? $"{Path.GetFullPath(path)} -> {Path.GetFullPath(arguments.GetProperty("new_path").GetString()!)}" : Path.GetFullPath(path); - audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary { ["path"] = auditPath })); + string eventName = operation == "delete" ? "file.delete" : "file.write"; + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, eventName, "completed", + new Dictionary { ["path"] = auditPath, ["operation"] = operation }, SessionId: context.SessionId)); return Success($"{operation} completed for {auditPath}", externalEffect: true); } @@ -225,8 +245,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit throw new ToolValidationException($"Unsupported dir operation '{operation}'."); } - private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments) + private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments) { + Guid taskId = context.Journal.TaskId; string executable = arguments.GetProperty("executable").GetString()!; string cwd = arguments.GetProperty("cwd").GetString()!; _paths.Demand(executable, FileCapability.Execute); @@ -234,10 +255,12 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray(); string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args); if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute); - int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : 300; + int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : settings.ProcessTimeoutSeconds; + if (seconds > settings.ProcessTimeoutSeconds) + throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({settings.ProcessTimeoutSeconds})."); string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null; RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits) - .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin); + .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId); string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}"; return new(result.ExitCode == 0, output, true, result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}"); @@ -276,9 +299,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit return current[..first] + newText + current[(first + oldText.Length)..]; } - private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl) + private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId) { public TaskResourceJournal Journal { get; } = journal; + public Guid SessionId { get; } = sessionId; public TaskAclManager Acl { get; } = acl; public List Acls { get; } = []; public TaskNetworkPolicy? Network { get; set; } -- cgit v1.2.3