summaryrefslogtreecommitdiff
path: root/src/Agent.Core/Runtime/AgentRuntime.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 05:17:32 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 05:17:32 +0900
commit35719799e62a18850efc52d380fb53bf3baa8022 (patch)
tree3cc1d110f290e5acab71ca202a4cd0d521e4a8b5 /src/Agent.Core/Runtime/AgentRuntime.cs
parentd5d0a84e573c833843279c520ce2d5765425ba93 (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/Agent.Core/Runtime/AgentRuntime.cs')
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs364
1 files changed, 244 insertions, 120 deletions
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