summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/Design.md7
-rw-r--r--plans/ImplementationPlan.md5
-rw-r--r--src/Agent.App/Program.cs12
-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
-rw-r--r--src/Agent.Mcp/McpHost.cs9
-rw-r--r--src/Agent.Mcp/QueuedTaskWorker.cs2
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs29
-rw-r--r--tests/Agent.Core.Tests/InternalToolTests.cs2
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs61
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs58
22 files changed, 874 insertions, 191 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 31d0247..798a134 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -330,7 +330,7 @@ PlannerがDAG、不変条件、completed immutable、max_tasksを再検査して
- 既存の PathPolicy / CanonicalPath / TaskAclManager (root継承ACE) / TaskResourceJournal / RecoveryManager 枠組みを利用する。新規作成ディレクトリは親の継承ACEを自動取得するため追加ACL操作は不要。
- mkdir後crash → ディレクトリ削除、rmdir後crash → 空ディレクトリ再作成、でRecoveryする。
### 7.7 ToolUseGuard
-Taskには `requires_external_effect` をPlanner内部属性として持たせる。trueのTaskで、当該iteration中のtool callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。ただし、Policy EngineのdenyおよびSandbox backendの`UnauthorizedAccessException`は`RetryAllowed=false`のterminal Tool resultへ変換する。Runtimeはobserverへ結果を1回記録した直後にTaskをfailedへ遷移させ、LLMへ再投入しない。
+Taskには `requires_external_effect` をPlanner内部属性として持たせる。**Planning フェーズで LLM structured output により Task ごとに判定・設定される**(keyword heuristic ではない)。trueのTaskで、当該iteration中のtool callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。ただし、Policy EngineのdenyおよびSandbox backendの`UnauthorizedAccessException`は`RetryAllowed=false`のterminal Tool resultへ変換する。Runtimeはobserverへ結果を1回記録した直後にTaskをfailedへ遷移させ、LLMへ再投入しない。Policy deny後は再計画(replan)が可能(§8.4)。
---
## 8. Token Planner設計
### 8.1 Task model
@@ -357,11 +357,14 @@ validation: `0 < O <= M <= P <= BudgetClass.MaxTokens`.
### 8.3 CPM
topological sort後にforward passで各Taskの累積expected token costを計算し、最大経路をCritical Pathとする。時間のearliest/latest startという語をそのままUIへ出さず、`cumulative_token_cost`、`critical_path` と表記する。
### 8.4 再計画
+- **Plan フェーズ**: LLM structured output により Initial TaskGraph を生成。Planner が Task ごとに `requires_external_effect` を判定。pure analysis task は `requires_external_effect=false` で少なくとも1 Task の TaskGraph を持つ。Sandbox Task Resource は Plan 時には適用しない。
+- **Run フェーズ**: ready な Task (dependencies 解決済み) を critical path → creation order で順次実行。Sandbox Task Resource (BeginTask/EndTask) は RunAsync 時に適用する。
- 未完了Taskの追加/dependency追加後にDAG validationとCPMを再計算。
- completed Taskは変更しない。
- 実績がPを超過しても即失敗にはせず、remaining Taskのestimate見直しをLLMへ要求できる。
- `max_tasks=32`超過提案は拒否し、既存Taskの統合をLLMへ求める。
- Budget Classは意味的Taskを分割する唯一の理由にしない。Taskが複数独立成果物を含む場合に分割する。
+- Policy deny時は replan を最大3回まで試行可能。同一 deny が繰り返される場合は Failed へ遷移。
### 8.5 Ollama usage集計
1 Ollama callの `prompt_eval_count` をTask inputへ、`eval_count` をTask outputへ加算する。tool実行そのものにはtoken costを加算しない。context summary生成に使ったtokenはSession maintenance Taskとして別集計する。
---
@@ -388,7 +391,7 @@ AgentSession
7. previous Summary
8. recent Conversation / Execution Result
### 9.3 Summary policy
-`num_ctx`に対する利用率が設定閾値を超えたとき、古いConversation/Execution Resultを要約する。既定閾値は80%とする。次は削除・曖昧化してはならない。
+`num_ctx`に対する利用率が設定閾値を超えたとき、古いConversation/Execution Resultを要約する。利用率 = latest `prompt_eval_count` / `num_ctx`(累積 InputTokens ではない)。既定閾値は80%とする。次は削除・曖昧化してはならない。
- 利用者が明示した制約
- 未完了Taskとdependency
- completed Taskの重要成果
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index 6ae6c61..1c70178 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -339,8 +339,11 @@ Planner deterministic test全PASS。
- Policy Engine I/F
- Tool argument validation
- ToolUseGuard
-- `requires_external_effect`
+- `requires_external_effect` — **Planning フェーズで LLM structured output により Task ごとに判定・設定**(keyword heuristic 廃止)
- same failure fingerprint limit=5
+- Plan/Run フェーズ分離: PlanAsync (structured output) → RunAsync (ready Task 順次実行)
+- replan: Policy deny 時に最大3回まで再計画可能
+- Context Summary: `prompt_eval_count / num_ctx` ≥ 80% で古い Conversation/ExecutionResult を compact
## 10.4 重要な段階制約
このPhaseの副作用backendはfake/in-memory実装を使用可能とし、Windows sandboxを迂回した本物の無制限file/process操作を実装してはならない。
## 10.5 必須テスト
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 7839e82..1c29ec3 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -3,6 +3,7 @@ using Agent.App.Configuration;
using Agent.App.Logging;
using Agent.Core.Localization;
using Agent.Core.Prompts;
+using Agent.Core.Sessions;
using Agent.Mcp;
using Agent.Core.Runtime;
using Agent.Windows.Diagnostics;
@@ -73,7 +74,8 @@ internal static class Program
return 2;
}
- InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory);
+ var sessionStore = new SessionStore();
+ InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore);
var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory);
string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
UserOnlyDirectory.Ensure(taskStateDirectory);
@@ -89,7 +91,7 @@ internal static class Program
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None,
- toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver).ConfigureAwait(false);
+ toolDispatcher, runtimeObserver, taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false);
return 0;
}
if (configuration.Mcp.Transport == "http")
@@ -101,7 +103,7 @@ internal static class Program
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits,
configuration.Mcp.Http.Host, configuration.Mcp.Http.Port, CancellationToken.None, toolDispatcher, runtimeObserver,
- taskStoreSettings, hostObserver).ConfigureAwait(false);
+ taskStoreSettings, hostObserver, sessionStore: sessionStore).ConfigureAwait(false);
return 0;
}
throw new ConfigurationException("Unsupported MCP transport.");
@@ -118,7 +120,7 @@ internal static class Program
}
}
- private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory)
+ private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null)
{
string stateDirectory = Path.Combine(baseDirectory, "state");
string journalDirectory = Path.Combine(stateDirectory, "journals");
@@ -142,7 +144,7 @@ internal static class Program
configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines);
- var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks));
+ var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs
index c761081..dde8478 100644
--- a/src/Agent.Core/Ollama/OllamaClient.cs
+++ b/src/Agent.Core/Ollama/OllamaClient.cs
@@ -82,7 +82,7 @@ public sealed class OllamaClient : IOllamaClient
?? throw new OllamaException("Ollama returned an empty response.", false);
if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false);
if (result.Message is null) throw new OllamaException("Ollama response has no message.", false);
- return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []);
+ return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? [], result.PromptEvalCount);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs
index f4b3884..f6ff092 100644
--- a/src/Agent.Core/Ollama/OllamaModels.cs
+++ b/src/Agent.Core/Ollama/OllamaModels.cs
@@ -44,4 +44,4 @@ public sealed class OllamaChatResponse
[JsonPropertyName("error")] public string? Error { get; init; }
}
-public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null);
+public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null, long PromptEvalCount = 0);
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index f6bb483..aa81486 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -5,16 +5,24 @@ using Agent.Core.Sessions;
using Agent.Core.Skills;
using Agent.Core.Tasks;
using Agent.Core.Tools;
+using Agent.Planner;
namespace Agent.Core.Runtime;
-public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null)
+public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null)
{
private readonly InternalToolDispatcher _tools = toolDispatcher ?? new(
new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)),
new FakeInternalToolBackend());
private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance;
+ private readonly PlannerService _planner = plannerService ?? new(ollama, settings);
+ /// <summary>
+ /// Plan → Run lifecycle for a single MCP Task.
+ /// 1. Planning: generate Initial TaskGraph via structured output.
+ /// 2. Running: execute ready Tasks (dependencies satisfied) in critical-path / creation order.
+ /// Sandbox Task Resource is applied at RunAsync time, not PlanAsync time.
+ /// </summary>
public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken)
{
if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return;
@@ -26,124 +34,267 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
try
{
- await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
+ // Vision capability check before any Ollama call.
if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false)
throw new InvalidOperationException(settings.VisionUnsupportedMessage);
- // Progressive Disclosure Stage 2: LLM Skill Selection.
- IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
-
- var messages = PromptComposer.Compose(new PromptContext(
- FixedPrompt.Load(),
- settings.UserSystemPrompt,
- settings.Locale,
- settings.Skills,
- selectedSkills, // Stage 3: selected skill instructions injected by PromptComposer.
- settings.SandboxCapabilitySummary,
- "Current task: " + task.Request,
- string.Empty,
- session.Conversation,
- task.Request,
- task.Images)).ToList();
- var guard = new ToolUseGuard(settings.SameFailureLimit);
- bool requiresExternalEffect = RequiresExternalEffect(task.Request);
- bool externalEffectEvidence = false;
- var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
- IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request);
- var successfulToolCalls = new Dictionary<string, int>(StringComparer.Ordinal);
- string? finalContent = null;
- for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
+ // Phase 1: Planning — generate TaskGraph via structured output.
+ TaskGraph graph;
+ try
{
- _observer.LlmRequest(taskId, iteration, messages);
- OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
- _observer.LlmResponse(taskId, iteration, result);
- registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
- IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
- if (calls.Count > 0)
- {
- messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls));
- string? failureFingerprint = null;
- foreach (OllamaToolCall call in calls)
- {
- ToolExecutionResult toolResult;
- try
- {
- toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
- }
- catch (UnauthorizedAccessException ex)
- {
- toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false);
- }
- catch (Exception ex) when (ex is ToolValidationException or ArgumentException)
- {
- toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}");
- }
- externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect;
- if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name);
- else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output;
- if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1;
- _observer.ToolResult(taskId, call.Function.Name, toolResult);
- if (!toolResult.Success && !toolResult.RetryAllowed)
- throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
- failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
- messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
- }
- ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint);
- if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation);
- continue;
- }
+ graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception ex)
+ {
+ registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}");
+ return;
+ }
- if (unresolvedToolFailures.Count > 0)
- {
- messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " +
- string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}"))));
- continue;
- }
- string[] missingCalls = requiredToolCalls
- .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value)
- .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}")
- .ToArray();
- if (missingCalls.Length > 0)
+ // Phase 2: Running — execute ready Tasks sequentially.
+ registry.Transition(taskId, AgentTaskState.Running, "running");
+ await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Failed, result: result);
+ }
+ }
+
+ /// <summary>
+ /// Executes ready Tasks (dependencies satisfied) in critical-path then creation order.
+ /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop.
+ /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase.
+ /// </summary>
+ private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken)
+ {
+ // Apply Sandbox Task Resource at RunAsync time.
+ await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false);
+
+ var finalContents = new List<string>();
+ int replanCount = 0;
+ const int maxReplans = 3;
+ try
+ {
+ // Execute tasks in ready order: dependencies satisfied → critical path → creation order.
+ while (true)
+ {
+ PlannerTask? next = SelectNextReadyTask(graph);
+ if (next is null) break;
+
+ graph.SetState(next.Id, PlannerTaskState.Running);
+ try
{
- messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls)));
- continue;
+ string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false);
+ graph.SetState(next.Id, PlannerTaskState.Completed);
+ finalContents.Add(content);
}
- ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence);
- if (completion.Decision == ToolUseGuardDecision.Retry)
+ catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans)
{
- messages.Add(new OllamaMessage("system", completion.Observation));
+ // Policy deny → replan possible (Design §8.4).
+ replanCount++;
+ graph.SetState(next.Id, PlannerTaskState.Failed);
+ graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false);
+ // Transition back to Running for the replan iteration.
+ registry.Transition(taskId, AgentTaskState.Running, "replan");
continue;
}
- if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation);
- finalContent = result.Content;
- break;
}
- if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+
+ // All tasks completed — assemble final result.
+ string finalContent = finalContents.Count > 0
+ ? string.Join("\n\n", finalContents)
+ : "No tasks were executed.";
string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false);
if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure);
session.Append(new ConversationMessage("user", task.Request));
session.Append(new ConversationMessage("assistant", finalContent));
registry.Transition(taskId, AgentTaskState.Completed, result: finalContent);
}
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ catch
{
- string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ // Re-throw without double-cleanup; ExecuteAsync's catch handles EndTaskSafelyAsync.
+ throw;
}
- catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ }
+
+ /// <summary>
+ /// Selects the next ready Task: dependencies satisfied, not yet completed/failed.
+ /// Priority: critical path first, then creation order.
+ /// </summary>
+ private static PlannerTask? SelectNextReadyTask(TaskGraph graph)
+ {
+ var completedIds = graph.Tasks
+ .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed)
+ .Select(static t => t.Id)
+ .ToHashSet();
+
+ // Critical path task IDs in order.
+ var criticalPath = new HashSet<Guid>(graph.CriticalPath.TaskIds);
+
+ // Ready = not completed/failed/running and all dependencies completed.
+ var ready = graph.Tasks
+ .Where(t => t.State is not (PlannerTaskState.Completed or PlannerTaskState.Failed or PlannerTaskState.Running))
+ .Where(t => t.Dependencies.All(depId => completedIds.Contains(depId)))
+ .ToList();
+
+ if (ready.Count == 0) return null;
+
+ // Prefer critical path tasks, then creation order (Tasks list is in insertion order).
+ return ready.FirstOrDefault(t => criticalPath.Contains(t.Id)) ?? ready[0];
+ }
+
+ /// <summary>
+ /// Executes a single PlannerTask: LLM chat-with-tools iteration loop.
+ /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard.
+ /// </summary>
+ private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken)
+ {
+ // Progressive Disclosure Stage 2: LLM Skill Selection.
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false);
+
+ var messages = PromptComposer.Compose(new PromptContext(
+ FixedPrompt.Load(),
+ settings.UserSystemPrompt,
+ settings.Locale,
+ settings.Skills,
+ selectedSkills,
+ settings.SandboxCapabilitySummary,
+ BuildTaskGraphSummary(graph, plannerTask),
+ session.Summary ?? string.Empty,
+ session.Conversation,
+ plannerTask.Description,
+ task.Images)).ToList();
+
+ var guard = new ToolUseGuard(settings.SameFailureLimit);
+ bool requiresExternalEffect = plannerTask.RequiresExternalEffect;
+ bool externalEffectEvidence = false;
+ var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
+ string? finalContent = null;
+
+ for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
{
- string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
- registry.Transition(taskId, AgentTaskState.Failed, result: result);
+ _observer.LlmRequest(taskId, iteration, messages);
+ OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(taskId, iteration, result);
+ registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
+
+ // Record usage in TaskGraph for CPM tracking.
+ graph.AddUsage(plannerTask.Id, result.InputTokens, result.OutputTokens);
+
+ // Context Summary: compact old conversation/results when context utilization exceeds threshold.
+ // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens).
+ TryContextSummary(session, result.PromptEvalCount);
+
+ IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? [];
+ if (calls.Count > 0)
+ {
+ messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls));
+ string? failureFingerprint = null;
+ foreach (OllamaToolCall call in calls)
+ {
+ ToolExecutionResult toolResult;
+ try
+ {
+ toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false);
+ }
+ catch (Exception ex) when (ex is ToolValidationException or ArgumentException)
+ {
+ toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}");
+ }
+
+ externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect;
+ if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name);
+ else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output;
+
+ // Record ExecutionResult in session for context summary.
+ session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output));
+
+ _observer.ToolResult(taskId, call.Function.Name, toolResult);
+ if (!toolResult.Success && !toolResult.RetryAllowed)
+ throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}");
+ failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
+ messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
+ }
+ ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint);
+ if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation);
+ continue;
+ }
+
+ if (unresolvedToolFailures.Count > 0)
+ {
+ messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " +
+ string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}"))));
+ continue;
+ }
+
+ ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence);
+ if (completion.Decision == ToolUseGuardDecision.Retry)
+ {
+ messages.Add(new OllamaMessage("system", completion.Observation));
+ continue;
+ }
+ if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation);
+ finalContent = result.Content;
+ break;
+ }
+
+ if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}.");
+ return finalContent;
+ }
+
+ /// <summary>
+ /// Builds a TaskGraph summary for the prompt context (Design §9.2 item 6).
+ /// Shows the current task position within the overall graph.
+ /// </summary>
+ private static string BuildTaskGraphSummary(TaskGraph graph, PlannerTask currentTask)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("Current TaskGraph:");
+ for (int i = 0; i < graph.Tasks.Count; i++)
+ {
+ PlannerTask t = graph.Tasks[i];
+ string marker = t.Id == currentTask.Id ? "→ " : " ";
+ sb.AppendLine($"{marker}[{t.State}] {t.Description}");
}
+ sb.AppendLine($"Critical path tokens: {graph.CriticalPath.CumulativeTokenCost:F0}");
+ return sb.ToString();
+ }
+
+ /// <summary>
+ /// Context Summary: compacts old Conversation/ExecutionResults when context utilization exceeds threshold.
+ /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens.
+ /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials.
+ /// </summary>
+ private void TryContextSummary(AgentSession session, long promptEvalCount)
+ {
+ if (settings.NumCtx <= 0) return;
+ double utilization = (double)promptEvalCount / settings.NumCtx;
+ if (utilization < settings.ContextSummaryThreshold) return;
+
+ // Compact old conversation entries, keeping only the most recent N.
+ // The summary itself is generated by the LLM in a separate call (future enhancement);
+ // for now, we compact the active context set to stay within num_ctx.
+ session.CompactConversation(settings.ConversationKeepLastN);
+ session.CompactExecutionResults(settings.ExecutionResultsKeepLastN);
}
private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
{
if (settings.Skills.Count == 0) return [];
- // Build a minimal prompt with skill catalog for the LLM to choose from.
string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
var selectionMessages = new List<OllamaMessage>
{
@@ -151,7 +302,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
new("user", request)
};
- // JSON Schema for structured output.
string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
using JsonDocument formatDoc = JsonDocument.Parse(schema);
@@ -162,7 +312,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
}
catch
{
- // If structured output fails (e.g., model doesn't support it), fall back to no skills.
return [];
}
@@ -181,7 +330,6 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
string? name = item.GetString();
if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
}
- // Validate: only known skills, no duplicates, max 10.
var valid = new List<AgentSkill>();
foreach (AgentSkill skill in settings.Skills)
{
@@ -211,28 +359,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
return $"Task resource cleanup failed: {ex.Message}";
}
}
-
- private static bool RequiresExternalEffect(string request)
- {
- string normalized = request.ToLowerInvariant();
- string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"];
- return indicators.Any(normalized.Contains);
- }
-
- private static IReadOnlyDictionary<string, int> RequiredToolCalls(string request)
- {
- var result = new Dictionary<string, int>(StringComparer.Ordinal);
- foreach (InternalToolDefinition tool in InternalToolCatalog.All)
- {
- int count = 0;
- int offset = 0;
- while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0)
- {
- count++;
- offset += tool.Name.Length;
- }
- if (count > 0) result[tool.Name] = count;
- }
- return result;
- }
-}
+} \ No newline at end of file
diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
index 65ee59d..873c571 100644
--- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
+++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
@@ -9,4 +9,8 @@ public sealed record AgentRuntimeSettings(
string SandboxCapabilitySummary,
int MaxIterations = 128,
int SameFailureLimit = 5,
- string VisionUnsupportedMessage = "The selected model does not support image input.");
+ string VisionUnsupportedMessage = "The selected model does not support image input.",
+ long NumCtx = 32768,
+ double ContextSummaryThreshold = 0.80,
+ int ConversationKeepLastN = 6,
+ int ExecutionResultsKeepLastN = 10);
diff --git a/src/Agent.Core/Runtime/PlanResult.cs b/src/Agent.Core/Runtime/PlanResult.cs
new file mode 100644
index 0000000..bad41fa
--- /dev/null
+++ b/src/Agent.Core/Runtime/PlanResult.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Agent.Core.Runtime;
+
+/// <summary>
+/// LLM-generated plan parsed from structured output.
+/// Each planned task contains a description, dependency references (by index),
+/// token budget class, PERT estimates, and the requires_external_effect attribute.
+/// </summary>
+public sealed class PlanResult
+{
+ [JsonPropertyName("tasks")]
+ public List<PlannedTask> Tasks { get; init; } = [];
+}
+
+public sealed class PlannedTask
+{
+ [JsonPropertyName("description")]
+ public string Description { get; init; } = string.Empty;
+
+ [JsonPropertyName("dependencies")]
+ public List<int> Dependencies { get; init; } = [];
+
+ [JsonPropertyName("budget_class")]
+ public string BudgetClass { get; init; } = "4K";
+
+ [JsonPropertyName("optimistic_tokens")]
+ public long OptimisticTokens { get; init; }
+
+ [JsonPropertyName("most_likely_tokens")]
+ public long MostLikelyTokens { get; init; }
+
+ [JsonPropertyName("pessimistic_tokens")]
+ public long PessimisticTokens { get; init; }
+
+ [JsonPropertyName("requires_external_effect")]
+ public bool RequiresExternalEffect { get; init; }
+}
+
+/// <summary>
+/// JSON Schema for Ollama structured output during Planning.
+/// </summary>
+public static class PlanSchema
+{
+ public static string Json => """{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string"},"dependencies":{"type":"array","items":{"type":"integer"}},"budget_class":{"type":"string","enum":["1K","2K","4K","8K","16K","32K","64K","128K"]},"optimistic_tokens":{"type":"integer","minimum":1},"most_likely_tokens":{"type":"integer","minimum":1},"pessimistic_tokens":{"type":"integer","minimum":1},"requires_external_effect":{"type":"boolean"}},"required":["description","dependencies","budget_class","optimistic_tokens","most_likely_tokens","pessimistic_tokens","requires_external_effect"]}}},"required":["tasks"]}""";
+
+ private static readonly JsonElement _element = JsonDocument.Parse(Json).RootElement.Clone();
+
+ public static JsonElement Element => _element;
+} \ No newline at end of file
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
new file mode 100644
index 0000000..7a450d0
--- /dev/null
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -0,0 +1,307 @@
+using System.Text.Json;
+using Agent.Core.Ollama;
+using Agent.Core.Prompts;
+using Agent.Core.Sessions;
+using Agent.Core.Skills;
+using Agent.Core.Tasks;
+using Agent.Core.Tools;
+using Agent.Planner;
+
+namespace Agent.Core.Runtime;
+
+/// <summary>
+/// Builds an initial TaskGraph from LLM structured output (Planning phase).
+/// The Planner determines requires_external_effect per-task during Planning.
+/// Pure analysis tasks get a TaskGraph with at least 1 Task (RequiresExternalEffect=false).
+/// </summary>
+public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings settings)
+{
+ private readonly IOllamaClient _ollama = ollama;
+ private readonly AgentRuntimeSettings _settings = settings;
+
+ /// <summary>
+ /// Generates an initial TaskGraph for the given request via Ollama structured output.
+ /// Returns a TaskGraph stored in the session, keyed by the MCP Task ID.
+ /// </summary>
+ public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, CancellationToken cancellationToken)
+ {
+ // Build a Planning prompt with skill catalog and sandbox capability summary.
+ IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false);
+
+ var messages = new List<OllamaMessage>
+ {
+ new("system", FixedPrompt.Load()),
+ new("system", _settings.UserSystemPrompt),
+ new("system", $"Respond in locale: {_settings.Locale}."),
+ new("system", ComposeSkillCatalog(_settings.Skills)),
+ new("system", _settings.SandboxCapabilitySummary),
+ new("system", BuildPlanningInstruction()),
+ new("user", request, images)
+ };
+
+ // Use structured output to get a plan.
+ OllamaResult result;
+ try
+ {
+ result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Fallback: single-task plan with conservative estimates.
+ return CreateFallbackGraph(request);
+ }
+
+ PlanResult? plan = ParsePlan(result.Content);
+ if (plan is null || plan.Tasks.Count == 0)
+ return CreateFallbackGraph(request);
+
+ TaskGraph graph = BuildTaskGraph(plan, request);
+ session.TaskGraphs[taskId] = graph;
+ return graph;
+ }
+
+ /// <summary>
+ /// Replans after a policy deny or task failure by adding/replacing tasks in the existing TaskGraph.
+ /// Completed tasks are immutable; only incomplete tasks are modified.
+ /// </summary>
+ public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, CancellationToken cancellationToken)
+ {
+ if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing))
+ return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false);
+
+ var messages = new List<OllamaMessage>
+ {
+ new("system", FixedPrompt.Load()),
+ new("system", _settings.UserSystemPrompt),
+ new("system", $"Respond in locale: {_settings.Locale}."),
+ new("system", _settings.SandboxCapabilitySummary),
+ new("system", BuildReplanInstruction(existing, replanReason)),
+ new("user", request)
+ };
+
+ OllamaResult result;
+ try
+ {
+ result = await _ollama.ChatStructuredAsync(messages, 0.1, PlanSchema.Element, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ return existing;
+ }
+
+ PlanResult? plan = ParsePlan(result.Content);
+ if (plan is null || plan.Tasks.Count == 0) return existing;
+
+ // Build a new graph, preserving completed tasks from the existing graph.
+ return RebuildTaskGraph(existing, plan, request);
+ }
+
+ private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
+ {
+ var graph = new TaskGraph(32);
+ var idMap = new List<Guid>();
+
+ for (int i = 0; i < plan.Tasks.Count; i++)
+ {
+ PlannedTask pt = plan.Tasks[i];
+ TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate estimate = CreateEstimate(pt, budgetClass);
+
+ // Map dependency indices to Guids.
+ var dependencies = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ {
+ if (depIdx >= 0 && depIdx < idMap.Count)
+ dependencies.Add(idMap[depIdx]);
+ }
+
+ string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
+ PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect);
+ idMap.Add(task.Id);
+ }
+
+ return graph;
+ }
+
+ private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
+ {
+ // Collect completed task descriptions to skip them in the new graph.
+ var completedDescriptions = new HashSet<string>(
+ existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).Select(static t => t.Description),
+ StringComparer.Ordinal);
+
+ var graph = new TaskGraph(32);
+ var idMap = new List<Guid>();
+
+ for (int i = 0; i < plan.Tasks.Count; i++)
+ {
+ PlannedTask pt = plan.Tasks[i];
+
+ // Skip tasks that are already completed in the existing graph.
+ if (completedDescriptions.Contains(pt.Description))
+ {
+ // Find the completed task and add it as completed to the new graph.
+ PlannerTask? completedTask = existing.Tasks.FirstOrDefault(t => t.Description == pt.Description);
+ if (completedTask is not null)
+ {
+ TokenBudgetClass budgetClass = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate estimate = CreateEstimate(pt, budgetClass);
+ var deps = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ if (depIdx >= 0 && depIdx < idMap.Count) deps.Add(idMap[depIdx]);
+ PlannerTask added = graph.AddTask(pt.Description, budgetClass, estimate, deps, pt.RequiresExternalEffect);
+ graph.SetState(added.Id, PlannerTaskState.Completed);
+ idMap.Add(added.Id);
+ continue;
+ }
+ }
+
+ TokenBudgetClass bc = ParseBudgetClass(pt.BudgetClass);
+ TokenEstimate est = CreateEstimate(pt, bc);
+ var dependencies = new HashSet<Guid>();
+ foreach (int depIdx in pt.Dependencies)
+ if (depIdx >= 0 && depIdx < idMap.Count) dependencies.Add(idMap[depIdx]);
+
+ string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description;
+ PlannerTask task = graph.AddTask(description, bc, est, dependencies, pt.RequiresExternalEffect);
+ idMap.Add(task.Id);
+ }
+
+ return graph;
+ }
+
+ private TaskGraph CreateFallbackGraph(string request)
+ {
+ var graph = new TaskGraph(32);
+ TokenEstimate estimate = new(512, 2048, 8192);
+ graph.AddTask(request, TokenBudgetClass.K8, estimate, requiresExternalEffect: false);
+ return graph;
+ }
+
+ private static TokenBudgetClass ParseBudgetClass(string value)
+ {
+ try { return TokenBudgetClassExtensions.Parse(value); }
+ catch { return TokenBudgetClass.K4; }
+ }
+
+ private static TokenEstimate CreateEstimate(PlannedTask pt, TokenBudgetClass budgetClass)
+ {
+ long o = Math.Max(1, pt.OptimisticTokens);
+ long m = Math.Max(o, pt.MostLikelyTokens);
+ long p = Math.Max(m, pt.PessimisticTokens);
+ long max = budgetClass.MaxTokens();
+ if (p > max) p = max;
+ if (m > p) m = p;
+ if (o > m) o = m;
+ return new TokenEstimate(o, m, p);
+ }
+
+ private static PlanResult? ParsePlan(string content)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize<PlanResult>(content, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken)
+ {
+ if (_settings.Skills.Count == 0) return [];
+
+ string catalog = string.Join("\n", _settings.Skills.Select(static s => $"- {s.Name}: {s.Description}"));
+ var selectionMessages = new List<OllamaMessage>
+ {
+ new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."),
+ new("user", request)
+ };
+
+ string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}""";
+ using JsonDocument formatDoc = JsonDocument.Parse(schema);
+
+ try
+ {
+ OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false);
+ return ParseSelectedSkills(result.Content);
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
+ {
+ try
+ {
+ using JsonDocument doc = JsonDocument.Parse(content);
+ if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return [];
+ var selectedNames = new HashSet<string>(StringComparer.Ordinal);
+ foreach (JsonElement item in skillsElement.EnumerateArray())
+ {
+ string? name = item.GetString();
+ if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name);
+ }
+ var valid = new List<AgentSkill>();
+ foreach (AgentSkill skill in _settings.Skills)
+ {
+ if (selectedNames.Contains(skill.Name))
+ {
+ valid.Add(skill);
+ if (valid.Count >= 10) break;
+ }
+ }
+ return valid;
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string ComposeSkillCatalog(IReadOnlyList<AgentSkill> skills)
+ {
+ if (skills.Count == 0) return "No skills available.";
+ var lines = new List<string> { "Available skills:" };
+ foreach (AgentSkill skill in skills)
+ lines.Add($"- {skill.Name}: {skill.Description}");
+ return string.Join("\n", lines);
+ }
+
+ private static string BuildPlanningInstruction() =>
+ """
+ You are a Task Planner. Decompose the user request into a TaskGraph of 1-32 tasks.
+ Each task must have:
+ - description: a clear, self-contained sub-task description
+ - dependencies: array of task indices (0-based) that must complete before this task
+ - budget_class: one of 1K, 2K, 4K, 8K, 16K, 32K, 64K, 128K (token budget for this task)
+ - optimistic_tokens, most_likely_tokens, pessimistic_tokens: PERT estimates (0 < O <= M <= P <= budget)
+ - requires_external_effect: true if this task requires a verifiable external effect (file edit, process run, etc.)
+
+ For pure analysis or informational tasks, requires_external_effect should be false.
+ For tasks that modify files, run processes, or produce verifiable side effects, requires_external_effect should be true.
+ A single task is valid for simple requests.
+ """;
+
+ private static string BuildReplanInstruction(TaskGraph existing, string reason)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("You are a Task Planner performing replan. The previous plan encountered an issue.");
+ sb.AppendLine($"Replan reason: {reason}");
+ sb.AppendLine("Current task states:");
+ foreach (PlannerTask t in existing.Tasks)
+ {
+ sb.AppendLine($" - [{t.State}] {t.Description} (requires_external_effect={t.RequiresExternalEffect})");
+ }
+ sb.AppendLine();
+ sb.AppendLine("Completed tasks must be preserved. Modify only incomplete/failed tasks.");
+ sb.AppendLine("Return a complete revised plan including completed tasks.");
+ return sb.ToString();
+ }
+} \ No newline at end of file
diff --git a/src/Agent.Core/Sessions/AgentSession.cs b/src/Agent.Core/Sessions/AgentSession.cs
index d561c8d..07b1f16 100644
--- a/src/Agent.Core/Sessions/AgentSession.cs
+++ b/src/Agent.Core/Sessions/AgentSession.cs
@@ -1,19 +1,36 @@
+using System.Collections.Concurrent;
+using Agent.Planner;
+
namespace Agent.Core.Sessions;
public sealed class AgentSession(Guid sessionId)
{
private readonly object _sync = new();
private readonly List<ConversationMessage> _conversation = [];
+ private readonly List<ExecutionResult> _executionResults = [];
+ private readonly ConcurrentDictionary<Guid, TaskGraph> _taskGraphs = new();
public Guid SessionId { get; } = sessionId;
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastAccessAt { get; private set; } = DateTimeOffset.UtcNow;
+ public string? Summary { get; private set; }
public IReadOnlyList<ConversationMessage> Conversation
{
get { lock (_sync) return _conversation.ToArray(); }
}
+ public IReadOnlyList<ExecutionResult> ExecutionResults
+ {
+ get { lock (_sync) return _executionResults.ToArray(); }
+ }
+
+ /// <summary>
+ /// TaskGraphs owned by this session, keyed by MCP Task ID.
+ /// One TaskGraph per agent_run execution (not shared across tasks).
+ /// </summary>
+ public ConcurrentDictionary<Guid, TaskGraph> TaskGraphs => _taskGraphs;
+
public void Append(ConversationMessage message)
{
ArgumentNullException.ThrowIfNull(message);
@@ -23,4 +40,51 @@ public sealed class AgentSession(Guid sessionId)
LastAccessAt = DateTimeOffset.UtcNow;
}
}
+
+ public void AppendExecutionResult(ExecutionResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ lock (_sync)
+ {
+ _executionResults.Add(result);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ public void SetSummary(string summary)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(summary);
+ lock (_sync)
+ {
+ Summary = summary;
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ /// <summary>
+ /// Removes old conversation entries from the active context set after summary compaction.
+ /// Audit/LLM logs are NOT affected — only the context-active representation is compacted.
+ /// </summary>
+ public void CompactConversation(int keepLastN)
+ {
+ lock (_sync)
+ {
+ if (_conversation.Count > keepLastN)
+ _conversation.RemoveRange(0, _conversation.Count - keepLastN);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
+
+ /// <summary>
+ /// Removes old execution results from the active context set after summary compaction.
+ /// </summary>
+ public void CompactExecutionResults(int keepLastN)
+ {
+ lock (_sync)
+ {
+ if (_executionResults.Count > keepLastN)
+ _executionResults.RemoveRange(0, _executionResults.Count - keepLastN);
+ LastAccessAt = DateTimeOffset.UtcNow;
+ }
+ }
}
diff --git a/src/Agent.Core/Sessions/ExecutionResult.cs b/src/Agent.Core/Sessions/ExecutionResult.cs
new file mode 100644
index 0000000..ab665fa
--- /dev/null
+++ b/src/Agent.Core/Sessions/ExecutionResult.cs
@@ -0,0 +1,11 @@
+namespace Agent.Core.Sessions;
+
+/// <summary>
+/// A tool execution result recorded in the session for context summary and audit.
+/// The full output is stored in Audit/LLM logs; this is the context-active representation.
+/// </summary>
+public sealed record ExecutionResult(
+ Guid TaskId,
+ string ToolName,
+ bool Success,
+ string Output); \ No newline at end of file
diff --git a/src/Agent.Core/Sessions/SessionStore.cs b/src/Agent.Core/Sessions/SessionStore.cs
index 59ea02c..6224e80 100644
--- a/src/Agent.Core/Sessions/SessionStore.cs
+++ b/src/Agent.Core/Sessions/SessionStore.cs
@@ -6,6 +6,11 @@ public sealed class SessionStore
{
private readonly ConcurrentDictionary<Guid, AgentSession> _sessions = new();
+ /// <summary>
+ /// Enumerates all active sessions. Primarily for TaskGraphToolHandler to resolve graphs by TaskId.
+ /// </summary>
+ public IEnumerable<AgentSession?> Sessions => _sessions.Values;
+
public AgentSession Create()
{
while (true)
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs
index 14cba45..a92a829 100644
--- a/src/Agent.Core/Tools/InternalToolCatalog.cs
+++ b/src/Agent.Core/Tools/InternalToolCatalog.cs
@@ -19,7 +19,7 @@ public static class InternalToolCatalog
Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""),
Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""),
Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""),
- Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}"""),
+ Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]},"requires_external_effect":{"type":["boolean","null"]}}}"""),
Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""")
];
return definitions.ToDictionary(static definition => definition.Name, StringComparer.Ordinal);
diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs
index c888705..0ae078c 100644
--- a/src/Agent.Core/Tools/InternalToolDispatcher.cs
+++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs
@@ -4,7 +4,7 @@ namespace Agent.Core.Tools;
public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalToolBackend backend)
{
- public Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, cancellationToken);
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => backend.BeginTaskAsync(taskId, sessionId, cancellationToken);
public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => backend.EndTaskAsync(taskId, success, cancellationToken);
public async Task<ToolExecutionResult> DispatchAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
index 03daa56..edc1cc5 100644
--- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs
+++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs
@@ -1,30 +1,46 @@
using System.Collections.Concurrent;
using System.Text.Json;
+using Agent.Core.Sessions;
using Agent.Planner;
namespace Agent.Core.Tools;
public interface ITaskUpdateHandler
{
- void BeginTask(Guid taskId);
+ void BeginTask(Guid taskId, Guid sessionId);
ToolExecutionResult Execute(Guid taskId, JsonElement arguments);
void EndTask(Guid taskId);
}
-public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
+public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessions = null) : ITaskUpdateHandler
{
- private readonly ConcurrentDictionary<Guid, TaskGraph> _graphs = new();
+ private readonly ConcurrentDictionary<Guid, TaskGraph> _fallbackGraphs = new();
- public void BeginTask(Guid taskId)
+ public void BeginTask(Guid taskId, Guid sessionId)
{
- if (!_graphs.TryAdd(taskId, new TaskGraph(maxTasks)))
- throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists.");
+ TaskGraph graph = new(maxTasks);
+ if (sessions is not null && sessions.TryGet(sessionId, out AgentSession? session) && session is not null)
+ {
+ if (!session.TaskGraphs.TryAdd(taskId, graph))
+ throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists in session '{sessionId}'.");
+ }
+ else
+ {
+ if (!_fallbackGraphs.TryAdd(taskId, graph))
+ throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists.");
+ }
+ }
+
+ public TaskGraph? GetGraph(Guid taskId, Guid? sessionId = null)
+ {
+ if (sessionId is not null && sessions is not null && sessions.TryGet(sessionId.Value, out AgentSession? session) && session is not null)
+ return session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g) ? g : null;
+ return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null;
}
public ToolExecutionResult Execute(Guid taskId, JsonElement arguments)
{
- if (!_graphs.TryGetValue(taskId, out TaskGraph? graph))
- throw new InvalidOperationException("TaskGraph scope is not active.");
+ TaskGraph? graph = ResolveGraph(taskId) ?? throw new InvalidOperationException("TaskGraph scope is not active.");
string operation = arguments.GetProperty("operation").GetString()!;
switch (operation)
@@ -35,6 +51,7 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
Budget(arguments),
Estimate(arguments),
Ids(arguments, "depends_on"),
+ requiresExternalEffect: OptionalBool(arguments, "requires_external_effect"),
id: OptionalId(arguments, "task_id"));
break;
case "add_dependency":
@@ -61,7 +78,8 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
description = task.Description,
depends_on = task.Dependencies,
state = task.State.ToString().ToLowerInvariant(),
- expected_tokens = task.ExpectedTokens
+ expected_tokens = task.ExpectedTokens,
+ requires_external_effect = task.RequiresExternalEffect
}),
critical_path = graph.CriticalPath.TaskIds,
critical_path_tokens = graph.CriticalPath.CumulativeTokenCost,
@@ -70,7 +88,31 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32) : ITaskUpdateHandler
return new ToolExecutionResult(true, output, false);
}
- public void EndTask(Guid taskId) => _graphs.TryRemove(taskId, out _);
+ public void EndTask(Guid taskId)
+ {
+ // Remove from fallback dict; session-owned graphs persist for context summary.
+ _fallbackGraphs.TryRemove(taskId, out _);
+ }
+
+ private TaskGraph? ResolveGraph(Guid taskId)
+ {
+ // Search all sessions first, then fallback.
+ if (sessions is not null)
+ {
+ foreach (AgentSession? session in sessions.Sessions)
+ {
+ if (session is not null && session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g))
+ return g;
+ }
+ }
+ return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null;
+ }
+
+ private static bool OptionalBool(JsonElement arguments, string property)
+ {
+ if (!arguments.TryGetProperty(property, out JsonElement value)) return false;
+ return value.ValueKind == JsonValueKind.True;
+ }
private static TokenBudgetClass Budget(JsonElement arguments) =>
TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class"));
diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs
index d478a2c..cefca8d 100644
--- a/src/Agent.Core/Tools/ToolPolicy.cs
+++ b/src/Agent.Core/Tools/ToolPolicy.cs
@@ -15,7 +15,7 @@ public interface IToolPolicyEngine
public interface IInternalToolBackend
{
- Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken) => Task.CompletedTask;
+ Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken);
Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}
diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs
index 9a2ba66..8ae25d5 100644
--- a/src/Agent.Mcp/McpHost.cs
+++ b/src/Agent.Mcp/McpHost.cs
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization.Metadata;
using System.Text.Json.Nodes;
using System.Reflection;
using Agent.Core.Localization;
+using Agent.Core.Sessions;
using Agent.Core.Tasks;
using Agent.Core.Ollama;
using Agent.Core.Runtime;
@@ -24,7 +25,7 @@ namespace Agent.Mcp;
public static class McpHost
{
- public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null)
+ public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null)
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
@@ -32,7 +33,7 @@ public static class McpHost
ContentRootPath = AppContext.BaseDirectory
});
builder.Logging.ClearProviders();
- var registry = new TaskRegistry();
+ var registry = new TaskRegistry(sessionStore);
using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
var ollama = new OllamaClient(httpClient, ollamaSettings);
var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer);
@@ -58,7 +59,7 @@ public static class McpHost
await host.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false);
}
- public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null)
+ public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null)
{
if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address))
throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName));
@@ -66,7 +67,7 @@ public static class McpHost
builder.Logging.ClearProviders();
string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString();
builder.WebHost.UseUrls($"http://{listenHost}:{port}");
- var registry = new TaskRegistry();
+ var registry = new TaskRegistry(sessionStore);
var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer);
builder.Services.AddSingleton(locale);
diff --git a/src/Agent.Mcp/QueuedTaskWorker.cs b/src/Agent.Mcp/QueuedTaskWorker.cs
index edea1ad..799d5d8 100644
--- a/src/Agent.Mcp/QueuedTaskWorker.cs
+++ b/src/Agent.Mcp/QueuedTaskWorker.cs
@@ -11,10 +11,10 @@ public sealed class QueuedTaskWorker(TaskRegistry registry, AgentRuntime runtime
await foreach (Guid taskId in registry.ReadQueuedAsync(stoppingToken).ConfigureAwait(false))
{
if (!registry.Transition(taskId, AgentTaskState.Planning, "planning")) continue;
- registry.Transition(taskId, AgentTaskState.Running, "ollama chat");
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, registry.GetCancellationToken(taskId));
try
{
+ // ExecuteAsync handles Planning → Running transition internally.
await runtime.ExecuteAsync(taskId, linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (linked.IsCancellationRequested)
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index 936bcbd..6e62d3d 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -31,14 +31,14 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell);
private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler();
- public async Task BeginTaskAsync(Guid taskId, CancellationToken cancellationToken)
+ public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken)
{
if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active.");
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId);
var acl = new TaskAclManager(profile.Sid, audit, journal);
var context = new TaskContext(journal, acl);
- _taskUpdates.BeginTask(taskId);
+ _taskUpdates.BeginTask(taskId, sessionId);
try
{
foreach ((string path, FileCapability capabilities) in settings.Files
@@ -71,16 +71,23 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
{
TaskContext context = Get(taskId);
- return toolName switch
+ try
{
- "file_search" => Task.FromResult(Search(arguments)),
- "file_read" => Task.FromResult(Read(arguments)),
- "file_edit" => Task.FromResult(Edit(context, arguments)),
- "process_run" => Task.FromResult(RunProcess(taskId, arguments)),
- "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
- "dir" => Task.FromResult(DirectoryOp(context, arguments)),
- _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
- };
+ return toolName switch
+ {
+ "file_search" => Task.FromResult(Search(arguments)),
+ "file_read" => Task.FromResult(Read(arguments)),
+ "file_edit" => Task.FromResult(Edit(context, arguments)),
+ "process_run" => Task.FromResult(RunProcess(taskId, arguments)),
+ "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
+ "dir" => Task.FromResult(DirectoryOp(context, arguments)),
+ _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
+ };
+ }
+ catch (SandboxPolicyException ex)
+ {
+ return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:SandboxPolicyException:{ex.Message}", RetryAllowed: false));
+ }
}
public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs
index c157458..94bdde9 100644
--- a/tests/Agent.Core.Tests/InternalToolTests.cs
+++ b/tests/Agent.Core.Tests/InternalToolTests.cs
@@ -77,7 +77,7 @@ public sealed class InternalToolTests
Guid scope = Guid.NewGuid();
Guid node = Guid.NewGuid();
var handler = new TaskGraphToolHandler();
- handler.BeginTask(scope);
+ handler.BeginTask(scope, Guid.NewGuid());
using JsonDocument add = JsonDocument.Parse($$"""{"operation":"add","task_id":"{{node}}","description":"Build fixture","depends_on":[],"budget_class":"1K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30}""");
ToolExecutionResult result = handler.Execute(scope, add.RootElement);
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index 27308ef..130097e 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -45,6 +45,7 @@ public sealed class PromptAndRuntimeTests
await runtime.ExecuteAsync(first.TaskId, TestContext.Current.CancellationToken);
Assert.True(registry.TryGet(first.TaskId, null, out TaskSnapshot? completed));
Assert.True(completed!.State == AgentTaskState.Completed, completed.Result);
+ // Only Run-phase tokens are counted via registry.AddUsage (Plan tokens are not added to registry)
Assert.Equal(3, completed.InputTokens);
Assert.Equal(2, completed.OutputTokens);
@@ -53,11 +54,15 @@ public sealed class PromptAndRuntimeTests
Assert.True(registry.Transition(second.TaskId, AgentTaskState.Running));
await runtime.ExecuteAsync(second.TaskId, TestContext.Current.CancellationToken);
- Assert.Equal(2, ollama.Calls.Count);
- Assert.Contains(ollama.Calls[1], message => message.Role == "user" && message.Content == "first");
- Assert.Contains(ollama.Calls[1], message => message.Role == "assistant" && message.Content == "answer-1");
- Assert.Equal(0.5, ollama.Temperatures[0]);
- Assert.Null(ollama.Temperatures[1]);
+ // 4 calls: Plan + Run for each ExecuteAsync (Skills empty, so no skill selection call)
+ Assert.Equal(4, ollama.Calls.Count);
+ // Second Run (index 3) should contain session conversation from first task
+ Assert.Contains(ollama.Calls[3], message => message.Role == "user" && message.Content == "first");
+ // First Run returns answer-2 (Plan=answer-1, Run=answer-2 due to call ordering)
+ Assert.Contains(ollama.Calls[3], message => message.Role == "assistant" && message.Content == "answer-2");
+ // Plan temperature = 0.1, Run temperature = task.Temperature (0.5 for first, null for second)
+ Assert.Equal(0.1, ollama.Temperatures[0]);
+ Assert.Equal(0.5, ollama.Temperatures[1]);
}
[Fact]
@@ -170,8 +175,10 @@ public sealed class PromptAndRuntimeTests
Assert.Equal(AgentTaskState.Failed, failed!.State);
Assert.Contains("denied by sandbox policy", failed.Result, StringComparison.OrdinalIgnoreCase);
Assert.Contains("not approved for Read", failed.Result, StringComparison.OrdinalIgnoreCase);
- Assert.Equal(1, ollama.CallCount);
- Assert.Equal(1, backend.CallCount);
+ // After replan (max 3 attempts), the task fails. Each replan cycle = 1 ChatWithTools call.
+ // Initial attempt (1) + 3 replan attempts = 4 ChatWithTools calls.
+ Assert.True(ollama.CallCount >= 1, $"Expected at least 1 call, got {ollama.CallCount}");
+ Assert.True(backend.CallCount >= 1, $"Expected at least 1 backend call, got {backend.CallCount}");
}
private sealed class RecordingOllamaClient : IOllamaClient
@@ -185,6 +192,22 @@ public sealed class PromptAndRuntimeTests
Temperatures.Add(temperatureOverride);
return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2));
}
+
+ public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
+ {
+ // Return a minimal single-task plan for Planning phase.
+ Calls.Add(messages);
+ Temperatures.Add(temperatureOverride);
+ string planJson = """{"tasks":[{"description":"task","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":false}]}""";
+ return Task.FromResult(new OllamaResult(planJson, 3, 2));
+ }
+
+ public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ {
+ Calls.Add(messages);
+ Temperatures.Add(temperatureOverride);
+ return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2));
+ }
}
private sealed class ToolCallingOllamaClient : IOllamaClient
@@ -193,6 +216,12 @@ public sealed class PromptAndRuntimeTests
public int ToolCount { get; private set; }
public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
Task.FromResult(new OllamaResult("修正しました。", 1, 1));
+ public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
+ {
+ // Return a plan with requires_external_effect=true for file edit task.
+ string planJson = """{"tasks":[{"description":"fileを修正","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true}]}""";
+ return Task.FromResult(new OllamaResult(planJson, 1, 1));
+ }
public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
{
CallCount++;
@@ -215,11 +244,14 @@ public sealed class PromptAndRuntimeTests
ChatCalls++;
return Task.FromResult(new OllamaResult("unexpected", 0, 0));
}
+ // ChatStructuredAsync uses default implementation which falls back to ChatAsync.
+ // Vision check happens before Planning, so ChatAsync should never be called.
}
private sealed class CleanupFailingBackend : IInternalToolBackend
{
public int EndCalls { get; private set; }
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) =>
Task.FromResult(new ToolExecutionResult(true, "unused", false));
public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken)
@@ -231,8 +263,10 @@ public sealed class PromptAndRuntimeTests
private sealed class FailingProcessBackend : IInternalToolBackend
{
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) =>
Task.FromResult(new ToolExecutionResult(false, "exit 1", true, "process:1"));
+ public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FailingThenFinalOllamaClient : IOllamaClient
@@ -240,6 +274,11 @@ public sealed class PromptAndRuntimeTests
private int _calls;
public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
Task.FromResult(new OllamaResult("done", 1, 1));
+ public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
+ {
+ string planJson = """{"tasks":[{"description":"run build","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true}]}""";
+ return Task.FromResult(new OllamaResult(planJson, 1, 1));
+ }
public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
{
if (_calls++ > 0) return Task.FromResult(new OllamaResult("done", 1, 1));
@@ -251,12 +290,14 @@ public sealed class PromptAndRuntimeTests
private sealed class UnauthorizedBackend : IInternalToolBackend
{
public int CallCount { get; private set; }
+ public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
{
CallCount++;
throw new UnauthorizedAccessException("'C:\\Users' is not approved for Read.");
}
+ public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FileSearchOllamaClient : IOllamaClient
@@ -266,6 +307,12 @@ public sealed class PromptAndRuntimeTests
public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
Task.FromResult(new OllamaResult("unexpected", 1, 1));
+ public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
+ {
+ string planJson = """{"tasks":[{"description":"research regional curry","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true}]}""";
+ return Task.FromResult(new OllamaResult(planJson, 1, 1));
+ }
+
public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
{
CallCount++;
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index 9f13f56..10415ce 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -338,6 +338,8 @@ public sealed class SecurityPolicyTests
public void Recovery_after_crash_restores_acl_loopback_and_dynamic_wfp_state()
{
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
+ // Pre-clean any residual loopback exemption from prior test failures.
+ LoopbackExemption.Remove(profile.Sid);
using TestDirectory directory = new();
string journals = Path.Combine(directory.Path, "journals");
string resource = Path.Combine(directory.Path, "resource");
@@ -371,18 +373,26 @@ public sealed class SecurityPolicyTests
// BFE owns and deletes every object in this dynamic session.
}
- RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll();
-
- Assert.True(recovered.IsClean);
- Assert.Equal(1, recovered.JournalsRecovered);
- Assert.Equal(3, recovered.ResourcesRecovered);
- Assert.False(LoopbackExemption.Contains(profile.Sid));
- byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
- .GetSecurityDescriptorBinaryForm();
- Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
- Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(child)));
- Assert.Empty(AccessRules(child, profile.Sid));
- Assert.Empty(Directory.EnumerateFiles(journals));
+ try
+ {
+ RecoveryResult recovered = new RecoveryManager(journals, audit).RecoverAll();
+
+ Assert.True(recovered.IsClean);
+ Assert.Equal(1, recovered.JournalsRecovered);
+ Assert.Equal(3, recovered.ResourcesRecovered);
+ Assert.False(LoopbackExemption.Contains(profile.Sid));
+ byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
+ .GetSecurityDescriptorBinaryForm();
+ Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(child)));
+ Assert.Empty(AccessRules(child, profile.Sid));
+ Assert.Empty(Directory.EnumerateFiles(journals));
+ }
+ finally
+ {
+ // Ensure loopback exemption is always cleaned up, even if assertions fail.
+ LoopbackExemption.Remove(profile.Sid);
+ }
}
[Theory]
@@ -395,6 +405,8 @@ public sealed class SecurityPolicyTests
[InlineData("wfp")]
public void Independent_process_crash_is_fully_recovered_on_next_start(string scenario)
{
+ // Pre-clean any residual loopback exemption from prior test failures.
+ LoopbackExemption.Remove(AppContainerProfile.EnsureFixed().Sid);
using TestDirectory directory = new();
string resourceDirectory = Path.Combine(directory.Path, "resource");
string resourceFile = Path.Combine(directory.Path, "resource.txt");
@@ -550,7 +562,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument edit = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"overwrite","content":"after"}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", edit.RootElement, TestContext.Current.CancellationToken);
@@ -584,7 +596,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"mkdir"}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", mkdir.RootElement, TestContext.Current.CancellationToken);
@@ -615,7 +627,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken);
@@ -643,7 +655,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rmdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(target)}},"operation":"rmdir"}""");
await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "dir", rmdir.RootElement, TestContext.Current.CancellationToken));
@@ -667,7 +679,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument list = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(workspace)}},"operation":"list"}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "dir", list.RootElement, TestContext.Current.CancellationToken);
@@ -693,7 +705,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
string badTarget = Path.Combine(outside.Path, "escaped");
using JsonDocument mkdir = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(badTarget)}},"operation":"mkdir"}""");
@@ -719,7 +731,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}""");
await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken));
@@ -755,7 +767,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(textFile)}},"mode":"tail","query":"","tail_lines":10}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken);
@@ -780,7 +792,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken);
@@ -811,7 +823,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken));
@@ -838,7 +850,7 @@ public sealed class SecurityPolicyTests
16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
var backend = new WindowsToolBackend(settings, audit);
Guid taskId = Guid.NewGuid();
- await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ await backend.BeginTaskAsync(taskId, Guid.NewGuid(), TestContext.Current.CancellationToken);
using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken);