summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
commit6c3458a5ec37a5b69957ad17c84de58984698f25 (patch)
treebe5c5716e7488fcf1bc1a58de0ded0b9238f34cb /src
parentb5ccecca01007f2a2459265df6d749fbfd31aa84 (diff)
Audit and align v1 runtime design
Diffstat (limited to 'src')
-rw-r--r--src/Agent.App/Cli/CommandLine.cs6
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs69
-rw-r--r--src/Agent.App/Logging/JsonLinesRuntimeObserver.cs46
-rw-r--r--src/Agent.App/Program.cs23
-rw-r--r--src/Agent.Core/Ollama/IOllamaClient.cs2
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs13
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs228
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs4
-rw-r--r--src/Agent.Core/Runtime/IAgentRuntimeObserver.cs17
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs177
-rw-r--r--src/Agent.Core/Tasks/TaskRegistry.cs55
-rw-r--r--src/Agent.Core/Tasks/TaskSnapshot.cs6
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs2
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs1
-rw-r--r--src/Agent.Core/Tools/TaskGraphToolHandler.cs25
-rw-r--r--src/Agent.Mcp/AgentTools.cs6
-rw-r--r--src/Agent.Mcp/McpHost.cs4
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs18
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs3
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs4
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs9
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs15
-rw-r--r--src/Agent.Windows/Security/SecurityAudit.cs6
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs46
24 files changed, 545 insertions, 240 deletions
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<AgentConfiguration>(File.ReadAllText(absolutePath))
+ AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(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<OllamaMessage> 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<OllamaMessage> 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<int>)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<Agent.Core.Skills.AgentSkill> 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<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) =>
ChatAsync(messages, temperatureOverride, cancellationToken);
+ Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) =>
+ ChatStructuredAsync(messages, temperatureOverride, format, 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
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<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> 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<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> 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<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> 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<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, null, format, thinkOverride, cancellationToken).ConfigureAwait(false);
public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken)
{
@@ -45,7 +48,7 @@ public sealed class OllamaClient : IOllamaClient
catch (HttpRequestException) { return null; }
}
- private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, CancellationToken cancellationToken)
+ private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, bool? thinkOverride, 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);
@@ -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.
/// </summary>
- 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<string>();
- int replanCount = 0;
const int maxReplans = 3;
+ var denyFingerprints = new HashSet<string>(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.
/// </summary>
- private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken)
+ private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask,
+ ExecutionCounters counters, CancellationToken cancellationToken)
{
// Progressive Disclosure Stage 2: LLM Skill Selection.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList<AgentSkill> 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<string, string>(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<OllamaToolCall> 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.
/// </summary>
- 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<OllamaMessage>
+ {
+ 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<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ private async Task<IReadOnlyList<AgentSkill>> 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<OllamaMessage>
{
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<OllamaMessage> 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<Guid, string> 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<string, object?> { ["message"] = detail });
+ }
+
+ private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message)
+ {
+ public string Fingerprint { get; } = fingerprint;
+ }
+
private IReadOnlyList<AgentSkill> 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<OllamaMessage> messages);
- void LlmResponse(Guid taskId, int iteration, OllamaResult result);
- void ToolResult(Guid taskId, string toolName, ToolExecutionResult result);
+ void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> 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<OllamaMessage> 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<OllamaMessage> 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.
/// </summary>
- public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, CancellationToken cancellationToken)
+ public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken)
{
// Build a Planning prompt with skill catalog and sandbox capability summary.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false);
var messages = new List<OllamaMessage>
{
@@ -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.
/// </summary>
- public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken)
+ public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason,
+ Action<string, IReadOnlyList<OllamaMessage>, 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<OllamaMessage>
{
@@ -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<Guid>();
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<Guid>();
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<string>(
- 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<Guid>();
+ var reusedCompleted = new HashSet<Guid>();
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<Guid>();
- 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<Guid>();
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<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, double? temperature,
+ Action<string, IReadOnlyList<OllamaMessage>, 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<TaskGraph> RequestValidGraphAsync(IReadOnlyList<OllamaMessage> messages, double? temperature, string phase,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, Func<PlanResult, TaskGraph> 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<AgentSkill> 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<string> Images);
+ IReadOnlyList<string> 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<NetworkEndpoint> allowances, ISecurityAuditSink audit)
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> 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<string, string> { ["added"] = loopbackAdded.ToString() });
IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
- audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string>
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string>
{
["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<string, string> { ["app_container_sid"] = appContainerSid.Value }));
- return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded);
+ new Dictionary<string, string> { ["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<string, string> { ["residual_filters"] = "0" }));
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
_audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
- new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }));
+ new Dictionary<string, string> { ["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<string, string>()));
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<string, string>()));
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.end", "clean", new Dictionary<string, string>()));
}
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<ExecutableApproval> 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<FilePolicyEntry> 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<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null)
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> 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<string, string> { ["code"] = ex.Code, ["executable"] = executable }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["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<string, string>
{
["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<string, string> { ["pid"] = process.dwProcessId.ToString() }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["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<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }, SessionId: sessionId));
if (exitCode != 0)
{
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
{
["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<string, string> Data);
+public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome,
+ IReadOnlyDictionary<string, string> 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<NetworkEndpoint> 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<string, string>(), SessionId: sessionId));
}
catch
{
@@ -71,6 +73,8 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
{
TaskContext context = Get(taskId);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted",
+ new Dictionary<string, string> { ["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<string, string> { ["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<string, string>(), SessionId: context.SessionId));
+ }
+ catch
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", "cleanup_failed",
+ new Dictionary<string, string>(), 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<string, string> { ["path"] = auditPath }));
+ string eventName = operation == "delete" ? "file.delete" : "file.write";
+ audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, eventName, "completed",
+ new Dictionary<string, string> { ["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<AclSnapshot> Acls { get; } = [];
public TaskNetworkPolicy? Network { get; set; }