summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/Design.md2
-rw-r--r--doc/Specifications.md2
-rw-r--r--plans/ImplementationPlan.md1
-rw-r--r--src/Agent.App/Logging/JsonLinesRuntimeObserver.cs2
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs8
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs2
-rw-r--r--src/Agent.Core/Tools/ToolPolicy.cs7
-rw-r--r--tests/Agent.Core.Tests/InternalToolTests.cs1
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs49
9 files changed, 68 insertions, 6 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 1bcbc99..693cdeb 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -317,7 +317,7 @@ Ollamaに公開するInternal Toolは次の5群に固定する。個々のOS com
```
PlannerがDAG、不変条件、completed immutable、max_tasksを再検査してから適用する。
### 7.6 ToolUseGuard
-Taskには `requires_external_effect` をPlanner内部属性として持たせる。trueのTaskで、当該iteration中のtool callが0かつ完了証拠がない場合、LLMへ「未実行」を示すsystem-generated observationを追加して再試行する。同一失敗fingerprintが5回に達したらTaskをfailedにする。
+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へ再投入しない。
---
## 8. Token Planner設計
### 8.1 Task model
diff --git a/doc/Specifications.md b/doc/Specifications.md
index e723d03..cc04762 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -231,7 +231,7 @@ Vision入力は設定可能な画像数、1画像あたりのsize、request合è¨
### 7.2 Tool未使用検出
-AgentはTaskの期待副作用と観測結果を比較し、実行が必要なのにtool callが0件の場合はTaskをcompletedにしない。再試行はAgent Loopの失敗回数へ算入する。純粋な説明・分析Taskにはtool callを強制しない。
+AgentはTaskの期待副作用と観測結果を比較し、実行が必要なのにtool callが0件の場合はTaskをcompletedにしない。再試行はAgent Loopの失敗回数へ算入する。純粋な説明・分析Taskにはtool callを強制しない。Sandbox Policyによる未許可操作は修正可能なTool失敗としてLLMへ再試行させず、初回のdenyでTaskをfailedにしてcleanupを実行する。
Internal Toolが返す読取・検索・tail結果は設定可能な上限を持ち、contextを無制限に消費してはならない。上限値は設計および設定schemaで定義する。
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index d87a357..8127701 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -346,6 +346,7 @@ Planner deterministic test全PASS。
- [ ] Ollamaには5群のみ公開
- [ ] malformed tool args拒否
- [ ] Policy deny時に副作用なし
+- [ ] Policy denyは初回でfailedとなり、LLMへ再試行されない
- [ ] external effect必須Taskでtool未使用ならcompletedにならない
- [ ] 同一失敗5回でfailed
- [ ] pure analysis TaskにはToolを強制しない
diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
index b7cbd26..20ef88d 100644
--- a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
+++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs
@@ -26,7 +26,7 @@ public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntim
public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new
{
timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName,
- result.Success, result.ExternalEffect, result.Output, result.FailureFingerprint
+ result.Success, result.ExternalEffect, result.RetryAllowed, result.Output, result.FailureFingerprint
});
private void Write(object value)
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index c2fa568..0fcf7d0 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -63,7 +63,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
{
toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
}
- catch (Exception ex) when (ex is ToolValidationException or ArgumentException or UnauthorizedAccessException)
+ 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}");
}
@@ -72,6 +76,8 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
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));
}
diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs
index 7302c1f..0dca5df 100644
--- a/src/Agent.Core/Tools/InternalToolDispatcher.cs
+++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs
@@ -11,7 +11,7 @@ public sealed class InternalToolDispatcher(IToolPolicyEngine policy, IInternalTo
{
InternalToolValidator.Validate(toolName, arguments);
ToolPolicyDecision decision = policy.Evaluate(toolName, arguments);
- if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}");
+ if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}", RetryAllowed: false);
return await backend.ExecuteAsync(taskId, toolName, arguments, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/src/Agent.Core/Tools/ToolPolicy.cs b/src/Agent.Core/Tools/ToolPolicy.cs
index c9f216a..d478a2c 100644
--- a/src/Agent.Core/Tools/ToolPolicy.cs
+++ b/src/Agent.Core/Tools/ToolPolicy.cs
@@ -20,7 +20,12 @@ public interface IInternalToolBackend
Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask;
}
-public sealed record ToolExecutionResult(bool Success, string Output, bool ExternalEffect, string? FailureFingerprint = null);
+public sealed record ToolExecutionResult(
+ bool Success,
+ string Output,
+ bool ExternalEffect,
+ string? FailureFingerprint = null,
+ bool RetryAllowed = true);
public sealed class DenyByDefaultPolicy(IReadOnlySet<string>? allowedTools = null) : IToolPolicyEngine
{
diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs
index 1e241ce..e452cab 100644
--- a/tests/Agent.Core.Tests/InternalToolTests.cs
+++ b/tests/Agent.Core.Tests/InternalToolTests.cs
@@ -36,6 +36,7 @@ public sealed class InternalToolTests
Assert.False(result.Success);
Assert.False(result.ExternalEffect);
+ Assert.False(result.RetryAllowed);
Assert.Empty(backend.Calls);
}
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index 23ad2d2..c3c634d 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -144,6 +144,29 @@ public sealed class PromptAndRuntimeTests
Assert.Contains("iteration limit", failed.Result, StringComparison.OrdinalIgnoreCase);
}
+ [Fact]
+ public async Task Runtime_does_not_retry_a_sandbox_policy_denial()
+ {
+ TaskRegistry registry = new();
+ var backend = new UnauthorizedBackend();
+ var dispatcher = new InternalToolDispatcher(
+ new DenyByDefaultPolicy(new HashSet<string>(["file_search"], StringComparer.Ordinal)), backend);
+ var ollama = new FileSearchOllamaClient();
+ var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher);
+ TaskSnapshot task = registry.Enqueue("research regional curry", null, null);
+ registry.Transition(task.TaskId, AgentTaskState.Planning);
+ registry.Transition(task.TaskId, AgentTaskState.Running);
+
+ await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken);
+
+ Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
+ 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);
+ }
+
private sealed class RecordingOllamaClient : IOllamaClient
{
public List<IReadOnlyList<OllamaMessage>> Calls { get; } = [];
@@ -217,4 +240,30 @@ public sealed class PromptAndRuntimeTests
return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))]));
}
}
+
+ private sealed class UnauthorizedBackend : IInternalToolBackend
+ {
+ public int CallCount { get; private set; }
+
+ public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ throw new UnauthorizedAccessException("'C:\\Users' is not approved for Read.");
+ }
+ }
+
+ private sealed class FileSearchOllamaClient : IOllamaClient
+ {
+ public int CallCount { get; private set; }
+
+ public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) =>
+ Task.FromResult(new OllamaResult("unexpected", 1, 1));
+
+ public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ using JsonDocument arguments = JsonDocument.Parse("""{"root":"C:\\Users","mode":"name","query":"*.txt"}""");
+ return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_search", arguments.RootElement.Clone()))]));
+ }
+ }
}