diff options
Diffstat (limited to 'src/Agent.Core/Runtime/AgentRuntime.cs')
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 228 |
1 files changed, 182 insertions, 46 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 +} |
