summaryrefslogtreecommitdiff
path: root/src/Agent.Core
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
commit6c3458a5ec37a5b69957ad17c84de58984698f25 (patch)
treebe5c5716e7488fcf1bc1a58de0ded0b9238f34cb /src/Agent.Core
parentb5ccecca01007f2a2459265df6d749fbfd31aa84 (diff)
Audit and align v1 runtime design
Diffstat (limited to 'src/Agent.Core')
-rw-r--r--src/Agent.Core/Ollama/IOllamaClient.cs2
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs13
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs228
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs4
-rw-r--r--src/Agent.Core/Runtime/IAgentRuntimeObserver.cs17
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs177
-rw-r--r--src/Agent.Core/Tasks/TaskRegistry.cs55
-rw-r--r--src/Agent.Core/Tasks/TaskSnapshot.cs6
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs2
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs1
-rw-r--r--src/Agent.Core/Tools/TaskGraphToolHandler.cs25
11 files changed, 348 insertions, 182 deletions
diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs
index d1cb99f..88a2bec 100644
--- a/src/Agent.Core/Ollama/IOllamaClient.cs
+++ b/src/Agent.Core/Ollama/IOllamaClient.cs
@@ -10,5 +10,7 @@ public interface IOllamaClient
ChatAsync(messages, temperatureOverride, cancellationToken);
Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) =>
ChatAsync(messages, temperatureOverride, cancellationToken);
+ Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) =>
+ ChatStructuredAsync(messages, temperatureOverride, format, cancellationToken);
Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(null);
}
diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs
index dde8478..428bca9 100644
--- a/src/Agent.Core/Ollama/OllamaClient.cs
+++ b/src/Agent.Core/Ollama/OllamaClient.cs
@@ -22,13 +22,16 @@ public sealed class OllamaClient : IOllamaClient
}
public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
- => await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false);
+ => await ChatCoreAsync(messages, temperatureOverride, null, null, null, cancellationToken).ConfigureAwait(false);
public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
- => await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false);
+ => await ChatCoreAsync(messages, temperatureOverride, tools, null, null, cancellationToken).ConfigureAwait(false);
public async Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
- => await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false);
+ => await ChatCoreAsync(messages, temperatureOverride, null, format, null, cancellationToken).ConfigureAwait(false);
+
+ public async Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken)
+ => await ChatCoreAsync(messages, temperatureOverride, null, format, thinkOverride, cancellationToken).ConfigureAwait(false);
public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken)
{
@@ -45,7 +48,7 @@ public sealed class OllamaClient : IOllamaClient
catch (HttpRequestException) { return null; }
}
- private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, CancellationToken cancellationToken)
+ private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, bool? thinkOverride, CancellationToken cancellationToken)
{
Dictionary<string, JsonElement> options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value);
@@ -54,7 +57,7 @@ public sealed class OllamaClient : IOllamaClient
Model = _settings.Model,
Messages = messages,
Stream = false,
- Think = _settings.Think,
+ Think = thinkOverride ?? _settings.Think,
KeepAlive = _settings.KeepAlive,
Options = options,
Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(),
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 0b261da..8cf1ddb 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Agent.Core.Ollama;
+using Agent.Core.Localization;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
using Agent.Core.Skills;
@@ -9,7 +10,7 @@ using Agent.Planner;
namespace Agent.Core.Runtime;
-public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null)
+public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null)
{
private readonly InternalToolDispatcher _tools = toolDispatcher ?? new(
new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)),
@@ -28,12 +29,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return;
if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null)
{
- registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found.");
+ registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found."));
return;
}
try
{
+ var counters = new ExecutionCounters();
// Vision capability check before any Ollama call.
if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false)
throw new InvalidOperationException(settings.VisionUnsupportedMessage);
@@ -42,29 +44,42 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
TaskGraph graph;
try
{
- graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false);
+ graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images,
+ (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
- registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message);
+ registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message));
return;
}
// Phase 2: Running — execute ready Tasks sequentially.
registry.Transition(taskId, AgentTaskState.Running, "running");
- await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false);
+ await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ if (cleanupFailure is null)
+ {
+ registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled."));
+ }
+ else
+ {
+ registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure);
+ registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure);
+ }
}
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
+ registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot);
+ string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed";
+ string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result;
+ registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage);
registry.Transition(taskId, AgentTaskState.Failed, result: result);
}
}
@@ -74,48 +89,61 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop.
/// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase.
/// </summary>
- private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken)
+ private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken)
{
// Apply Sandbox Task Resource at RunAsync time.
await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false);
var finalContents = new List<string>();
- int replanCount = 0;
const int maxReplans = 3;
+ var denyFingerprints = new HashSet<string>(StringComparer.Ordinal);
try
{
+ UpdateTaskProgress(taskId, graph, null, counters);
// Execute tasks in ready order: dependencies satisfied → critical path → creation order.
while (true)
{
PlannerTask? next = SelectNextReadyTask(graph);
- if (next is null) break;
+ if (next is null)
+ {
+ if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break;
+ throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task.");
+ }
graph.SetState(next.Id, PlannerTaskState.Running);
+ UpdateTaskProgress(taskId, graph, next.Description, counters);
try
{
- string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false);
+ string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false);
graph.SetState(next.Id, PlannerTaskState.Completed);
finalContents.Add(content);
+ UpdateTaskProgress(taskId, graph, null, counters);
}
- catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans)
+ catch (PolicyDeniedException ex)
{
- // Policy deny → replan possible (Design §8.4).
- replanCount++;
graph.SetState(next.Id, PlannerTaskState.Failed);
- graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false);
- // Transition back to Running for the replan iteration.
- registry.Transition(taskId, AgentTaskState.Running, "replan");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message);
+ if (!denyFingerprints.Add(ex.Fingerprint))
+ throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}");
+ if (counters.Replans >= maxReplans)
+ throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}");
+
+ string previousSignature = GraphSignature(graph);
+ counters.Replans++;
+ registry.Transition(taskId, AgentTaskState.Planning, "replan");
+ graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message,
+ (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false);
+ if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal))
+ throw new InvalidOperationException("Replan did not change the incomplete TaskGraph.");
+ if (!registry.Transition(taskId, AgentTaskState.Running, "replan"))
+ throw new InvalidOperationException("Task state could not return to running after replan.");
+ UpdateTaskProgress(taskId, graph, null, counters);
continue;
}
}
- // All tasks completed — assemble final result.
- // If replan was attempted but no tasks could execute, provide a meaningful message.
- string finalContent = finalContents.Count > 0
- ? string.Join("\n\n", finalContents)
- : replanCount > 0
- ? "The request could not be completed because the sandbox policy denied required operations."
- : "No tasks were executed.";
+ if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result.");
+ string finalContent = string.Join("\n\n", finalContents);
string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false);
if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure);
session.Append(new ConversationMessage("user", task.Request));
@@ -136,7 +164,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
private static PlannerTask? SelectNextReadyTask(TaskGraph graph)
{
var completedIds = graph.Tasks
- .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed)
+ .Where(static t => t.State == PlannerTaskState.Completed)
.Select(static t => t.Id)
.ToHashSet();
@@ -159,10 +187,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Executes a single PlannerTask: LLM chat-with-tools iteration loop.
/// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard.
/// </summary>
- private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken)
+ private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask,
+ ExecutionCounters counters, CancellationToken cancellationToken)
{
// Progressive Disclosure Stage 2: LLM Skill Selection.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false);
var messages = PromptComposer.Compose(new PromptContext(
FixedPrompt.Load(),
@@ -183,11 +212,24 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
string? finalContent = null;
- for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
+ while (counters.Iterations < settings.MaxIterations)
{
- _observer.LlmRequest(taskId, iteration, messages);
- OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
- _observer.LlmResponse(taskId, iteration, result);
+ int iteration = counters.Iterations++;
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id,
+ "running", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ OllamaResult result;
+ try
+ {
+ result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message);
+ throw;
+ }
+ _observer.LlmResponse(callContext, result);
registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
// Record usage in TaskGraph for CPM tracking.
@@ -195,7 +237,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
// Context Summary: compact old conversation/results when context utilization exceeds threshold.
// Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens).
- TryContextSummary(session, result.PromptEvalCount);
+ await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false);
IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
if (calls.Count > 0)
@@ -227,9 +269,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
// Record ExecutionResult in session for context summary.
session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output));
- _observer.ToolResult(taskId, call.Function.Name, toolResult);
+ _observer.ToolResult(callContext, call.Function.Name, toolResult);
if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool)
- throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
+ throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output);
failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
}
@@ -256,7 +298,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
break;
}
- if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+ if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}.");
return finalContent;
}
@@ -283,20 +325,53 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens.
/// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials.
/// </summary>
- private void TryContextSummary(AgentSession session, long promptEvalCount)
+ private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount,
+ ExecutionCounters counters, CancellationToken cancellationToken)
{
if (settings.NumCtx <= 0) return;
double utilization = (double)promptEvalCount / settings.NumCtx;
if (utilization < settings.ContextSummaryThreshold) return;
- // Compact old conversation entries, keeping only the most recent N.
- // The summary itself is generated by the LLM in a separate call (future enhancement);
- // for now, we compact the active context set to stay within num_ctx.
- session.CompactConversation(settings.ConversationKeepLastN);
- session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
+ string source = JsonSerializer.Serialize(new
+ {
+ previous_summary = session.Summary,
+ conversation = session.Conversation,
+ execution_results = session.ExecutionResults
+ });
+ var messages = new List<OllamaMessage>
+ {
+ new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."),
+ new("user", source)
+ };
+ using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}""");
+ AgentLlmCallContext? callContext = null;
+ try
+ {
+ if (counters.Iterations >= settings.MaxIterations) return;
+ int iteration = counters.Iterations++;
+ callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null,
+ "context_summary", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ using JsonDocument response = JsonDocument.Parse(result.Content);
+ string? summary = response.RootElement.GetProperty("summary").GetString();
+ if (string.IsNullOrWhiteSpace(summary)) return;
+ session.SetSummary(summary);
+ session.CompactConversation(settings.ConversationKeepLastN);
+ session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message);
+ }
}
- private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken)
{
if (settings.Skills.Count == 0) return [];
@@ -304,25 +379,86 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
var selectionMessages = new List<OllamaMessage>
{
new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."),
- new("user", request)
+ new("user", task.Request)
};
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
OllamaResult result;
+ AgentLlmCallContext? callContext = null;
try
{
- result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ if (counters.Iterations >= settings.MaxIterations) return [];
+ int iteration = counters.Iterations++;
+ callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
+ "skill_selection", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, selectionMessages);
+ result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
+ registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
}
- catch
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
+ if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message);
return [];
}
return ParseSelectedSkills(result.Content);
}
+ private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase,
+ IReadOnlyList<OllamaMessage> messages, OllamaResult? result, Exception? error)
+ {
+ if (counters.Iterations >= settings.MaxIterations)
+ throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}.");
+ counters.Iterations++;
+ int iteration = counters.Iterations - 1;
+ var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
+ phase, iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ if (result is not null)
+ {
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
+ }
+ if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message);
+ registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
+ }
+
+ private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters)
+ {
+ int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed);
+ registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ }
+
+ private static string GraphSignature(TaskGraph graph)
+ {
+ IReadOnlyDictionary<Guid, string> descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description);
+ return string.Join("\n", graph.Tasks
+ .Where(static item => item.State != PlannerTaskState.Completed)
+ .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}"));
+ }
+
+ private sealed class ExecutionCounters
+ {
+ public int Iterations { get; set; }
+ public int Replans { get; set; }
+ }
+
+ private string Message(string key, string fallback, string? detail = null)
+ {
+ if (locale is null) return fallback;
+ return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary<string, object?> { ["message"] = detail });
+ }
+
+ private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message)
+ {
+ public string Fingerprint { get; } = fingerprint;
+ }
+
private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
{
try
@@ -364,4 +500,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
return $"Task resource cleanup failed: {ex.Message}";
}
}
-} \ No newline at end of file
+}
diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
index 873c571..b6d8504 100644
--- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
+++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
@@ -13,4 +13,6 @@ public sealed record AgentRuntimeSettings(
long NumCtx = 32768,
double ContextSummaryThreshold = 0.80,
int ConversationKeepLastN = 6,
- int ExecutionResultsKeepLastN = 10);
+ int ExecutionResultsKeepLastN = 10,
+ int MaxTasks = 32,
+ string Model = "");
diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs
index e4e26e3..5098a92 100644
--- a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs
+++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs
@@ -5,15 +5,20 @@ namespace Agent.Core.Runtime;
public interface IAgentRuntimeObserver
{
- void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages);
- void LlmResponse(Guid taskId, int iteration, OllamaResult result);
- void ToolResult(Guid taskId, string toolName, ToolExecutionResult result);
+ void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages);
+ void LlmResponse(AgentLlmCallContext context, OllamaResult result);
+ void LlmFailure(AgentLlmCallContext context, string errorCode, string message);
+ void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result);
}
+public sealed record AgentLlmCallContext(Guid CallId, Guid SessionId, Guid TaskId, Guid? PlannerTaskId,
+ string Phase, int Iteration, int Replan, string Model);
+
public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver
{
public static NullAgentRuntimeObserver Instance { get; } = new();
- public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) { }
- public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { }
- public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { }
+ public void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages) { }
+ public void LlmResponse(AgentLlmCallContext context, OllamaResult result) { }
+ public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) { }
+ public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) { }
}
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
index bf896f0..99569e4 100644
--- a/src/Agent.Core/Runtime/PlannerService.cs
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -23,10 +23,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
/// Generates an initial TaskGraph for the given request via Ollama structured output.
/// Returns a TaskGraph stored in the session, keyed by the MCP Task ID.
/// </summary>
- public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, CancellationToken cancellationToken)
+ public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken)
{
// Build a Planning prompt with skill catalog and sandbox capability summary.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false);
var messages = new List<OllamaMessage>
{
@@ -39,39 +40,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
new("user", request, images)
};
- // Use structured output to get a plan.
- OllamaResult result;
- try
- {
- result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
- }
- catch
- {
- // Fallback: single-task plan with conservative estimates.
- TaskGraph fallback = CreateFallbackGraph(request);
- session.TaskGraphs[taskId] = fallback;
- return fallback;
- }
-
- PlanResult? plan = ParsePlan(result.Content);
- if (plan is null || plan.Tasks.Count == 0)
- {
- TaskGraph fallback = CreateFallbackGraph(request);
- session.TaskGraphs[taskId] = fallback;
- return fallback;
- }
-
- TaskGraph graph;
- try
- {
- graph = BuildTaskGraph(plan, request);
- }
- catch
- {
- TaskGraph fallback = CreateFallbackGraph(request);
- session.TaskGraphs[taskId] = fallback;
- return fallback;
- }
+ TaskGraph graph = await RequestValidGraphAsync(messages, temperature, "planning", observeAttempt,
+ plan => BuildTaskGraph(plan, request), cancellationToken).ConfigureAwait(false);
session.TaskGraphs[taskId] = graph;
return graph;
}
@@ -80,10 +50,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
/// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph.
/// Completed tasks are immutable; only incomplete tasks are modified.
/// </summary>
- public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken)
+ public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken)
{
if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing))
- return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false);
+ return await PlanAsync(taskId, sessionId, session, request, temperature, null, observeAttempt, cancellationToken).ConfigureAwait(false);
var messages = new List<OllamaMessage>
{
@@ -95,26 +66,15 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
new("user", request)
};
- OllamaResult result;
- try
- {
- result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
- }
- catch
- {
- return existing;
- }
-
- PlanResult? plan = ParsePlan(result.Content);
- if (plan is null || plan.Tasks.Count == 0) return existing;
-
- // Build a new graph, preserving completed tasks from the existing graph.
- return RebuildTaskGraph(existing, plan, request);
+ TaskGraph rebuilt = await RequestValidGraphAsync(messages, temperature, "replanning", observeAttempt,
+ plan => RebuildTaskGraph(existing, plan, request), cancellationToken).ConfigureAwait(false);
+ session.TaskGraphs[taskId] = rebuilt;
+ return rebuilt;
}
private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
{
- var graph = new TaskGraph(32);
+ var graph = new TaskGraph(_settings.MaxTasks);
var idMap = new List<Guid>();
for (int i = 0; i < plan.Tasks.Count; i++)
@@ -127,81 +87,65 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
var dependencies = new HashSet<Guid>();
foreach (int depIdx in pt.Dependencies)
{
- if (depIdx >= 0 && depIdx < idMap.Count)
- dependencies.Add(idMap[depIdx]);
+ if (depIdx < 0 || depIdx >= idMap.Count)
+ throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}.");
+ dependencies.Add(idMap[depIdx]);
}
string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
- try
- {
- PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect);
- idMap.Add(task.Id);
- }
- catch (PlannerValidationException)
- {
- // If a task fails validation (e.g., bad estimate, cycle), fall back to single-task graph.
- return CreateFallbackGraph(originalRequest);
- }
+ PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect);
+ idMap.Add(task.Id);
}
- // If all tasks were filtered out, fall back.
- if (graph.Count == 0) return CreateFallbackGraph(originalRequest);
+ if (graph.Count == 0) throw new PlannerValidationException("Structured plan did not contain an executable Task.");
return graph;
}
private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
{
- // Collect completed task descriptions to skip them in the new graph.
- var completedDescriptions = new HashSet<string>(
- existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description),
- StringComparer.Ordinal);
-
- var graph = new TaskGraph(32);
+ PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray();
+ var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal)
+ .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal);
+ var graph = new TaskGraph(_settings.MaxTasks);
+ foreach (PlannerTask item in completed)
+ {
+ Guid[] dependencies = item.Dependencies.Where(id => completed.Any(candidate => candidate.Id == id)).ToArray();
+ PlannerTask copy = graph.AddTask(item.Description, item.BudgetClass, item.Estimate, dependencies, item.RequiresExternalEffect, item.Id);
+ graph.AddUsage(copy.Id, item.ActualInputTokens, item.ActualOutputTokens);
+ graph.SetState(copy.Id, PlannerTaskState.Completed);
+ }
var idMap = new List<Guid>();
+ var reusedCompleted = new HashSet<Guid>();
for (int i = 0; i < plan.Tasks.Count; i++)
{
PlannedTask pt = plan.Tasks[i];
// Skip tasks that are already completed in the existing graph.
- if (completedDescriptions.Contains(pt.Description))
+ if (completedByDescription.TryGetValue(pt.Description, out PlannerTask? completedTask) && reusedCompleted.Add(completedTask.Id))
{
- // Find the completed task and add it as completed to the new graph.
- PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description);
- if (completedTask is not null)
- {
- TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
- TokenEstimate estimate = CreateEstimate(pt, budgetClass);
- var deps = new HashSet<Guid>();
- foreach (int depIdx in pt.Dependencies)
- if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]);
- PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect);
- graph.SetState(added.Id, PlannerTaskState.Completed);
- idMap.Add(added.Id);
- continue;
- }
+ idMap.Add(completedTask.Id);
+ continue;
}
TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass);
TokenEstimate est = CreateEstimate(pt, bc);
var dependencies = new HashSet<Guid>();
foreach (int depIdx in pt.Dependencies)
- if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]);
+ {
+ if (depIdx < 0 || depIdx >= idMap.Count)
+ throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}.");
+ dependencies.Add(idMap[depIdx]);
+ }
string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect);
idMap.Add(task.Id);
}
- return graph;
- }
-
- private TaskGraph CreateFallbackGraph(string request)
- {
- var graph = new TaskGraph(32);
- TokenEstimate estimate = new(512, 2048, 8192);
- graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false);
+ if (graph.Tasks.All(static task => task.State == PlannerTaskState.Completed))
+ throw new PlannerValidationException("Replan did not produce an incomplete executable Task.");
return graph;
}
@@ -238,7 +182,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
}
}
- private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, double? temperature,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken)
{
if (_settings.Skills.Count == 0) return [];
@@ -254,15 +199,45 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
try
{
- OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ observeAttempt?.Invoke("skill_selection", selectionMessages, result, null);
return ParseSelectedSkills(result.Content);
}
- catch
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
+ observeAttempt?.Invoke("skill_selection", selectionMessages, null, ex);
return [];
}
}
+ private async Task<TaskGraph> RequestValidGraphAsync(IReadOnlyList<OllamaMessage> messages, double? temperature, string phase,
+ Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, Func<PlanResult, TaskGraph> buildGraph, CancellationToken cancellationToken)
+ {
+ Exception? firstFailure = null;
+ for (int attempt = 0; attempt < 2; attempt++)
+ {
+ OllamaResult? result = null;
+ try
+ {
+ bool? thinkOverride = attempt == 0 ? null : false;
+ result = await _ollama.ChatStructuredAsync(messages, temperature, PlanSchema.Element, thinkOverride, cancellationToken).ConfigureAwait(false);
+ PlanResult? plan = ParsePlan(result.Content);
+ if (plan is null || plan.Tasks.Count == 0)
+ throw new PlannerValidationException("Ollama returned an empty or invalid structured plan.");
+ TaskGraph graph = buildGraph(plan);
+ observeAttempt?.Invoke(phase, messages, result, null);
+ return graph;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ observeAttempt?.Invoke(phase, messages, result, ex);
+ firstFailure ??= ex;
+ }
+ }
+ throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {firstFailure?.Message}");
+ }
+
private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
{
try
@@ -331,4 +306,4 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
sb.AppendLine("Return a complete revised plan including completed tasks.");
return sb.ToString();
}
-} \ No newline at end of file
+}
diff --git a/src/Agent.Core/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs
index b7a4afb..a89be6b 100644
--- a/src/Agent.Core/Tasks/TaskRegistry.cs
+++ b/src/Agent.Core/Tasks/TaskRegistry.cs
@@ -60,6 +60,12 @@ public sealed class TaskRegistry
public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) =>
_tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens);
+ public bool UpdateProgress(Guid taskId, int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) =>
+ _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateProgress(completedTasks, totalTasks, currentTask, criticalPathTokens);
+
+ public bool UpdateDiagnostics(Guid taskId, int? iterations, int? replans, string? errorCode = null, string? error = null) =>
+ _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateDiagnostics(iterations, replans, errorCode, error);
+
public CancellationToken GetCancellationToken(Guid taskId) =>
_tasks.TryGetValue(taskId, out MutableTask? task) ? task.CancellationToken : CancellationToken.None;
@@ -81,6 +87,13 @@ public sealed class TaskRegistry
private string? _result;
private long _inputTokens;
private long _outputTokens;
+ private int _completedTasks;
+ private int _totalTasks = 1;
+ private double _criticalPathTokens;
+ private int _iterations;
+ private int _replans;
+ private string? _lastErrorCode;
+ private string? _lastError;
private readonly CancellationTokenSource _cancellation = new();
private readonly TaskCompletionSource _terminal = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -93,8 +106,9 @@ public sealed class TaskRegistry
{
lock (_sync)
{
- int completed = _state == AgentTaskState.Completed ? 1 : 0;
- return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images);
+ return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result,
+ _completedTasks, _totalTasks, _inputTokens, _outputTokens, _criticalPathTokens, images,
+ _iterations, _replans, _lastErrorCode, _lastError);
}
}
@@ -122,16 +136,45 @@ public sealed class TaskRegistry
}
}
+ public bool UpdateProgress(int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens)
+ {
+ if (completedTasks < 0 || totalTasks < 0 || completedTasks > totalTasks || criticalPathTokens < 0) return false;
+ lock (_sync)
+ {
+ _completedTasks = completedTasks;
+ _totalTasks = totalTasks;
+ _currentTask = currentTask;
+ _criticalPathTokens = criticalPathTokens;
+ return true;
+ }
+ }
+
+ public bool UpdateDiagnostics(int? iterations, int? replans, string? errorCode, string? error)
+ {
+ if (iterations < 0 || replans < 0) return false;
+ lock (_sync)
+ {
+ if (iterations.HasValue) _iterations = iterations.Value;
+ if (replans.HasValue) _replans = replans.Value;
+ if (errorCode is not null) _lastErrorCode = errorCode;
+ if (error is not null) _lastError = error;
+ return true;
+ }
+ }
+
public bool Cancel()
{
lock (_sync)
{
if (_state is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) return false;
_cancellation.Cancel();
- _state = AgentTaskState.Cancelled;
- _currentTask = null;
- _result = "cancelled";
- _terminal.TrySetResult();
+ if (_state == AgentTaskState.Queued)
+ {
+ _state = AgentTaskState.Cancelled;
+ _currentTask = null;
+ _result = "cancelled";
+ _terminal.TrySetResult();
+ }
return true;
}
}
diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs
index 0a15de3..64844a8 100644
--- a/src/Agent.Core/Tasks/TaskSnapshot.cs
+++ b/src/Agent.Core/Tasks/TaskSnapshot.cs
@@ -13,4 +13,8 @@ public sealed record TaskSnapshot(
long InputTokens,
long OutputTokens,
double CriticalPathTokens,
- IReadOnlyList<string> Images);
+ IReadOnlyList<string> Images,
+ int Iterations = 0,
+ int Replans = 0,
+ string? LastErrorCode = null,
+ string? LastError = null);
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs
index a92a829..d261269 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]},"requires_external_effect":{"type":["boolean","null"]}}}"""),
+ Define("task_update", "Update the deterministic task graph structure or estimates.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","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},"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/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs
index d789dbf..57f58b2 100644
--- a/src/Agent.Core/Tools/InternalToolValidator.cs
+++ b/src/Agent.Core/Tools/InternalToolValidator.cs
@@ -74,7 +74,6 @@ public static class InternalToolValidator
string operation = arguments.GetProperty("operation").GetString()!;
if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens");
if (operation == "add_dependency") Require(arguments, "task_id", "depends_on");
- if (operation == "set_state") Require(arguments, "task_id", "state");
if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens");
}
// dir: path and operation are already required by the schema; no operation-specific
diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
index 10094d3..26d5a27 100644
--- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs
+++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
@@ -56,15 +56,15 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio
break;
case "add_dependency":
Guid taskIdValue = RequiredId(arguments, "task_id");
+ DemandStructuralMutation(graph, taskIdValue);
Guid[] dependencies = Ids(arguments, "depends_on");
if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id.");
foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency);
break;
- case "set_state":
- graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state")));
- break;
case "set_estimate":
- graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments));
+ Guid estimateTaskId = RequiredId(arguments, "task_id");
+ DemandStructuralMutation(graph, estimateTaskId);
+ graph.SetEstimate(estimateTaskId, Budget(arguments), Estimate(arguments));
break;
default:
throw new ToolValidationException($"Unsupported task_update operation '{operation}'.");
@@ -114,6 +114,13 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio
return value.ValueKind == JsonValueKind.True;
}
+ private static void DemandStructuralMutation(TaskGraph graph, Guid taskId)
+ {
+ PlannerTaskState state = graph.Get(taskId).State;
+ if (state is not (PlannerTaskState.Queued or PlannerTaskState.Planning))
+ throw new PlannerValidationException($"TaskGraph structure or estimate cannot be changed while Task '{taskId}' is {state}.");
+ }
+
private static TokenBudgetClass Budget(JsonElement arguments) =>
TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class"));
@@ -152,14 +159,4 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio
return [.. ids];
}
- private static PlannerTaskState ParseState(string state) => state switch
- {
- "queued" => PlannerTaskState.Queued,
- "planning" => PlannerTaskState.Planning,
- "running" => PlannerTaskState.Running,
- "completed" => PlannerTaskState.Completed,
- "failed" => PlannerTaskState.Failed,
- "cancelled" => PlannerTaskState.Cancelled,
- _ => throw new ToolValidationException($"Unknown task state '{state}'.")
- };
}