summaryrefslogtreecommitdiff
path: root/src/Agent.Core
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Core')
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs2
-rw-r--r--src/Agent.Core/Ollama/OllamaModels.cs2
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs364
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs6
-rw-r--r--src/Agent.Core/Runtime/PlanResult.cs51
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs307
-rw-r--r--src/Agent.Core/Sessions/AgentSession.cs64
-rw-r--r--src/Agent.Core/Sessions/ExecutionResult.cs11
-rw-r--r--src/Agent.Core/Sessions/SessionStore.cs5
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs2
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs2
-rw-r--r--src/Agent.Core/Tools/TaskGraphToolHandler.cs62
-rw-r--r--src/Agent.Core/Tools/ToolPolicy.cs2
13 files changed, 744 insertions, 136 deletions
diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs
index c761081..dde8478 100644
--- a/src/Agent.Core/Ollama/OllamaClient.cs
+++ b/src/Agent.Core/Ollama/OllamaClient.cs
@@ -82,7 +82,7 @@ public sealed class OllamaClient : IOllamaClient
?? throw new OllamaException("Ollama returned an empty response.", false);
if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false);
if (result.Message is null) throw new OllamaException("Ollama response has no message.", false);
- return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []);
+ return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? [], result.PromptEvalCount);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs
index f4b3884..f6ff092 100644
--- a/src/Agent.Core/Ollama/OllamaModels.cs
+++ b/src/Agent.Core/Ollama/OllamaModels.cs
@@ -44,4 +44,4 @@ public sealed class OllamaChatResponse
[JsonPropertyName("error")] public string? Error { get; init; }
}
-public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null);
+public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null, long PromptEvalCount = 0);
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index f6bb483..aa81486 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -5,16 +5,24 @@ using Agent.Core.Sessions;
using Agent.Core.Skills;
using Agent.Core.Tasks;
using Agent.Core.Tools;
+using Agent.Planner;
namespace Agent.Core.Runtime;
-public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null)
+public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null)
{
private readonly InternalToolDispatcher _tools = toolDispatcher ?? new(
new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)),
new FakeInternalToolBackend());
private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance;
+ private readonly PlannerService _planner = plannerService ?? new(ollama, settings);
+ /// <summary>
+ /// Plan → Run lifecycle for a single MCP Task.
+ /// 1. Planning: generate Initial TaskGraph via structured output.
+ /// 2. Running: execute ready Tasks (dependencies satisfied) in critical-path / creation order.
+ /// Sandbox Task Resource is applied at RunAsync time, not PlanAsync time.
+ /// </summary>
public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken)
{
if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return;
@@ -26,124 +34,267 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
try
{
- await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
+ // Vision capability check before any Ollama call.
if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false)
throw new InvalidOperationException(settings.VisionUnsupportedMessage);
- // Progressive Disclosure Stage 2: LLM Skill Selection.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
-
- var messages = PromptComposer.Compose(new PromptContext(
- FixedPrompt.Load(),
- settings.UserSystemPrompt,
- settings.Locale,
- settings.Skills,
- selectedSkills, // Stage 3: selected skill instructions injected by PromptComposer.
- settings.SandboxCapabilitySummary,
- "Current task: " + task.Request,
- string.Empty,
- session.Conversation,
- task.Request,
- task.Images)).ToList();
- var guard = new ToolUseGuard(settings.SameFailureLimit);
- bool requiresExternalEffect = RequiresExternalEffect(task.Request);
- bool externalEffectEvidence = false;
- var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
- IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request);
- var successfulToolCalls = new Dictionary<string, int>(StringComparer.Ordinal);
- string? finalContent = null;
- for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
+ // Phase 1: Planning — generate TaskGraph via structured output.
+ TaskGraph graph;
+ try
{
- _observer.LlmRequest(taskId, iteration, messages);
- OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
- _observer.LlmResponse(taskId, iteration, result);
- registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
- IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
- if (calls.Count > 0)
- {
- messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls));
- string? failureFingerprint = null;
- foreach (OllamaToolCall call in calls)
- {
- ToolExecutionResult toolResult;
- try
- {
- toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
- }
- catch (UnauthorizedAccessException ex)
- {
- toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false);
- }
- catch (Exception ex) when (ex is ToolValidationException or ArgumentException)
- {
- toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}");
- }
- externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect;
- if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name);
- else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output;
- if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1;
- _observer.ToolResult(taskId, call.Function.Name, toolResult);
- if (!toolResult.Success && !toolResult.RetryAllowed)
- throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
- failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
- messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
- }
- ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint);
- if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation);
- continue;
- }
+ graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}");
+ return;
+ }
- if (unresolvedToolFailures.Count > 0)
- {
- messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " +
- string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}"))));
- continue;
- }
- string[] missingCalls = requiredToolCalls
- .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value)
- .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}")
- .ToArray();
- if (missingCalls.Length > 0)
+ // Phase 2: Running — execute ready Tasks sequentially.
+ registry.Transition(taskId, AgentTaskState.Running, "running");
+ await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Failed, result: result);
+ }
+ }
+
+ /// <summary>
+ /// Executes ready Tasks (dependencies satisfied) in critical-path then creation order.
+ /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop.
+ /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase.
+ /// </summary>
+ private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken)
+ {
+ // Apply Sandbox Task Resource at RunAsync time.
+ await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false);
+
+ var finalContents = new List<string>();
+ int replanCount = 0;
+ const int maxReplans = 3;
+ try
+ {
+ // Execute tasks in ready order: dependencies satisfied → critical path → creation order.
+ while (true)
+ {
+ PlannerTask? next = SelectNextReadyTask(graph);
+ if (next is null) break;
+
+ graph.SetState(next.Id, PlannerTaskState.Running);
+ try
{
- messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls)));
- continue;
+ string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false);
+ graph.SetState(next.Id, PlannerTaskState.Completed);
+ finalContents.Add(content);
}
- ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence);
- if (completion.Decision == ToolUseGuardDecision.Retry)
+ catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans)
{
- messages.Add(new OllamaMessage("system", completion.Observation));
+ // Policy deny → replan possible (Design §8.4).
+ replanCount++;
+ graph.SetState(next.Id, PlannerTaskState.Failed);
+ graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false);
+ // Transition back to Running for the replan iteration.
+ registry.Transition(taskId, AgentTaskState.Running, "replan");
continue;
}
- if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation);
- finalContent = result.Content;
- break;
}
- if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+
+ // All tasks completed — assemble final result.
+ string finalContent = finalContents.Count > 0
+ ? string.Join("\n\n", finalContents)
+ : "No tasks were executed.";
string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false);
if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure);
session.Append(new ConversationMessage("user", task.Request));
session.Append(new ConversationMessage("assistant", finalContent));
registry.Transition(taskId, AgentTaskState.Completed, result: finalContent);
}
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ catch
{
- string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ // Re-throw without double-cleanup; ExecuteAsync's catch handles EndTaskSafelyAsync.
+ throw;
}
- catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ }
+
+ /// <summary>
+ /// Selects the next ready Task: dependencies satisfied, not yet completed/failed.
+ /// Priority: critical path first, then creation order.
+ /// </summary>
+ private static PlannerTask? SelectNextReadyTask(TaskGraph graph)
+ {
+ var completedIds = graph.Tasks
+ .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed)
+ .Select(static t => t.Id)
+ .ToHashSet();
+
+ // Critical path task IDs in order.
+ var criticalPath = new HashSet<Guid>(graph.CriticalPath.TaskIds);
+
+ // Ready = not completed/failed/running and all dependencies completed.
+ var ready = graph.Tasks
+ .Where(t => t.State is not (PlannerTaskState.Completed or PlannerTaskState.Failed or PlannerTaskState.Running))
+ .Where(t => t.Dependencies.All(depId => completedIds.Contains(depId)))
+ .ToList();
+
+ if (ready.Count == 0) return null;
+
+ // Prefer critical path tasks, then creation order (Tasks list is in insertion order).
+ return ready.FirstOrDefault(t => criticalPath.Contains(t.Id)) ?? ready[0];
+ }
+
+ /// <summary>
+ /// Executes a single PlannerTask: LLM chat-with-tools iteration loop.
+ /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard.
+ /// </summary>
+ private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken)
+ {
+ // Progressive Disclosure Stage 2: LLM Skill Selection.
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
+
+ var messages = PromptComposer.Compose(new PromptContext(
+ FixedPrompt.Load(),
+ settings.UserSystemPrompt,
+ settings.Locale,
+ settings.Skills,
+ selectedSkills,
+ settings.SandboxCapabilitySummary,
+ BuildTaskGraphSummary(graph, plannerTask),
+ session.Summary ?? string.Empty,
+ session.Conversation,
+ plannerTask.Description,
+ task.Images)).ToList();
+
+ var guard = new ToolUseGuard(settings.SameFailureLimit);
+ bool requiresExternalEffect = plannerTask.RequiresExternalEffect;
+ bool externalEffectEvidence = false;
+ var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
+ string? finalContent = null;
+
+ for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
{
- string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Failed, result: result);
+ _observer.LlmRequest(taskId, iteration, messages);
+ OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(taskId, iteration, result);
+ registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
+
+ // Record usage in TaskGraph for CPM tracking.
+ graph.AddUsage(plannerTask.Id, result.InputTokens, result.OutputTokens);
+
+ // Context Summary: compact old conversation/results when context utilization exceeds threshold.
+ // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens).
+ TryContextSummary(session, result.PromptEvalCount);
+
+ IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
+ if (calls.Count > 0)
+ {
+ messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls));
+ string? failureFingerprint = null;
+ foreach (OllamaToolCall call in calls)
+ {
+ ToolExecutionResult toolResult;
+ try
+ {
+ toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false);
+ }
+ catch (Exception ex) when (ex is ToolValidationException or ArgumentException)
+ {
+ toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}");
+ }
+
+ externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect;
+ if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name);
+ else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output;
+
+ // Record ExecutionResult in session for context summary.
+ session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output));
+
+ _observer.ToolResult(taskId, call.Function.Name, toolResult);
+ if (!toolResult.Success && !toolResult.RetryAllowed)
+ throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
+ failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
+ messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
+ }
+ ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint);
+ if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation);
+ continue;
+ }
+
+ if (unresolvedToolFailures.Count > 0)
+ {
+ messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " +
+ string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}"))));
+ continue;
+ }
+
+ ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence);
+ if (completion.Decision == ToolUseGuardDecision.Retry)
+ {
+ messages.Add(new OllamaMessage("system", completion.Observation));
+ continue;
+ }
+ if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation);
+ finalContent = result.Content;
+ break;
+ }
+
+ if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+ return finalContent;
+ }
+
+ /// <summary>
+ /// Builds a TaskGraph summary for the prompt context (Design §9.2 item 6).
+ /// Shows the current task position within the overall graph.
+ /// </summary>
+ private static string BuildTaskGraphSummary(TaskGraph graph, PlannerTask currentTask)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("Current TaskGraph:");
+ for (int i = 0; i < graph.Tasks.Count; i++)
+ {
+ PlannerTask t = graph.Tasks[i];
+ string marker = t.Id == currentTask.Id ? "→ " : " ";
+ sb.AppendLine($"{marker}[{t.State}] {t.Description}");
}
+ sb.AppendLine($"Critical path tokens: {graph.CriticalPath.CumulativeTokenCost:F0}");
+ return sb.ToString();
+ }
+
+ /// <summary>
+ /// Context Summary: compacts old Conversation/ExecutionResults when context utilization exceeds threshold.
+ /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens.
+ /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials.
+ /// </summary>
+ private void TryContextSummary(AgentSession session, long promptEvalCount)
+ {
+ if (settings.NumCtx <= 0) return;
+ double utilization = (double)promptEvalCount / settings.NumCtx;
+ if (utilization < settings.ContextSummaryThreshold) return;
+
+ // Compact old conversation entries, keeping only the most recent N.
+ // The summary itself is generated by the LLM in a separate call (future enhancement);
+ // for now, we compact the active context set to stay within num_ctx.
+ session.CompactConversation(settings.ConversationKeepLastN);
+ session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
}
private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
{
if (settings.Skills.Count == 0) return [];
- // Build a minimal prompt with skill catalog for the LLM to choose from.
string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
var selectionMessages = new List<OllamaMessage>
{
@@ -151,7 +302,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
new("user", request)
};
- // JSON Schema for structured output.
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
@@ -162,7 +312,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
}
catch
{
- // If structured output fails (e.g., model doesn't support it), fall back to no skills.
return [];
}
@@ -181,7 +330,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
string? name = item.GetString();
if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
}
- // Validate: only known skills, no duplicates, max 10.
var valid = new List<AgentSkill>();
foreach (AgentSkill skill in settings.Skills)
{
@@ -211,28 +359,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
return $"Task resource cleanup failed: {ex.Message}";
}
}
-
- private static bool RequiresExternalEffect(string request)
- {
- string normalized = request.ToLowerInvariant();
- string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"];
- return indicators.Any(normalized.Contains);
- }
-
- private static IReadOnlyDictionary<string, int> RequiredToolCalls(string request)
- {
- var result = new Dictionary<string, int>(StringComparer.Ordinal);
- foreach (InternalToolDefinition tool in InternalToolCatalog.All)
- {
- int count = 0;
- int offset = 0;
- while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0)
- {
- count++;
- offset += tool.Name.Length;
- }
- if (count > 0) result[tool.Name] = count;
- }
- return result;
- }
-}
+} \ No newline at end of file
diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
index 65ee59d..873c571 100644
--- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
+++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
@@ -9,4 +9,8 @@ public sealed record AgentRuntimeSettings(
string SandboxCapabilitySummary,
int MaxIterations = 128,
int SameFailureLimit = 5,
- string VisionUnsupportedMessage = "The selected model does not support image input.");
+ string VisionUnsupportedMessage = "The selected model does not support image input.",
+ long NumCtx = 32768,
+ double ContextSummaryThreshold = 0.80,
+ int ConversationKeepLastN = 6,
+ int ExecutionResultsKeepLastN = 10);
diff --git a/src/Agent.Core/Runtime/PlanResult.cs b/src/Agent.Core/Runtime/PlanResult.cs
new file mode 100644
index 0000000..bad41fa
--- /dev/null
+++ b/src/Agent.Core/Runtime/PlanResult.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Agent.Core.Runtime;
+
+/// <summary>
+/// LLM-generated plan parsed from structured output.
+/// Each planned task contains a description, dependency references (by index),
+/// token budget class, PERT estimates, and the requires_external_effect attribute.
+/// </summary>
+public sealed class PlanResult
+{
+ [JsonPropertyName("tasks")]
+ public List<PlannedTask> Tasks { get; init; } = [];
+}
+
+public sealed class PlannedTask
+{
+ [JsonPropertyName("description")]
+ public string Description { get; init; } = string.Empty;
+
+ [JsonPropertyName("dependencies")]
+ public List<int> Dependencies { get; init; } = [];
+
+ [JsonPropertyName("budget_class")]
+ public string BudgetClass { get; init; } = "4K";
+
+ [JsonPropertyName("optimistic_tokens")]
+ public long OptimisticTokens { get; init; }
+
+ [JsonPropertyName("most_likely_tokens")]
+ public long MostLikelyTokens { get; init; }
+
+ [JsonPropertyName("pessimistic_tokens")]
+ public long PessimisticTokens { get; init; }
+
+ [JsonPropertyName("requires_external_effect")]
+ public bool RequiresExternalEffect { get; init; }
+}
+
+/// <summary>
+/// JSON Schema for Ollama structured output during Planning.
+/// </summary>
+public static class PlanSchema
+{
+ public static string Json => """{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string"},"dependencies":{"type":"array","items":{"type":"integer"}},"budget_class":{"type":"string","enum":["1K","2K","4K","8K","16K","32K","64K","128K"]},"optimistic_tokens":{"type":"integer","minimum":1},"most_likely_tokens":{"type":"integer","minimum":1},"pessimistic_tokens":{"type":"integer","minimum":1},"requires_external_effect":{"type":"boolean"}},"required":["description","dependencies","budget_class","optimistic_tokens","most_likely_tokens","pessimistic_tokens","requires_external_effect"]}}},"required":["tasks"]}""";
+
+ private static readonly JsonElement _element = JsonDocument.Parse(Json).RootElement.Clone();
+
+ public static JsonElement Element => _element;
+} \ No newline at end of file
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
new file mode 100644
index 0000000..7a450d0
--- /dev/null
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -0,0 +1,307 @@
+using System.Text.Json;
+using Agent.Core.Ollama;
+using Agent.Core.Prompts;
+using Agent.Core.Sessions;
+using Agent.Core.Skills;
+using Agent.Core.Tasks;
+using Agent.Core.Tools;
+using Agent.Planner;
+
+namespace Agent.Core.Runtime;
+
+/// <summary>
+/// Builds an initial TaskGraph from LLM structured output (Planning phase).
+/// The Planner determines requires_external_effect per-task during Planning.
+/// Pure analysis tasks get a TaskGraph with at least 1 Task (RequiresExternalEffect=false).
+/// </summary>
+public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings settings)
+{
+ private readonly IOllamaClient _ollama = ollama;
+ private readonly AgentRuntimeSettings _settings = settings;
+
+ /// <summary>
+ /// Generates an initial TaskGraph for the given request via Ollama structured output.
+ /// Returns a TaskGraph stored in the session, keyed by the MCP Task ID.
+ /// </summary>
+ public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, CancellationToken cancellationToken)
+ {
+ // Build a Planning prompt with skill catalog and sandbox capability summary.
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false);
+
+ var messages = new List<OllamaMessage>
+ {
+ new("system", FixedPrompt.Load()),
+ new("system", _settings.UserSystemPrompt),
+ new("system", $"Respond in locale: {_settings.Locale}."),
+ new("system", ComposeSkillCatalog(_settings.Skills)),
+ new("system", _settings.SandboxCapabilitySummary),
+ new("system", BuildPlanningInstruction()),
+ new("user", request, images)
+ };
+
+ // Use structured output to get a plan.
+ OllamaResult result;
+ try
+ {
+ result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Fallback: single-task plan with conservative estimates.
+ return CreateFallbackGraph(request);
+ }
+
+ PlanResult? plan = ParsePlan(result.Content);
+ if (plan is null || plan.Tasks.Count == 0)
+ return CreateFallbackGraph(request);
+
+ TaskGraph graph = BuildTaskGraph(plan, request);
+ session.TaskGraphs[taskId] = graph;
+ return graph;
+ }
+
+ /// <summary>
+ /// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph.
+ /// Completed tasks are immutable; only incomplete tasks are modified.
+ /// </summary>
+ public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken)
+ {
+ if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing))
+ return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false);
+
+ var messages = new List<OllamaMessage>
+ {
+ new("system", FixedPrompt.Load()),
+ new("system", _settings.UserSystemPrompt),
+ new("system", $"Respond in locale: {_settings.Locale}."),
+ new("system", _settings.SandboxCapabilitySummary),
+ new("system", BuildReplanInstruction(existing, replanReason)),
+ new("user", request)
+ };
+
+ OllamaResult result;
+ try
+ {
+ result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ return existing;
+ }
+
+ PlanResult? plan = ParsePlan(result.Content);
+ if (plan is null || plan.Tasks.Count == 0) return existing;
+
+ // Build a new graph, preserving completed tasks from the existing graph.
+ return RebuildTaskGraph(existing, plan, request);
+ }
+
+ private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
+ {
+ var graph = new TaskGraph(32);
+ var idMap = new List<Guid>();
+
+ for (int i = 0; i < plan.Tasks.Count; i++)
+ {
+ PlannedTask pt = plan.Tasks[i];
+ TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate estimate = CreateEstimate(pt, budgetClass);
+
+ // Map dependency indices to Guids.
+ var dependencies = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ {
+ if (depIdx >= 0 && depIdx < idMap.Count)
+ dependencies.Add(idMap[depIdx]);
+ }
+
+ string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
+ PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect);
+ idMap.Add(task.Id);
+ }
+
+ return graph;
+ }
+
+ private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
+ {
+ // Collect completed task descriptions to skip them in the new graph.
+ var completedDescriptions = new HashSet<string>(
+ existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description),
+ StringComparer.Ordinal);
+
+ var graph = new TaskGraph(32);
+ var idMap = new List<Guid>();
+
+ for (int i = 0; i < plan.Tasks.Count; i++)
+ {
+ PlannedTask pt = plan.Tasks[i];
+
+ // Skip tasks that are already completed in the existing graph.
+ if (completedDescriptions.Contains(pt.Description))
+ {
+ // Find the completed task and add it as completed to the new graph.
+ PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description);
+ if (completedTask is not null)
+ {
+ TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate estimate = CreateEstimate(pt, budgetClass);
+ var deps = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]);
+ PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect);
+ graph.SetState(added.Id, PlannerTaskState.Completed);
+ idMap.Add(added.Id);
+ continue;
+ }
+ }
+
+ TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate est = CreateEstimate(pt, bc);
+ var dependencies = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]);
+
+ string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
+ PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect);
+ idMap.Add(task.Id);
+ }
+
+ return graph;
+ }
+
+ private TaskGraph CreateFallbackGraph(string request)
+ {
+ var graph = new TaskGraph(32);
+ TokenEstimate estimate = new(512, 2048, 8192);
+ graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false);
+ return graph;
+ }
+
+ private static TokenBudgetClass ParseBudgetClass(string value)
+ {
+ try { return TokenBudgetClassExtensions.Parse(value); }
+ catch { return TokenBudgetClass.K4; }
+ }
+
+ private static TokenEstimate CreateEstimate(PlannedTask pt, TokenBudgetClass budgetClass)
+ {
+ long o = Math.Max(1, pt.OptimisticTokens);
+ long m = Math.Max(o, pt.MostLikelyTokens);
+ long p = Math.Max(m, pt.PessimisticTokens);
+ long max = budgetClass.MaxTokens();
+ if (p > max) p = max;
+ if (m > p) m = p;
+ if (o > m) o = m;
+ return new TokenEstimate(o, m, p);
+ }
+
+ private static PlanResult? ParsePlan(string content)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize<PlanResult>(content, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ {
+ if (_settings.Skills.Count == 0) return [];
+
+ string catalog = string.Join("\n", _settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
+ var selectionMessages = new List<OllamaMessage>
+ {
+ new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."),
+ new("user", request)
+ };
+
+ string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
+ using JsonDocument formatDoc = JsonDocument.Parse(schema);
+
+ try
+ {
+ OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ return ParseSelectedSkills(result.Content);
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
+ {
+ try
+ {
+ using JsonDocument doc = JsonDocument.Parse(content);
+ if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return [];
+ var selectedNames = new HashSet<string>(StringComparer.Ordinal);
+ foreach (JsonElement item in skillsElement.EnumerateArray())
+ {
+ string? name = item.GetString();
+ if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
+ }
+ var valid = new List<AgentSkill>();
+ foreach (AgentSkill skill in _settings.Skills)
+ {
+ if (selectedNames.Contains(skill.Name))
+ {
+ valid.Add(skill);
+ if (valid.Count >= 10) break;
+ }
+ }
+ return valid;
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string ComposeSkillCatalog(IReadOnlyList<AgentSkill> skills)
+ {
+ if (skills.Count == 0) return "No skills available.";
+ var lines = new List<string> { "Available skills:" };
+ foreach (AgentSkill skill in skills)
+ lines.Add($"- {skill.Name}: {skill.Description}");
+ return string.Join("\n", lines);
+ }
+
+ private static string BuildPlanningInstruction() =>
+ """
+ You are a Task Planner. Decompose the user request into a TaskGraph of 1-32 tasks.
+ Each task must have:
+ - description: a clear, self-contained sub-task description
+ - dependencies: array of task indices (0-based) that must complete before this task
+ - budget_class: one of 1K, 2K, 4K, 8K, 16K, 32K, 64K, 128K (token budget for this task)
+ - optimistic_tokens, most_likely_tokens, pessimistic_tokens: PERT estimates (0 < O <= M <= P <= budget)
+ - requires_external_effect: true if this task requires a verifiable external effect (file edit, process run, etc.)
+
+ For pure analysis or informational tasks, requires_external_effect should be false.
+ For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true.
+ A single task is valid for simple requests.
+ """;
+
+ private static string BuildReplanInstruction(TaskGraph existing, string reason)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("You are a Task Planner performing replan. The previous plan encountered an issue.");
+ sb.AppendLine($"Replan reason: {reason}");
+ sb.AppendLine("Current task states:");
+ foreach (PlannerTask t in existing.Tasks)
+ {
+ sb.AppendLine($" - [{t.State}] {t.Description} (requires_external_effect={t.RequiresExternalEffect})");
+ }
+ sb.AppendLine();
+ sb.AppendLine("Completed tasks must be preserved. Modify only incomplete/failed tasks.");
+ sb.AppendLine("Return a complete revised plan including completed tasks.");
+ return sb.ToString();
+ }
+} \ No newline at end of file
diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs
index d561c8d..07b1f16 100644
--- a/src/Agent.Core/Sessions/AgentSession.cs
+++ b/src/Agent.Core/Sessions/AgentSession.cs
@@ -1,19 +1,36 @@
+using System.Collections.Concurrent;
+using Agent.Planner;
+
namespace Agent.Core.Sessions;
public sealed class AgentSession(Guid sessionId)
{
private readonly object _sync = new();
private readonly List<ConversationMessage> _conversation = [];
+ private readonly List<ExecutionResult> _executionResults = [];
+ private readonly ConcurrentDictionary<Guid, TaskGraph> _taskGraphs = new();
public Guid SessionId { get; } = sessionId;
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow;
+ public string? Summary { get; private set; }
public IReadOnlyList<ConversationMessage> Conversation
{
get { lock (_sync) return _conversation.ToArray(); }
}
+ public IReadOnlyList<ExecutionResult> ExecutionResults
+ {
+ get { lock (_sync) return _executionResults.ToArray(); }
+ }
+
+ /// <summary>
+ /// TaskGraphs owned by this session, keyed by MCP Task ID.
+ /// One TaskGraph per agent_run execution (not shared across tasks).
+ /// </summary>
+ public ConcurrentDictionary<Guid, TaskGraph> TaskGraphs => _taskGraphs;
+
public void Append(ConversationMessage message)
{
ArgumentNullException.ThrowIfNull(message);
@@ -23,4 +40,51 @@ public sealed class AgentSession(Guid sessionId)
LastAccessAt = DateTimeOffset.UtcNow;
}
}
+
+ public void AppendExecutionResult(ExecutionResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ lock (_sync)
+ {
+ _executionResults.Add(result);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ public void SetSummary(string summary)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(summary);
+ lock (_sync)
+ {
+ Summary = summary;
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ /// <summary>
+ /// Removes old conversation entries from the active context set after summary compaction.
+ /// Audit/LLM logs are NOT affected — only the context-active representation is compacted.
+ /// </summary>
+ public void CompactConversation(int keepLastN)
+ {
+ lock (_sync)
+ {
+ if (_conversation.Count > keepLastN)
+ _conversation.RemoveRange(0, _conversation.Count - keepLastN);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ /// <summary>
+ /// Removes old execution results from the active context set after summary compaction.
+ /// </summary>
+ public void CompactExecutionResults(int keepLastN)
+ {
+ lock (_sync)
+ {
+ if (_executionResults.Count > keepLastN)
+ _executionResults.RemoveRange(0, _executionResults.Count - keepLastN);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
}
diff --git a/src/Agent.Core/Sessions/ExecutionResult.cs b/src/Agent.Core/Sessions/ExecutionResult.cs
new file mode 100644
index 0000000..ab665fa
--- /dev/null
+++ b/src/Agent.Core/Sessions/ExecutionResult.cs
@@ -0,0 +1,11 @@
+namespace Agent.Core.Sessions;
+
+/// <summary>
+/// A tool execution result recorded in the session for context summary and audit.
+/// The full output is stored in Audit/LLM logs; this is the context-active representation.
+/// </summary>
+public sealed record ExecutionResult(
+ Guid TaskId,
+ string ToolName,
+ bool Success,
+ string Output); \ No newline at end of file
diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs
index 59ea02c..6224e80 100644
--- a/src/Agent.Core/Sessions/SessionStore.cs
+++ b/src/Agent.Core/Sessions/SessionStore.cs
@@ -6,6 +6,11 @@ public sealed class SessionStore
{
private readonly ConcurrentDictionary<Guid, AgentSession> _sessions = new();
+ /// <summary>
+ /// Enumerates all active sessions. Primarily for TaskGraphToolHandler to resolve graphs by TaskId.
+ /// </summary>
+ public IEnumerable<AgentSession?> Sessions => _sessions.Values;
+
public AgentSession Create()
{
while (true)
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs
index 14cba45..a92a829 100644
--- a/src/Agent.Core/Tools/InternalToolCatalog.cs
+++ b/src/Agent.Core/Tools/InternalToolCatalog.cs
@@ -19,7 +19,7 @@ public static class InternalToolCatalog
Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""),
Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""),
Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""),
- Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}"""),
+ Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]},"requires_external_effect":{"type":["boolean","null"]}}}"""),
Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""")
];
return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal);
diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs
index c888705..0ae078c 100644
--- a/src/Agent.Core/Tools/InternalToolDispatcher.cs
+++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs
@@ -4,7 +4,7 @@ namespace Agent.Core.Tools;
public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend)
{
- public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken);
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, sessionId, cancellationToken);
public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken);
public async Task<ToolExecutionResult> DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
index 03daa56..edc1cc5 100644
--- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs
+++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
@@ -1,30 +1,46 @@
using System.Collections.Concurrent;
using System.Text.Json;
+using Agent.Core.Sessions;
using Agent.Planner;
namespace Agent.Core.Tools;
public interface ITaskUpdateHandler
{
- void BeginTask(Guid taskId);
+ void BeginTask(Guid taskId, Guid sessionId);
ToolExecutionResult Execute(Guid taskId, JsonElement arguments);
void EndTask(Guid taskId);
}
-public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
+public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessions = null) : ITaskUpdateHandler
{
- private readonly ConcurrentDictionary<Guid, TaskGraph> _graphs = new();
+ private readonly ConcurrentDictionary<Guid, TaskGraph> _fallbackGraphs = new();
- public void BeginTask(Guid taskId)
+ public void BeginTask(Guid taskId, Guid sessionId)
{
- if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks)))
- throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists.");
+ TaskGraph graph = new(maxTasks);
+ if (sessions is not null && sessions.TryGet(sessionId, out AgentSession? session) && session is not null)
+ {
+ if (!session.TaskGraphs.TryAdd(taskId, graph))
+ throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists in session '{sessionId}'.");
+ }
+ else
+ {
+ if (!_fallbackGraphs.TryAdd(taskId, graph))
+ throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists.");
+ }
+ }
+
+ public TaskGraph? GetGraph(Guid taskId, Guid? sessionId = null)
+ {
+ if (sessionId is not null && sessions is not null && sessions.TryGet(sessionId.Value, out AgentSession? session) && session is not null)
+ return session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g) ? g : null;
+ return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null;
}
public ToolExecutionResult Execute(Guid taskId, JsonElement arguments)
{
- if (!_graphs.TryGetValue(taskId, out TaskGraph? graph))
- throw new InvalidOperationException("TaskGraph scope is not active.");
+ TaskGraph? graph = ResolveGraph(taskId) ?? throw new InvalidOperationException("TaskGraph scope is not active.");
string operation = arguments.GetProperty("operation").GetString()!;
switch (operation)
@@ -35,6 +51,7 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
Budget(arguments),
Estimate(arguments),
Ids(arguments, "depends_on"),
+ requiresExternalEffect: OptionalBool(arguments, "requires_external_effect"),
id: OptionalId(arguments, "task_id"));
break;
case "add_dependency":
@@ -61,7 +78,8 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
description = task.Description,
depends_on = task.Dependencies,
state = task.State.ToString().ToLowerInvariant(),
- expected_tokens = task.ExpectedTokens
+ expected_tokens = task.ExpectedTokens,
+ requires_external_effect = task.RequiresExternalEffect
}),
critical_path = graph.CriticalPath.TaskIds,
critical_path_tokens = graph.CriticalPath.CumulativeTokenCost,
@@ -70,7 +88,31 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
return new ToolExecutionResult(true, output, false);
}
- public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _);
+ public void EndTask(Guid taskId)
+ {
+ // Remove from fallback dict; session-owned graphs persist for context summary.
+ _fallbackGraphs.TryRemove(taskId, out _);
+ }
+
+ private TaskGraph? ResolveGraph(Guid taskId)
+ {
+ // Search all sessions first, then fallback.
+ if (sessions is not null)
+ {
+ foreach (AgentSession? session in sessions.Sessions)
+ {
+ if (session is not null && session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g))
+ return g;
+ }
+ }
+ return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null;
+ }
+
+ private static bool OptionalBool(JsonElement arguments, string property)
+ {
+ if (!arguments.TryGetProperty(property, out JsonElement value)) return false;
+ return value.ValueKind == JsonValueKind.True;
+ }
private static TokenBudgetClass Budget(JsonElement arguments) =>
TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class"));
diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs
index d478a2c..cefca8d 100644
--- a/src/Agent.Core/Tools/ToolPolicy.cs
+++ b/src/Agent.Core/Tools/ToolPolicy.cs
@@ -15,7 +15,7 @@ public interface IToolPolicyEngine
public interface IInternalToolBackend
{
- Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask;
+ Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken);
Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}