diff options
Diffstat (limited to 'src/Agent.Core/Runtime')
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 228 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 4 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 17 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlannerService.cs | 177 |
4 files changed, 272 insertions, 154 deletions
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 +} |
