summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 21:53:57 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 21:53:57 +0900
commit7c9794f8d4d05074b96bb525d19db4ee1f6b3e95 (patch)
treecb8961ee170d9f6874513e8b1a99b06855bb0707
parent6c3458a5ec37a5b69957ad17c84de58984698f25 (diff)
Complete runtime diagnostics and verification coverage
-rw-r--r--config/locales/en.json8
-rw-r--r--config/locales/ja.json8
-rw-r--r--doc/Design.md5
-rw-r--r--src/Agent.App/Agent.App.csproj3
-rw-r--r--src/Agent.App/Program.cs28
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs50
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs1
-rw-r--r--src/Agent.Core/Tools/InternalToolDispatcher.cs3
-rw-r--r--src/Agent.Core/Tools/ToolPolicy.cs7
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs6
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs13
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs17
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs32
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs4
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs11
15 files changed, 170 insertions, 26 deletions
diff --git a/config/locales/en.json b/config/locales/en.json
index 8c80170..4330cb7 100644
--- a/config/locales/en.json
+++ b/config/locales/en.json
@@ -8,6 +8,8 @@
"check.wfp_admin_required": "Windows administrator elevation is required for WFP task isolation. Start agent-mcp.exe from an elevated terminal.",
"config.invalid": "Invalid configuration: {message}",
"locale.invalid": "Invalid locale: {message}",
+ "cli.invalid": "Invalid command line: {message}",
+ "app.io_error": "Startup I/O failed: {message}",
"mcp.task_queued": "Task {task} was queued in session {session}.",
"mcp.task_not_found": "Task {task} was not found.",
"mcp.status.queued": "The task is queued.",
@@ -20,5 +22,9 @@
"ollama.vision_unsupported": "The selected model does not support image input.",
"runtime.session_not_found": "The session was not found.",
"runtime.planning_failed": "Planning failed: {message}",
- "runtime.cancelled": "The task was cancelled."
+ "runtime.cancelled": "The task was cancelled.",
+ "runtime.policy_denied": "The sandbox policy denied the requested operation (code={code}, path={path}, required_permission={permission}). Resolution: {resolution}",
+ "runtime.policy_replan_exhausted": "The sandbox policy denial remained unresolved after {message} replans.",
+ "runtime.policy_repeated": "The same sandbox policy denial occurred again, so replanning was stopped.",
+ "runtime.replan_unchanged": "Replanning did not change the incomplete task plan."
}
diff --git a/config/locales/ja.json b/config/locales/ja.json
index 45da1f8..c5a6931 100644
--- a/config/locales/ja.json
+++ b/config/locales/ja.json
@@ -8,6 +8,8 @@
"check.wfp_admin_required": "WFPによるTask分離にはWindows管理者権限が必要です。管理者として起動したターミナルからagent-mcp.exeを起動してください。",
"config.invalid": "設定が不正です: {message}",
"locale.invalid": "ロケールが不正です: {message}",
+ "cli.invalid": "コマンドラインが不正です: {message}",
+ "app.io_error": "起動時のI/O処理に失敗しました: {message}",
"mcp.task_queued": "タスク {task} をセッション {session} に登録しました。",
"mcp.task_not_found": "タスク {task} が見つかりません。",
"mcp.status.queued": "タスクは待機中です。",
@@ -20,5 +22,9 @@
"ollama.vision_unsupported": "選択されたモデルは画像入力に対応していません。",
"runtime.session_not_found": "セッションが見つかりません。",
"runtime.planning_failed": "計画に失敗しました: {message}",
- "runtime.cancelled": "タスクを取り消しました。"
+ "runtime.cancelled": "タスクを取り消しました。",
+ "runtime.policy_denied": "要求された操作はSandbox Policyにより拒否されました(code={code}、path={path}、必要権限={permission})。解決方法: {resolution}",
+ "runtime.policy_replan_exhausted": "Sandbox Policyによる拒否を{message}回の再計画で解消できませんでした。",
+ "runtime.policy_repeated": "同一のSandbox Policy拒否が再発したため、再計画を停止しました。",
+ "runtime.replan_unchanged": "再計画後も未完了のタスク計画が変更されませんでした。"
}
diff --git a/doc/Design.md b/doc/Design.md
index dfce7f3..f8f5d0b 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -333,7 +333,10 @@ PlannerがDAG、不変条件、completed immutable、max_tasksを再検査して
- list は Read capabilityを要求し、直下のエントリ一覧を返す。外部効果ではない。
- 既存の PathPolicy / CanonicalPath / TaskAclManager (root継承ACE) / TaskResourceJournal / RecoveryManager 枠組みを利用する。新規作成ディレクトリは親の継承ACEを自動取得するため追加ACL操作は不要。
- mkdir後crash → ディレクトリ削除、rmdir後crash → 空ディレクトリ再作成、でRecoveryする。
-### 7.7 ToolUseGuard
+### 7.7 Tool error表現
+既存のTool resultへ、`error_code`、`error_category`、`target_path`、`required_permission`、`retry_allowed`、`resolution`をoptional fieldとして保持する。これは独立したerror体系ではなく、既存の`SandboxPolicyException`と`ToolExecutionResult`の分類情報を失わずRuntime、Audit、localized messageへ渡す表現である。内部messageと利用者向けlocalized messageは分離し、内部Tool名は利用者向け結果へ含めない。
+
+### 7.8 ToolUseGuard
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回記録した後、同一Tool呼出しへ再投入せず、§8.4の条件付きreplanへ遷移する。
---
## 8. Token Planner設計
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj
index 4059624..17b7bc9 100644
--- a/src/Agent.App/Agent.App.csproj
+++ b/src/Agent.App/Agent.App.csproj
@@ -11,6 +11,9 @@
<ProjectReference Include="..\Agent.Windows\Agent.Windows.csproj" />
</ItemGroup>
<ItemGroup>
+ <InternalsVisibleTo Include="Agent.Core.Tests" />
+ </ItemGroup>
+ <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 6f3485f..3d2d2db 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -110,7 +110,7 @@ internal static class Program
}
catch (Exception ex) when (ex is CommandLineException or ConfigurationException or LocaleException or IOException or UnauthorizedAccessException)
{
- Console.Error.WriteLine($"agent-mcp: {ex.Message}");
+ Console.Error.WriteLine($"agent-mcp: {FormatStartupError(args, ex)}");
return 2;
}
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
@@ -120,6 +120,30 @@ internal static class Program
}
}
+ internal static string FormatStartupError(string[] args, Exception error)
+ {
+ string language = "en";
+ for (int i = 0; i + 1 < args.Length; i++)
+ if (args[i] == "--lang") language = args[i + 1];
+ try
+ {
+ string directory = Path.Combine(AppContext.BaseDirectory, "config", "locales");
+ LocaleCatalog locale = LocaleCatalog.Load(directory, language, "en");
+ string key = error switch
+ {
+ CommandLineException => "cli.invalid",
+ ConfigurationException => "config.invalid",
+ LocaleException => "locale.invalid",
+ _ => "app.io_error"
+ };
+ return locale.Format(key, new Dictionary<string, object?> { ["message"] = error.Message });
+ }
+ catch
+ {
+ return error.Message;
+ }
+ }
+
private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null)
{
string stateDirectory = Path.Combine(baseDirectory, "state");
@@ -155,7 +179,7 @@ internal static class Program
}
}
- private static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language,
+ internal static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language,
IReadOnlyList<Agent.Core.Skills.AgentSkill> skills, LocaleCatalog locale) => new(
userPrompt,
language,
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 8cf1ddb..bc9fc5e 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -75,8 +75,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
{
string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
- string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot);
+ string baseResult = failedSnapshot?.LastErrorCode is not null and not "context_summary_failed"
+ ? failedSnapshot.LastError ?? ex.Message
+ : ex.Message;
+ string result = cleanupFailure is null ? baseResult : $"{baseResult} {cleanupFailure}";
string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed";
string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result;
registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage);
@@ -122,11 +125,20 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
catch (PolicyDeniedException ex)
{
graph.SetState(next.Id, PlannerTaskState.Failed);
- registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message);
+ string localizedDenial = LocalizePolicyDenial(ex);
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, ex.ErrorCode, localizedDenial);
if (!denyFingerprints.Add(ex.Fingerprint))
- throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}");
+ {
+ string repeated = Message("runtime.policy_repeated", $"The same sandbox policy denial occurred again: {ex.Message}");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.repeated", repeated);
+ throw new InvalidOperationException(repeated);
+ }
if (counters.Replans >= maxReplans)
- throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}");
+ {
+ string exhausted = Message("runtime.policy_replan_exhausted", $"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}", maxReplans.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.replan_exhausted", exhausted);
+ throw new InvalidOperationException(exhausted);
+ }
string previousSignature = GraphSignature(graph);
counters.Replans++;
@@ -134,7 +146,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message,
(phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false);
if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal))
- throw new InvalidOperationException("Replan did not change the incomplete TaskGraph.");
+ {
+ string unchanged = Message("runtime.replan_unchanged", "Replan did not change the incomplete TaskGraph.");
+ registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning.replan_unchanged", unchanged);
+ throw new InvalidOperationException(unchanged);
+ }
if (!registry.Transition(taskId, AgentTaskState.Running, "replan"))
throw new InvalidOperationException("Task state could not return to running after replan.");
UpdateTaskProgress(taskId, graph, null, counters);
@@ -271,7 +287,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
_observer.ToolResult(callContext, call.Function.Name, toolResult);
if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool)
- throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output);
+ throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output,
+ toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath,
+ toolResult.RequiredPermission, toolResult.Resolution);
failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
}
@@ -454,9 +472,27 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary<string, object?> { ["message"] = detail });
}
- private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message)
+ private sealed class PolicyDeniedException(string fingerprint, string message, string errorCode, string? errorCategory,
+ string? targetPath, string? requiredPermission, string? resolution) : Exception(message)
{
public string Fingerprint { get; } = fingerprint;
+ public string ErrorCode { get; } = errorCode;
+ public string? ErrorCategory { get; } = errorCategory;
+ public string? TargetPath { get; } = targetPath;
+ public string? RequiredPermission { get; } = requiredPermission;
+ public string? Resolution { get; } = resolution;
+ }
+
+ private string LocalizePolicyDenial(PolicyDeniedException error)
+ {
+ if (locale is null) return error.Message;
+ return locale.Format("runtime.policy_denied", new Dictionary<string, object?>
+ {
+ ["code"] = error.ErrorCode,
+ ["path"] = error.TargetPath ?? "-",
+ ["permission"] = error.RequiredPermission ?? "-",
+ ["resolution"] = error.Resolution ?? "-"
+ });
}
private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content)
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
index 99569e4..7d8c3e1 100644
--- a/src/Agent.Core/Runtime/PlannerService.cs
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -288,6 +288,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
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.
+ Read-only operations (file_read, file_search, and dir list) do not create an external effect and must use requires_external_effect=false.
A single task is valid for simple requests.
""";
diff --git a/src/Agent.Core/Tools/InternalToolDispatcher.cs b/src/Agent.Core/Tools/InternalToolDispatcher.cs
index 0ae078c..c88b92c 100644
--- a/src/Agent.Core/Tools/InternalToolDispatcher.cs
+++ b/src/Agent.Core/Tools/InternalToolDispatcher.cs
@@ -11,7 +11,8 @@ 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}", RetryAllowed: false);
+ if (!decision.Allowed) return new(false, decision.Reason, false, $"policy:{toolName}:{decision.Reason}", RetryAllowed: false,
+ ErrorCode: "policy.tool_denied", ErrorCategory: "policy", Resolution: "Review the enabled Internal Tool policy.");
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 cefca8d..66f63ab 100644
--- a/src/Agent.Core/Tools/ToolPolicy.cs
+++ b/src/Agent.Core/Tools/ToolPolicy.cs
@@ -25,7 +25,12 @@ public sealed record ToolExecutionResult(
string Output,
bool ExternalEffect,
string? FailureFingerprint = null,
- bool RetryAllowed = true);
+ bool RetryAllowed = true,
+ string? ErrorCode = null,
+ string? ErrorCategory = null,
+ string? TargetPath = null,
+ string? RequiredPermission = null,
+ string? Resolution = null);
public sealed class DenyByDefaultPolicy(IReadOnlySet<string>? allowedTools = null) : IToolPolicyEngine
{
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 1f36164..6c72724 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -13,12 +13,12 @@ public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals,
public VerifiedExecutable Verify(string executable)
{
string canonical = CanonicalPath.Resolve(executable);
- if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.");
- if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.");
+ if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell");
+ if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read);
string actualHash = Convert.ToHexString(SHA256.HashData(stream));
if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash)))
- throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.");
+ throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
return new(canonical, actualHash);
}
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
index 4f6eec4..3de4804 100644
--- a/src/Agent.Windows/Security/PathPolicy.cs
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -18,7 +18,8 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
public void Demand(string path, FileCapability required)
{
if (!IsAllowed(path, required))
- throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.");
+ throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.",
+ "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}");
}
private static bool IsAtOrBelow(string candidate, string root) =>
@@ -43,7 +44,8 @@ public static class CanonicalPath
{
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget;
- throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.");
+ throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.",
+ "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target));
}
}
if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
@@ -51,7 +53,12 @@ public static class CanonicalPath
}
}
-public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message)
+public sealed class SandboxPolicyException(string code, string message, string category = "policy", string? targetPath = null,
+ string? requiredPermission = null, string? resolution = null) : UnauthorizedAccessException(message)
{
public string Code { get; } = code;
+ public string Category { get; } = category;
+ public string? TargetPath { get; } = targetPath;
+ public string? RequiredPermission { get; } = requiredPermission;
+ public string? Resolution { get; } = resolution;
}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index 5c1fe49..c432894 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -93,7 +93,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied",
new Dictionary<string, string> { ["tool"] = toolName, ["message"] = ex.Message },
SessionId: context.SessionId, ErrorCode: ex.Code));
- return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false));
+ return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:{ex.Code}:{ex.Message}", RetryAllowed: false,
+ ErrorCode: ex.Code, ErrorCategory: ex.Category, TargetPath: ex.TargetPath,
+ RequiredPermission: ex.RequiredPermission, Resolution: ex.Resolution));
}
}
@@ -255,9 +257,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray();
string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args);
if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute);
- int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : settings.ProcessTimeoutSeconds;
- if (seconds > settings.ProcessTimeoutSeconds)
- throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({settings.ProcessTimeoutSeconds}).");
+ int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds);
string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null;
RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits)
.Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId);
@@ -266,6 +266,15 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
}
+ internal static int ResolveProcessTimeout(JsonElement arguments, int configuredTimeoutSeconds)
+ {
+ int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds;
+ if (seconds <= 0) throw new ToolValidationException("timeout_seconds must be positive.");
+ if (seconds > configuredTimeoutSeconds)
+ throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({configuredTimeoutSeconds}).");
+ return seconds;
+ }
+
private async Task CleanupAsync(Guid taskId, TaskContext context)
{
List<Exception> failures = [];
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs
index e84d132..f6db9f2 100644
--- a/tests/Agent.Core.Tests/ConfigurationTests.cs
+++ b/tests/Agent.Core.Tests/ConfigurationTests.cs
@@ -1,5 +1,7 @@
using Agent.App.Cli;
using Agent.App.Configuration;
+using Agent.App;
+using Agent.Core.Localization;
using Agent.Core.Skills;
namespace Agent.Core.Tests;
@@ -101,6 +103,36 @@ public sealed class ConfigurationTests
}
[Fact]
+ public void Runtime_configuration_values_are_propagated()
+ {
+ var configuration = new AgentConfiguration
+ {
+ Agent = new RuntimeOptions { MaxIterations = 41, SameFailureLimit = 4 },
+ Planner = new PlannerOptions { MaxTasks = 17 },
+ Ollama = new OllamaConfiguration { Model = "model-x", Options = new OllamaGenerationOptions { NumCtx = 12345 } },
+ Limits = new LimitOptions { ContextSummaryRatio = 0.65 }
+ };
+ LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "ja");
+
+ Agent.Core.Runtime.AgentRuntimeSettings settings = Program.CreateRuntimeSettings(configuration, "prompt", "ja", [], locale);
+
+ Assert.Equal(41, settings.MaxIterations);
+ Assert.Equal(4, settings.SameFailureLimit);
+ Assert.Equal(17, settings.MaxTasks);
+ Assert.Equal(12345, settings.NumCtx);
+ Assert.Equal(0.65, settings.ContextSummaryThreshold);
+ Assert.Equal("model-x", settings.Model);
+ }
+
+ [Fact]
+ public void Startup_errors_follow_the_cli_locale()
+ {
+ string message = Program.FormatStartupError(["--lang", "ja"], new CommandLineException("bad option"));
+ Assert.Contains("コマンドラインが不正です", message, StringComparison.Ordinal);
+ Assert.Contains("bad option", message, StringComparison.Ordinal);
+ }
+
+ [Fact]
public void SkillLoader_loads_standard_skill_directories_only()
{
using var directory = new TestDirectory();
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index 85aba56..836ed5e 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -173,8 +173,8 @@ public sealed class PromptAndRuntimeTests
Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed));
Assert.Equal(AgentTaskState.Failed, failed!.State);
Assert.Contains("Replan did not change", failed.Result, StringComparison.OrdinalIgnoreCase);
- Assert.Equal("policy_denied", failed.LastErrorCode);
- Assert.Contains("not approved for Read", failed.LastError, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("planning.replan_unchanged", failed.LastErrorCode);
+ Assert.Contains("Replan did not change", failed.LastError, StringComparison.OrdinalIgnoreCase);
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}");
}
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index 7489dad..da6b325 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -15,6 +15,17 @@ namespace Agent.Windows.Tests;
public sealed class SecurityPolicyTests
{
[Fact]
+ public void Process_timeout_uses_configured_default_and_rejects_higher_values()
+ {
+ using JsonDocument omitted = JsonDocument.Parse("{}");
+ Assert.Equal(45, WindowsToolBackend.ResolveProcessTimeout(omitted.RootElement, 45));
+ using JsonDocument exact = JsonDocument.Parse("{\"timeout_seconds\":45}");
+ Assert.Equal(45, WindowsToolBackend.ResolveProcessTimeout(exact.RootElement, 45));
+ using JsonDocument tooHigh = JsonDocument.Parse("{\"timeout_seconds\":46}");
+ Assert.Throws<ToolValidationException>(() => WindowsToolBackend.ResolveProcessTimeout(tooHigh.RootElement, 45));
+ }
+
+ [Fact]
public void Read_write_and_delete_capabilities_are_independent()
{
using TestDirectory directory = new();