using System.Text.Json; using Agent.Core.Ollama; using Agent.Core.Prompts; using Agent.Core.Sessions; using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; using Agent.Planner; namespace Agent.Core.Runtime; /// /// Builds an initial TaskGraph from LLM structured output (Planning phase). /// The Planner determines requires_external_effect per-task during Planning. /// Pure analysis tasks get a TaskGraph with at least 1 Task (RequiresExternalEffect=false). /// public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings settings) { private readonly IOllamaClient _ollama = ollama; private readonly AgentRuntimeSettings _settings = settings; /// /// 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, Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { // Build a Planning prompt with skill catalog and sandbox capability summary. IReadOnlyList selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { new("system", FixedPrompt.Load()), new("system", _settings.UserSystemPrompt), new("system", $"Respond in locale: {_settings.Locale}."), new("system", ComposeSkillCatalog(_settings.Skills)), new("system", _settings.SandboxCapabilitySummary), new("system", BuildPlanningInstruction()), new("user", request, images) }; TaskGraph graph = await RequestValidGraphAsync(messages, temperature, "planning", observeAttempt, plan => BuildTaskGraph(plan, request), cancellationToken).ConfigureAwait(false); session.TaskGraphs[taskId] = graph; return graph; } /// /// 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, Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing)) return await PlanAsync(taskId, sessionId, session, request, temperature, null, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List { new("system", FixedPrompt.Load()), new("system", _settings.UserSystemPrompt), new("system", $"Respond in locale: {_settings.Locale}."), new("system", _settings.SandboxCapabilitySummary), new("system", BuildReplanInstruction(existing, replanReason)), new("user", 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(_settings.MaxTasks); var idMap = new List(); for (int i = 0; i < plan.Tasks.Count; i++) { PlannedTask pt = plan.Tasks[i]; TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass); TokenEstimate estimate = CreateEstimate(pt, budgetClass); // Map dependency indices to Guids. var dependencies = new HashSet(); foreach (int depIdx in pt.Dependencies) { 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, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); idMap.Add(task.Id); } 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) { 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 (completedByDescription.TryGetValue(pt.Description, out PlannerTask? completedTask) && reusedCompleted.Add(completedTask.Id)) { 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) 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); } if (graph.Tasks.All(static task => task.State == PlannerTaskState.Completed)) throw new PlannerValidationException("Replan did not produce an incomplete executable Task."); return graph; } private static TokenBudgetClass ParseBudgetClass(string value) { try { return TokenBudgetClassExtensions.Parse(value); } catch { return TokenBudgetClass.K4; } } private static TokenEstimate CreateEstimate(PlannedTask pt, TokenBudgetClass budgetClass) { long o = Math.Max(1, pt.OptimisticTokens); long m = Math.Max(o, pt.MostLikelyTokens); long p = Math.Max(m, pt.PessimisticTokens); long max = budgetClass.MaxTokens(); if (p > max) p = max; if (m > p) m = p; if (o > m) o = m; return new TokenEstimate(o, m, p); } private static PlanResult? ParsePlan(string content) { try { return JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch { return null; } } private async Task> SelectSkillsAsync(string request, double? temperature, Action, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (_settings.Skills.Count == 0) return []; string catalog = string.Join("\n", _settings.Skills.Select(static s => $"- {s.Name}: {s.Description}")); 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) }; string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); try { 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 (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 { using JsonDocument doc = JsonDocument.Parse(content); if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return []; var selectedNames = new HashSet(StringComparer.Ordinal); foreach (JsonElement item in skillsElement.EnumerateArray()) { string? name = item.GetString(); if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name); } var valid = new List(); foreach (AgentSkill skill in _settings.Skills) { if (selectedNames.Contains(skill.Name)) { valid.Add(skill); if (valid.Count >= 10) break; } } return valid; } catch { return []; } } private static string ComposeSkillCatalog(IReadOnlyList skills) { if (skills.Count == 0) return "No skills available."; var lines = new List { "Available skills:" }; foreach (AgentSkill skill in skills) lines.Add($"- {skill.Name}: {skill.Description}"); return string.Join("\n", lines); } private static string BuildPlanningInstruction() => """ You are a Task Planner. Decompose the user request into a TaskGraph of 1-32 tasks. Each task must have: - description: a clear, self-contained sub-task description - dependencies: array of task indices (0-based) that must complete before this task - budget_class: one of 1K, 2K, 4K, 8K, 16K, 32K, 64K, 128K (token budget for this task) - optimistic_tokens, most_likely_tokens, pessimistic_tokens: PERT estimates (0 < O <= M <= P <= budget) - requires_external_effect: true if this task requires a verifiable external effect (file edit, process run, etc.) For pure analysis or informational tasks, requires_external_effect should be false. For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true. Read-only operations (file_read, file_search, and dir list) do not create an external effect and must use requires_external_effect=false. Preserve every user-supplied absolute path, executable, cwd, argument array, operation name, and literal value exactly in the task that uses it; never abbreviate these values into a shell-like command. Keep each task limited to its own described operation. Do not put later dependent operations into an earlier task. A single task is valid for simple requests. """; private static string BuildReplanInstruction(TaskGraph existing, string reason) { var sb = new System.Text.StringBuilder(); sb.AppendLine("You are a Task Planner performing replan. The previous plan encountered an issue."); sb.AppendLine($"Replan reason: {reason}"); sb.AppendLine("Current task states:"); foreach (PlannerTask t in existing.Tasks) { sb.AppendLine($" - [{t.State}] {t.Description} (requires_external_effect={t.RequiresExternalEffect})"); } sb.AppendLine(); sb.AppendLine("Completed tasks must be preserved. Modify only incomplete/failed tasks."); sb.AppendLine("Return a complete revised plan including completed tasks."); return sb.ToString(); } }