diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-11 05:17:32 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-11 05:17:32 +0900 |
| commit | 35719799e62a18850efc52d380fb53bf3baa8022 (patch) | |
| tree | 3cc1d110f290e5acab71ca202a4cd0d521e4a8b5 /src | |
| parent | d5d0a84e573c833843279c520ce2d5765425ba93 (diff) | |
Implement P2/P3: Plan-Run flow separation, PlannerService, and Windows test fixes
## P3: Session/Planning Foundation
### PlanAsync / RunAsync flow separation (Design §8.4)
- AgentRuntime.ExecuteAsync now follows a two-phase lifecycle:
1. Planning: generate Initial TaskGraph via Ollama structured output
2. Running: execute ready Tasks (dependencies satisfied) in
critical-path then creation order
- Sandbox Task Resource (BeginTask/EndTask) is applied at RunAsync
time, not PlanAsync time (Design constraint)
- QueuedTaskWorker transitions to Planning; ExecuteAsync transitions
to Running internally
### PlannerService (new: src/Agent.Core/Runtime/PlannerService.cs)
- PlanAsync: uses Ollama ChatStructuredAsync with JSON Schema to
generate a TaskGraph with per-Task requires_external_effect
- ReplanAsync: rebuilds TaskGraph after policy deny, preserving
completed tasks (Design §8.4)
- Fallback: single-task plan with conservative estimates when
structured output fails or returns empty
- Pure analysis tasks get RequiresExternalEffect=false with at
least 1 Task (Design constraint)
### PlanResult / PlanSchema (new: src/Agent.Core/Runtime/PlanResult.cs)
- JSON model for LLM structured output during Planning
- JSON Schema with tasks array: description, dependencies (by index),
budget_class, PERT estimates, requires_external_effect
### requires_external_effect integration (P2 + P3)
- Removed keyword heuristic RequiresExternalEffect() from AgentRuntime
- Removed tool-name-counting RequiredToolCalls() from AgentRuntime
- ToolUseGuard now receives PlannerTask.RequiresExternalEffect
(set by Planner during Planning, not by keyword matching)
- requires_external_effect is NOT always true; it is a per-Task
attribute determined by the Planner (Design constraint)
### RunAsync / ready Task execution
- SelectNextReadyTask: dependencies satisfied, not completed/failed,
critical path first then creation order
- Each PlannerTask executed via ExecutePlannerTaskAsync (LLM
chat-with-tools iteration loop)
- TaskGraph.AddUsage records per-Task token usage for CPM tracking
### Replan implementation
- Policy deny (InvalidOperationException with "denied by sandbox
policy") triggers ReplanAsync, up to max 3 replan attempts
- Failed task is marked Failed in TaskGraph before replan
- Registry transitions back to Running for replan iteration
## P3-A: AgentSession + TaskGraph ownership
### AgentSession expansion (src/Agent.Core/Sessions/AgentSession.cs)
- Added Summary, ExecutionResults, TaskGraphs
(ConcurrentDictionary<Guid, TaskGraph>)
- TaskGraph is per-MCP-Task (1 agent_run = 1 TaskGraph), NOT shared
per-Session (Design constraint)
- Added CompactConversation, CompactExecutionResults, SetSummary
methods for context summary compaction
### ExecutionResult (new: src/Agent.Core/Sessions/ExecutionResult.cs)
- Records tool execution results in session for context summary
- Fields: TaskId, ToolName, Success, Output
### SessionStore (src/Agent.Core/Sessions/SessionStore.cs)
- Added Sessions property for TaskGraphToolHandler to resolve graphs
by TaskId across sessions
### sessionId threading
- IInternalToolBackend.BeginTaskAsync signature changed to include
sessionId parameter
- InternalToolDispatcher, WindowsToolBackend, TaskGraphToolHandler
updated to thread sessionId through
- task_update schema includes requires_external_effect property
- McpHost and Program.cs pass SessionStore to TaskRegistry
## P3: Context Summary (Design §9.3)
- Context utilization = latest prompt_eval_count / num_ctx
(NOT cumulative InputTokens) (Design constraint)
- TryContextSummary: compacts old Conversation/ExecutionResults when
utilization exceeds 80% threshold
- AgentRuntimeSettings: added NumCtx, ContextSummaryThreshold,
ConversationKeepLastN, ExecutionResultsKeepLastN
- OllamaResult: added PromptEvalCount field
- OllamaClient: passes PromptEvalCount from OllamaChatResponse
## P3: ExecutionResults Session recording
- ExecutePlannerTaskAsync records each tool execution result via
session.AppendExecutionResult for context summary and audit
## Windows test fixes
### SandboxPolicyException catch (src/Agent.Windows/Tools/WindowsToolBackend.cs)
- ExecuteAsync now catches SandboxPolicyException and returns
ToolExecutionResult(false, ..., RetryAllowed: false) instead of
propagating the exception
- Fixes Dir_mkdir_outside_approved_root_is_denied and
File_edit_rename_rejects_outside_approved_root tests
### LoopbackExemption cleanup (tests/Agent.Windows.Tests/SecurityPolicyTests.cs)
- Recovery_after_crash: added pre-clean and try/finally to ensure
LoopbackExemption is always removed, even on assertion failure
- Independent_process_crash: added pre-clean to remove residual
loopback exemption from prior test failures
- Fixes 7 Independent_process_crash scenarios that were failing due
to LoopbackExemption residue from Recovery_after_crash
## Test updates (tests/Agent.Core.Tests/PromptAndRuntimeTests.cs)
- FakeOllamaClient implementations updated with ChatStructuredAsync
and ChatWithToolsAsync overrides to support Plan/Run flow
- RecordingOllamaClient: returns single-task plan JSON for
ChatStructuredAsync, tracks all call types
- ToolCallingOllamaClient: returns plan with
requires_external_effect=true for file edit tasks
- Test expectations updated for Plan+Run call counts and token
accounting (Plan tokens not added to registry usage)
## Documentation updates
- Design.md §7.7: requires_external_effect is set by Planner during
Planning, not keyword heuristic
- Design.md §8.4: Plan/Run phase separation, replan after policy deny
- Design.md §9.3: context utilization formula clarified
- ImplementationPlan.md §10.3: Plan/Run flow, replan, Context Summary
## Test results (all passing)
- Agent.Core.Tests: 61/61
- Agent.Planner.Tests: 10/10
- Agent.Windows.Tests (elevated): 41/41
- Build: 0 errors, 0 warnings
Diffstat (limited to 'src')
| -rw-r--r-- | src/Agent.App/Program.cs | 12 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaClient.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaModels.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 364 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 6 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlanResult.cs | 51 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlannerService.cs | 307 | ||||
| -rw-r--r-- | src/Agent.Core/Sessions/AgentSession.cs | 64 | ||||
| -rw-r--r-- | src/Agent.Core/Sessions/ExecutionResult.cs | 11 | ||||
| -rw-r--r-- | src/Agent.Core/Sessions/SessionStore.cs | 5 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolCatalog.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolDispatcher.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/TaskGraphToolHandler.cs | 62 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/ToolPolicy.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Mcp/McpHost.cs | 9 | ||||
| -rw-r--r-- | src/Agent.Mcp/QueuedTaskWorker.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 29 |
17 files changed, 775 insertions, 157 deletions
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index 7839e82..1c29ec3 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -3,6 +3,7 @@ using Agent.App.Configuration; using Agent.App.Logging; using Agent.Core.Localization; using Agent.Core.Prompts; +using Agent.Core.Sessions; using Agent.Mcp; using Agent.Core.Runtime; using Agent.Windows.Diagnostics; @@ -73,7 +74,8 @@ internal static class Program return 2; } - InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory); + var sessionStore = new SessionStore(); + InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore); var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory); string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks"); UserOnlyDirectory.Ensure(taskStateDirectory); @@ -89,7 +91,7 @@ internal static class Program var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported")); var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None, - toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver).ConfigureAwait(false); + toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false); return 0; } if (configuration.Mcp.Transport == "http") @@ -101,7 +103,7 @@ internal static class Program var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes); await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver, - taskStoreSettings, hostObserver).ConfigureAwait(false); + taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false); return 0; } throw new ConfigurationException("Unsupported MCP transport."); @@ -118,7 +120,7 @@ internal static class Program } } - private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory) + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null) { string stateDirectory = Path.Combine(baseDirectory, "state"); string journalDirectory = Path.Combine(stateDirectory, "journals"); @@ -142,7 +144,7 @@ internal static class Program configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(), journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024), configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines); - var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks)); + var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore)); var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal); return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend); diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index c761081..dde8478 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -82,7 +82,7 @@ public sealed class OllamaClient : IOllamaClient ?? throw new OllamaException("Ollama returned an empty response.", false); if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); - return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? [], result.PromptEvalCount); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) { diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs index f4b3884..f6ff092 100644 --- a/src/Agent.Core/Ollama/OllamaModels.cs +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -44,4 +44,4 @@ public sealed class OllamaChatResponse [JsonPropertyName("error")] public string? Error { get; init; } } -public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null); +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null, long PromptEvalCount = 0); diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index f6bb483..aa81486 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -5,16 +5,24 @@ using Agent.Core.Sessions; using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; +using Agent.Planner; namespace Agent.Core.Runtime; -public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null) +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null) { private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), new FakeInternalToolBackend()); private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance; + private readonly PlannerService _planner = plannerService ?? new(ollama, settings); + /// <summary> + /// Plan → Run lifecycle for a single MCP Task. + /// 1. Planning: generate Initial TaskGraph via structured output. + /// 2. Running: execute ready Tasks (dependencies satisfied) in critical-path / creation order. + /// Sandbox Task Resource is applied at RunAsync time, not PlanAsync time. + /// </summary> public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken) { if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; @@ -26,124 +34,267 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag try { - await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + // 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); - // Progressive Disclosure Stage 2: LLM Skill Selection. - IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); - - var messages = PromptComposer.Compose(new PromptContext( - FixedPrompt.Load(), - settings.UserSystemPrompt, - settings.Locale, - settings.Skills, - selectedSkills, // Stage 3: selected skill instructions injected by PromptComposer. - settings.SandboxCapabilitySummary, - "Current task: " + task.Request, - string.Empty, - session.Conversation, - task.Request, - task.Images)).ToList(); - var guard = new ToolUseGuard(settings.SameFailureLimit); - bool requiresExternalEffect = RequiresExternalEffect(task.Request); - bool externalEffectEvidence = false; - var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal); - IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request); - var successfulToolCalls = new Dictionary<string, int>(StringComparer.Ordinal); - string? finalContent = null; - for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + // Phase 1: Planning — generate TaskGraph via structured output. + TaskGraph graph; + try { - _observer.LlmRequest(taskId, iteration, messages); - OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); - _observer.LlmResponse(taskId, iteration, result); - registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); - IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? []; - if (calls.Count > 0) - { - messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); - string? failureFingerprint = null; - foreach (OllamaToolCall call in calls) - { - ToolExecutionResult toolResult; - try - { - toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); - } - catch (UnauthorizedAccessException ex) - { - toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false); - } - catch (Exception ex) when (ex is ToolValidationException or ArgumentException) - { - toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); - } - externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect; - if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); - else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; - if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1; - _observer.ToolResult(taskId, call.Function.Name, toolResult); - if (!toolResult.Success && !toolResult.RetryAllowed) - throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}"); - failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; - messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); - } - ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); - if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); - continue; - } + graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}"); + return; + } - if (unresolvedToolFailures.Count > 0) - { - messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + - string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); - continue; - } - string[] missingCalls = requiredToolCalls - .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value) - .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}") - .ToArray(); - if (missingCalls.Length > 0) + // Phase 2: Running — execute ready Tasks sequentially. + registry.Transition(taskId, AgentTaskState.Running, "running"); + await RunAsync(taskId, task, session, graph, 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); + } + 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.Transition(taskId, AgentTaskState.Failed, result: result); + } + } + + /// <summary> + /// Executes ready Tasks (dependencies satisfied) in critical-path then creation order. + /// 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) + { + // 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; + try + { + // Execute tasks in ready order: dependencies satisfied → critical path → creation order. + while (true) + { + PlannerTask? next = SelectNextReadyTask(graph); + if (next is null) break; + + graph.SetState(next.Id, PlannerTaskState.Running); + try { - messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls))); - continue; + string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false); + graph.SetState(next.Id, PlannerTaskState.Completed); + finalContents.Add(content); } - ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); - if (completion.Decision == ToolUseGuardDecision.Retry) + catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans) { - messages.Add(new OllamaMessage("system", completion.Observation)); + // 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"); continue; } - if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); - finalContent = result.Content; - break; } - if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + + // All tasks completed — assemble final result. + string finalContent = finalContents.Count > 0 + ? string.Join("\n\n", finalContents) + : "No tasks were executed."; 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)); session.Append(new ConversationMessage("assistant", finalContent)); registry.Transition(taskId, AgentTaskState.Completed, result: finalContent); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch { - 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); + // Re-throw without double-cleanup; ExecuteAsync's catch handles EndTaskSafelyAsync. + throw; } - catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + } + + /// <summary> + /// Selects the next ready Task: dependencies satisfied, not yet completed/failed. + /// Priority: critical path first, then creation order. + /// </summary> + private static PlannerTask? SelectNextReadyTask(TaskGraph graph) + { + var completedIds = graph.Tasks + .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed) + .Select(static t => t.Id) + .ToHashSet(); + + // Critical path task IDs in order. + var criticalPath = new HashSet<Guid>(graph.CriticalPath.TaskIds); + + // Ready = not completed/failed/running and all dependencies completed. + var ready = graph.Tasks + .Where(t => t.State is not (PlannerTaskState.Completed or PlannerTaskState.Failed or PlannerTaskState.Running)) + .Where(t => t.Dependencies.All(depId => completedIds.Contains(depId))) + .ToList(); + + if (ready.Count == 0) return null; + + // Prefer critical path tasks, then creation order (Tasks list is in insertion order). + return ready.FirstOrDefault(t => criticalPath.Contains(t.Id)) ?? ready[0]; + } + + /// <summary> + /// 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) + { + // Progressive Disclosure Stage 2: LLM Skill Selection. + IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); + + var messages = PromptComposer.Compose(new PromptContext( + FixedPrompt.Load(), + settings.UserSystemPrompt, + settings.Locale, + settings.Skills, + selectedSkills, + settings.SandboxCapabilitySummary, + BuildTaskGraphSummary(graph, plannerTask), + session.Summary ?? string.Empty, + session.Conversation, + plannerTask.Description, + task.Images)).ToList(); + + var guard = new ToolUseGuard(settings.SameFailureLimit); + bool requiresExternalEffect = plannerTask.RequiresExternalEffect; + bool externalEffectEvidence = false; + var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal); + string? finalContent = null; + + for (int iteration = 0; iteration < settings.MaxIterations; iteration++) { - string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); - string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; - registry.Transition(taskId, AgentTaskState.Failed, result: result); + _observer.LlmRequest(taskId, iteration, messages); + OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(taskId, iteration, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + + // Record usage in TaskGraph for CPM tracking. + graph.AddUsage(plannerTask.Id, result.InputTokens, result.OutputTokens); + + // 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); + + IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? []; + if (calls.Count > 0) + { + messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); + string? failureFingerprint = null; + foreach (OllamaToolCall call in calls) + { + ToolExecutionResult toolResult; + try + { + toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (UnauthorizedAccessException ex) + { + toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false); + } + catch (Exception ex) when (ex is ToolValidationException or ArgumentException) + { + toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); + } + + externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect; + if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); + else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; + + // 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); + if (!toolResult.Success && !toolResult.RetryAllowed) + throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}"); + failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; + messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); + } + ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); + if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); + continue; + } + + if (unresolvedToolFailures.Count > 0) + { + messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + + string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); + continue; + } + + ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); + if (completion.Decision == ToolUseGuardDecision.Retry) + { + messages.Add(new OllamaMessage("system", completion.Observation)); + continue; + } + if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); + finalContent = result.Content; + break; + } + + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + return finalContent; + } + + /// <summary> + /// Builds a TaskGraph summary for the prompt context (Design §9.2 item 6). + /// Shows the current task position within the overall graph. + /// </summary> + private static string BuildTaskGraphSummary(TaskGraph graph, PlannerTask currentTask) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Current TaskGraph:"); + for (int i = 0; i < graph.Tasks.Count; i++) + { + PlannerTask t = graph.Tasks[i]; + string marker = t.Id == currentTask.Id ? "→ " : " "; + sb.AppendLine($"{marker}[{t.State}] {t.Description}"); } + sb.AppendLine($"Critical path tokens: {graph.CriticalPath.CumulativeTokenCost:F0}"); + return sb.ToString(); + } + + /// <summary> + /// Context Summary: compacts old Conversation/ExecutionResults when context utilization exceeds threshold. + /// 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) + { + 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); } private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken) { if (settings.Skills.Count == 0) return []; - // Build a minimal prompt with skill catalog for the LLM to choose from. string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}")); var selectionMessages = new List<OllamaMessage> { @@ -151,7 +302,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag new("user", request) }; - // JSON Schema for structured output. string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); @@ -162,7 +312,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag } catch { - // If structured output fails (e.g., model doesn't support it), fall back to no skills. return []; } @@ -181,7 +330,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag string? name = item.GetString(); if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name); } - // Validate: only known skills, no duplicates, max 10. var valid = new List<AgentSkill>(); foreach (AgentSkill skill in settings.Skills) { @@ -211,28 +359,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag return $"Task resource cleanup failed: {ex.Message}"; } } - - private static bool RequiresExternalEffect(string request) - { - string normalized = request.ToLowerInvariant(); - string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"]; - return indicators.Any(normalized.Contains); - } - - private static IReadOnlyDictionary<string, int> RequiredToolCalls(string request) - { - var result = new Dictionary<string, int>(StringComparer.Ordinal); - foreach (InternalToolDefinition tool in InternalToolCatalog.All) - { - int count = 0; - int offset = 0; - while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0) - { - count++; - offset += tool.Name.Length; - } - if (count > 0) result[tool.Name] = count; - } - return result; - } -} +}
\ No newline at end of file diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs index 65ee59d..873c571 100644 --- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -9,4 +9,8 @@ public sealed record AgentRuntimeSettings( string SandboxCapabilitySummary, int MaxIterations = 128, int SameFailureLimit = 5, - string VisionUnsupportedMessage = "The selected model does not support image input."); + string VisionUnsupportedMessage = "The selected model does not support image input.", + long NumCtx = 32768, + double ContextSummaryThreshold = 0.80, + int ConversationKeepLastN = 6, + int ExecutionResultsKeepLastN = 10); diff --git a/src/Agent.Core/Runtime/PlanResult.cs b/src/Agent.Core/Runtime/PlanResult.cs new file mode 100644 index 0000000..bad41fa --- /dev/null +++ b/src/Agent.Core/Runtime/PlanResult.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Agent.Core.Runtime; + +/// <summary> +/// LLM-generated plan parsed from structured output. +/// Each planned task contains a description, dependency references (by index), +/// token budget class, PERT estimates, and the requires_external_effect attribute. +/// </summary> +public sealed class PlanResult +{ + [JsonPropertyName("tasks")] + public List<PlannedTask> Tasks { get; init; } = []; +} + +public sealed class PlannedTask +{ + [JsonPropertyName("description")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("dependencies")] + public List<int> Dependencies { get; init; } = []; + + [JsonPropertyName("budget_class")] + public string BudgetClass { get; init; } = "4K"; + + [JsonPropertyName("optimistic_tokens")] + public long OptimisticTokens { get; init; } + + [JsonPropertyName("most_likely_tokens")] + public long MostLikelyTokens { get; init; } + + [JsonPropertyName("pessimistic_tokens")] + public long PessimisticTokens { get; init; } + + [JsonPropertyName("requires_external_effect")] + public bool RequiresExternalEffect { get; init; } +} + +/// <summary> +/// JSON Schema for Ollama structured output during Planning. +/// </summary> +public static class PlanSchema +{ + public static string Json => """{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string"},"dependencies":{"type":"array","items":{"type":"integer"}},"budget_class":{"type":"string","enum":["1K","2K","4K","8K","16K","32K","64K","128K"]},"optimistic_tokens":{"type":"integer","minimum":1},"most_likely_tokens":{"type":"integer","minimum":1},"pessimistic_tokens":{"type":"integer","minimum":1},"requires_external_effect":{"type":"boolean"}},"required":["description","dependencies","budget_class","optimistic_tokens","most_likely_tokens","pessimistic_tokens","requires_external_effect"]}}},"required":["tasks"]}"""; + + private static readonly JsonElement _element = JsonDocument.Parse(Json).RootElement.Clone(); + + public static JsonElement Element => _element; +}
\ No newline at end of file diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs new file mode 100644 index 0000000..7a450d0 --- /dev/null +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -0,0 +1,307 @@ +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; + +/// <summary> +/// 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). +/// </summary> +public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings settings) +{ + private readonly IOllamaClient _ollama = ollama; + private readonly AgentRuntimeSettings _settings = settings; + + /// <summary> + /// 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) + { + // Build a Planning prompt with skill catalog and sandbox capability summary. + IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false); + + var messages = new List<OllamaMessage> + { + 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) + }; + + // 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. + return CreateFallbackGraph(request); + } + + PlanResult? plan = ParsePlan(result.Content); + if (plan is null || plan.Tasks.Count == 0) + return CreateFallbackGraph(request); + + TaskGraph graph = BuildTaskGraph(plan, request); + session.TaskGraphs[taskId] = graph; + return graph; + } + + /// <summary> + /// 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) + { + if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing)) + return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false); + + var messages = new List<OllamaMessage> + { + 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) + }; + + 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); + } + + private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest) + { + var graph = new TaskGraph(32); + var idMap = new List<Guid>(); + + 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<Guid>(); + foreach (int depIdx in pt.Dependencies) + { + if (depIdx >= 0 && depIdx < idMap.Count) + 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); + } + + 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); + var idMap = new List<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)) + { + // 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; + } + } + + 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]); + + 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); + 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<PlanResult>(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch + { + return null; + } + } + + private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, 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<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) + }; + + 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, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + return ParseSelectedSkills(result.Content); + } + catch + { + return []; + } + } + + private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content) + { + try + { + using JsonDocument doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return []; + var selectedNames = new HashSet<string>(StringComparer.Ordinal); + foreach (JsonElement item in skillsElement.EnumerateArray()) + { + string? name = item.GetString(); + if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name); + } + var valid = new List<AgentSkill>(); + 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<AgentSkill> skills) + { + if (skills.Count == 0) return "No skills available."; + var lines = new List<string> { "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. + 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(); + } +}
\ No newline at end of file diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs index d561c8d..07b1f16 100644 --- a/src/Agent.Core/Sessions/AgentSession.cs +++ b/src/Agent.Core/Sessions/AgentSession.cs @@ -1,19 +1,36 @@ +using System.Collections.Concurrent; +using Agent.Planner; + namespace Agent.Core.Sessions; public sealed class AgentSession(Guid sessionId) { private readonly object _sync = new(); private readonly List<ConversationMessage> _conversation = []; + private readonly List<ExecutionResult> _executionResults = []; + private readonly ConcurrentDictionary<Guid, TaskGraph> _taskGraphs = new(); public Guid SessionId { get; } = sessionId; public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow; + public string? Summary { get; private set; } public IReadOnlyList<ConversationMessage> Conversation { get { lock (_sync) return _conversation.ToArray(); } } + public IReadOnlyList<ExecutionResult> ExecutionResults + { + get { lock (_sync) return _executionResults.ToArray(); } + } + + /// <summary> + /// TaskGraphs owned by this session, keyed by MCP Task ID. + /// One TaskGraph per agent_run execution (not shared across tasks). + /// </summary> + public ConcurrentDictionary<Guid, TaskGraph> TaskGraphs => _taskGraphs; + public void Append(ConversationMessage message) { ArgumentNullException.ThrowIfNull(message); @@ -23,4 +40,51 @@ public sealed class AgentSession(Guid sessionId) LastAccessAt = DateTimeOffset.UtcNow; } } + + public void AppendExecutionResult(ExecutionResult result) + { + ArgumentNullException.ThrowIfNull(result); + lock (_sync) + { + _executionResults.Add(result); + LastAccessAt = DateTimeOffset.UtcNow; + } + } + + public void SetSummary(string summary) + { + ArgumentException.ThrowIfNullOrWhiteSpace(summary); + lock (_sync) + { + Summary = summary; + LastAccessAt = DateTimeOffset.UtcNow; + } + } + + /// <summary> + /// Removes old conversation entries from the active context set after summary compaction. + /// Audit/LLM logs are NOT affected — only the context-active representation is compacted. + /// </summary> + public void CompactConversation(int keepLastN) + { + lock (_sync) + { + if (_conversation.Count > keepLastN) + _conversation.RemoveRange(0, _conversation.Count - keepLastN); + LastAccessAt = DateTimeOffset.UtcNow; + } + } + + /// <summary> + /// Removes old execution results from the active context set after summary compaction. + /// </summary> + public void CompactExecutionResults(int keepLastN) + { + lock (_sync) + { + if (_executionResults.Count > keepLastN) + _executionResults.RemoveRange(0, _executionResults.Count - keepLastN); + LastAccessAt = DateTimeOffset.UtcNow; + } + } } diff --git a/src/Agent.Core/Sessions/ExecutionResult.cs b/src/Agent.Core/Sessions/ExecutionResult.cs new file mode 100644 index 0000000..ab665fa --- /dev/null +++ b/src/Agent.Core/Sessions/ExecutionResult.cs @@ -0,0 +1,11 @@ +namespace Agent.Core.Sessions; + +/// <summary> +/// A tool execution result recorded in the session for context summary and audit. +/// The full output is stored in Audit/LLM logs; this is the context-active representation. +/// </summary> +public sealed record ExecutionResult( + Guid TaskId, + string ToolName, + bool Success, + string Output);
\ No newline at end of file diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs index 59ea02c..6224e80 100644 --- a/src/Agent.Core/Sessions/SessionStore.cs +++ b/src/Agent.Core/Sessions/SessionStore.cs @@ -6,6 +6,11 @@ public sealed class SessionStore { private readonly ConcurrentDictionary<Guid, AgentSession> _sessions = new(); + /// <summary> + /// Enumerates all active sessions. Primarily for TaskGraphToolHandler to resolve graphs by TaskId. + /// </summary> + public IEnumerable<AgentSession?> Sessions => _sessions.Values; + public AgentSession Create() { while (true) diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs index 14cba45..a92a829 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]}}}"""), + 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("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/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs index c888705..0ae078c 100644 --- a/src/Agent.Core/Tools/InternalToolDispatcher.cs +++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs @@ -4,7 +4,7 @@ namespace Agent.Core.Tools; public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend) { - public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken); + public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, sessionId, cancellationToken); public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken); public async Task<ToolExecutionResult> DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs index 03daa56..edc1cc5 100644 --- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -1,30 +1,46 @@ using System.Collections.Concurrent; using System.Text.Json; +using Agent.Core.Sessions; using Agent.Planner; namespace Agent.Core.Tools; public interface ITaskUpdateHandler { - void BeginTask(Guid taskId); + void BeginTask(Guid taskId, Guid sessionId); ToolExecutionResult Execute(Guid taskId, JsonElement arguments); void EndTask(Guid taskId); } -public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler +public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessions = null) : ITaskUpdateHandler { - private readonly ConcurrentDictionary<Guid, TaskGraph> _graphs = new(); + private readonly ConcurrentDictionary<Guid, TaskGraph> _fallbackGraphs = new(); - public void BeginTask(Guid taskId) + public void BeginTask(Guid taskId, Guid sessionId) { - if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks))) - throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + TaskGraph graph = new(maxTasks); + if (sessions is not null && sessions.TryGet(sessionId, out AgentSession? session) && session is not null) + { + if (!session.TaskGraphs.TryAdd(taskId, graph)) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists in session '{sessionId}'."); + } + else + { + if (!_fallbackGraphs.TryAdd(taskId, graph)) + throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); + } + } + + public TaskGraph? GetGraph(Guid taskId, Guid? sessionId = null) + { + if (sessionId is not null && sessions is not null && sessions.TryGet(sessionId.Value, out AgentSession? session) && session is not null) + return session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g) ? g : null; + return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null; } public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) { - if (!_graphs.TryGetValue(taskId, out TaskGraph? graph)) - throw new InvalidOperationException("TaskGraph scope is not active."); + TaskGraph? graph = ResolveGraph(taskId) ?? throw new InvalidOperationException("TaskGraph scope is not active."); string operation = arguments.GetProperty("operation").GetString()!; switch (operation) @@ -35,6 +51,7 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler Budget(arguments), Estimate(arguments), Ids(arguments, "depends_on"), + requiresExternalEffect: OptionalBool(arguments, "requires_external_effect"), id: OptionalId(arguments, "task_id")); break; case "add_dependency": @@ -61,7 +78,8 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler description = task.Description, depends_on = task.Dependencies, state = task.State.ToString().ToLowerInvariant(), - expected_tokens = task.ExpectedTokens + expected_tokens = task.ExpectedTokens, + requires_external_effect = task.RequiresExternalEffect }), critical_path = graph.CriticalPath.TaskIds, critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, @@ -70,7 +88,31 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler return new ToolExecutionResult(true, output, false); } - public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _); + public void EndTask(Guid taskId) + { + // Remove from fallback dict; session-owned graphs persist for context summary. + _fallbackGraphs.TryRemove(taskId, out _); + } + + private TaskGraph? ResolveGraph(Guid taskId) + { + // Search all sessions first, then fallback. + if (sessions is not null) + { + foreach (AgentSession? session in sessions.Sessions) + { + if (session is not null && session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g)) + return g; + } + } + return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null; + } + + private static bool OptionalBool(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out JsonElement value)) return false; + return value.ValueKind == JsonValueKind.True; + } private static TokenBudgetClass Budget(JsonElement arguments) => TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs index d478a2c..cefca8d 100644 --- a/src/Agent.Core/Tools/ToolPolicy.cs +++ b/src/Agent.Core/Tools/ToolPolicy.cs @@ -15,7 +15,7 @@ public interface IToolPolicyEngine public interface IInternalToolBackend { - Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask; + Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask; Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken); Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs index 9a2ba66..8ae25d5 100644 --- a/src/Agent.Mcp/McpHost.cs +++ b/src/Agent.Mcp/McpHost.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization.Metadata; using System.Text.Json.Nodes; using System.Reflection; using Agent.Core.Localization; +using Agent.Core.Sessions; using Agent.Core.Tasks; using Agent.Core.Ollama; using Agent.Core.Runtime; @@ -24,7 +25,7 @@ namespace Agent.Mcp; public static class McpHost { - public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null) + public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null) { HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { @@ -32,7 +33,7 @@ public static class McpHost ContentRootPath = AppContext.BaseDirectory }); builder.Logging.ClearProviders(); - var registry = new TaskRegistry(); + var registry = new TaskRegistry(sessionStore); using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; var ollama = new OllamaClient(httpClient, ollamaSettings); var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer); @@ -58,7 +59,7 @@ public static class McpHost await host.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false); } - public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null) + public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null) { if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address)) throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName)); @@ -66,7 +67,7 @@ public static class McpHost builder.Logging.ClearProviders(); string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); builder.WebHost.UseUrls($"http://{listenHost}:{port}"); - var registry = new TaskRegistry(); + var registry = new TaskRegistry(sessionStore); var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer); builder.Services.AddSingleton(locale); diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs index edea1ad..799d5d8 100644 --- a/src/Agent.Mcp/QueuedTaskWorker.cs +++ b/src/Agent.Mcp/QueuedTaskWorker.cs @@ -11,10 +11,10 @@ public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime await foreach (Guid taskId in registry.ReadQueuedAsync(stoppingToken).ConfigureAwait(false)) { if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue; - registry.Transition(taskId, AgentTaskState.Running, "ollama chat"); using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, registry.GetCancellationToken(taskId)); try { + // ExecuteAsync handles Planning → Running transition internally. await runtime.ExecuteAsync(taskId, linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (linked.IsCancellationRequested) diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index 936bcbd..6e62d3d 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -31,14 +31,14 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell); private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler(); - public async Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) + public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) { if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active."); AppContainerProfile profile = AppContainerProfile.EnsureFixed(); TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId); var acl = new TaskAclManager(profile.Sid, audit, journal); var context = new TaskContext(journal, acl); - _taskUpdates.BeginTask(taskId); + _taskUpdates.BeginTask(taskId, sessionId); try { foreach ((string path, FileCapability capabilities) in settings.Files @@ -71,16 +71,23 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { TaskContext context = Get(taskId); - return toolName switch + try { - "file_search" => Task.FromResult(Search(arguments)), - "file_read" => Task.FromResult(Read(arguments)), - "file_edit" => Task.FromResult(Edit(context, arguments)), - "process_run" => Task.FromResult(RunProcess(taskId, arguments)), - "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), - "dir" => Task.FromResult(DirectoryOp(context, arguments)), - _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") - }; + return toolName switch + { + "file_search" => Task.FromResult(Search(arguments)), + "file_read" => Task.FromResult(Read(arguments)), + "file_edit" => Task.FromResult(Edit(context, arguments)), + "process_run" => Task.FromResult(RunProcess(taskId, arguments)), + "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), + "dir" => Task.FromResult(DirectoryOp(context, arguments)), + _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") + }; + } + catch (SandboxPolicyException ex) + { + return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:SandboxPolicyException:{ex.Message}", RetryAllowed: false)); + } } public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) |
