From 6c3458a5ec37a5b69957ad17c84de58984698f25 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 11 Aug 2026 21:44:53 +0900 Subject: Audit and align v1 runtime design --- src/Agent.Core/Ollama/IOllamaClient.cs | 2 + src/Agent.Core/Ollama/OllamaClient.cs | 13 +- src/Agent.Core/Runtime/AgentRuntime.cs | 228 +++++++++++++++++++----- src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 4 +- src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 17 +- src/Agent.Core/Runtime/PlannerService.cs | 177 ++++++++---------- src/Agent.Core/Tasks/TaskRegistry.cs | 55 +++++- src/Agent.Core/Tasks/TaskSnapshot.cs | 6 +- src/Agent.Core/Tools/InternalToolCatalog.cs | 2 +- src/Agent.Core/Tools/InternalToolValidator.cs | 1 - src/Agent.Core/Tools/TaskGraphToolHandler.cs | 25 ++- 11 files changed, 348 insertions(+), 182 deletions(-) (limited to 'src/Agent.Core') diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs index d1cb99f..88a2bec 100644 --- a/src/Agent.Core/Ollama/IOllamaClient.cs +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -10,5 +10,7 @@ public interface IOllamaClient ChatAsync(messages, temperatureOverride, cancellationToken); Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => ChatAsync(messages, temperatureOverride, cancellationToken); + Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) => + ChatStructuredAsync(messages, temperatureOverride, format, cancellationToken); Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(null); } diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index dde8478..428bca9 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -22,13 +22,16 @@ public sealed class OllamaClient : IOllamaClient } public async Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, null, null, cancellationToken).ConfigureAwait(false); public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, tools, null, null, cancellationToken).ConfigureAwait(false); public async Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, format, null, cancellationToken).ConfigureAwait(false); + + public async Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, format, thinkOverride, cancellationToken).ConfigureAwait(false); public async Task SupportsVisionAsync(CancellationToken cancellationToken) { @@ -45,7 +48,7 @@ public sealed class OllamaClient : IOllamaClient catch (HttpRequestException) { return null; } } - private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, JsonElement? format, CancellationToken cancellationToken) + private async Task ChatCoreAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList? tools, JsonElement? format, bool? thinkOverride, CancellationToken cancellationToken) { Dictionary options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); @@ -54,7 +57,7 @@ public sealed class OllamaClient : IOllamaClient Model = _settings.Model, Messages = messages, Stream = false, - Think = _settings.Think, + Think = thinkOverride ?? _settings.Think, KeepAlive = _settings.KeepAlive, Options = options, Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(), diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 0b261da..8cf1ddb 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Agent.Core.Ollama; +using Agent.Core.Localization; using Agent.Core.Prompts; using Agent.Core.Sessions; using Agent.Core.Skills; @@ -9,7 +10,7 @@ using Agent.Planner; namespace Agent.Core.Runtime; -public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null) +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null) { private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), @@ -28,12 +29,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) { - registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found."); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found.")); return; } try { + var counters = new ExecutionCounters(); // Vision capability check before any Ollama call. if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) throw new InvalidOperationException(settings.VisionUnsupportedMessage); @@ -42,29 +44,42 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag TaskGraph graph; try { - graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false); + graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message)); return; } // Phase 2: Running — execute ready Tasks sequentially. registry.Transition(taskId, AgentTaskState.Running, "running"); - await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false); + await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); - string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}"; - registry.Transition(taskId, AgentTaskState.Cancelled, result: result); + if (cleanupFailure is null) + { + registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled.")); + } + else + { + registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure); + registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure); + } } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; + registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot); + string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed"; + string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result; + registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage); registry.Transition(taskId, AgentTaskState.Failed, result: result); } } @@ -74,48 +89,61 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop. /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase. /// - private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken) + private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken) { // Apply Sandbox Task Resource at RunAsync time. await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false); var finalContents = new List(); - int replanCount = 0; const int maxReplans = 3; + var denyFingerprints = new HashSet(StringComparer.Ordinal); try { + UpdateTaskProgress(taskId, graph, null, counters); // Execute tasks in ready order: dependencies satisfied → critical path → creation order. while (true) { PlannerTask? next = SelectNextReadyTask(graph); - if (next is null) break; + if (next is null) + { + if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break; + throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task."); + } graph.SetState(next.Id, PlannerTaskState.Running); + UpdateTaskProgress(taskId, graph, next.Description, counters); try { - string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false); + string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false); graph.SetState(next.Id, PlannerTaskState.Completed); finalContents.Add(content); + UpdateTaskProgress(taskId, graph, null, counters); } - catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans) + catch (PolicyDeniedException ex) { - // Policy deny → replan possible (Design §8.4). - replanCount++; graph.SetState(next.Id, PlannerTaskState.Failed); - graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false); - // Transition back to Running for the replan iteration. - registry.Transition(taskId, AgentTaskState.Running, "replan"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message); + if (!denyFingerprints.Add(ex.Fingerprint)) + throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}"); + if (counters.Replans >= maxReplans) + throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}"); + + string previousSignature = GraphSignature(graph); + counters.Replans++; + registry.Transition(taskId, AgentTaskState.Planning, "replan"); + graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); + if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal)) + throw new InvalidOperationException("Replan did not change the incomplete TaskGraph."); + if (!registry.Transition(taskId, AgentTaskState.Running, "replan")) + throw new InvalidOperationException("Task state could not return to running after replan."); + UpdateTaskProgress(taskId, graph, null, counters); continue; } } - // All tasks completed — assemble final result. - // If replan was attempted but no tasks could execute, provide a meaningful message. - string finalContent = finalContents.Count > 0 - ? string.Join("\n\n", finalContents) - : replanCount > 0 - ? "The request could not be completed because the sandbox policy denied required operations." - : "No tasks were executed."; + if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result."); + string finalContent = string.Join("\n\n", finalContents); string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); session.Append(new ConversationMessage("user", task.Request)); @@ -136,7 +164,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag private static PlannerTask? SelectNextReadyTask(TaskGraph graph) { var completedIds = graph.Tasks - .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed) + .Where(static t => t.State == PlannerTaskState.Completed) .Select(static t => t.Id) .ToHashSet(); @@ -159,10 +187,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Executes a single PlannerTask: LLM chat-with-tools iteration loop. /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard. /// - private async Task ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken) + private async Task ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, + ExecutionCounters counters, CancellationToken cancellationToken) { // Progressive Disclosure Stage 2: LLM Skill Selection. - IReadOnlyList selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); + IReadOnlyList selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false); var messages = PromptComposer.Compose(new PromptContext( FixedPrompt.Load(), @@ -183,11 +212,24 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var unresolvedToolFailures = new Dictionary(StringComparer.Ordinal); string? finalContent = null; - for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + while (counters.Iterations < settings.MaxIterations) { - _observer.LlmRequest(taskId, iteration, messages); - OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); - _observer.LlmResponse(taskId, iteration, result); + int iteration = counters.Iterations++; + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id, + "running", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result; + try + { + result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message); + throw; + } + _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); // Record usage in TaskGraph for CPM tracking. @@ -195,7 +237,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Context Summary: compact old conversation/results when context utilization exceeds threshold. // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens). - TryContextSummary(session, result.PromptEvalCount); + await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false); IReadOnlyList calls = result.ToolCalls ?? []; if (calls.Count > 0) @@ -227,9 +269,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Record ExecutionResult in session for context summary. session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output)); - _observer.ToolResult(taskId, call.Function.Name, toolResult); + _observer.ToolResult(callContext, call.Function.Name, toolResult); if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool) - throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}"); + throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output); failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } @@ -256,7 +298,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag break; } - if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}."); return finalContent; } @@ -283,20 +325,53 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens. /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials. /// - private void TryContextSummary(AgentSession session, long promptEvalCount) + private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount, + ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.NumCtx <= 0) return; double utilization = (double)promptEvalCount / settings.NumCtx; if (utilization < settings.ContextSummaryThreshold) return; - // Compact old conversation entries, keeping only the most recent N. - // The summary itself is generated by the LLM in a separate call (future enhancement); - // for now, we compact the active context set to stay within num_ctx. - session.CompactConversation(settings.ConversationKeepLastN); - session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + string source = JsonSerializer.Serialize(new + { + previous_summary = session.Summary, + conversation = session.Conversation, + execution_results = session.ExecutionResults + }); + var messages = new List + { + new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."), + new("user", source) + }; + using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}"""); + AgentLlmCallContext? callContext = null; + try + { + if (counters.Iterations >= settings.MaxIterations) return; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null, + "context_summary", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + using JsonDocument response = JsonDocument.Parse(result.Content); + string? summary = response.RootElement.GetProperty("summary").GetString(); + if (string.IsNullOrWhiteSpace(summary)) return; + session.SetSummary(summary); + session.CompactConversation(settings.ConversationKeepLastN); + session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message); + } } - private async Task> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.Skills.Count == 0) return []; @@ -304,25 +379,86 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var selectionMessages = new List { new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."), - new("user", request) + new("user", task.Request) }; string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); OllamaResult result; + AgentLlmCallContext? callContext = null; try { - result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + if (counters.Iterations >= settings.MaxIterations) return []; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + "skill_selection", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, selectionMessages); + result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message); return []; } return ParseSelectedSkills(result.Content); } + private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase, + IReadOnlyList messages, OllamaResult? result, Exception? error) + { + if (counters.Iterations >= settings.MaxIterations) + throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}."); + counters.Iterations++; + int iteration = counters.Iterations - 1; + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + phase, iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + if (result is not null) + { + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + } + if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); + } + + private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters) + { + int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed); + registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + } + + private static string GraphSignature(TaskGraph graph) + { + IReadOnlyDictionary descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description); + return string.Join("\n", graph.Tasks + .Where(static item => item.State != PlannerTaskState.Completed) + .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}")); + } + + private sealed class ExecutionCounters + { + public int Iterations { get; set; } + public int Replans { get; set; } + } + + private string Message(string key, string fallback, string? detail = null) + { + if (locale is null) return fallback; + return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary { ["message"] = detail }); + } + + private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message) + { + public string Fingerprint { get; } = fingerprint; + } + private IReadOnlyList ParseSelectedSkills(string content) { try @@ -364,4 +500,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag return $"Task resource cleanup failed: {ex.Message}"; } } -} \ No newline at end of file +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs index 873c571..b6d8504 100644 --- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -13,4 +13,6 @@ public sealed record AgentRuntimeSettings( long NumCtx = 32768, double ContextSummaryThreshold = 0.80, int ConversationKeepLastN = 6, - int ExecutionResultsKeepLastN = 10); + int ExecutionResultsKeepLastN = 10, + int MaxTasks = 32, + string Model = ""); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs index e4e26e3..5098a92 100644 --- a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -5,15 +5,20 @@ namespace Agent.Core.Runtime; public interface IAgentRuntimeObserver { - void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages); - void LlmResponse(Guid taskId, int iteration, OllamaResult result); - void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); + void LlmRequest(AgentLlmCallContext context, IReadOnlyList messages); + void LlmResponse(AgentLlmCallContext context, OllamaResult result); + void LlmFailure(AgentLlmCallContext context, string errorCode, string message); + void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result); } +public sealed record AgentLlmCallContext(Guid CallId, Guid SessionId, Guid TaskId, Guid? PlannerTaskId, + string Phase, int Iteration, int Replan, string Model); + public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver { public static NullAgentRuntimeObserver Instance { get; } = new(); - public void LlmRequest(Guid taskId, int iteration, IReadOnlyList messages) { } - public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } - public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } + public void LlmRequest(AgentLlmCallContext context, IReadOnlyList messages) { } + public void LlmResponse(AgentLlmCallContext context, OllamaResult result) { } + public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) { } + public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) { } } diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index bf896f0..99569e4 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -23,10 +23,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// Generates an initial TaskGraph for the given request via Ollama structured output. /// Returns a TaskGraph stored in the session, keyed by the MCP Task ID. /// - public async Task PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList? images, CancellationToken cancellationToken) + public async Task PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList? images, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { // Build a Planning prompt with skill catalog and sandbox capability summary. - IReadOnlyList selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false); + IReadOnlyList selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { @@ -39,39 +40,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se new("user", request, images) }; - // Use structured output to get a plan. - OllamaResult result; - try - { - result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false); - } - catch - { - // Fallback: single-task plan with conservative estimates. - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - PlanResult? plan = ParsePlan(result.Content); - if (plan is null || plan.Tasks.Count == 0) - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - TaskGraph graph; - try - { - graph = BuildTaskGraph(plan, request); - } - catch - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } + TaskGraph graph = await RequestValidGraphAsync(messages, temperature, "planning", observeAttempt, + plan => BuildTaskGraph(plan, request), cancellationToken).ConfigureAwait(false); session.TaskGraphs[taskId] = graph; return graph; } @@ -80,10 +50,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph. /// Completed tasks are immutable; only incomplete tasks are modified. /// - public async Task ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken) + public async Task ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing)) - return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false); + return await PlanAsync(taskId, sessionId, session, request, temperature, null, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { @@ -95,26 +66,15 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se new("user", request) }; - OllamaResult result; - try - { - result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false); - } - catch - { - return existing; - } - - PlanResult? plan = ParsePlan(result.Content); - if (plan is null || plan.Tasks.Count == 0) return existing; - - // Build a new graph, preserving completed tasks from the existing graph. - return RebuildTaskGraph(existing, plan, request); + TaskGraph rebuilt = await RequestValidGraphAsync(messages, temperature, "replanning", observeAttempt, + plan => RebuildTaskGraph(existing, plan, request), cancellationToken).ConfigureAwait(false); + session.TaskGraphs[taskId] = rebuilt; + return rebuilt; } private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest) { - var graph = new TaskGraph(32); + var graph = new TaskGraph(_settings.MaxTasks); var idMap = new List(); for (int i = 0; i < plan.Tasks.Count; i++) @@ -127,81 +87,65 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se var dependencies = new HashSet(); foreach (int depIdx in pt.Dependencies) { - if (depIdx >= 0 && depIdx < idMap.Count) - dependencies.Add(idMap[depIdx]); + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + dependencies.Add(idMap[depIdx]); } string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description; - try - { - PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); - idMap.Add(task.Id); - } - catch (PlannerValidationException) - { - // If a task fails validation (e.g., bad estimate, cycle), fall back to single-task graph. - return CreateFallbackGraph(originalRequest); - } + PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); + idMap.Add(task.Id); } - // If all tasks were filtered out, fall back. - if (graph.Count == 0) return CreateFallbackGraph(originalRequest); + if (graph.Count == 0) throw new PlannerValidationException("Structured plan did not contain an executable Task."); return graph; } private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest) { - // Collect completed task descriptions to skip them in the new graph. - var completedDescriptions = new HashSet( - existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description), - StringComparer.Ordinal); - - var graph = new TaskGraph(32); + PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray(); + var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + var graph = new TaskGraph(_settings.MaxTasks); + foreach (PlannerTask item in completed) + { + Guid[] dependencies = item.Dependencies.Where(id => completed.Any(candidate => candidate.Id == id)).ToArray(); + PlannerTask copy = graph.AddTask(item.Description, item.BudgetClass, item.Estimate, dependencies, item.RequiresExternalEffect, item.Id); + graph.AddUsage(copy.Id, item.ActualInputTokens, item.ActualOutputTokens); + graph.SetState(copy.Id, PlannerTaskState.Completed); + } var idMap = new List(); + var reusedCompleted = new HashSet(); for (int i = 0; i < plan.Tasks.Count; i++) { PlannedTask pt = plan.Tasks[i]; // Skip tasks that are already completed in the existing graph. - if (completedDescriptions.Contains(pt.Description)) + if (completedByDescription.TryGetValue(pt.Description, out PlannerTask? completedTask) && reusedCompleted.Add(completedTask.Id)) { - // Find the completed task and add it as completed to the new graph. - PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description); - if (completedTask is not null) - { - TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass); - TokenEstimate estimate = CreateEstimate(pt, budgetClass); - var deps = new HashSet(); - foreach (int depIdx in pt.Dependencies) - if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]); - PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect); - graph.SetState(added.Id, PlannerTaskState.Completed); - idMap.Add(added.Id); - continue; - } + idMap.Add(completedTask.Id); + continue; } TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass); TokenEstimate est = CreateEstimate(pt, bc); var dependencies = new HashSet(); foreach (int depIdx in pt.Dependencies) - if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]); + { + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + dependencies.Add(idMap[depIdx]); + } string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description; PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect); idMap.Add(task.Id); } - return graph; - } - - private TaskGraph CreateFallbackGraph(string request) - { - var graph = new TaskGraph(32); - TokenEstimate estimate = new(512, 2048, 8192); - graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false); + if (graph.Tasks.All(static task => task.State == PlannerTaskState.Completed)) + throw new PlannerValidationException("Replan did not produce an incomplete executable Task."); return graph; } @@ -238,7 +182,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se } } - private async Task> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task> SelectSkillsAsync(string request, double? temperature, + Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (_settings.Skills.Count == 0) return []; @@ -254,15 +199,45 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se try { - OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + observeAttempt?.Invoke("skill_selection", selectionMessages, result, null); return ParseSelectedSkills(result.Content); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + observeAttempt?.Invoke("skill_selection", selectionMessages, null, ex); return []; } } + private async Task RequestValidGraphAsync(IReadOnlyList messages, double? temperature, string phase, + Action, OllamaResult?, Exception?>? observeAttempt, Func buildGraph, CancellationToken cancellationToken) + { + Exception? firstFailure = null; + for (int attempt = 0; attempt < 2; attempt++) + { + OllamaResult? result = null; + try + { + bool? thinkOverride = attempt == 0 ? null : false; + result = await _ollama.ChatStructuredAsync(messages, temperature, PlanSchema.Element, thinkOverride, cancellationToken).ConfigureAwait(false); + PlanResult? plan = ParsePlan(result.Content); + if (plan is null || plan.Tasks.Count == 0) + throw new PlannerValidationException("Ollama returned an empty or invalid structured plan."); + TaskGraph graph = buildGraph(plan); + observeAttempt?.Invoke(phase, messages, result, null); + return graph; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + observeAttempt?.Invoke(phase, messages, result, ex); + firstFailure ??= ex; + } + } + throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {firstFailure?.Message}"); + } + private IReadOnlyList ParseSelectedSkills(string content) { try @@ -331,4 +306,4 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se sb.AppendLine("Return a complete revised plan including completed tasks."); return sb.ToString(); } -} \ No newline at end of file +} diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs index b7a4afb..a89be6b 100644 --- a/src/Agent.Core/Tasks/TaskRegistry.cs +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -60,6 +60,12 @@ public sealed class TaskRegistry public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + public bool UpdateProgress(Guid taskId, int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateProgress(completedTasks, totalTasks, currentTask, criticalPathTokens); + + public bool UpdateDiagnostics(Guid taskId, int? iterations, int? replans, string? errorCode = null, string? error = null) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateDiagnostics(iterations, replans, errorCode, error); + public CancellationToken GetCancellationToken(Guid taskId) => _tasks.TryGetValue(taskId, out MutableTask? task) ? task.CancellationToken : CancellationToken.None; @@ -81,6 +87,13 @@ public sealed class TaskRegistry private string? _result; private long _inputTokens; private long _outputTokens; + private int _completedTasks; + private int _totalTasks = 1; + private double _criticalPathTokens; + private int _iterations; + private int _replans; + private string? _lastErrorCode; + private string? _lastError; private readonly CancellationTokenSource _cancellation = new(); private readonly TaskCompletionSource _terminal = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -93,8 +106,9 @@ public sealed class TaskRegistry { lock (_sync) { - int completed = _state == AgentTaskState.Completed ? 1 : 0; - return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, + _completedTasks, _totalTasks, _inputTokens, _outputTokens, _criticalPathTokens, images, + _iterations, _replans, _lastErrorCode, _lastError); } } @@ -122,16 +136,45 @@ public sealed class TaskRegistry } } + public bool UpdateProgress(int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) + { + if (completedTasks < 0 || totalTasks < 0 || completedTasks > totalTasks || criticalPathTokens < 0) return false; + lock (_sync) + { + _completedTasks = completedTasks; + _totalTasks = totalTasks; + _currentTask = currentTask; + _criticalPathTokens = criticalPathTokens; + return true; + } + } + + public bool UpdateDiagnostics(int? iterations, int? replans, string? errorCode, string? error) + { + if (iterations < 0 || replans < 0) return false; + lock (_sync) + { + if (iterations.HasValue) _iterations = iterations.Value; + if (replans.HasValue) _replans = replans.Value; + if (errorCode is not null) _lastErrorCode = errorCode; + if (error is not null) _lastError = error; + return true; + } + } + public bool Cancel() { lock (_sync) { if (_state is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) return false; _cancellation.Cancel(); - _state = AgentTaskState.Cancelled; - _currentTask = null; - _result = "cancelled"; - _terminal.TrySetResult(); + if (_state == AgentTaskState.Queued) + { + _state = AgentTaskState.Cancelled; + _currentTask = null; + _result = "cancelled"; + _terminal.TrySetResult(); + } return true; } } diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs index 0a15de3..64844a8 100644 --- a/src/Agent.Core/Tasks/TaskSnapshot.cs +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -13,4 +13,8 @@ public sealed record TaskSnapshot( long InputTokens, long OutputTokens, double CriticalPathTokens, - IReadOnlyList Images); + IReadOnlyList Images, + int Iterations = 0, + int Replans = 0, + string? LastErrorCode = null, + string? LastError = null); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs index a92a829..d261269 100644 --- a/src/Agent.Core/Tools/InternalToolCatalog.cs +++ b/src/Agent.Core/Tools/InternalToolCatalog.cs @@ -19,7 +19,7 @@ public static class InternalToolCatalog Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""), Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""), Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""), - Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]},"requires_external_effect":{"type":["boolean","null"]}}}"""), + Define("task_update", "Update the deterministic task graph structure or estimates.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"requires_external_effect":{"type":["boolean","null"]}}}"""), Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""") ]; return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal); diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs index d789dbf..57f58b2 100644 --- a/src/Agent.Core/Tools/InternalToolValidator.cs +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -74,7 +74,6 @@ public static class InternalToolValidator string operation = arguments.GetProperty("operation").GetString()!; if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); - if (operation == "set_state") Require(arguments, "task_id", "state"); if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); } // dir: path and operation are already required by the schema; no operation-specific diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs index 10094d3..26d5a27 100644 --- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -56,15 +56,15 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio break; case "add_dependency": Guid taskIdValue = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, taskIdValue); Guid[] dependencies = Ids(arguments, "depends_on"); if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); break; - case "set_state": - graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); - break; case "set_estimate": - graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + Guid estimateTaskId = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, estimateTaskId); + graph.SetEstimate(estimateTaskId, Budget(arguments), Estimate(arguments)); break; default: throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); @@ -114,6 +114,13 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio return value.ValueKind == JsonValueKind.True; } + private static void DemandStructuralMutation(TaskGraph graph, Guid taskId) + { + PlannerTaskState state = graph.Get(taskId).State; + if (state is not (PlannerTaskState.Queued or PlannerTaskState.Planning)) + throw new PlannerValidationException($"TaskGraph structure or estimate cannot be changed while Task '{taskId}' is {state}."); + } + private static TokenBudgetClass Budget(JsonElement arguments) => TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); @@ -152,14 +159,4 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio return [.. ids]; } - private static PlannerTaskState ParseState(string state) => state switch - { - "queued" => PlannerTaskState.Queued, - "planning" => PlannerTaskState.Planning, - "running" => PlannerTaskState.Running, - "completed" => PlannerTaskState.Completed, - "failed" => PlannerTaskState.Failed, - "cancelled" => PlannerTaskState.Cancelled, - _ => throw new ToolValidationException($"Unknown task state '{state}'.") - }; } -- cgit v1.2.3