summaryrefslogtreecommitdiff
path: root/src/Agent.Core/Runtime/AgentRuntime.cs
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/Runtime/AgentRuntime.cs
parentb5ccecca01007f2a2459265df6d749fbfd31aa84 (diff)
Audit and align v1 runtime design
Diffstat (limited to 'src/Agent.Core/Runtime/AgentRuntime.cs')
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs228
1 files changed, 182 insertions, 46 deletions
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 0b261da..8cf1ddb 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Agent.Core.Ollama;
+using Agent.Core.Localization;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
using Agent.Core.Skills;
@@ -9,7 +10,7 @@ using Agent.Planner;
namespace Agent.Core.Runtime;
-public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null)
+public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null)
{
private readonly InternalToolDispatcher _tools = toolDispatcher ?? new(
new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)),
@@ -28,12 +29,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return;
if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null)
{
- registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found.");
+ registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found."));
return;
}
try
{
+ var counters = new ExecutionCounters();
// Vision capability check before any Ollama call.
if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false)
throw new InvalidOperationException(settings.VisionUnsupportedMessage);
@@ -42,29 +44,42 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
TaskGraph graph;
try
{
- graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false);
+ graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images,
+ (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
- registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message);
+ registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message));
return;
}
// Phase 2: Running — execute ready Tasks sequentially.
registry.Transition(taskId, AgentTaskState.Running, "running");
- await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false);
+ await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ if (cleanupFailure is null)
+ {
+ registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled."));
+ }
+ else
+ {
+ registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure);
+ registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure);
+ }
}
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
+ registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot);
+ string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed";
+ string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result;
+ registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage);
registry.Transition(taskId, AgentTaskState.Failed, result: result);
}
}
@@ -74,48 +89,61 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop.
/// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase.
/// </summary>
- private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken)
+ private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken)
{
// Apply Sandbox Task Resource at RunAsync time.
await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false);
var finalContents = new List<string>();
- int replanCount = 0;
const int maxReplans = 3;
+ var denyFingerprints = new HashSet<string>(StringComparer.Ordinal);
try
{
+ UpdateTaskProgress(taskId, graph, null, counters);
// Execute tasks in ready order: dependencies satisfied → critical path → creation order.
while (true)
{
PlannerTask? next = SelectNextReadyTask(graph);
- if (next is null) break;
+ if (next is null)
+ {
+ if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break;
+ throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task.");
+ }
graph.SetState(next.Id, PlannerTaskState.Running);
+ UpdateTaskProgress(taskId, graph, next.Description, counters);
try
{
- string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false);
+ string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false);
graph.SetState(next.Id, PlannerTaskState.Completed);
finalContents.Add(content);
+ UpdateTaskProgress(taskId, graph, null, counters);
}
- catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans)
+ catch (PolicyDeniedException ex)
{
- // Policy deny → replan possible (Design §8.4).
- replanCount++;
graph.SetState(next.Id, PlannerTaskState.Failed);
- graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false);
- // Transition back to Running for the replan iteration.
- registry.Transition(taskId, AgentTaskState.Running, "replan");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message);
+ if (!denyFingerprints.Add(ex.Fingerprint))
+ throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}");
+ if (counters.Replans >= maxReplans)
+ throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}");
+
+ string previousSignature = GraphSignature(graph);
+ counters.Replans++;
+ registry.Transition(taskId, AgentTaskState.Planning, "replan");
+ graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message,
+ (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false);
+ if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal))
+ throw new InvalidOperationException("Replan did not change the incomplete TaskGraph.");
+ if (!registry.Transition(taskId, AgentTaskState.Running, "replan"))
+ throw new InvalidOperationException("Task state could not return to running after replan.");
+ UpdateTaskProgress(taskId, graph, null, counters);
continue;
}
}
- // All tasks completed — assemble final result.
- // If replan was attempted but no tasks could execute, provide a meaningful message.
- string finalContent = finalContents.Count > 0
- ? string.Join("\n\n", finalContents)
- : replanCount > 0
- ? "The request could not be completed because the sandbox policy denied required operations."
- : "No tasks were executed.";
+ if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result.");
+ string finalContent = string.Join("\n\n", finalContents);
string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false);
if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure);
session.Append(new ConversationMessage("user", task.Request));
@@ -136,7 +164,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
private static PlannerTask? SelectNextReadyTask(TaskGraph graph)
{
var completedIds = graph.Tasks
- .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed)
+ .Where(static t => t.State == PlannerTaskState.Completed)
.Select(static t => t.Id)
.ToHashSet();
@@ -159,10 +187,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Executes a single PlannerTask: LLM chat-with-tools iteration loop.
/// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard.
/// </summary>
- private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken)
+ private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask,
+ ExecutionCounters counters, CancellationToken cancellationToken)
{
// Progressive Disclosure Stage 2: LLM Skill Selection.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false);
var messages = PromptComposer.Compose(new PromptContext(
FixedPrompt.Load(),
@@ -183,11 +212,24 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
string? finalContent = null;
- for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
+ while (counters.Iterations < settings.MaxIterations)
{
- _observer.LlmRequest(taskId, iteration, messages);
- OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
- _observer.LlmResponse(taskId, iteration, result);
+ int iteration = counters.Iterations++;
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id,
+ "running", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ OllamaResult result;
+ try
+ {
+ result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message);
+ throw;
+ }
+ _observer.LlmResponse(callContext, result);
registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
// Record usage in TaskGraph for CPM tracking.
@@ -195,7 +237,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
// Context Summary: compact old conversation/results when context utilization exceeds threshold.
// Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens).
- TryContextSummary(session, result.PromptEvalCount);
+ await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false);
IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
if (calls.Count > 0)
@@ -227,9 +269,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
// Record ExecutionResult in session for context summary.
session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output));
- _observer.ToolResult(taskId, call.Function.Name, toolResult);
+ _observer.ToolResult(callContext, call.Function.Name, toolResult);
if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool)
- throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
+ throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output);
failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
}
@@ -256,7 +298,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
break;
}
- if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+ if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}.");
return finalContent;
}
@@ -283,20 +325,53 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
/// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens.
/// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials.
/// </summary>
- private void TryContextSummary(AgentSession session, long promptEvalCount)
+ private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount,
+ ExecutionCounters counters, CancellationToken cancellationToken)
{
if (settings.NumCtx <= 0) return;
double utilization = (double)promptEvalCount / settings.NumCtx;
if (utilization < settings.ContextSummaryThreshold) return;
- // Compact old conversation entries, keeping only the most recent N.
- // The summary itself is generated by the LLM in a separate call (future enhancement);
- // for now, we compact the active context set to stay within num_ctx.
- session.CompactConversation(settings.ConversationKeepLastN);
- session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
+ string source = JsonSerializer.Serialize(new
+ {
+ previous_summary = session.Summary,
+ conversation = session.Conversation,
+ execution_results = session.ExecutionResults
+ });
+ var messages = new List<OllamaMessage>
+ {
+ new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."),
+ new("user", source)
+ };
+ using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}""");
+ AgentLlmCallContext? callContext = null;
+ try
+ {
+ if (counters.Iterations >= settings.MaxIterations) return;
+ int iteration = counters.Iterations++;
+ callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null,
+ "context_summary", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ using JsonDocument response = JsonDocument.Parse(result.Content);
+ string? summary = response.RootElement.GetProperty("summary").GetString();
+ if (string.IsNullOrWhiteSpace(summary)) return;
+ session.SetSummary(summary);
+ session.CompactConversation(settings.ConversationKeepLastN);
+ session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message);
+ }
}
- private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken)
{
if (settings.Skills.Count == 0) return [];
@@ -304,25 +379,86 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
var selectionMessages = new List<OllamaMessage>
{
new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."),
- new("user", request)
+ new("user", task.Request)
};
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
OllamaResult result;
+ AgentLlmCallContext? callContext = null;
try
{
- result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ if (counters.Iterations >= settings.MaxIterations) return [];
+ int iteration = counters.Iterations++;
+ callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
+ "skill_selection", iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, selectionMessages);
+ result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
+ registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
}
- catch
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
+ if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message);
return [];
}
return ParseSelectedSkills(result.Content);
}
+ private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase,
+ IReadOnlyList<OllamaMessage> messages, OllamaResult? result, Exception? error)
+ {
+ if (counters.Iterations >= settings.MaxIterations)
+ throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}.");
+ counters.Iterations++;
+ int iteration = counters.Iterations - 1;
+ var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null,
+ phase, iteration, counters.Replans, settings.Model);
+ _observer.LlmRequest(callContext, messages);
+ if (result is not null)
+ {
+ _observer.LlmResponse(callContext, result);
+ registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens);
+ }
+ if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message);
+ registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans);
+ }
+
+ private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters)
+ {
+ int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed);
+ registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans);
+ }
+
+ private static string GraphSignature(TaskGraph graph)
+ {
+ IReadOnlyDictionary<Guid, string> descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description);
+ return string.Join("\n", graph.Tasks
+ .Where(static item => item.State != PlannerTaskState.Completed)
+ .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}"));
+ }
+
+ private sealed class ExecutionCounters
+ {
+ public int Iterations { get; set; }
+ public int Replans { get; set; }
+ }
+
+ private string Message(string key, string fallback, string? detail = null)
+ {
+ if (locale is null) return fallback;
+ return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary<string, object?> { ["message"] = detail });
+ }
+
+ private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message)
+ {
+ public string Fingerprint { get; } = fingerprint;
+ }
+
private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
{
try
@@ -364,4 +500,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
return $"Task resource cleanup failed: {ex.Message}";
}
}
-} \ No newline at end of file
+}