diff options
55 files changed, 1255 insertions, 355 deletions
@@ -51,6 +51,15 @@ logging: retained_files: 5 ``` +配布された`agent.yaml`と同じdirectoryに任意の`agent.local.yaml`を置くと、環境固有値だけを上書きできます。mapは再帰merge、scalarと配列はlocal側で置換され、merge後の全設定を通常どおり検証します。相対pathは`agent.yaml`のdirectory基準です。local設定から利用者固有のSystem PromptやSkill directoryを指定できます。`agent.local.yaml`と、その参照先prompt/SkillはRelease Bundleへ含まれず、publishでも上書きされません。雛形は`config/agent.local.example.yaml`です。 + +引数なし、または最初の引数がoptionの場合は`serve`として扱われます。次の2つは同じです。 + +```powershell +.\agent-mcp.exe serve --config .\agent.yaml +.\agent-mcp.exe --config .\agent.yaml +``` + Task状態は`state/mcp-tasks/`へatomic保存されます。再起動時の未完了Taskは`failed/server_restarted`となり、自動再実行されません。 ```yaml @@ -93,7 +102,7 @@ Ollama通信は管理Broker側から行うため、Sandbox processのnetwork許å ## Agent Skills -`skills/<skill-name>/SKILL.md`形式と、互換用の`skills/*.md`を読み込みます。SkillはLLMへの補助指示であり、Filesystem/Process/Network権限は付与しません。Skillが使用する実行ファイルは、別途`agent.yaml`でabsolute pathとSHA-256を承認してください。 +`skills/<skill-name>/SKILL.md`形式だけを読み込みます。legacyの`skills/*.md`は読み込みません。SkillはLLMへの補助指示であり、Filesystem/Process/Network権限は付与しません。Skillが使用する実行ファイルは、別途`agent.yaml`でabsolute pathとSHA-256を承認してください。 ## stdio @@ -139,6 +148,13 @@ HTTP logは`X-Forwarded-For`を接続元として信用せず、Authorization、 HTTP request処理はconsole/file出力と同期しません。運用logはbounded queue経由でbackground出力し、consoleが一時停止してもHTTP requestを停止させません。queue飽和時はrequest継続を優先し、drop件数をapplication logへ警告します。 +LLM/Audit JSONLはtask、session、event、call単位で絞り込めます。 + +```powershell +.\scripts\Read-AgentLog.ps1 -Path .\logs\llm.jsonl -TaskId <task-id> -Tail 100 +.\scripts\Read-AgentLog.ps1 -Path .\logs\audit.jsonl -Event policy.deny -Tail 100 +``` + 正常停止後、`agent-mcp.exe` descendant、追加ACL、WFP filter、loopback exemption、`state/backups`、`state/journals` が残っていないことを確認してください。loopback exemptionは `CheckNetIsolation.exe LoopbackExempt -s` で確認できます。 ## よくあるエラー diff --git a/config/agent.local.example.yaml b/config/agent.local.example.yaml new file mode 100644 index 0000000..f974dbe --- /dev/null +++ b/config/agent.local.example.yaml @@ -0,0 +1,18 @@ +# Copy this file to agent.local.yaml beside agent.yaml. +# agent.local.yaml is deliberately excluded from the Release Bundle. +application: + language: ja +agent: + user_system_prompt: local/prompts/user-system.md +skills: + directory: local/skills +sandbox: + filesystem: + read: + - C:/work/project + write: + - C:/work/project + execute: + - C:/Program Files/dotnet/dotnet.exe + delete: + - C:/work/project diff --git a/config/locales/en.json b/config/locales/en.json index ebc69a1..8c80170 100644 --- a/config/locales/en.json +++ b/config/locales/en.json @@ -17,5 +17,8 @@ "mcp.status.failed": "The task failed.", "mcp.status.cancelled": "The task was cancelled.", "sandbox.access_denied": "Access denied: {path}", - "ollama.vision_unsupported": "The selected model does not support image input." + "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." } diff --git a/config/locales/ja.json b/config/locales/ja.json index 94348a2..45da1f8 100644 --- a/config/locales/ja.json +++ b/config/locales/ja.json @@ -17,5 +17,8 @@ "mcp.status.failed": "タスクが失敗しました。", "mcp.status.cancelled": "タスクが取り消されました。", "sandbox.access_denied": "アクセスが拒否されました: {path}", - "ollama.vision_unsupported": "選択されたモデルは画像入力に対応していません。" + "ollama.vision_unsupported": "選択されたモデルは画像入力に対応していません。", + "runtime.session_not_found": "セッションが見つかりません。", + "runtime.planning_failed": "計画に失敗しました: {message}", + "runtime.cancelled": "タスクを取り消しました。" } diff --git a/doc/AuditReport.md b/doc/AuditReport.md new file mode 100644 index 0000000..937c155 --- /dev/null +++ b/doc/AuditReport.md @@ -0,0 +1,28 @@ +# v1再監査報告 + +## 基準 + +2026-08-11、commit `b5ccecc`を起点とする現行working treeを対象とした。2026-08-09のPhase/Release証跡は履歴として扱い、現行PASS判定には流用していない。 + +## 確認した矛盾と是正 + +| 観点 | 監査時の状態 | 是正 | +|---|---|---| +| Internal Tool | 仕様・設計・計画で5群/6群が混在 | 仕様から数とcatalogを除去。外部性質だけを残し、名前/schemaをDesignのTool catalogへ移管 | +| policy deny | 初回failedと最大3回replanが混在 | 同一呼出しへ再投入しない条件付きreplanへ統一。同一deny、計画未変更、実行不能、replan失敗でfailed | +| Skill形式 | READMEにlegacy互換記述 | `<skill-name>/SKILL.md`だけに統一 | +| Task完了 | 実行Taskなしでもcompletedになり得た | TaskGraph全件completedだけを完了条件とし、実行不能は理由付きfailedへ変更 | +| Planner fallback | 空/不正planから曖昧なfallback graphを生成 | `think=false`で1回だけ再試行し、再失敗はplanning failureへ変更 | +| task_update | LLMが状態遷移可能 | Tool catalogには維持し、Task追加/dependency追加/estimate変更だけに限定 | +| cancel | CancellationToken通知時点でterminal | Task Resource cleanup完了後だけcancelled。cleanup失敗はfailed | +| progress/token | progress固定値、phase別token欠落 | TaskGraph同期、critical path実値、全Ollama phase集計へ変更 | +| context | 閾値超過時に単純削除 | structured要約成功後だけcompactし、失敗時は原文保持 | +| Configuration | validationのみでruntime未接続、local overrideなし | max_tasks/num_ctx/ratio/timeoutを接続し、`agent.local.yaml` mergeを追加 | +| CLI/運用 | `serve`必須、publish後の環境値復旧が必要 | 引数なし/先頭optionをserve化。local prompt/Skill/configをBundle対象外に分離 | +| Logging/Audit | 相関field・iteration・event名が不一致 | LLM相関/rotationとAudit必須eventへ統一。read-only JSONL viewerを追加 | + +## 現在の判定 + +Release buildはwarning 0/error 0、Core 78件とPlanner 10件はPASS/skip 0となった。self-contained Bundle、ZIP、checksumも生成し、local overrideが非同梱であることを確認した。Windows suiteは22/41件がPASSしたが、残り19件はWFP/loopbackのWin32 error 5またはsymlink privilege不足で、実際の管理者tokenでは再検証できていない。AppContainer、ACL、Job、WFP、Recovery crash matrix、Bundle runtime check、stdio/HTTP Operational E2Eは`PARTIAL`または`NOT REVERIFIED`のままとする。詳細は`plans/RequirementTraceability.md`を正本とする。 + +既存の仕様語彙で表現できない独立概念は実装へ追加していない。今回追加したlocal override、status診断field、ログ相関fieldは実装前にSpecifications/Designへ定義した。 diff --git a/doc/Design.md b/doc/Design.md index 798a134..dfce7f3 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -194,6 +194,10 @@ completed_tasks: <n>/<total> current_task: <localized summary or empty> token_usage: input=<n> output=<n> total=<n> critical_path_tokens: <n> +iterations: <n> +replans: <n> +last_error_code: <stable code or empty> +last_error: <localized message or empty> message: <localized status> result: <final natural-language answer; completed only> ``` @@ -249,14 +253,14 @@ overrideはTask objectにcopyし、global configurationを変更しない。Sess - 既定上限は4画像、1画像10 MiB、request合計20 MiBとする。 - model capability確認でVision非対応と判定した場合はOllama呼出し前にlocalized error。能力確認不能時は呼出しを行い、Ollama errorを正規化して返す。 ### 6.5 Tool calling -Ollamaに公開するInternal Toolは次の6群に固定する。個々のOS command (`grep.exe`等) をtoolとして増殖させない。 +Ollamaに公開する現行Internal Tool catalogは次のとおりとする。個々のOS command (`grep.exe`等) をtoolとして増殖させない。catalogの構成は設計事項であり、仕様上の固定数として扱わない。 | Tool | 責務 | 主なoperation | | --- | --- | --- | | file_search | 検索 | glob / name / text / regex / tail相当 | | file_read | 読取 | whole / range / metadata | | file_edit | 編集 | overwrite / patch / atomic_replace / delete | | process_run | 承認済Process実行 | exe + args + cwd + timeout | -| task_update | TaskGraph更新 | add / dependency / state / estimate | +| task_update | TaskGraph更新 | add / dependency / estimate | | dir | ディレクトリ操作 | mkdir / rmdir / list | --- ## 7. Internal Tool Schema設計 @@ -308,17 +312,17 @@ Ollamaに公開するInternal Toolは次の6群に固定する。個々のOS com ### 7.5 task_update ```json { - "operation":"add|add_dependency|set_state|set_estimate", + "operation":"add|add_dependency|set_estimate", "task_id":"string|null", "description":"string|null", "depends_on":["string"], "budget_class":"1K|2K|4K|8K|16K|32K|64K|128K|null", "optimistic_tokens":0, "most_likely_tokens":0, - "pessimistic_tokens":0, - "state":"queued|planning|running|completed|failed|cancelled|null" + "pessimistic_tokens":0 } ``` +Task状態遷移はAgent Runtimeだけが行う。`task_update` はTask追加、dependency追加、estimate変更に限定し、LLMから状態を変更するschemaを公開しない。 PlannerがDAG、不変条件、completed immutable、max_tasksを再検査してから適用する。 ### 7.6 dir ```json @@ -330,7 +334,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内部属性として持たせる。**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)。 +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設計 ### 8.1 Task model @@ -364,7 +368,10 @@ topological sort後にforward passで各Taskの累積expected token costを計ç® - 実績がPを超過しても即失敗にはせず、remaining Taskのestimate見直しをLLMへ要求できる。 - `max_tasks=32`超過提案は拒否し、既存Taskの統合をLLMへ求める。 - Budget Classは意味的Taskを分割する唯一の理由にしない。Taskが複数独立成果物を含む場合に分割する。 -- Policy deny時は replan を最大3回まで試行可能。同一 deny が繰り返される場合は Failed へ遷移。 +- Policy denyは同一Tool呼出しへ再投入しない。許可済み能力だけで代替できる可能性がある場合、`running -> planning -> running` と遷移してreplanを最大3回まで試行できる。 +- completed TaskのID、状態、usageを保持する。failed Taskをdependency解決済みとして扱わない。 +- 同一deny fingerprintの再発、計画未変更、実行可能Taskなし、structured replan失敗は即Failedとする。全Taskがcompletedの場合だけAgent Taskをcompletedにする。 +- structured planが空または不正な場合は`think=false`で1回だけ再試行し、それでも失敗した場合はplanning failureとする。外部効果要否が不明なfallback graphは作成しない。 ### 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として別集計する。 --- @@ -462,6 +469,7 @@ skills: logging: directory: logs ``` +base YAMLと同じdirectoryに`agent.local.yaml`が存在する場合、YAML nodeを読み取ってmapを再帰mergeし、scalarとsequenceはlocal nodeで置換してからtyped configurationへdeserializeする。unknown propertyを含むvalidationはmerge後に行い、全相対pathのbaseはbase YAMLのdirectoryとする。local file、そこから参照するUser System PromptおよびSkill directoryはRelease Bundleのpublish入力に含めない。 ### 10.2 Filesystem entry 各list itemはpath文字列とする。起動時に絶対canonical pathへ正規化して保持する。file/directoryは実在時に種別を記録し、未作成write targetは最も近い既存親directoryまで検査する。 ### 10.3 Process approval entry @@ -599,7 +607,7 @@ patchは適用前にtarget hashまたはcontext一致を検査する。曖昧適 - log levelは設定可能だがCLI optionにはしない。 - stdoutへ通常ログを出すコードはtestで禁止する。 ### 15.2 LLM log -call ID、session ID、task ID、model、request metadata、prompt、response、tool_calls、token usageを記録する。秘密情報の自動継承は禁止だが、利用者入力にsecretが含まれる可能性はあるためlog directoryはuser-only ACLにする。 +call ID、session ID、task ID、Planner Task ID、phase、iteration、replan番号、model、request metadata、prompt、response、tool_calls、token usageを記録する。Planning、Skill選択、再計画、実行、context要約を同じ相関形式で記録し、Tool resultにもcall IDとiterationを付ける。秘密情報の自動継承は禁止だが、利用者入力にsecretが含まれる可能性はあるためlog directoryはuser-only ACLにする。既存rotation設定はLLM logにも適用する。 ### 15.3 Audit JSONL Auditはappend-only JSONLを採用する。1 event = 1 line。 ```json @@ -607,7 +615,7 @@ Auditはappend-only JSONLを採用する。1 event = 1 line。 {"ts":"...","event":"file.write","task_id":"...","path":"...","before_sha256":"...","after_sha256":"..."} {"ts":"...","event":"policy.deny","task_id":"...","kind":"network","target":"example.com:443","reason":"not_allowlisted"} ``` -最低event集合: `task.start`, `task.end`, `tool.call`, `process.start`, `process.exit`, `file.write`, `file.delete`, `policy.deny`, `network.rule.apply`, `network.rule.remove`, `recovery.start`, `recovery.end`. +最低event集合: `task.start`, `task.end`, `tool.call`, `process.start`, `process.exit`, `file.write`, `file.delete`, `policy.deny`, `network.rule.apply`, `network.rule.remove`, `recovery.start`, `recovery.end`。各eventは可能な限りsession ID、task ID、内部error codeを持つ。 --- ## 16. Recovery・終了設計 ### 16.1 graceful stop @@ -727,7 +735,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC ## 22. Codex実装制約 - 新規NuGet dependencyを勝手に追加しない。追加時は標準ライブラリで不能な理由を示す。 - MCP Toolを `agent_run` / `agent_status` 以外に増やさない。 -- Internal ToolをOSコマンド名ごとに増やさず、定義済み6群に収める。 +- Internal ToolをOSコマンド名ごとに増やさず、本章のTool catalogとschemaを変更管理の単位にする。 - Legacy HTTP+SSE transport、WebSocket、独自transportを追加しない。 - Streamable HTTPをnon-loopbackへbind可能にしない。 - 認証/TLSを「便利だから」という理由でv1へ追加しない。 @@ -783,6 +791,11 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC | mcp.transport | stdio/http | | mcp.http.host | loopback address only | | mcp.http.port | listen port | +| mcp.images.max_count | 画像数上限 | +| mcp.images.max_bytes_each | 1画像byte上限 | +| mcp.images.max_total_bytes | request画像合計byte上限 | +| mcp.tasks.time_to_live_hours | 永続MCP Task保持時間 | +| mcp.tasks.poll_interval_ms | Task poll間隔 | | agent.user_system_prompt | 追加prompt path | | agent.max_iterations | 128 default | | agent.same_failure_limit | 5 default | @@ -809,13 +822,14 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC | localization.fallback | en | | skills.directory | Skill directory | | logging.directory | log directory | +| logging.level | Application log閾値 | +| logging.http_requests | HTTP request log有効化 | +| logging.max_file_bytes | rotation size | +| logging.retained_files | rotation保持数 | | limits.whole_read_bytes | whole read上限 | | limits.search_max_results | search結果上限 | | limits.tail_max_lines | tail行数上限 | | limits.context_summary_ratio | context要約開始比率 | -| mcp.images.max_count | 画像数上限 | -| mcp.images.max_bytes_each | 1画像byte上限 | -| mcp.images.max_total_bytes | request画像合計byte上限 | ## 28. 主要データ型索引 - `AgentSession` - `AgentTask` diff --git a/doc/Specifications.md b/doc/Specifications.md index 007ce51..92dc176 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -1,7 +1,7 @@ # AI Agent Sandbox MCP Server — 仕様書 **文書名:** `Specifications.md` -**版:** 0.1 Draft +**版:** 0.2 Audit Revision **対象OS:** Windows 11 x64 **主実装:** C# / .NET 10 LTS **目的:** 実装前に、外部から観測可能な性質・制約・安全条件を一意に定義する。 @@ -144,6 +144,8 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨ | MCP-STS-01 | task_id | Yes | agent_runが返したTask ID。 | | MCP-STS-02 | session_id | No | 指定時はTaskとの整合を検査する。 | +`agent_status` のtext resultは既存のTask/Session ID、状態、進捗、token実績、最終結果に加え、実行iteration数、再計画回数、最後のerror codeと選択localeのerror messageを返す。内部Tool名、Sandbox実装詳細、chain-of-thoughtは返してはならない。 + ### 5.5 Task状態 | 状態 | 意味 | 遷移先 | @@ -167,11 +169,11 @@ IPA「厳密な仕様記述入門」の考え方に従い、課題・仕様・è¨ v1のCLI surfaceは次に限定する。 ```text -agent-mcp serve [--config <path>] [--lang <locale>] [--transport <stdio|http>] +agent-mcp [serve] [--config <path>] [--lang <locale>] [--transport <stdio|http>] agent-mcp check [--config <path>] [--lang <locale>] ``` -CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxPolicyやOllama sampling値を個別overrideするI/Fにしない。 +command省略時、および先頭引数がoptionの場合は `serve` として扱う。CLIは起動方式と表示言語を変更するためのI/Fであり、SandboxPolicyやOllama sampling値を個別overrideするI/Fにしない。 ### 5.7 MCP 2026-07-28とTasks Extension @@ -231,7 +233,7 @@ Vision入力は設定可能な画像数、1画像あたりのsize、request合è¨ ### 7.2 Tool未使用検出 -AgentはTaskの期待副作用と観測結果を比較し、実行が必要なのにtool callが0件の場合はTaskをcompletedにしない。再試行はAgent Loopの失敗回数へ算入する。純粋な説明・分析Taskにはtool callを強制しない。Sandbox Policyによる未許可操作は修正可能なTool失敗としてLLMへ再試行させず、初回のdenyでTaskをfailedにしてcleanupを実行する。 +AgentはTaskの期待副作用と観測結果を比較し、実行が必要なのにtool callが0件の場合はTaskをcompletedにしない。再試行はAgent Loopの失敗回数へ算入する。純粋な説明・分析Taskにはtool callを強制しない。Sandbox Policyによる未許可操作は同一Tool呼出しへ再投入しない。許可済み能力だけで代替できる可能性がある場合に限り再計画でき、同一denyの再発、計画未変更、実行可能Taskなし、再計画失敗、または既定最大3回への到達でTaskをfailedにしてcleanupする。 Internal Toolが返す読取・検索・tail結果は設定可能な上限を持ち、contextを無制限に消費してはならない。上限値は設計および設定schemaで定義する。 @@ -319,7 +321,8 @@ Context要約を開始する利用率閾値は設定可能でなければならã - Skillロードは権限付与を意味しない。`allowed-tools` metadataはSandbox permissionとして解釈しない。 - Skillに `scripts/` が含まれていても自動実行許可しない。実行には通常どおりFilesystem execute capability、Process Approval、path + SHA-256、Sandbox Brokerを必須とする。 - Skill directory内resourceについてcanonicalization、reparse point検査、Skill root外へのescape禁止を行う。 -- Skill選択・本文ロードはInternal Toolとして公開せず、Agent Runtime / Prompt Context Managementの責務として実装する。Internal Toolは既存5群+dir=6群に固定する。 +- Skill選択・本文ロードはInternal Toolとして公開せず、Agent Runtime / Prompt Context Managementの責務として実装する。 +- Internal Toolの具体的なcatalogとschemaは設計事項とする。MCP Clientへ公開せず、Internal Toolによる副作用は必ずPolicy検査を通過しなければならない。 --- @@ -441,6 +444,8 @@ Context要約を開始する利用率閾値は設定可能でなければならã - 設定変更の反映には再起動を必須とする。 - Hot Reloadは明示的に非対応。 - CLIの `--config` で設定ファイルpathを選択可能。 +- base設定と同じdirectoryに `agent.local.yaml` が存在する場合は任意overrideとして読み込む。mapは再帰mergeし、scalarと配列はlocal側で置換する。merge後の設定全体をvalidationし、相対pathはbase設定directoryを基準に解決する。 +- Release Bundleは既定設定だけを配布し、利用者のlocal設定、User System Prompt、Skillをpublishで上書きしてはならない。 - 起動時にschema、path、hash approval、locale、policyの静的validationを行う。 --- @@ -450,8 +455,8 @@ Context要約を開始する利用率閾値は設定可能でなければならã | 種別 | 要件 | | --- | --- | | Application Log | stderrに出力可能。stdio時stdoutへログを出してはならない。ファイルにも保存する。 | -| LLM Log | promptとresponseを記録する。secretを継承しない前提でも、ログ出力先は保護する。 | -| Audit Log | 通常ログと分離する。process、args、exit code、file write、delete、denied access等を記録する。 | +| LLM Log | promptとresponseを記録する。call、Session、Task、Planner Task、phase、iteration、replan、model、token usageを相関できなければならない。secretを継承しない前提でも、ログ出力先は保護する。 | +| Audit Log | 通常ログと分離する。Task lifecycle、Tool、process、args、exit code、file write、delete、denied access、network rule、recoveryをSession/Taskと相関して記録する。 | | Token Metrics | Task/Call単位のinput/output/total tokenを記録する。 | --- diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md index 1c70178..f90b423 100644 --- a/plans/ImplementationPlan.md +++ b/plans/ImplementationPlan.md @@ -112,7 +112,7 @@ | 2 | MCP Public Interface | stdio、非同期Task受付、`agent_run/status` | G2 | | 3 | Session / Ollama / Prompt | text会話、Session、token usage、Prompt優先順位 | G3 | | 4 | Token Planner | TaskGraph、Token PERT/CPM、再計画 | G4 | -| 5 | Internal Tool / Policy Boundary | 5 Internal Tool、ToolUseGuard、Policy Engine | G5 | +| 5 | Internal Tool / Policy Boundary | Design.mdのTool catalog、ToolUseGuard、Policy Engine | G5 | | 6 | Filesystem / Process Sandbox | AppContainer、ACL、Job、hash approval | G6 | | 7 | Transaction / Recovery | ACL Resource Transaction、File rollback、Recovery Journal | G7 | | 8 | Network Sandbox | WFP deny / host-port allow-list | G8 | @@ -323,14 +323,14 @@ Planner deterministic test全PASS。 - AGT-01〜06 - OLL-10〜11 - SEC-INV-01〜02 -## 10.2 Internal Toolを5群に固定 +## 10.2 Internal Tool catalog | Tool | Operation | | --- | --- | | `file_search` | glob / name / text / regex / tail | | `file_read` | whole / range / metadata | | `file_edit` | overwrite / patch / atomic_replace / delete | | `process_run` | exe + args + cwd + timeout | -| `task_update` | add / dependency / state / estimate | +| `task_update` | add / dependency / estimate | 個々の `grep`、`find`、`tail` 等を独立Toolにしない。 ## 10.3 実装対象 - JSON Schema @@ -348,10 +348,10 @@ Planner deterministic test全PASS。 このPhaseの副作用backendはfake/in-memory実装を使用可能とし、Windows sandboxを迂回した本物の無制限file/process操作を実装してはならない。 ## 10.5 必須テスト - [ ] MCP ClientからInternal Toolが見えない -- [ ] Ollamaには5群のみ公開 +- [ ] OllamaにはDesign.md 7章のcatalogだけを公開 - [ ] malformed tool args拒否 - [ ] Policy deny時に副作用なし -- [ ] Policy denyは初回でfailedとなり、LLMへ再試行されない +- [ ] Policy denyは同一Tool呼出しへ再投入せず、許可済み能力内の条件付き再計画を最大3回行い、同一deny・計画未変更・実行可能Taskなし・再計画失敗でfailedとなる - [ ] external effect必須Taskでtool未使用ならcompletedにならない - [ ] 同一失敗5回でfailed - [ ] pure analysis TaskにはToolを強制しない @@ -589,7 +589,7 @@ AT-01〜AT-20をすべて再実行する。 - [ ] non-loopback HTTPなし - [ ] Fixed Prompt変更I/Fなし - [ ] MCP/LLMによるPolicy変更I/Fなし -- [ ] Internal Toolが5群から増えていない +- [ ] Internal Tool catalogとschemaがDesign.md 7章と一致する - [ ] AppContainer profileをTaskごとに作成していない - [ ] WFPが汎用Firewall engine化していない - [ ] Database/ORMなし diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md index 9d6e93b..1983a6d 100644 --- a/plans/RequirementTraceability.md +++ b/plans/RequirementTraceability.md @@ -1,83 +1,77 @@ # Requirement Traceability -`PASS` は2026-08-09のRelease buildおよび管理者権限の最終実行で、実行可能なtest/evidenceが確認できたことを示す。最終証跡は `artifacts/evidence/final-elevated/` に保存する。 +基準は`b5ccecc`を起点とする2026-08-11の現行working treeである。2026-08-09の結果は`plans/progress/`に履歴として保持するが、現行実装のPASS根拠には使用しない。 + +状態は次の意味で使う。 + +- `PASS`: 現行sourceに実装があり、現行working treeで対応testを再実行した。 +- `PARTIAL`: 実装または自動testは確認したが、Requirementの一部、実機強制、またはOperational E2Eが未確認。 +- `FAIL`: 現行実装がRequirementを満たさない証拠がある。 +- `NOT REVERIFIED`: 過去証跡はあるが、現行working treeでは必要な権限・環境で再実行していない。 + +## Requirement mapping + +| Requirement | 主な実装 | Test / 現行証跡 | Status | +|---|---|---|---| +| OLL-01〜05 | `OllamaClient`, `OllamaSettingsFactory`, typed/extra options | `OllamaClientTests`, `ConfigurationTests` | PASS | +| OLL-06〜09 | Task temperature、固定model/prompt、Vision capability | `PromptAndRuntimeTests`, `VisionTests` | PASS | +| OLL-10〜13 | native Tool call、ToolUseGuard、retry、全phase token集計 | `InternalToolTests`, `PromptAndRuntimeTests`, `OllamaClientTests` | PASS | +| AGT-01〜06 | `AgentRuntime`, `InternalToolDispatcher`, MCP公開境界 | completion/replan/iteration/catalog/MCP stdio tests | PASS | +| PLN-01〜06 | `TaskGraph`, `PlannerService`, structural `task_update` | `Agent.Planner.Tests`, `InternalToolTests`, structured-plan retry tests | PASS | +| SES-01〜04 | `SessionStore`, conversation/graph/result保持 | session/runtime tests | PASS | +| SES-05〜06 | structured context要約、成功時のみcompact | summary success/failure runtime tests | PASS | +| FS-01〜07 | `PathPolicy`, `TaskAclManager`, `WindowsToolBackend` | policy unit testsは現行sourceで実行対象。AppContainer/ACL実効果は管理者再検証待ち | PARTIAL | +| PRC-01〜07 | `ExecutablePolicy`, `RestrictedProcessLauncher`, Job/child restriction | deterministic validationあり。実process/Jobは管理者再検証待ち | PARTIAL | +| REC-01〜05 | Ollama retry、timeout、cancel後cleanup | Core cancel lifecycle tests。Windows Job停止は管理者再検証待ち | PARTIAL | +| REC-06〜07 | `RecoveryManager`, journal、serve前Recovery | crash matrixおよび残留0は管理者再検証待ち | NOT REVERIFIED | +| SEC-INV-01〜05 | deny-by-default、Policy非変更、path/hash/descendant制約 | Core境界はPASS、OS強制部分は管理者再検証待ち | PARTIAL | +| SEC-INV-06〜08 | WFP allow/deny、credential非継承 | Windows Security/Operational E2E再検証待ち | NOT REVERIFIED | +| SEC-INV-09 | Fixed Prompt precedence | prompt tests | PASS | +| SEC-INV-10〜11 | cleanup、Recovery gate | Core cleanup lifecycleはPASS、OS resource残留0は再検証待ち | PARTIAL | +| SEC-INV-12 | stdio stdout purity | MCP stdio integration | PASS | +| NFR-01〜02 | default deny、決定的Policy/Graph/PERT | Core/Planner tests | PASS | +| NFR-03 | journal recovery | elevated crash matrix再検証待ち | NOT REVERIFIED | +| NFR-04〜06 | 公開MCP Tool 2個、依存管理、Windows限定設計 | MCP schema tests、build | PASS | +| NFR-07 | Task/Tool/Process/Token/deny相関log | `ObservabilityTests`、Audit event実装 | PASS | +| NFR-08〜09 | en/ja catalog、同一key検証、用語/設定索引 | locale/configuration tests、文書監査 | PASS | ## Acceptance Tests -| ID | Implementation / evidence | Status | +| ID | Current implementation / test | Status | |---|---|---| -| AT-01 | `McpStdioTests.InitializeListAndCallExposeOnlyTwoStrictTools` | PASS | -| AT-02 | `McpHttpTests`、E2E-02 `127.0.0.1` / `::1` | PASS | -| AT-03 | HTTP GET `/mcp`, `/sse`, `/message` assertions | PASS | -| AT-04 | `TaskRegistryTests`, MCP stdio integration | PASS | -| AT-05 | `OllamaClientTests.Chat_sends_expected_request_and_returns_usage` | PASS | -| AT-06 | `Non_vision_model_rejects_images_with_selected_locale_message_before_chat` | PASS | -| AT-07 | Windows backend実効果試験、E2E Audit | PASS | -| AT-08 | junction / symlink chain escape試験 | PASS | -| AT-09 | path/hash一致・不一致試験 | PASS | -| AT-10 | AppContainer loopback actual traffic default deny | PASS | -| AT-11 | exact host:port allowおよびwrong-port actual deny | PASS | -| AT-12 | child-process restriction 4経路、Job timeout/orphan 0 | PASS | -| AT-13 | ACL/WFP/loopback journal crash recovery試験 | PASS | -| AT-14 | `TaskGraphTests.Cycle_is_rejected_without_mutating_graph` | PASS | -| AT-15 | `TaskGraphTests.Completed_task_is_immutable` | PASS | -| AT-16 | deterministic PERT/CPM tests | PASS | -| AT-17 | runtime and graph usage accumulation tests | PASS | -| AT-18 | en/ja locale、3経路の日本語Operational E2E | PASS | -| AT-19 | `LocaleCatalogTests.AdditionalLocaleLoadsWithoutCodeChange` | PASS | -| AT-20 | malformed/unknown locale tests | PASS | +| AT-01 | MCP stdioの公開Tool 2個とstrict schema | PASS | +| AT-02 | loopback Streamable HTTP integration | PASS | +| AT-03 | GET/Legacy SSE拒否 | PASS | +| AT-04 | Task lifecycle、永続Task store、cancel連携 | PASS | +| AT-05 | Ollama request/usage unit test | PASS | +| AT-06 | 非Vision modelのlocalized拒否 | PASS | +| AT-07〜13 | Windows backend実効果、reparse、process、WFP、crash recovery | NOT REVERIFIED | +| AT-14〜17 | cycle、immutable、PERT/CPM、token集計 | PASS | +| AT-18〜20 | en/ja、追加locale、malformed/unknown locale | PASS | ## Security Tests -| ID | Implementation / evidence | Status | +| ID | Current implementation / test | Status | |---|---|---| -| ST-01 | E2E `file_read`実行、capability試験 | PASS | -| ST-02 | outside-root deny、副作用なし | PASS | -| ST-03 | backend `file_edit`実効果、ACL復旧 | PASS | -| ST-04 | delete capability独立試験 | PASS | -| ST-05 | junction escape拒否 | PASS | -| ST-06 | two-link symlink chain escape拒否 | PASS | -| ST-07 | approved canonical path/hash process成功 | PASS | -| ST-08 | hash mismatch拒否 | PASS | -| ST-09 | shell disabled拒否 | PASS | -| ST-10 | `Start-Process`、Process API、shell、rename/copyの4経路をOS child restrictionで拒否、marker/PIDなし、Audit deny | PASS | -| ST-11 | Job timeout後PID不存在、Operational E2E orphan 0 | PASS | -| ST-12 | AppContainer actual loopback default deny | PASS | -| ST-13 | IPv4/IPv6 filter生成、IPv4 exact allow actual traffic | PASS | -| ST-14 | actual wrong-port deny | PASS | -| ST-15 | public MCP exposes only two strict tools | PASS | -| ST-16 | strict tool schema、path policy deny時backend call 0 | PASS | -| ST-17 | applied ACL journalのcrash recovery、descriptor一致 | PASS | -| ST-18 | dynamic WFP engine close+loopback exemption journal recovery、残留0 | PASS | -| ST-19 | malformed locale fatal tests | PASS | -| ST-20 | non-loopback bind rejection test | PASS | +| ST-01〜09 | capability/path/reparse/hash/shellの決定的検査あり。ACL/AppContainer実効果は再検証待ち | PARTIAL | +| ST-10〜14 | child restriction、Job timeout、WFP actual traffic | NOT REVERIFIED | +| ST-15〜16 | MCP非公開境界、Internal Tool name/schema/Policy境界 | PASS | +| ST-17〜18 | ACL/WFP/loopback crash recovery | NOT REVERIFIED | +| ST-19〜20 | malformed locale fatal、non-loopback bind拒否 | PASS | -## Operational / Release +Internal Tool testは件数を仕様としてassertしない。`Design.md`のTool catalogに記載された`file_search / file_read / file_edit / process_run / task_update / dir`の名前、schema、Policy境界、MCP非公開性を検査する。 -| Requirement | Evidence | Status | -|---|---|---| -| Release build | Release build warning 0/error 0 | PASS | -| Unit/integration | Planner 10/10, Core 47/47, skip 0 | PASS | -| win-x64 self-contained Bundle | publish directory, ZIP, manifest, checksum | PASS | -| Bundle-only elevated startup | `bundle-check.log`: administrator/BFE/WFP probe PASS | PASS | -| E2E-01 stdio | `e2e-01-stdio.log`: local Ollama、read/edit/build/test、日本語、Audit、cleanup | PASS | -| E2E-02 HTTP (`127.0.0.1`, `::1`) | `e2e-02-http-ipv4.log` / `e2e-02-http-ipv6.log` | PASS | -| clean resource state | process/ACL/WFP/loopback/temp/journal/backup残留0 | PASS | -| README-only fresh setup | ZIP fresh extraction、source参照0、README手順による最終runner | PASS | - -## MCP 2026-07-28 Migration +## Current verification record -| Requirement | Implementation / evidence | Status | +| Verification | Date / privilege | Result | |---|---|---| -| SDK 2.0.0 / stateless discover | `Directory.Packages.props`, `McpHost`, official `server-stateless` evidence | PASS (28/28 core checks, unexpected failure 0) | -| GET/DELETE 405、Legacy SSE無効、Session IDなし | `McpHttpTests` | PASS | -| stdio / strict two Tool compatibility | test-only `Agent.Mcp.TestHost`, `McpStdioTests` | PASS | -| official Tasks negotiation/lifecycle | `WithTasks`, `PersistentMcpTaskStore`, `McpHttpTests`, conformance Tasks evidence | PARTIAL: lifecycle/cancel/error/required-task PASS; MRTR fixture composition remains incompatible with alpha.11 | -| Task persistence/restart/TTL | `PersistentMcpTaskStoreTests` | PASS | -| CLI preflight / ready | `Program`, `McpHostLogObserver`, locale catalog | PASS (elevated live run required for final Gate) | -| HTTP request log/redaction | `McpHostLogObserver`, `ObservabilityTests` | PASS | -| conformance acquisition/offline runner | `Acquire-McpConformance.ps1`, `Run-McpConformance.ps1`, `Run-McpTasksConformance.ps1` | PASS | - -`0.2.0-alpha.11`のTasks extension scenarioはrelease requirementに含まれずinformationalとして実行され、正しい`CreateTaskResult`を通常`CallToolResult` schemaで検査する`wire-schema-valid`既知制約がある。これは`runner_limitations`へ分離し、application failureとは数えない。MRTR input/composition scenarioはSDK 2.0.0との組み合わせで未達のため、Tasks Extension GateはPASS扱いにしない。 +| Agent.Core.Tests | 2026-08-11 / non-elevated / Release | 78 PASS、0 FAIL、0 skip | +| Agent.Planner.Tests | 2026-08-11 / non-elevated | 10 PASS、0 FAIL、0 skip | +| Release build | 2026-08-11 / non-elevated | warning 0、error 0 | +| Agent.Windows.Tests | 2026-08-11 / elevation request経由だがWindows tokenは非管理者 | 22 PASS、19 FAIL、0 skip。19件はWFP/loopback Win32 error 5またはsymlink privilege不足で、管理者再検証が必要 | +| win-x64 self-contained Bundle | 2026-08-11 / runtime check省略 | publish/ZIP/checksum生成PASS。`agent.local.yaml`とlocal example非同梱。SHA-256 `2cdf2ef6e38d740ef87a489eb27f883c1e9be9ec9d06dbabf5895f1ddfc61c63` | +| Task Resource repository state | 2026-08-11 | `state/journals`, `state/backups`, `state/mcp-tasks`はいずれも不存在 | +| Elevated Security/Recovery/E2E/Bundle runtime check | current working tree / administrator | NOT REVERIFIED | +| MCP Tasks conformance | current working tree | NOT REVERIFIED。runner制約をapplication failureから分離し、未達をPASSにしない | -公式core runnerの個別結果では`server-stateless` 28/28、DNS rebinding、multiple POST streamがPASSした。一方、test-only conformance hostのfixture不足またはscenario間のcapability競合により、`tools-list`(description)、`json-schema-2020-12`(診断Tool)、`caching`(Prompt/Resource/Template)、`http-header-validation`(Tasks必須ToolをTasks非対応clientが呼ぶ組合せ)は未達である。production hostのexpected-failureとして隠さず、conformance総合GateはPARTIALのままとする。 +完了Gateは`Specifications.md` 20章に従う。管理者環境の再検証が終わるまで、Release全体をPASSとはしない。 diff --git a/plans/progress/Phase0.md b/plans/progress/Phase0.md index d648dd1..45b2ee9 100644 --- a/plans/progress/Phase0.md +++ b/plans/progress/Phase0.md @@ -1,5 +1,7 @@ # Phase 0 Completion Report +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + ## 実施内容 - Specifications、Design、ImplementationPlanの優先順位に従い、単一Task実行、option競合、実装上限、Release/E2E要件を整合した。 diff --git a/plans/progress/Phase1.md b/plans/progress/Phase1.md index da58a47..1f96068 100644 --- a/plans/progress/Phase1.md +++ b/plans/progress/Phase1.md @@ -1,5 +1,7 @@ # Phase 1 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: strict typed YAML、起動時validation、en/ja localeとfallback、embedded Fixed Prompt、限定CLI、rolling/Application/LLM/Audit log基盤。 - 自動検証: configuration、locale、fixed promptのCore testsがPASS。 - Dependency差分: 承認済みYamlDotNet、Hostingのみ。追加なし。 diff --git a/plans/progress/Phase10.md b/plans/progress/Phase10.md index de3b81b..970b48e 100644 --- a/plans/progress/Phase10.md +++ b/plans/progress/Phase10.md @@ -1,5 +1,7 @@ # Phase 10 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: Application/LLM/Audit log、user-only log DACL、Task ID audit、locale message基盤。 - 現在の走査: Security TODO/FIXME 0、test skip 0。 - 最終管理者run: Core 42/42、Planner 10/10、Windows Security/Recovery 25/25、skip 0。Requirement TraceabilityのST-01~20 / AT-01~20を全PASSへ更新した。 diff --git a/plans/progress/Phase11.md b/plans/progress/Phase11.md index c03a473..09b7944 100644 --- a/plans/progress/Phase11.md +++ b/plans/progress/Phase11.md @@ -1,5 +1,7 @@ # Phase 11 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 生成済み: win-x64 self-contained directory/ZIP、required files、356-entry SHA-256 manifest、ZIP checksum。 - fresh temporary extraction、source-tree reference scan、管理者Bundle-only `check`(BFE/WFP add/delete probe)PASS。 - local OllamaによるE2E-01 stdio、E2E-02 HTTP `127.0.0.1` / `::1` が全PASS。実file read/edit、broker process、in-process build、独立self-test、日本語応答、Task ID Auditを確認した。 diff --git a/plans/progress/Phase2.md b/plans/progress/Phase2.md index b97c15b..722457e 100644 --- a/plans/progress/Phase2.md +++ b/plans/progress/Phase2.md @@ -1,5 +1,7 @@ # Phase 2 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: stdio、公開Toolを `agent_run` / `agent_status` の2つに限定、strict schema、UUID、非同期queue、global single-reader worker、stdout purity。 - 自動検証: stdio SDK integrationを含む最終Core 42/42 PASS、skip 0。 - Gate G2: PASS(Release Bundle E2EはPhase 11で判定)。 diff --git a/plans/progress/Phase3.md b/plans/progress/Phase3.md index 1a0cf26..24b6ffa 100644 --- a/plans/progress/Phase3.md +++ b/plans/progress/Phase3.md @@ -1,5 +1,7 @@ # Phase 3 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: Ollama Chat API、native tool call、session、prompt order、token usage、Task temperature、250/500/1000 ms retry、typed option優先。 - 実測: Ollama 0.32.6、local Gemma modelでtext `LOCAL_OK`、native tool call、Vision `VISION_OK` を確認。cloudはRelease Gate外。 - Gate G3: PASS。offline Operational E2EはG11で再検証する。 diff --git a/plans/progress/Phase4.md b/plans/progress/Phase4.md index cb45fc0..e142932 100644 --- a/plans/progress/Phase4.md +++ b/plans/progress/Phase4.md @@ -1,5 +1,7 @@ # Phase 4 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: TaskGraph、DAG rollback、32件default、completed immutable、Token Budget、PERT/CPM、usage累積。 - 統合: `task_update` を実TaskGraphへ接続し、計画更新を外部効果証拠から除外。 - 自動検証: Planner 10/10 PASS、Core TaskGraph tool test PASS、skip 0。 diff --git a/plans/progress/Phase5.md b/plans/progress/Phase5.md index 2915584..8115369 100644 --- a/plans/progress/Phase5.md +++ b/plans/progress/Phase5.md @@ -1,6 +1,8 @@ # Phase 5 Progress -- 実装: Internal Tool 5群、strict schema/validator、deny-by-default dispatcher、ToolUseGuard、同一失敗5回、外部効果証拠。 +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + +- 実装: 当時のDesign.mdに記載されたInternal Tool catalog、strict schema/validator、deny-by-default dispatcher、ToolUseGuard、同一失敗5回、外部効果証拠。 - Phase 5 backendはfake、Phase 6以降はWindows backendへ差し替え。 - 自動検証: malformed/unknown field、policy deny、effect evidence、failure limitを確認。 - Gate G5: PASS。 diff --git a/plans/progress/Phase6.md b/plans/progress/Phase6.md index ff9a6d0..fdfd175 100644 --- a/plans/progress/Phase6.md +++ b/plans/progress/Phase6.md @@ -1,5 +1,7 @@ # Phase 6 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: fixed AppContainer、canonical/reparse policy、能力別ACL、path+SHA-256、Job Object、`PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`、Broker launch。 - 実測: approved PowerShellからの `Start-Process`、Process API、shell、rename/copyの4経路を試し、全unbrokered childをOS restrictionで拒否した。markerなし、Job process不変、非zero親結果、Audit `policy.deny`を確認した。 - symlink chain/junction、path/hash、shell toggle、Job timeoutおよびbroker crash後orphan 0を管理者環境で確認した。 diff --git a/plans/progress/Phase7.md b/plans/progress/Phase7.md index 45d2518..9a686ed 100644 --- a/plans/progress/Phase7.md +++ b/plans/progress/Phase7.md @@ -1,5 +1,7 @@ # Phase 7 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: durable Task Resource Journal、atomic file replace/delete、inline/large backup、hash guard、reverse recovery、ACL/WFP recovery。 - 自動検証: commit/rollback、外部変更時rollback拒否、起動Recovery、journal/backup cleanupを確認。 - 独立processをFailFastするcrash matrixでACL適用前後、file replace前後、process起動後、cleanup途中、WFP適用後を検証した。次回Recovery後にfile/ACL/backup/temp/journal/loopback exemption残留0、broker crash後orphan 0。 diff --git a/plans/progress/Phase8.md b/plans/progress/Phase8.md index 6bb0921..4ff2ed6 100644 --- a/plans/progress/Phase8.md +++ b/plans/progress/Phase8.md @@ -1,5 +1,7 @@ # Phase 8 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: dynamic WFP session/transaction、AppContainer SID条件、IPv4/IPv6 permit/default-block、hard permit、DNS snapshot、Task中だけのloopback exemption、explicit cleanup、Recovery、`agent-mcp check`。 - 実測: 非管理者はengine open成功後transaction error 5。管理者実測ではfilter add/delete testがPASS。サービス/helper/恒久ruleなし。 - AppContainer curlのactual trafficでloopback default deny、exact IPv4:port allow、wrong-port denyを確認。dynamic engine crashとloopback journal Recovery後の残留0も確認した。 diff --git a/plans/progress/Phase9.md b/plans/progress/Phase9.md index ea229be..4a71c6e 100644 --- a/plans/progress/Phase9.md +++ b/plans/progress/Phase9.md @@ -1,5 +1,7 @@ # Phase 9 Progress +> Historical evidence: この記録は作成当時のcommitに対する結果であり、現行commitの再監査結果ではない。 + - 実装: Vision count/per-image/total validation、Ollama capability check、loopback-only Streamable HTTP、Origin検査、GET/Legacy SSE拒否。 - 自動検証: 127.0.0.1 SDK integration、non-loopback bind拒否、hostile Origin拒否。 - 最終検証: `127.0.0.1` / `::1` Operational E2E PASS、localized non-Vision rejectionをOllama call前に確認。 diff --git a/scripts/Operational-E2E.ps1 b/scripts/Operational-E2E.ps1 index c1b45a4..138cd55 100644 --- a/scripts/Operational-E2E.ps1 +++ b/scripts/Operational-E2E.ps1 @@ -249,7 +249,7 @@ process_runで executable=$dotnetYaml, cwd=$fixtureYaml, args=[\"$dllPath\",\"-- } } $auditText = $audit -join "`n" - if ($auditText -notmatch 'file.overwrite' -or $auditText -notmatch 'process.start' -or $auditText -notmatch 'wfp.remove' -or $auditText -notmatch 'acl.restore') { throw 'Audit trace is incomplete.' } + if ($auditText -notmatch 'file.write' -or $auditText -notmatch 'process.start' -or $auditText -notmatch 'network.rule.remove' -or $auditText -notmatch 'acl.restore') { throw 'Audit trace is incomplete.' } $passed = $true Write-Output "Operational E2E $Transport/$HttpHost PASS: $root" } diff --git a/scripts/Publish-Release.ps1 b/scripts/Publish-Release.ps1 index 78b97ef..a36c2b4 100644 --- a/scripts/Publish-Release.ps1 +++ b/scripts/Publish-Release.ps1 @@ -40,6 +40,9 @@ $required = @( foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $bundle $relative))) { throw "Release Bundle is missing $relative" } } +foreach ($relative in @('agent.local.yaml', 'config\agent.local.example.yaml')) { + if (Test-Path -LiteralPath (Join-Path $bundle $relative)) { throw "Release Bundle must not contain local override input: $relative" } +} $manifestPath = Join-Path $bundle 'publish-manifest.sha256' Get-ChildItem -LiteralPath $bundle -File -Recurse | diff --git a/scripts/Read-AgentLog.ps1 b/scripts/Read-AgentLog.ps1 new file mode 100644 index 0000000..f842e45 --- /dev/null +++ b/scripts/Read-AgentLog.ps1 @@ -0,0 +1,38 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Path, + [string]$TaskId, + [string]$SessionId, + [string]$Event, + [string]$CallId, + [ValidateRange(1, 100000)] + [int]$Tail = 200 +) + +$ErrorActionPreference = 'Stop' +$resolvedLog = (Resolve-Path -LiteralPath $Path).Path +Get-Content -LiteralPath $resolvedLog -Tail $Tail | ForEach-Object { + try { $entry = $_ | ConvertFrom-Json -ErrorAction Stop } + catch { return } + + $entryTaskId = $entry.task_id ?? $entry.taskId + $entrySessionId = $entry.session_id ?? $entry.sessionId + if ($TaskId -and [string]$entryTaskId -ne $TaskId) { return } + if ($SessionId -and [string]$entrySessionId -ne $SessionId) { return } + if ($Event -and [string]$entry.event_name -ne $Event -and [string]$entry.event -ne $Event) { return } + if ($CallId -and [string]$entry.call_id -ne $CallId) { return } + + [pscustomobject]@{ + timestamp = $entry.timestamp ?? $entry.ts + event = $entry.event_name ?? $entry.event + task_id = $entryTaskId + session_id = $entrySessionId + call_id = $entry.call_id + phase = $entry.phase + iteration = $entry.iteration + replan = $entry.replan + tool = $entry.tool_name + success = $entry.success + output = $entry.output + } +} | Format-Table -AutoSize -Wrap diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs index df65080..3fdac9f 100644 --- a/src/Agent.App/Cli/CommandLine.cs +++ b/src/Agent.App/Cli/CommandLine.cs @@ -6,8 +6,8 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? { public static CommandLine Parse(string[] args) { - if (args.Length == 0) throw new CommandLineException("Expected 'serve' or 'check'."); - AppCommand command = args[0] switch + bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal); + AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch { "serve" => AppCommand.Serve, "check" => AppCommand.Check, @@ -17,7 +17,7 @@ public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? string? config = null; string? language = null; string? transport = null; - for (int i = 1; i < args.Length; i++) + for (int i = implicitServe ? 0 : 1; i < args.Length; i++) { string option = args[i]; if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'."); diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index 298a143..efb3db1 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.RegularExpressions; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.RepresentationModel; namespace Agent.App.Configuration; @@ -17,10 +18,14 @@ public static partial class ConfigurationLoader try { + string yaml = File.ReadAllText(absolutePath); + string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml"); + if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath)) + yaml = MergeYaml(yaml, File.ReadAllText(localPath)); var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); - AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath)) + AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(yaml) ?? throw new ConfigurationException("Configuration document is empty."); Validate(configuration, Path.GetDirectoryName(absolutePath)!); return configuration; @@ -60,6 +65,16 @@ public static partial class ConfigurationLoader foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals) { if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters."); + string approvalPath = Path.GetFullPath(approval.Path); + bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath)); + if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute."); + } + foreach (string executeEntry in value.Sandbox.Filesystem.Execute) + { + string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry); + if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval => + string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase))) + Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals."); } if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid."); if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid."); @@ -81,6 +96,58 @@ public static partial class ConfigurationLoader } public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory); + + private static bool ContainsPath(string baseDirectory, string configuredPath, string target) + { + string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath); + if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true; + string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static string MergeYaml(string baseYaml, string localYaml) + { + YamlNode baseRoot = LoadRoot(baseYaml, "base configuration"); + YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml"); + YamlNode merged = MergeNode(baseRoot, localRoot); + var stream = new YamlStream(new YamlDocument(merged)); + using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + stream.Save(writer, assignAnchors: false); + return writer.ToString(); + } + + private static YamlNode LoadRoot(string yaml, string source) + { + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document."); + return stream.Documents[0].RootNode; + } + + private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode) + { + if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap) + return Clone(localNode); + + var result = (YamlMappingNode)Clone(baseMap); + foreach ((YamlNode key, YamlNode localValue) in localMap.Children) + { + YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal)); + if (existingKey is null) result.Add(Clone(key), Clone(localValue)); + else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue); + } + return result; + } + + private static YamlNode Clone(YamlNode node) + { + var stream = new YamlStream(new YamlDocument(node)); + using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + stream.Save(writer, assignAnchors: false); + return LoadRoot(writer.ToString(), "merged configuration"); + } + private static void Throw(string message) => throw new ConfigurationException(message); [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)] private static partial Regex Sha256Regex(); diff --git a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs index 20ef88d..78ad47b 100644 --- a/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs +++ b/src/Agent.App/Logging/JsonLinesRuntimeObserver.cs @@ -5,33 +5,51 @@ using Agent.Core.Tools; namespace Agent.App.Logging; -public sealed class JsonLinesRuntimeObserver(string logDirectory) : IAgentRuntimeObserver +public sealed class JsonLinesRuntimeObserver : IAgentRuntimeObserver, IDisposable { - private readonly object _sync = new(); - private readonly string _path = Path.Combine(logDirectory, "llm.jsonl"); + private readonly RollingFileWriter _writer; - public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) => Write(new + public JsonLinesRuntimeObserver(string logDirectory, long maxBytes = 10 * 1024 * 1024, int retainedFiles = 5) => + _writer = new RollingFileWriter(Path.Combine(logDirectory, "llm.jsonl"), maxBytes, retainedFiles); + + public void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.request", iteration, - messages = messages.Select(static message => new { message.Role, message.Content, image_count = message.Images?.Count ?? 0, message.ToolName }) + timestamp = DateTimeOffset.UtcNow, event_name = "llm.request", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + messages = messages.Select(static message => new { role = message.Role, content = message.Content, image_count = message.Images?.Count ?? 0, tool_name = message.ToolName }) }); - public void LlmResponse(Guid taskId, int iteration, OllamaResult result) => Write(new + public void LlmResponse(AgentLlmCallContext context, OllamaResult result) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.response", iteration, - result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, + timestamp = DateTimeOffset.UtcNow, event_name = "llm.response", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + content = result.Content, input_tokens = result.InputTokens, output_tokens = result.OutputTokens, tool_calls = result.ToolCalls?.Select(static call => call.Function.Name).ToArray() ?? [] }); - public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) => Write(new + public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) => Write(new { - timestamp = DateTimeOffset.UtcNow, task_id = taskId, event_name = "llm.tool_result", tool_name = toolName, - result.Success, result.ExternalEffect, result.RetryAllowed, result.Output, result.FailureFingerprint + timestamp = DateTimeOffset.UtcNow, event_name = "llm.failure", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, + error_code = errorCode, message + }); + + public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) => Write(new + { + timestamp = DateTimeOffset.UtcNow, event_name = "llm.tool_result", call_id = context.CallId, + session_id = context.SessionId, task_id = context.TaskId, planner_task_id = context.PlannerTaskId, + phase = context.Phase, iteration = context.Iteration, replan = context.Replan, model = context.Model, tool_name = toolName, + success = result.Success, external_effect = result.ExternalEffect, retry_allowed = result.RetryAllowed, + output = result.Output, failure_fingerprint = result.FailureFingerprint }); private void Write(object value) { - string line = JsonSerializer.Serialize(value); - lock (_sync) File.AppendAllText(_path, line + Environment.NewLine); + _writer.WriteLine(JsonSerializer.Serialize(value)); } + + public void Dispose() => _writer.Dispose(); } diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index 1c29ec3..6f3485f 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -76,7 +76,7 @@ internal static class Program var sessionStore = new SessionStore(); InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore); - var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory); + using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles); string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks"); UserOnlyDirectory.Ensure(taskStateDirectory); var taskStoreSettings = new McpTaskStoreSettings(taskStateDirectory, @@ -88,7 +88,7 @@ internal static class Program string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); var skills = SkillLoader.Load(skillDirectory); - 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")); + AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale); 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, sessionStore: sessionStore).ConfigureAwait(false); @@ -99,7 +99,7 @@ internal static class Program string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt)); string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory); var skills = SkillLoader.Load(skillDirectory); - 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")); + AgentRuntimeSettings runtimeSettings = CreateRuntimeSettings(configuration, userPrompt, language, skills, locale); 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, @@ -143,7 +143,8 @@ internal static class Program configuration.Sandbox.Network.Mode, 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); + configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines, + configuration.Sandbox.Resources.ProcessTimeoutSeconds); 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); @@ -153,4 +154,18 @@ internal static class Program foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability)); } } + + private static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language, + IReadOnlyList<Agent.Core.Skills.AgentSkill> skills, LocaleCatalog locale) => new( + 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"), + configuration.Ollama.Options.NumCtx ?? 32768, + configuration.Limits.ContextSummaryRatio, + MaxTasks: configuration.Planner.MaxTasks, + Model: configuration.Ollama.Model); } diff --git a/src/Agent.Core/Ollama/IOllamaClient.cs b/src/Agent.Core/Ollama/IOllamaClient.cs index d1cb99f..88a2bec 100644 --- a/src/Agent.Core/Ollama/IOllamaClient.cs +++ b/src/Agent.Core/Ollama/IOllamaClient.cs @@ -10,5 +10,7 @@ public interface IOllamaClient ChatAsync(messages, temperatureOverride, cancellationToken); Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => ChatAsync(messages, temperatureOverride, cancellationToken); + Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) => + ChatStructuredAsync(messages, temperatureOverride, format, cancellationToken); Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(null); } diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index dde8478..428bca9 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -22,13 +22,16 @@ public sealed class OllamaClient : IOllamaClient } public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, null, null, cancellationToken).ConfigureAwait(false); public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, tools, null, null, cancellationToken).ConfigureAwait(false); public async Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) - => await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false); + => await ChatCoreAsync(messages, temperatureOverride, null, format, null, cancellationToken).ConfigureAwait(false); + + public async Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) + => await ChatCoreAsync(messages, temperatureOverride, null, format, thinkOverride, cancellationToken).ConfigureAwait(false); public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) { @@ -45,7 +48,7 @@ public sealed class OllamaClient : IOllamaClient catch (HttpRequestException) { return null; } } - private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, CancellationToken cancellationToken) + private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, bool? thinkOverride, CancellationToken cancellationToken) { Dictionary<string, JsonElement> options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value); @@ -54,7 +57,7 @@ public sealed class OllamaClient : IOllamaClient Model = _settings.Model, Messages = messages, Stream = false, - Think = _settings.Think, + Think = thinkOverride ?? _settings.Think, KeepAlive = _settings.KeepAlive, Options = options, Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(), diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 0b261da..8cf1ddb 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Agent.Core.Ollama; +using Agent.Core.Localization; using Agent.Core.Prompts; using Agent.Core.Sessions; using Agent.Core.Skills; @@ -9,7 +10,7 @@ using Agent.Planner; namespace Agent.Core.Runtime; -public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null) +public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null) { private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), @@ -28,12 +29,13 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) { - registry.Transition(taskId, AgentTaskState.Failed, result: "Session not found."); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found.")); return; } try { + var counters = new ExecutionCounters(); // Vision capability check before any Ollama call. if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) throw new InvalidOperationException(settings.VisionUnsupportedMessage); @@ -42,29 +44,42 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag TaskGraph graph; try { - graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, cancellationToken).ConfigureAwait(false); + graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { - registry.Transition(taskId, AgentTaskState.Failed, result: $"Planning failed: {ex.Message}"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message); + registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message)); return; } // Phase 2: Running — execute ready Tasks sequentially. registry.Transition(taskId, AgentTaskState.Running, "running"); - await RunAsync(taskId, task, session, graph, cancellationToken).ConfigureAwait(false); + await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); - string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}"; - registry.Transition(taskId, AgentTaskState.Cancelled, result: result); + if (cleanupFailure is null) + { + registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled.")); + } + else + { + registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure); + registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure); + } } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}"; + registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot); + string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed"; + string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result; + registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage); registry.Transition(taskId, AgentTaskState.Failed, result: result); } } @@ -74,48 +89,61 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop. /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase. /// </summary> - private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, CancellationToken cancellationToken) + private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken) { // Apply Sandbox Task Resource at RunAsync time. await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false); var finalContents = new List<string>(); - int replanCount = 0; const int maxReplans = 3; + var denyFingerprints = new HashSet<string>(StringComparer.Ordinal); try { + UpdateTaskProgress(taskId, graph, null, counters); // Execute tasks in ready order: dependencies satisfied → critical path → creation order. while (true) { PlannerTask? next = SelectNextReadyTask(graph); - if (next is null) break; + if (next is null) + { + if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break; + throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task."); + } graph.SetState(next.Id, PlannerTaskState.Running); + UpdateTaskProgress(taskId, graph, next.Description, counters); try { - string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, cancellationToken).ConfigureAwait(false); + string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false); graph.SetState(next.Id, PlannerTaskState.Completed); finalContents.Add(content); + UpdateTaskProgress(taskId, graph, null, counters); } - catch (InvalidOperationException ex) when (ex.Message.Contains("denied by sandbox policy", StringComparison.OrdinalIgnoreCase) && replanCount < maxReplans) + catch (PolicyDeniedException ex) { - // Policy deny → replan possible (Design §8.4). - replanCount++; graph.SetState(next.Id, PlannerTaskState.Failed); - graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, cancellationToken).ConfigureAwait(false); - // Transition back to Running for the replan iteration. - registry.Transition(taskId, AgentTaskState.Running, "replan"); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy_denied", ex.Message); + if (!denyFingerprints.Add(ex.Fingerprint)) + throw new InvalidOperationException($"The same sandbox policy denial occurred again: {ex.Message}"); + if (counters.Replans >= maxReplans) + throw new InvalidOperationException($"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}"); + + string previousSignature = GraphSignature(graph); + counters.Replans++; + registry.Transition(taskId, AgentTaskState.Planning, "replan"); + graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, + (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); + if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal)) + throw new InvalidOperationException("Replan did not change the incomplete TaskGraph."); + if (!registry.Transition(taskId, AgentTaskState.Running, "replan")) + throw new InvalidOperationException("Task state could not return to running after replan."); + UpdateTaskProgress(taskId, graph, null, counters); continue; } } - // All tasks completed — assemble final result. - // If replan was attempted but no tasks could execute, provide a meaningful message. - string finalContent = finalContents.Count > 0 - ? string.Join("\n\n", finalContents) - : replanCount > 0 - ? "The request could not be completed because the sandbox policy denied required operations." - : "No tasks were executed."; + if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result."); + string finalContent = string.Join("\n\n", finalContents); string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); session.Append(new ConversationMessage("user", task.Request)); @@ -136,7 +164,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag private static PlannerTask? SelectNextReadyTask(TaskGraph graph) { var completedIds = graph.Tasks - .Where(static t => t.State is PlannerTaskState.Completed or PlannerTaskState.Failed) + .Where(static t => t.State == PlannerTaskState.Completed) .Select(static t => t.Id) .ToHashSet(); @@ -159,10 +187,11 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Executes a single PlannerTask: LLM chat-with-tools iteration loop. /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard. /// </summary> - private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, CancellationToken cancellationToken) + private async Task<string> ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, + ExecutionCounters counters, CancellationToken cancellationToken) { // Progressive Disclosure Stage 2: LLM Skill Selection. - IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task.Request, cancellationToken).ConfigureAwait(false); + IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false); var messages = PromptComposer.Compose(new PromptContext( FixedPrompt.Load(), @@ -183,11 +212,24 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal); string? finalContent = null; - for (int iteration = 0; iteration < settings.MaxIterations; iteration++) + while (counters.Iterations < settings.MaxIterations) { - _observer.LlmRequest(taskId, iteration, messages); - OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); - _observer.LlmResponse(taskId, iteration, result); + int iteration = counters.Iterations++; + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id, + "running", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result; + try + { + result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message); + throw; + } + _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); // Record usage in TaskGraph for CPM tracking. @@ -195,7 +237,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Context Summary: compact old conversation/results when context utilization exceeds threshold. // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens). - TryContextSummary(session, result.PromptEvalCount); + await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false); IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? []; if (calls.Count > 0) @@ -227,9 +269,9 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag // Record ExecutionResult in session for context summary. session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output)); - _observer.ToolResult(taskId, call.Function.Name, toolResult); + _observer.ToolResult(callContext, call.Function.Name, toolResult); if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool) - throw new InvalidOperationException($"Tool '{call.Function.Name}' was denied by sandbox policy and will not be retried: {toolResult.Output}"); + throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output); failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } @@ -256,7 +298,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag break; } - if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the iteration limit of {settings.MaxIterations}."); + if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}."); return finalContent; } @@ -283,20 +325,53 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens. /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials. /// </summary> - private void TryContextSummary(AgentSession session, long promptEvalCount) + private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount, + ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.NumCtx <= 0) return; double utilization = (double)promptEvalCount / settings.NumCtx; if (utilization < settings.ContextSummaryThreshold) return; - // Compact old conversation entries, keeping only the most recent N. - // The summary itself is generated by the LLM in a separate call (future enhancement); - // for now, we compact the active context set to stay within num_ctx. - session.CompactConversation(settings.ConversationKeepLastN); - session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + string source = JsonSerializer.Serialize(new + { + previous_summary = session.Summary, + conversation = session.Conversation, + execution_results = session.ExecutionResults + }); + var messages = new List<OllamaMessage> + { + new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."), + new("user", source) + }; + using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}"""); + AgentLlmCallContext? callContext = null; + try + { + if (counters.Iterations >= settings.MaxIterations) return; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null, + "context_summary", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + using JsonDocument response = JsonDocument.Parse(result.Content); + string? summary = response.RootElement.GetProperty("summary").GetString(); + if (string.IsNullOrWhiteSpace(summary)) return; + session.SetSummary(summary); + session.CompactConversation(settings.ConversationKeepLastN); + session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message); + } } - private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.Skills.Count == 0) return []; @@ -304,25 +379,86 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag var selectionMessages = new List<OllamaMessage> { new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."), - new("user", request) + new("user", task.Request) }; string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); OllamaResult result; + AgentLlmCallContext? callContext = null; try { - result = await ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + if (counters.Iterations >= settings.MaxIterations) return []; + int iteration = counters.Iterations++; + callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + "skill_selection", iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, selectionMessages); + result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message); return []; } return ParseSelectedSkills(result.Content); } + private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase, + IReadOnlyList<OllamaMessage> messages, OllamaResult? result, Exception? error) + { + if (counters.Iterations >= settings.MaxIterations) + throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}."); + counters.Iterations++; + int iteration = counters.Iterations - 1; + var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, + phase, iteration, counters.Replans, settings.Model); + _observer.LlmRequest(callContext, messages); + if (result is not null) + { + _observer.LlmResponse(callContext, result); + registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); + } + if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message); + registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); + } + + private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters) + { + int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed); + registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost); + registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); + } + + private static string GraphSignature(TaskGraph graph) + { + IReadOnlyDictionary<Guid, string> descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description); + return string.Join("\n", graph.Tasks + .Where(static item => item.State != PlannerTaskState.Completed) + .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}")); + } + + private sealed class ExecutionCounters + { + public int Iterations { get; set; } + public int Replans { get; set; } + } + + private string Message(string key, string fallback, string? detail = null) + { + if (locale is null) return fallback; + return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary<string, object?> { ["message"] = detail }); + } + + private sealed class PolicyDeniedException(string fingerprint, string message) : Exception(message) + { + public string Fingerprint { get; } = fingerprint; + } + private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content) { try @@ -364,4 +500,4 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag return $"Task resource cleanup failed: {ex.Message}"; } } -}
\ No newline at end of file +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs index 873c571..b6d8504 100644 --- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -13,4 +13,6 @@ public sealed record AgentRuntimeSettings( long NumCtx = 32768, double ContextSummaryThreshold = 0.80, int ConversationKeepLastN = 6, - int ExecutionResultsKeepLastN = 10); + int ExecutionResultsKeepLastN = 10, + int MaxTasks = 32, + string Model = ""); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs index e4e26e3..5098a92 100644 --- a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -5,15 +5,20 @@ namespace Agent.Core.Runtime; public interface IAgentRuntimeObserver { - void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages); - void LlmResponse(Guid taskId, int iteration, OllamaResult result); - void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); + void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages); + void LlmResponse(AgentLlmCallContext context, OllamaResult result); + void LlmFailure(AgentLlmCallContext context, string errorCode, string message); + void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result); } +public sealed record AgentLlmCallContext(Guid CallId, Guid SessionId, Guid TaskId, Guid? PlannerTaskId, + string Phase, int Iteration, int Replan, string Model); + public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver { public static NullAgentRuntimeObserver Instance { get; } = new(); - public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) { } - public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } - public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } + public void LlmRequest(AgentLlmCallContext context, IReadOnlyList<OllamaMessage> messages) { } + public void LlmResponse(AgentLlmCallContext context, OllamaResult result) { } + public void LlmFailure(AgentLlmCallContext context, string errorCode, string message) { } + public void ToolResult(AgentLlmCallContext context, string toolName, ToolExecutionResult result) { } } diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index bf896f0..99569e4 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -23,10 +23,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// 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) + public async Task<TaskGraph> PlanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, IReadOnlyList<string>? images, + Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { // Build a Planning prompt with skill catalog and sandbox capability summary. - IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, cancellationToken).ConfigureAwait(false); + IReadOnlyList<AgentSkill> selectedSkills = await SelectSkillsAsync(request, temperature, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List<OllamaMessage> { @@ -39,39 +40,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se 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. - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - PlanResult? plan = ParsePlan(result.Content); - if (plan is null || plan.Tasks.Count == 0) - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } - - TaskGraph graph; - try - { - graph = BuildTaskGraph(plan, request); - } - catch - { - TaskGraph fallback = CreateFallbackGraph(request); - session.TaskGraphs[taskId] = fallback; - return fallback; - } + TaskGraph graph = await RequestValidGraphAsync(messages, temperature, "planning", observeAttempt, + plan => BuildTaskGraph(plan, request), cancellationToken).ConfigureAwait(false); session.TaskGraphs[taskId] = graph; return graph; } @@ -80,10 +50,11 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se /// 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) + public async Task<TaskGraph> ReplanAsync(Guid taskId, Guid sessionId, AgentSession session, string request, double? temperature, string replanReason, + Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (!session.TaskGraphs.TryGetValue(taskId, out TaskGraph? existing)) - return await PlanAsync(taskId, sessionId, session, request, temperature, null, cancellationToken).ConfigureAwait(false); + return await PlanAsync(taskId, sessionId, session, request, temperature, null, observeAttempt, cancellationToken).ConfigureAwait(false); var messages = new List<OllamaMessage> { @@ -95,26 +66,15 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se 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); + TaskGraph rebuilt = await RequestValidGraphAsync(messages, temperature, "replanning", observeAttempt, + plan => RebuildTaskGraph(existing, plan, request), cancellationToken).ConfigureAwait(false); + session.TaskGraphs[taskId] = rebuilt; + return rebuilt; } private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest) { - var graph = new TaskGraph(32); + var graph = new TaskGraph(_settings.MaxTasks); var idMap = new List<Guid>(); for (int i = 0; i < plan.Tasks.Count; i++) @@ -127,81 +87,65 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se var dependencies = new HashSet<Guid>(); foreach (int depIdx in pt.Dependencies) { - if (depIdx >= 0 && depIdx < idMap.Count) - dependencies.Add(idMap[depIdx]); + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + dependencies.Add(idMap[depIdx]); } string description = string.IsNullOrWhiteSpace(pt.Description) ? originalRequest : pt.Description; - try - { - PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); - idMap.Add(task.Id); - } - catch (PlannerValidationException) - { - // If a task fails validation (e.g., bad estimate, cycle), fall back to single-task graph. - return CreateFallbackGraph(originalRequest); - } + PlannerTask task = graph.AddTask(description, budgetClass, estimate, dependencies, pt.RequiresExternalEffect); + idMap.Add(task.Id); } - // If all tasks were filtered out, fall back. - if (graph.Count == 0) return CreateFallbackGraph(originalRequest); + if (graph.Count == 0) throw new PlannerValidationException("Structured plan did not contain an executable Task."); 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); + PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray(); + var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + var graph = new TaskGraph(_settings.MaxTasks); + foreach (PlannerTask item in completed) + { + Guid[] dependencies = item.Dependencies.Where(id => completed.Any(candidate => candidate.Id == id)).ToArray(); + PlannerTask copy = graph.AddTask(item.Description, item.BudgetClass, item.Estimate, dependencies, item.RequiresExternalEffect, item.Id); + graph.AddUsage(copy.Id, item.ActualInputTokens, item.ActualOutputTokens); + graph.SetState(copy.Id, PlannerTaskState.Completed); + } var idMap = new List<Guid>(); + var reusedCompleted = new HashSet<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)) + if (completedByDescription.TryGetValue(pt.Description, out PlannerTask? completedTask) && reusedCompleted.Add(completedTask.Id)) { - // 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; - } + idMap.Add(completedTask.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]); + { + if (depIdx < 0 || depIdx >= idMap.Count) + throw new PlannerValidationException($"Task {i} contains invalid dependency index {depIdx}."); + 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); + if (graph.Tasks.All(static task => task.State == PlannerTaskState.Completed)) + throw new PlannerValidationException("Replan did not produce an incomplete executable Task."); return graph; } @@ -238,7 +182,8 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se } } - private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, CancellationToken cancellationToken) + private async Task<IReadOnlyList<AgentSkill>> SelectSkillsAsync(string request, double? temperature, + Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, CancellationToken cancellationToken) { if (_settings.Skills.Count == 0) return []; @@ -254,15 +199,45 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se try { - OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, 0.0, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + OllamaResult result = await _ollama.ChatStructuredAsync(selectionMessages, temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); + observeAttempt?.Invoke("skill_selection", selectionMessages, result, null); return ParseSelectedSkills(result.Content); } - catch + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { + observeAttempt?.Invoke("skill_selection", selectionMessages, null, ex); return []; } } + private async Task<TaskGraph> RequestValidGraphAsync(IReadOnlyList<OllamaMessage> messages, double? temperature, string phase, + Action<string, IReadOnlyList<OllamaMessage>, OllamaResult?, Exception?>? observeAttempt, Func<PlanResult, TaskGraph> buildGraph, CancellationToken cancellationToken) + { + Exception? firstFailure = null; + for (int attempt = 0; attempt < 2; attempt++) + { + OllamaResult? result = null; + try + { + bool? thinkOverride = attempt == 0 ? null : false; + result = await _ollama.ChatStructuredAsync(messages, temperature, PlanSchema.Element, thinkOverride, cancellationToken).ConfigureAwait(false); + PlanResult? plan = ParsePlan(result.Content); + if (plan is null || plan.Tasks.Count == 0) + throw new PlannerValidationException("Ollama returned an empty or invalid structured plan."); + TaskGraph graph = buildGraph(plan); + observeAttempt?.Invoke(phase, messages, result, null); + return graph; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + observeAttempt?.Invoke(phase, messages, result, ex); + firstFailure ??= ex; + } + } + throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {firstFailure?.Message}"); + } + private IReadOnlyList<AgentSkill> ParseSelectedSkills(string content) { try @@ -331,4 +306,4 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se 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/Tasks/TaskRegistry.cs b/src/Agent.Core/Tasks/TaskRegistry.cs index b7a4afb..a89be6b 100644 --- a/src/Agent.Core/Tasks/TaskRegistry.cs +++ b/src/Agent.Core/Tasks/TaskRegistry.cs @@ -60,6 +60,12 @@ public sealed class TaskRegistry public bool AddUsage(Guid taskId, long inputTokens, long outputTokens) => _tasks.TryGetValue(taskId, out MutableTask? task) && task.AddUsage(inputTokens, outputTokens); + public bool UpdateProgress(Guid taskId, int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateProgress(completedTasks, totalTasks, currentTask, criticalPathTokens); + + public bool UpdateDiagnostics(Guid taskId, int? iterations, int? replans, string? errorCode = null, string? error = null) => + _tasks.TryGetValue(taskId, out MutableTask? task) && task.UpdateDiagnostics(iterations, replans, errorCode, error); + public CancellationToken GetCancellationToken(Guid taskId) => _tasks.TryGetValue(taskId, out MutableTask? task) ? task.CancellationToken : CancellationToken.None; @@ -81,6 +87,13 @@ public sealed class TaskRegistry private string? _result; private long _inputTokens; private long _outputTokens; + private int _completedTasks; + private int _totalTasks = 1; + private double _criticalPathTokens; + private int _iterations; + private int _replans; + private string? _lastErrorCode; + private string? _lastError; private readonly CancellationTokenSource _cancellation = new(); private readonly TaskCompletionSource _terminal = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -93,8 +106,9 @@ public sealed class TaskRegistry { lock (_sync) { - int completed = _state == AgentTaskState.Completed ? 1 : 0; - return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, completed, 1, _inputTokens, _outputTokens, 0, images); + return new(TaskId, SessionId, _state, request, temperature, _currentTask, _result, + _completedTasks, _totalTasks, _inputTokens, _outputTokens, _criticalPathTokens, images, + _iterations, _replans, _lastErrorCode, _lastError); } } @@ -122,16 +136,45 @@ public sealed class TaskRegistry } } + public bool UpdateProgress(int completedTasks, int totalTasks, string? currentTask, double criticalPathTokens) + { + if (completedTasks < 0 || totalTasks < 0 || completedTasks > totalTasks || criticalPathTokens < 0) return false; + lock (_sync) + { + _completedTasks = completedTasks; + _totalTasks = totalTasks; + _currentTask = currentTask; + _criticalPathTokens = criticalPathTokens; + return true; + } + } + + public bool UpdateDiagnostics(int? iterations, int? replans, string? errorCode, string? error) + { + if (iterations < 0 || replans < 0) return false; + lock (_sync) + { + if (iterations.HasValue) _iterations = iterations.Value; + if (replans.HasValue) _replans = replans.Value; + if (errorCode is not null) _lastErrorCode = errorCode; + if (error is not null) _lastError = error; + return true; + } + } + public bool Cancel() { lock (_sync) { if (_state is AgentTaskState.Completed or AgentTaskState.Failed or AgentTaskState.Cancelled) return false; _cancellation.Cancel(); - _state = AgentTaskState.Cancelled; - _currentTask = null; - _result = "cancelled"; - _terminal.TrySetResult(); + if (_state == AgentTaskState.Queued) + { + _state = AgentTaskState.Cancelled; + _currentTask = null; + _result = "cancelled"; + _terminal.TrySetResult(); + } return true; } } diff --git a/src/Agent.Core/Tasks/TaskSnapshot.cs b/src/Agent.Core/Tasks/TaskSnapshot.cs index 0a15de3..64844a8 100644 --- a/src/Agent.Core/Tasks/TaskSnapshot.cs +++ b/src/Agent.Core/Tasks/TaskSnapshot.cs @@ -13,4 +13,8 @@ public sealed record TaskSnapshot( long InputTokens, long OutputTokens, double CriticalPathTokens, - IReadOnlyList<string> Images); + IReadOnlyList<string> Images, + int Iterations = 0, + int Replans = 0, + string? LastErrorCode = null, + string? LastError = null); diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs index a92a829..d261269 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]},"requires_external_effect":{"type":["boolean","null"]}}}"""), + Define("task_update", "Update the deterministic task graph structure or estimates.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","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},"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/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs index d789dbf..57f58b2 100644 --- a/src/Agent.Core/Tools/InternalToolValidator.cs +++ b/src/Agent.Core/Tools/InternalToolValidator.cs @@ -74,7 +74,6 @@ public static class InternalToolValidator string operation = arguments.GetProperty("operation").GetString()!; if (operation == "add") Require(arguments, "description", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); if (operation == "add_dependency") Require(arguments, "task_id", "depends_on"); - if (operation == "set_state") Require(arguments, "task_id", "state"); if (operation == "set_estimate") Require(arguments, "task_id", "budget_class", "optimistic_tokens", "most_likely_tokens", "pessimistic_tokens"); } // dir: path and operation are already required by the schema; no operation-specific diff --git a/src/Agent.Core/Tools/TaskGraphToolHandler.cs b/src/Agent.Core/Tools/TaskGraphToolHandler.cs index 10094d3..26d5a27 100644 --- a/src/Agent.Core/Tools/TaskGraphToolHandler.cs +++ b/src/Agent.Core/Tools/TaskGraphToolHandler.cs @@ -56,15 +56,15 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio break; case "add_dependency": Guid taskIdValue = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, taskIdValue); Guid[] dependencies = Ids(arguments, "depends_on"); if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); break; - case "set_state": - graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); - break; case "set_estimate": - graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); + Guid estimateTaskId = RequiredId(arguments, "task_id"); + DemandStructuralMutation(graph, estimateTaskId); + graph.SetEstimate(estimateTaskId, Budget(arguments), Estimate(arguments)); break; default: throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); @@ -114,6 +114,13 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio return value.ValueKind == JsonValueKind.True; } + private static void DemandStructuralMutation(TaskGraph graph, Guid taskId) + { + PlannerTaskState state = graph.Get(taskId).State; + if (state is not (PlannerTaskState.Queued or PlannerTaskState.Planning)) + throw new PlannerValidationException($"TaskGraph structure or estimate cannot be changed while Task '{taskId}' is {state}."); + } + private static TokenBudgetClass Budget(JsonElement arguments) => TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); @@ -152,14 +159,4 @@ public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessio return [.. ids]; } - private static PlannerTaskState ParseState(string state) => state switch - { - "queued" => PlannerTaskState.Queued, - "planning" => PlannerTaskState.Planning, - "running" => PlannerTaskState.Running, - "completed" => PlannerTaskState.Completed, - "failed" => PlannerTaskState.Failed, - "cancelled" => PlannerTaskState.Cancelled, - _ => throw new ToolValidationException($"Unknown task state '{state}'.") - }; } diff --git a/src/Agent.Mcp/AgentTools.cs b/src/Agent.Mcp/AgentTools.cs index 6a5759f..623c6a4 100644 --- a/src/Agent.Mcp/AgentTools.cs +++ b/src/Agent.Mcp/AgentTools.cs @@ -40,7 +40,7 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi if (mcpTaskId is null) return queued; using CancellationTokenRegistration registration = cancellationToken.Register(() => registry.Cancel(task.TaskId)); - TaskSnapshot terminal = await registry.WaitForTerminalAsync(task.TaskId, cancellationToken).ConfigureAwait(false); + TaskSnapshot terminal = await registry.WaitForTerminalAsync(task.TaskId, CancellationToken.None).ConfigureAwait(false); if (terminal.State == AgentTaskState.Failed) throw new InvalidOperationException(terminal.Result ?? "Agent task failed."); if (terminal.State == AgentTaskState.Cancelled) throw new OperationCanceledException("Agent task was cancelled.", cancellationToken); return FormatStatus(terminal); @@ -94,6 +94,10 @@ public sealed class AgentTools(TaskRegistry registry, LocaleCatalog locale, Visi $"current_task: {task.CurrentTask ?? string.Empty}", $"token_usage: input={task.InputTokens} output={task.OutputTokens} total={task.InputTokens + task.OutputTokens}", $"critical_path_tokens: {task.CriticalPathTokens.ToString(System.Globalization.CultureInfo.InvariantCulture)}", + $"iterations: {task.Iterations}", + $"replans: {task.Replans}", + $"last_error_code: {task.LastErrorCode ?? string.Empty}", + $"last_error: {task.LastError ?? string.Empty}", $"message: {locale.Get("mcp.status." + state)}", terminal ? $"result: {task.Result}" : "result:"); } diff --git a/src/Agent.Mcp/McpHost.cs b/src/Agent.Mcp/McpHost.cs index 8ae25d5..bfb6d66 100644 --- a/src/Agent.Mcp/McpHost.cs +++ b/src/Agent.Mcp/McpHost.cs @@ -36,7 +36,7 @@ public static class McpHost 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); + var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer, locale: locale); builder.Services.AddSingleton(locale); builder.Services.AddSingleton(registry); builder.Services.AddSingleton(runtime); @@ -69,7 +69,7 @@ public static class McpHost builder.WebHost.UseUrls($"http://{listenHost}:{port}"); 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); + var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer, locale: locale); builder.Services.AddSingleton(locale); builder.Services.AddSingleton(registry); builder.Services.AddSingleton(runtime); diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs index 59b2602..bc4d291 100644 --- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -13,16 +13,18 @@ public sealed class TaskNetworkPolicy : IDisposable private readonly SecurityIdentifier _appContainerSid; private readonly Guid _loopbackEntryId; private readonly bool _loopbackAdded; + private readonly Guid? _sessionId; private bool _disposed; private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, - SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded) + SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId) { _session = session; _journal = journal; _audit = audit; _entryId = entryId; _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; + _sessionId = sessionId; } - public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit) + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit, Guid? sessionId = null) { bool originallyExempt = LoopbackExemption.Contains(appContainerSid); Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, @@ -39,13 +41,13 @@ public sealed class TaskNetworkPolicy : IDisposable journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() }); IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances); journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) }); - audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string> + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string> { ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" - })); + }, SessionId: sessionId)); audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", - new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value })); - return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded); + new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId)); + return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId); } catch { @@ -65,9 +67,9 @@ public sealed class TaskNetworkPolicy : IDisposable _journal.MarkCleaned(_entryId); if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); _journal.MarkCleaned(_loopbackEntryId); - _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId)); _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", - new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" })); + new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId)); _disposed = true; } } diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs index c9b28bf..9dfeef9 100644 --- a/src/Agent.Windows/Recovery/RecoveryManager.cs +++ b/src/Agent.Windows/Recovery/RecoveryManager.cs @@ -24,6 +24,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink try { TaskResourceJournal journal = TaskResourceJournal.Open(path); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.start", "started", new Dictionary<string, string>())); foreach (JournalEntry entry in journal.Entries.Reverse()) { if (entry.State == JournalEntryState.Cleaned) continue; @@ -33,7 +34,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink resources++; } journal.CompleteAndDelete(); - audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.complete", "clean", new Dictionary<string, string>())); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "recovery.end", "clean", new Dictionary<string, string>())); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs index 3d20bfa..1f36164 100644 --- a/src/Agent.Windows/Security/ExecutablePolicy.cs +++ b/src/Agent.Windows/Security/ExecutablePolicy.cs @@ -14,11 +14,11 @@ public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, { 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."); + 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."); 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."); + throw new SandboxPolicyException("process.hash_denied", $"Executable SHA-256 does not match its approval: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals."); return new(canonical, actualHash); } diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs index 32a949c..4f6eec4 100644 --- a/src/Agent.Windows/Security/PathPolicy.cs +++ b/src/Agent.Windows/Security/PathPolicy.cs @@ -17,7 +17,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}."); + 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."); } private static bool IsAtOrBelow(string candidate, string root) => @@ -39,7 +40,11 @@ public static class CanonicalPath current = Path.Combine(current, segment); if (!File.Exists(current) && !Directory.Exists(current)) continue; if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'."); + { + 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."); + } } if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath); return Path.TrimEndingDirectorySeparator(fullPath); diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs index 0786150..687af94 100644 --- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs +++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs @@ -19,7 +19,8 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable private const uint ProcessCreationChildProcessRestricted = 0x1; private readonly SandboxJobLimits _jobLimits = jobLimits ?? new(); - public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null) + public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, + string? standardInput = null, Guid? sessionId = null) { VerifiedExecutable verified; VerifiedExecutable? managedEntry = null; @@ -31,7 +32,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable } catch (SandboxPolicyException ex) { - audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } @@ -142,7 +143,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume" - })); + }, SessionId: sessionId, ErrorCode: ex.Code)); throw; } if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread"); @@ -157,23 +158,23 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable processAudit["managed_entry_path"] = managedEntry.CanonicalPath; processAudit["managed_entry_sha256"] = managedEntry.Sha256; } - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit)); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId)); WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1))); if (wait == WAIT_EVENT.WAIT_TIMEOUT) { _ = PInvoke.TerminateJobObject(job, 1460); - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId)); throw new TimeoutException("Sandbox process exceeded its timeout."); } if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess"); - audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() })); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }, SessionId: sessionId)); if (exitCode != 0) { audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string> { ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(), ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString() - })); + }, SessionId: sessionId, ErrorCode: "process.child_denied")); } return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr)); } diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs index 8858c2b..616b08f 100644 --- a/src/Agent.Windows/Security/SecurityAudit.cs +++ b/src/Agent.Windows/Security/SecurityAudit.cs @@ -2,7 +2,8 @@ using System.Text.Json; namespace Agent.Windows.Security; -public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data); +public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, + IReadOnlyDictionary<string, string> Data, Guid? SessionId = null, string? ErrorCode = null); public interface ISecurityAuditSink { @@ -12,11 +13,12 @@ public interface ISecurityAuditSink public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink { private readonly object _sync = new(); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public void Write(SecurityAuditEvent auditEvent) { string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (directory is not null) Directory.CreateDirectory(directory); - string line = JsonSerializer.Serialize(auditEvent); + string line = JsonSerializer.Serialize(auditEvent, Json); lock (_sync) File.AppendAllText(path, line + Environment.NewLine); } } diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs index 6e62d3d..5c1fe49 100644 --- a/src/Agent.Windows/Tools/WindowsToolBackend.cs +++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs @@ -22,7 +22,8 @@ public sealed record WindowsSandboxSettings( long MemoryBackupLimitBytes, long WholeReadLimitBytes, int SearchResultLimit, - int TailLineLimit); + int TailLineLimit, + int ProcessTimeoutSeconds = 300); public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend { @@ -37,7 +38,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit 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); + var context = new TaskContext(journal, acl, sessionId); _taskUpdates.BeginTask(taskId, sessionId); try { @@ -57,8 +58,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow" ? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false) : []; - context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit); + context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId); if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists."); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId)); } catch { @@ -71,6 +73,8 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { TaskContext context = Get(taskId); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "tool.call", "attempted", + new Dictionary<string, string> { ["tool"] = toolName }, SessionId: context.SessionId)); try { return toolName switch @@ -78,7 +82,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit "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)), + "process_run" => Task.FromResult(RunProcess(context, arguments)), "task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)), "dir" => Task.FromResult(DirectoryOp(context, arguments)), _ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.") @@ -86,14 +90,28 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit } catch (SandboxPolicyException ex) { - return Task.FromResult(new ToolExecutionResult(false, ex.Message, false, $"{toolName}:SandboxPolicyException:{ex.Message}", RetryAllowed: false)); + 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)); } } public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) { if (!_contexts.TryRemove(taskId, out TaskContext? context)) return; - try { await CleanupAsync(taskId, context).ConfigureAwait(false); } + try + { + await CleanupAsync(taskId, context).ConfigureAwait(false); + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", success ? "completed" : "stopped", + new Dictionary<string, string>(), SessionId: context.SessionId)); + } + catch + { + audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.end", "cleanup_failed", + new Dictionary<string, string>(), SessionId: context.SessionId)); + throw; + } finally { _taskUpdates.EndTask(taskId); } } @@ -188,7 +206,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit string auditPath = operation == "rename" ? $"{Path.GetFullPath(path)} -> {Path.GetFullPath(arguments.GetProperty("new_path").GetString()!)}" : Path.GetFullPath(path); - audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = auditPath })); + string eventName = operation == "delete" ? "file.delete" : "file.write"; + audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, eventName, "completed", + new Dictionary<string, string> { ["path"] = auditPath, ["operation"] = operation }, SessionId: context.SessionId)); return Success($"{operation} completed for {auditPath}", externalEffect: true); } @@ -225,8 +245,9 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit throw new ToolValidationException($"Unsupported dir operation '{operation}'."); } - private ToolExecutionResult RunProcess(Guid taskId, JsonElement arguments) + private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments) { + Guid taskId = context.Journal.TaskId; string executable = arguments.GetProperty("executable").GetString()!; string cwd = arguments.GetProperty("cwd").GetString()!; _paths.Demand(executable, FileCapability.Execute); @@ -234,10 +255,12 @@ 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() : 300; + 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})."); 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); + .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId); string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}"; return new(result.ExitCode == 0, output, true, result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}"); @@ -276,9 +299,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit return current[..first] + newText + current[(first + oldText.Length)..]; } - private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl) + private sealed class TaskContext(TaskResourceJournal journal, TaskAclManager acl, Guid sessionId) { public TaskResourceJournal Journal { get; } = journal; + public Guid SessionId { get; } = sessionId; public TaskAclManager Acl { get; } = acl; public List<AclSnapshot> Acls { get; } = []; public TaskNetworkPolicy? Network { get; set; } diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index 79c746d..e84d132 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -44,6 +44,63 @@ public sealed class ConfigurationTests } [Fact] + public void CommandLine_defaults_to_serve_when_command_is_omitted() + { + Assert.Equal(AppCommand.Serve, CommandLine.Parse([]).Command); + CommandLine withOption = CommandLine.Parse(["--lang", "ja"]); + Assert.Equal(AppCommand.Serve, withOption.Command); + Assert.Equal("ja", withOption.Language); + } + + [Fact] + public void Local_configuration_recursively_merges_maps_and_replaces_sequences() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + directory.File("agent.local.yaml", """ + application: + language: ja + sandbox: + filesystem: + read: [C:/local-only] + """); + + AgentConfiguration result = ConfigurationLoader.Load(config); + + Assert.Equal("ja", result.Application.Language); + Assert.Equal(["C:/local-only"], result.Sandbox.Filesystem.Read); + Assert.Equal("model", result.Ollama.Model); + } + + [Fact] + public void Unknown_local_configuration_property_is_fatal() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + directory.File("agent.local.yaml", "unknown_local: true\n"); + + Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + } + + [Fact] + public void Execute_file_without_process_approval_reports_canonical_keys() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + string executable = directory.File("tools/tool.exe", "fixture").Replace('\\', '/'); + directory.File("agent.local.yaml", $$""" + sandbox: + filesystem: + execute: [{{executable}}] + """); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + Assert.Contains("sandbox.filesystem.execute", error.Message, StringComparison.Ordinal); + Assert.Contains("sandbox.process.approvals", error.Message, StringComparison.Ordinal); + Assert.Contains(Path.GetFullPath(executable), error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] public void SkillLoader_loads_standard_skill_directories_only() { using var directory = new TestDirectory(); diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs index 94bdde9..4128863 100644 --- a/tests/Agent.Core.Tests/InternalToolTests.cs +++ b/tests/Agent.Core.Tests/InternalToolTests.cs @@ -6,7 +6,7 @@ namespace Agent.Core.Tests; public sealed class InternalToolTests { [Fact] - public void Catalog_exposes_six_strict_tool_groups() + public void Catalog_matches_the_design_and_uses_strict_schemas() { Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update", "dir"], InternalToolCatalog.All.Select(x => x.Name)); foreach (InternalToolDefinition tool in InternalToolCatalog.All) Assert.False(tool.Parameters.GetProperty("additionalProperties").GetBoolean()); @@ -19,6 +19,7 @@ public sealed class InternalToolTests [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")] [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")] [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")] + [InlineData("task_update", "{\"operation\":\"set_state\",\"task_id\":null}")] [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\"}")] [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\",\"new_path\":\"\"}")] public void Malformed_arguments_are_rejected(string tool, string json) diff --git a/tests/Agent.Core.Tests/ObservabilityTests.cs b/tests/Agent.Core.Tests/ObservabilityTests.cs index c259eb0..8231ab9 100644 --- a/tests/Agent.Core.Tests/ObservabilityTests.cs +++ b/tests/Agent.Core.Tests/ObservabilityTests.cs @@ -1,14 +1,70 @@ using Agent.App.Logging; using Agent.Core.Localization; +using Agent.Core.Ollama; +using Agent.Core.Runtime; +using Agent.Core.Tools; using Agent.Mcp; using System.Diagnostics; using System.Text; +using System.Text.Json; namespace Agent.Core.Tests; public sealed class ObservabilityTests { [Fact] + public void Llm_log_carries_correlation_usage_and_tool_iteration_fields() + { + using var directory = new TestDirectory(); + Guid sessionId = Guid.NewGuid(); + Guid taskId = Guid.NewGuid(); + Guid plannerTaskId = Guid.NewGuid(); + Guid callId = Guid.NewGuid(); + var context = new AgentLlmCallContext(callId, sessionId, taskId, plannerTaskId, "replan", 7, 2, "model-a"); + using (var observer = new JsonLinesRuntimeObserver(directory.Path, 1024 * 1024, 2)) + { + observer.LlmRequest(context, [new OllamaMessage("user", "request")]); + observer.LlmResponse(context, new OllamaResult("response", 11, 13)); + observer.ToolResult(context, "file_read", new ToolExecutionResult(true, "ok", false)); + observer.LlmFailure(context, "planning_failed", "invalid plan"); + } + + JsonElement[] entries = File.ReadLines(Path.Combine(directory.Path, "llm.jsonl")) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()).ToArray(); + Assert.Equal(4, entries.Length); + Assert.All(entries, entry => + { + Assert.Equal(callId, entry.GetProperty("call_id").GetGuid()); + Assert.Equal(taskId, entry.GetProperty("task_id").GetGuid()); + Assert.Equal(sessionId, entry.GetProperty("session_id").GetGuid()); + Assert.Equal(plannerTaskId, entry.GetProperty("planner_task_id").GetGuid()); + Assert.Equal(7, entry.GetProperty("iteration").GetInt32()); + Assert.Equal(2, entry.GetProperty("replan").GetInt32()); + }); + Assert.Equal(11, entries[1].GetProperty("input_tokens").GetInt64()); + Assert.Equal("file_read", entries[2].GetProperty("tool_name").GetString()); + Assert.Equal("llm.failure", entries[3].GetProperty("event_name").GetString()); + Assert.Equal("planning_failed", entries[3].GetProperty("error_code").GetString()); + } + + [Fact] + public void Read_agent_log_script_exposes_read_only_filters() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "scripts", "Read-AgentLog.ps1"))) root = root.Parent; + Assert.NotNull(root); + string scriptPath = Path.Combine(root!.FullName, "scripts", "Read-AgentLog.ps1"); + string script = File.ReadAllText(scriptPath); + Assert.Contains("[string]$TaskId", script, StringComparison.Ordinal); + Assert.Contains("[string]$SessionId", script, StringComparison.Ordinal); + Assert.Contains("[string]$Event", script, StringComparison.Ordinal); + Assert.Contains("[string]$CallId", script, StringComparison.Ordinal); + Assert.Contains("[int]$Tail", script, StringComparison.Ordinal); + Assert.DoesNotContain("Set-Content", script, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Remove-Item", script, StringComparison.OrdinalIgnoreCase); + } + + [Fact] public void Ready_is_always_visible_even_when_log_threshold_is_fatal() { using var directory = new TestDirectory(); diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs index 130097e..85aba56 100644 --- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -45,9 +45,8 @@ 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); + Assert.Equal(6, completed.InputTokens); + Assert.Equal(4, completed.OutputTokens); TaskSnapshot second = registry.Enqueue("second", first.SessionId, null); Assert.True(registry.Transition(second.TaskId, AgentTaskState.Planning)); @@ -60,8 +59,8 @@ public sealed class PromptAndRuntimeTests 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]); + // The task-scoped temperature is propagated to Planning and Run calls. + Assert.Equal(0.5, ollama.Temperatures[0]); Assert.Equal(0.5, ollama.Temperatures[1]); } @@ -116,7 +115,7 @@ public sealed class PromptAndRuntimeTests Assert.Single(backend.Calls); Assert.Equal("file_edit", backend.Calls[0].Name); Assert.Equal(2, ollama.CallCount); - Assert.Equal(6, ollama.ToolCount); + Assert.Equal(InternalToolCatalog.All.Count, ollama.ToolCount); } [Fact] @@ -173,14 +172,200 @@ public sealed class PromptAndRuntimeTests 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); - // After replan (max 3 attempts), the task fails. Each replan cycle = 1 ChatWithTools call. - // Initial attempt (1) + 3 replan attempts = 4 ChatWithTools calls. + 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.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}"); } + [Fact] + public async Task Policy_denial_replanning_is_limited_to_three_changed_plans() + { + TaskRegistry registry = new(); + var ollama = new ChangingPolicyPlanOllamaClient(); + var backend = new ChangingPolicyBackend(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(new HashSet<string>(["file_search"], StringComparer.Ordinal)), backend); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("search", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + 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.Equal(3, failed.Replans); + Assert.Equal(4, backend.CallCount); + Assert.Contains("3 replans", failed.Result, StringComparison.Ordinal); + } + + [Fact] + public async Task Repeated_identical_policy_denial_stops_after_one_replan() + { + TaskRegistry registry = new(); + var ollama = new ChangingPolicyPlanOllamaClient(sameDeny: true); + var backend = new ChangingPolicyBackend(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(new HashSet<string>(["file_search"], StringComparer.Ordinal)), backend); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("search", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + 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.Equal(1, failed.Replans); + Assert.Equal(2, backend.CallCount); + Assert.Contains("same sandbox policy denial", failed.Result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Invalid_structured_plan_retries_once_with_think_disabled() + { + TaskRegistry registry = new(); + var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); + TaskSnapshot task = registry.Enqueue("analyze", null, 0.7); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); + Assert.Equal(AgentTaskState.Completed, completed!.State); + Assert.Equal([null, false], ollama.ThinkOverrides); + Assert.All(ollama.Temperatures, value => Assert.Equal(0.7, value)); + } + + [Fact] + public async Task Invalid_structured_plan_after_retry_fails_without_running_tools() + { + TaskRegistry registry = new(); + var ollama = new ThinkRetryOllamaClient(succeedOnRetry: false); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); + TaskSnapshot task = registry.Enqueue("change a file", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + 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("think=false retry", failed.Result, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, ollama.RunCalls); + } + + [Fact] + public async Task Failed_context_summary_does_not_delete_original_context() + { + var sessions = new SessionStore(); + AgentSession session = sessions.Create(); + for (int index = 0; index < 10; index++) session.Append(new ConversationMessage("user", $"constraint-{index}")); + var registry = new TaskRegistry(sessions); + var runtime = new AgentRuntime(registry, new SummaryOllamaClient(summarySucceeds: false), + new AgentRuntimeSettings("", "en", [], "sandbox", NumCtx: 100, ContextSummaryThreshold: 0.8)); + TaskSnapshot task = registry.Enqueue("analyze", session.SessionId, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.Null(session.Summary); + Assert.Contains(session.Conversation, message => message.Content == "constraint-0"); + } + + [Fact] + public async Task Successful_context_summary_preserves_required_categories_before_compaction() + { + TaskRegistry registry = new(); + var ollama = new SummaryOllamaClient(summarySucceeds: true); + var runtime = new AgentRuntime(registry, ollama, + new AgentRuntimeSettings("", "en", [], "sandbox", NumCtx: 100, ContextSummaryThreshold: 0.8, ConversationKeepLastN: 2)); + TaskSnapshot task = registry.Enqueue("analyze", null, null); + Assert.True(registry.Sessions.TryGet(task.SessionId, out AgentSession? session)); + for (int i = 0; i < 10; i++) session!.Append(new ConversationMessage("user", $"constraint-{i}")); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.Equal("preserved constraints", session!.Summary); + Assert.True(session.Conversation.Count <= 4); + string summaryPrompt = string.Join("\n", ollama.SummaryRequest!.Select(static message => message.Content)); + Assert.Contains("incomplete Tasks", summaryPrompt, StringComparison.Ordinal); + Assert.Contains("security denials", summaryPrompt, StringComparison.Ordinal); + Assert.Contains("verification results", summaryPrompt, StringComparison.Ordinal); + Assert.Contains("constraint-0", summaryPrompt, StringComparison.Ordinal); + } + + [Fact] + public async Task Running_task_becomes_cancelled_only_after_cleanup_finishes() + { + TaskRegistry registry = new(); + var backend = new BlockingCleanupBackend(); + var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); + var runtime = new AgentRuntime(registry, new CancellableOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("wait", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); + await backend.Begun.Task.WaitAsync(TestContext.Current.CancellationToken); + Assert.True(registry.Cancel(task.TaskId)); + await backend.CleanupStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? stopping)); + Assert.NotEqual(AgentTaskState.Cancelled, stopping!.State); + + backend.AllowCleanup.TrySetResult(); + await execution; + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? cancelled)); + Assert.Equal(AgentTaskState.Cancelled, cancelled!.State); + } + + [Fact] + public async Task Cancellation_cleanup_failure_becomes_failed() + { + TaskRegistry registry = new(); + var backend = new BlockingCleanupBackend(failCleanup: true); + var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); + var runtime = new AgentRuntime(registry, new CancellableOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("wait", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); + await backend.Begun.Task.WaitAsync(TestContext.Current.CancellationToken); + Assert.True(registry.Cancel(task.TaskId)); + await backend.CleanupStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + backend.AllowCleanup.TrySetResult(); + await execution; + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Equal("cleanup_failed", failed.LastErrorCode); + } + + [Fact] + public async Task Task_progress_current_task_and_critical_path_follow_the_graph() + { + TaskRegistry registry = new(); + var ollama = new TwoTaskProgressOllamaClient(); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); + TaskSnapshot task = registry.Enqueue("two steps", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + + Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); + await ollama.SecondTaskStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? progress)); + Assert.Equal(1, progress!.CompletedTasks); + Assert.Equal(2, progress.TotalTasks); + Assert.Equal("second", progress.CurrentTask); + Assert.True(progress.CriticalPathTokens > 0); + + ollama.AllowSecondTask.TrySetResult(); + await execution; + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); + Assert.Equal(2, completed!.CompletedTasks); + Assert.Equal(2, completed.TotalTasks); + Assert.Null(completed.CurrentTask); + } + private sealed class RecordingOllamaClient : IOllamaClient { public List<IReadOnlyList<OllamaMessage>> Calls { get; } = []; @@ -320,4 +505,132 @@ public sealed class PromptAndRuntimeTests return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_search", arguments.RootElement.Clone()))])); } } + + private sealed class ThinkRetryOllamaClient(bool succeedOnRetry) : IOllamaClient + { + public List<bool?> ThinkOverrides { get; } = []; + public List<double?> Temperatures { get; } = []; + public int RunCalls { get; private set; } + 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, bool? thinkOverride, CancellationToken cancellationToken) + { + ThinkOverrides.Add(thinkOverride); + Temperatures.Add(temperatureOverride); + string content = ThinkOverrides.Count == 2 && succeedOnRetry + ? """{"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""" + : "not-json"; + return Task.FromResult(new OllamaResult(content, 1, 1)); + } + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + RunCalls++; + Temperatures.Add(temperatureOverride); + return Task.FromResult(new OllamaResult("done", 1, 1)); + } + } + + private sealed class ChangingPolicyPlanOllamaClient(bool sameDeny = false) : IOllamaClient + { + private int _plans; + private int _runs; + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("unused", 1, 1)); + public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) + { + int plan = Interlocked.Increment(ref _plans); + string json = $$"""{"tasks":[{"description":"search-{{plan}}","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true}]}"""; + return Task.FromResult(new OllamaResult(json, 1, 1)); + } + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + int run = Interlocked.Increment(ref _runs); + string root = sameDeny ? "C:/denied/same" : $"C:/denied/{run}"; + using JsonDocument arguments = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(root)}},"mode":"name","query":"*.txt"}"""); + return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_search", arguments.RootElement.Clone()))])); + } + } + + private sealed class ChangingPolicyBackend : IInternalToolBackend + { + public int CallCount { get; private set; } + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) + { + CallCount++; + string root = arguments.GetProperty("root").GetString()!; + return Task.FromResult(new ToolExecutionResult(false, $"denied: {root}", false, root, RetryAllowed: false)); + } + } + + private sealed class SummaryOllamaClient(bool summarySucceeds) : IOllamaClient + { + public IReadOnlyList<OllamaMessage>? SummaryRequest { get; private set; } + 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, bool? thinkOverride, CancellationToken cancellationToken) + { + if (format.GetProperty("properties").TryGetProperty("summary", out _)) + { + SummaryRequest = messages.ToArray(); + if (!summarySucceeds) throw new InvalidOperationException("summary failed"); + return Task.FromResult(new OllamaResult("""{"summary":"preserved constraints"}""", 1, 1)); + } + return Task.FromResult(new OllamaResult("""{"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); + } + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("done", 1, 1, PromptEvalCount: 90)); + } + + private sealed class CancellableOllamaClient : IOllamaClient + { + 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, bool? thinkOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("""{"tasks":[{"description":"wait","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); + public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + } + } + + private sealed class TwoTaskProgressOllamaClient : IOllamaClient + { + private int _runCalls; + public TaskCompletionSource SecondTaskStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowSecondTask { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + 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, bool? thinkOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("""{"tasks":[{"description":"first","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false},{"description":"second","dependencies":[0],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); + public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _runCalls) == 2) + { + SecondTaskStarted.TrySetResult(); + await AllowSecondTask.Task.WaitAsync(cancellationToken); + } + return new OllamaResult("done", 1, 1); + } + } + + private sealed class BlockingCleanupBackend(bool failCleanup = false) : IInternalToolBackend + { + public TaskCompletionSource Begun { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CleanupStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowCleanup { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) + { + Begun.TrySetResult(); + return Task.CompletedTask; + } + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => + Task.FromResult(new ToolExecutionResult(true, "unused", false)); + public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + CleanupStarted.TrySetResult(); + await AllowCleanup.Task.WaitAsync(cancellationToken); + if (failCleanup) throw new InvalidOperationException("cleanup failed"); + } + } } diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs index 28a2844..7e84dfd 100644 --- a/tests/Agent.Core.Tests/TaskRegistryTests.cs +++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs @@ -18,6 +18,23 @@ public sealed class TaskRegistryTests } [Fact] + public async Task Running_cancel_is_terminal_only_after_runtime_cleanup_transition() + { + var registry = new TaskRegistry(); + TaskSnapshot task = registry.Enqueue("work", null, null); + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Running)); + + Assert.True(registry.Cancel(task.TaskId)); + Assert.True(registry.GetCancellationToken(task.TaskId).IsCancellationRequested); + Task wait = registry.WaitForTerminalAsync(task.TaskId, CancellationToken.None); + Assert.False(wait.IsCompleted); + + Assert.True(registry.Transition(task.TaskId, AgentTaskState.Cancelled, result: "clean")); + Assert.Equal(AgentTaskState.Cancelled, (await registry.WaitForTerminalAsync(task.TaskId, TestContext.Current.CancellationToken)).State); + } + + [Fact] public void NewTaskGetsUniqueTaskAndSessionIds() { var registry = new TaskRegistry(); diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs index dd170ab..d025eac 100644 --- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs +++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs @@ -62,7 +62,7 @@ internal static class CrashScenarioBootstrap new ExecutablePolicy([new ExecutableApproval(powershell, ExecutablePolicy.ComputeSha256(powershell))], true), processAudit); string escapedAudit = auditPath.Replace("'", "''", StringComparison.Ordinal); string crashError = Path.Combine(root, "crash-error.txt").Replace("'", "''", StringComparison.Ordinal); - string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"Event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force"; + string watchdogScript = $"$d=[DateTime]::UtcNow.AddSeconds(10); while([DateTime]::UtcNow -lt $d){{ if((Test-Path -LiteralPath '{escapedAudit}') -and (Select-String -LiteralPath '{escapedAudit}' -SimpleMatch '\"event\":\"process.start\"' -Quiet)){{ Stop-Process -Id {Environment.ProcessId} -Force; exit 0 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{crashError}' -Value 'Sandbox process did not start before crash'; Stop-Process -Id {Environment.ProcessId} -Force"; _ = Process.Start(new ProcessStartInfo { FileName = powershell, UseShellExecute = false, CreateNoWindow = true, diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs index 10415ce..7489dad 100644 --- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs +++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs @@ -457,7 +457,7 @@ public sealed class SecurityPolicyTests { string auditPath = Path.Combine(directory.Path, "crash-audit.jsonl"); using JsonDocument audit = JsonDocument.Parse(File.ReadLines(auditPath).Single(line => line.Contains("process.start", StringComparison.Ordinal))); - int sandboxPid = int.Parse(audit.RootElement.GetProperty("Data").GetProperty("pid").GetString()!, + int sandboxPid = int.Parse(audit.RootElement.GetProperty("data").GetProperty("pid").GetString()!, System.Globalization.CultureInfo.InvariantCulture); Assert.True(SpinWait.SpinUntil(() => !ProcessExists(sandboxPid), TimeSpan.FromSeconds(5)), $"Orphan sandbox PID {sandboxPid} survived broker crash."); } @@ -562,7 +562,8 @@ 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, Guid.NewGuid(), TestContext.Current.CancellationToken); + Guid sessionId = Guid.NewGuid(); + await backend.BeginTaskAsync(taskId, sessionId, 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); @@ -576,7 +577,11 @@ public sealed class SecurityPolicyTests Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals"))); string backups = Path.Combine(directory.Path, "backups"); Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any()); - Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + Assert.Contains(audit.Events, item => item.Event == "network.rule.remove" && item.Data["residual_filters"] == "0"); + Assert.Contains(audit.Events, item => item.Event == "task.start" && item.SessionId == sessionId); + Assert.Contains(audit.Events, item => item.Event == "tool.call" && item.SessionId == sessionId); + Assert.Contains(audit.Events, item => item.Event == "file.write" && item.SessionId == sessionId); + Assert.Contains(audit.Events, item => item.Event == "task.end" && item.SessionId == sessionId); Assert.Single(audit.Events, item => item.Event == "acl.apply"); Assert.Single(audit.Events, item => item.Event == "acl.restore"); } @@ -925,7 +930,7 @@ public sealed class SecurityPolicyTests Assert.False(deniedPort.Accepted); } allowJournal.CompleteAndDelete(); - Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0"); + Assert.Contains(audit.Events, item => item.Event == "network.rule.remove" && item.Data["residual_filters"] == "0"); } private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) => |
