alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,4728 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import importlib
5
+ import inspect
6
+ import ipaddress
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ from collections import Counter
12
+ from collections.abc import Callable, Collection
13
+ from dataclasses import dataclass, field, replace
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path, PurePosixPath
16
+ from time import perf_counter
17
+ from typing import Any, Protocol
18
+ from urllib.parse import urlsplit, urlunsplit
19
+
20
+ from ..approval_scope import (
21
+ exact_command_scope,
22
+ exact_file_set_scope,
23
+ exact_verify_command_set_scope,
24
+ )
25
+ from ..config import (
26
+ AppConfig,
27
+ ConfigError,
28
+ resolve_web_search_policy,
29
+ resolve_web_tools_enabled,
30
+ )
31
+ from ..context.tool_schema_budgeter import (
32
+ CUSTOM_MCP_SCHEMA_FAMILIES,
33
+ DEFAULT_CUSTOM_MCP_DESCRIPTION_MAX_CHARS,
34
+ compact_custom_mcp_tool_parameters,
35
+ )
36
+ from ..crash_diagnostics import CrashDiagnosticLogger
37
+ from ..custom_tools import (
38
+ CustomToolDiscoveryResult,
39
+ CustomToolSessionState,
40
+ CustomToolSpec,
41
+ build_custom_tool_session_state,
42
+ run_custom_tool,
43
+ )
44
+ from ..diff_paths import iter_patch_paths
45
+ from ..dispatch_timing import (
46
+ DISPATCH_OVERHEAD_OPERATION,
47
+ DispatchOverheadAccount,
48
+ run_cancellable_wait,
49
+ )
50
+ from ..durable_service_manager import DurableServiceManager, ProcessOwnership
51
+ from ..edit_discipline import EditDisciplineState
52
+ from ..execution_deadline import (
53
+ DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
54
+ MINIMUM_TOOL_START_SECONDS,
55
+ DeadlineExhausted,
56
+ DeadlineOperation,
57
+ DeadlinePhase,
58
+ ExecutionDeadline,
59
+ deadline_timeout_or_raise,
60
+ )
61
+ from ..extensions.activation import ActivationDecision
62
+ from ..extensions.models import normalize_extension_id
63
+ from ..host_actions import (
64
+ HOST_ACTION_TOOL_NAMES,
65
+ HostActionError,
66
+ HostActionHandler,
67
+ normalize_host_action_arguments,
68
+ normalized_host_action_capabilities,
69
+ )
70
+ from ..ide.managed_browser import (
71
+ BrowserArtifact,
72
+ BrowserError,
73
+ BrowserSessionStatus,
74
+ ManagedBrowserService,
75
+ )
76
+ from ..ide.protocol import redact_secrets
77
+ from ..mcp.manager import ForgeTaskScopedMcpManager, McpManager
78
+ from ..mcp.models import ResolvedMcpConfig, ResolvedMcpServer
79
+ from ..model_registry import ModelRegistry
80
+ from ..personas import is_persona_name, persona_modes_enabled
81
+ from ..pipeline_facts import resolve_pipeline_stage_status
82
+ from ..policy import evaluate_shell_command
83
+ from ..process_reaping import ProcessGroupRegistry
84
+ from ..runtime_kind import RuntimeKind, normalize_runtime_kind
85
+ from ..service_persistence import (
86
+ PersistentServiceRecord,
87
+ PersistentServiceRegistry,
88
+ check_service,
89
+ readiness_spec_for_port,
90
+ resolve_probe_port,
91
+ )
92
+ from ..session_store import SessionStore
93
+ from ..skills import SkillBundle, SkillReadError, read_skill_bundle_file, resolve_skill_by_name
94
+ from ..subagents import (
95
+ EDIT_CAPABLE_SUBAGENT_TOOL_NAMES,
96
+ SubagentDefinition,
97
+ helper_subagent_names,
98
+ routable_subagent_names,
99
+ )
100
+ from ..surface import (
101
+ ApprovalRequest,
102
+ NoopSurface,
103
+ PatchEvent,
104
+ )
105
+ from ..surface.base import Surface
106
+ from ..task_scope import (
107
+ ancestor_directory_scope_patterns,
108
+ is_non_material_untracked_path,
109
+ scope_path_matches_pattern,
110
+ )
111
+ from ..terminal_manager import ProcessOutputSnapshot, TerminalLimitError, TerminalManager
112
+ from ..tools.artifacts import SessionArtifactReadError, session_artifact_read
113
+ from ..tools.availability import mark_available, mark_unavailable, register_tool_availability
114
+ from ..tools.fs import (
115
+ FsError,
116
+ StaleFileError,
117
+ assert_file_precondition,
118
+ capture_file_precondition,
119
+ classify_sensitive_path,
120
+ fs_copy,
121
+ fs_delete,
122
+ fs_list,
123
+ fs_mkdir,
124
+ fs_move,
125
+ fs_read,
126
+ fs_read_lines,
127
+ prepare_fs_edit,
128
+ prepare_fs_write,
129
+ write_prepared_fs_edit,
130
+ write_prepared_fs_write,
131
+ )
132
+ from ..tools.git import git_apply_patch, git_diff, git_history, git_status
133
+ from ..tools.history import history_search
134
+ from ..tools.image_generation import (
135
+ ImageGenerationError,
136
+ generate_images,
137
+ plan_image_output_paths,
138
+ )
139
+ from ..tools.registry import (
140
+ REPORT_BLOCKER_MAX_MESSAGE_CHARS,
141
+ built_in_subagent_tool_names,
142
+ copied_tool_parameters,
143
+ iter_builtin_tool_metadata,
144
+ require_builtin_tool_metadata,
145
+ )
146
+ from ..tools.repo_map import repo_map
147
+ from ..tools.search import search_rg
148
+ from ..tools.shell import shell_run
149
+ from ..tools.symbols import symbol_search
150
+ from ..tools.test_discovery import test_discover
151
+ from ..tools.web import web_fetch
152
+ from ..tools.web_search import WebSearchError, resolve_web_search_runtime_status, web_search
153
+ from ..usage_tracker import UsageSummary
154
+ from ..verification_command_analysis import (
155
+ VerificationCommandEvidentiaryCapability,
156
+ analyze_verification_command,
157
+ )
158
+ from ..verify_gate import (
159
+ ResolvedVerifyCommands,
160
+ VerifyError,
161
+ is_authoritative_verify_command_selection,
162
+ resolve_verify_commands,
163
+ run_task_verification,
164
+ trusted_shell_expression_command_set,
165
+ validation_errors_for_selection,
166
+ verification_command_specs_payload,
167
+ verification_selection_payload,
168
+ verify_run_result_to_payload,
169
+ )
170
+ from ..web_research import (
171
+ build_web_fetch_recovery_search_query,
172
+ canonicalize_web_url_input,
173
+ normalize_web_url,
174
+ )
175
+ from ..workspace_context import resolve_workspace_context
176
+ from . import _patchable
177
+ from .errors import AgentRuntimeError, ApprovalDeclinedError, SessionWorkdirError
178
+ from .mutation_classification import classify_mutation_paths
179
+ from .prompt_context import (
180
+ _MODE_FULLACCESS,
181
+ ALWAYS_PROTECTED_WRITE_PREFIXES,
182
+ _component_plugin_allowed,
183
+ _normalize_rel_match_path,
184
+ _normalize_workspace_relpath,
185
+ _normalized_authoritative_verify_commands,
186
+ _normalized_verify_commands,
187
+ _paths_require_verification,
188
+ _PluginActivationIndex,
189
+ _workspace_relpath_for_path,
190
+ resolve_workdir_relpath_within_workspace,
191
+ )
192
+ from .read_ledger import SessionReadLedger
193
+ from .steering import SteerInbox
194
+ from .subagent_execution import (
195
+ _AUTHORITATIVE_SUBAGENT_FINAL_TEXT_SOURCES as _AUTHORITATIVE_SUBAGENT_FINAL_TEXT_SOURCES,
196
+ )
197
+ from .subagent_execution import (
198
+ _MODE_PERMISSIVENESS_ORDER as _MODE_PERMISSIVENESS_ORDER,
199
+ )
200
+ from .subagent_execution import (
201
+ _MODE_PERMISSIVENESS_RANK as _MODE_PERMISSIVENESS_RANK,
202
+ )
203
+ from .subagent_execution import (
204
+ _ROUTING_MODE_CODE_ONLY as _ROUTING_MODE_CODE_ONLY,
205
+ )
206
+ from .subagent_execution import (
207
+ _SUBAGENT_CANCELLATION_TOKEN_ARG as _SUBAGENT_CANCELLATION_TOKEN_ARG,
208
+ )
209
+ from .subagent_execution import (
210
+ ChildRunRegistry,
211
+ ChildScheduler,
212
+ SubagentLauncher,
213
+ )
214
+ from .subagent_execution import (
215
+ _create_session_for_subagent as _create_session_for_subagent,
216
+ )
217
+ from .subagent_execution import (
218
+ _latest_subagent_message_text as _latest_subagent_message_text,
219
+ )
220
+ from .subagent_execution import (
221
+ _latest_subagent_store_final_text as _latest_subagent_store_final_text,
222
+ )
223
+ from .subagent_execution import (
224
+ _persist_internal_subagent_report as _persist_internal_subagent_report,
225
+ )
226
+ from .subagent_execution import (
227
+ _resolve_subagent_final_text as _resolve_subagent_final_text,
228
+ )
229
+ from .subagent_execution import (
230
+ _subagent_artifact_requirement as _subagent_artifact_requirement,
231
+ )
232
+ from .subagent_execution import (
233
+ _subagent_exact_tool_catalog_message as _subagent_exact_tool_catalog_message,
234
+ )
235
+ from .subagent_execution import (
236
+ _subagent_final_report_problem as _subagent_final_report_problem,
237
+ )
238
+ from .subagent_execution import (
239
+ _subagent_success_event_types as _subagent_success_event_types,
240
+ )
241
+ from .subagent_execution import (
242
+ _subagent_termination_kind as _subagent_termination_kind,
243
+ )
244
+ from .subagent_workspace import SubagentWorkspaceProvider
245
+ from .verification_commands import (
246
+ _expand_simple_verify_command_chain,
247
+ _has_disallowed_shell_control_flow,
248
+ _verify_run_commands_match_effective_contract,
249
+ )
250
+ from .verification_evidence import (
251
+ VerificationEvidence,
252
+ VerificationEvidenceCategory,
253
+ _evidence_v2_enabled,
254
+ classify_verification_evidence,
255
+ command_is_qualifying_execution_evidence,
256
+ )
257
+
258
+ _turn_snapshot = importlib.import_module("alysis_code.agent.turn.snapshot")
259
+ _SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX = _turn_snapshot._SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX
260
+ _detect_command_mutation_paths = _turn_snapshot._detect_command_mutation_paths
261
+ _list_git_workspace_snapshot_paths = _turn_snapshot._list_git_workspace_snapshot_paths
262
+ _normalize_snapshot_ignore_paths = _turn_snapshot._normalize_snapshot_ignore_paths
263
+ _path_matches_snapshot_ignore = _turn_snapshot._path_matches_snapshot_ignore
264
+ _run_with_command_mutation_detection = _turn_snapshot._run_with_command_mutation_detection
265
+ _snapshot_workspace_for_command_mutation_detection = (
266
+ _turn_snapshot._snapshot_workspace_for_command_mutation_detection
267
+ )
268
+ _walk_workspace_snapshot_paths = _turn_snapshot._walk_workspace_snapshot_paths
269
+ _workspace_snapshot_signature = _turn_snapshot._workspace_snapshot_signature
270
+
271
+
272
+ def _call_with_optional_kwargs(
273
+ func: Callable[..., Any],
274
+ *,
275
+ required_kwargs: dict[str, Any],
276
+ optional_kwargs: dict[str, Any],
277
+ ) -> Any:
278
+ try:
279
+ signature = inspect.signature(func)
280
+ except (TypeError, ValueError):
281
+ return func(**required_kwargs, **optional_kwargs)
282
+ accepts_var_kwargs = any(
283
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
284
+ for parameter in signature.parameters.values()
285
+ )
286
+ accepted_kwargs = dict(required_kwargs)
287
+ for key, value in optional_kwargs.items():
288
+ if accepts_var_kwargs or key in signature.parameters:
289
+ accepted_kwargs[key] = value
290
+ return func(**accepted_kwargs)
291
+
292
+
293
+ def _command_mutation_metadata(
294
+ *,
295
+ root: Path,
296
+ touched_repo_paths: list[str],
297
+ command_was_verification: bool = False,
298
+ ) -> dict[str, Any]:
299
+ classifications = classify_mutation_paths(
300
+ touched_repo_paths,
301
+ root=root,
302
+ command_was_verification=command_was_verification,
303
+ )
304
+ material = [item.path for item in classifications if item.is_material]
305
+ benign = [item.path for item in classifications if not item.is_material]
306
+ out: dict[str, Any] = {
307
+ "mutation_path_classifications": [item.as_payload() for item in classifications],
308
+ }
309
+ if material:
310
+ out["material_touched_repo_paths"] = material
311
+ if benign:
312
+ out["benign_runtime_paths"] = benign
313
+ return out
314
+
315
+
316
+ def _verification_relevant_material_paths(paths: list[str]) -> list[str]:
317
+ if not paths or not _paths_require_verification(set(paths)):
318
+ return []
319
+ return list(paths)
320
+
321
+
322
+ def _aggregate_tool_evidence_payload(records: list[VerificationEvidence]) -> dict[str, Any]:
323
+ if not records:
324
+ return {
325
+ "verification_evidence_category": VerificationEvidenceCategory.NOT_VERIFICATION.value,
326
+ "verification_evidence_reason": "no_verification_evidence",
327
+ "verification_evidence_allowed": False,
328
+ "verification_evidence_supplemental_only": False,
329
+ }
330
+ priority = {
331
+ VerificationEvidenceCategory.AUTHORITATIVE: 0,
332
+ VerificationEvidenceCategory.REPO_NATIVE: 1,
333
+ VerificationEvidenceCategory.TASK_ACCEPTANCE: 2,
334
+ VerificationEvidenceCategory.NOT_VERIFICATION: 3,
335
+ }
336
+ primary = sorted(records, key=lambda item: priority[item.category])[0]
337
+ allowed = all(
338
+ item.allowed_to_satisfy_contract
339
+ for item in records
340
+ if item.category != VerificationEvidenceCategory.NOT_VERIFICATION
341
+ )
342
+ if any(item.category == VerificationEvidenceCategory.NOT_VERIFICATION for item in records):
343
+ allowed = False
344
+ return {
345
+ "verification_evidence_category": primary.category.value,
346
+ "verification_evidence_reason": (
347
+ primary.reason
348
+ if allowed
349
+ else next(
350
+ (item.reason for item in records if not item.allowed_to_satisfy_contract),
351
+ primary.reason,
352
+ )
353
+ ),
354
+ "verification_evidence_allowed": allowed,
355
+ "verification_evidence_supplemental_only": all(item.supplemental_only for item in records),
356
+ "verification_evidence_records": [item.as_payload() for item in records],
357
+ }
358
+
359
+
360
+ def _custom_tool_plugin_id(
361
+ tool: CustomToolSpec,
362
+ index: _PluginActivationIndex,
363
+ ) -> str | None:
364
+ parts = PurePosixPath(tool.relative_tool_path).parts
365
+ if len(parts) >= 2 and parts[0] == "plugins":
366
+ return index.slug_to_plugin_id.get(parts[1])
367
+ return None
368
+
369
+
370
+ def _filter_custom_tool_session_state_for_plugins(
371
+ *,
372
+ state: CustomToolSessionState,
373
+ activation_decision: ActivationDecision,
374
+ index: _PluginActivationIndex,
375
+ ) -> tuple[CustomToolSessionState, Counter[str]]:
376
+ dropped_counts: Counter[str] = Counter()
377
+ keep_cache: dict[str, bool] = {}
378
+
379
+ def keep(tool: CustomToolSpec) -> bool:
380
+ cache_key = os.fspath(tool.source_path)
381
+ if cache_key in keep_cache:
382
+ return keep_cache[cache_key]
383
+ allowed = _component_plugin_allowed(
384
+ _custom_tool_plugin_id(tool, index),
385
+ activation_decision,
386
+ dropped_counts,
387
+ )
388
+ keep_cache[cache_key] = allowed
389
+ return allowed
390
+
391
+ filtered_discovery = CustomToolDiscoveryResult(
392
+ global_tools=tuple(tool for tool in state.discovery.global_tools if keep(tool)),
393
+ project_tools=tuple(tool for tool in state.discovery.project_tools if keep(tool)),
394
+ effective_tools=tuple(tool for tool in state.discovery.effective_tools if keep(tool)),
395
+ shadowed_tools=tuple(tool for tool in state.discovery.shadowed_tools if keep(tool)),
396
+ issues=state.discovery.issues,
397
+ )
398
+ return (
399
+ CustomToolSessionState(
400
+ discovery=filtered_discovery,
401
+ trust_state=state.trust_state,
402
+ catalog_entries=tuple(
403
+ entry for entry in state.catalog_entries if entry.spec is None or keep(entry.spec)
404
+ ),
405
+ effective_tools_by_name=filtered_discovery.effective_tools_by_name(),
406
+ exposed_tools_by_name={
407
+ name: tool for name, tool in state.exposed_tools_by_name.items() if keep(tool)
408
+ },
409
+ ),
410
+ dropped_counts,
411
+ )
412
+
413
+
414
+ def _mcp_server_plugin_id(server: ResolvedMcpServer) -> str | None:
415
+ raw = str(server.id or "")
416
+ if "/" not in raw:
417
+ return None
418
+ return normalize_extension_id(raw.split("/", 1)[0])
419
+
420
+
421
+ def _filter_mcp_config_for_plugins(
422
+ *,
423
+ config: ResolvedMcpConfig,
424
+ activation_decision: ActivationDecision,
425
+ ) -> tuple[ResolvedMcpConfig, Counter[str]]:
426
+ dropped_counts: Counter[str] = Counter()
427
+ servers = tuple(
428
+ server
429
+ for server in config.servers
430
+ if _component_plugin_allowed(
431
+ _mcp_server_plugin_id(server),
432
+ activation_decision,
433
+ dropped_counts,
434
+ )
435
+ )
436
+ return replace(config, servers=servers), dropped_counts
437
+
438
+
439
+ FULLACCESS_DENYLIST_PATTERNS = [
440
+ r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/\s*$",
441
+ r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/\*",
442
+ r"\bgit\s+push\s+.*--force.*\b(main|master)\b",
443
+ r"\bsudo\b",
444
+ r"\bcurl\s+[^|]*\|\s*sh\b",
445
+ r"\bwget\s+[^|]*\|\s*sh\b",
446
+ r"\bdd\s+if=/dev/",
447
+ r"\bmkfs\.",
448
+ r":\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:",
449
+ r"\bchmod\s+-R\s+777\s+/",
450
+ r">\s*/dev/sd[a-z]",
451
+ ]
452
+
453
+
454
+ def _normalize_fullaccess_shell_command(cmd: str) -> str:
455
+ return " ".join(str(cmd).strip().split())
456
+
457
+
458
+ def _fullaccess_denylist_match(cmd: str) -> str | None:
459
+ normalized = _normalize_fullaccess_shell_command(cmd)
460
+ for pattern in FULLACCESS_DENYLIST_PATTERNS:
461
+ if re.search(pattern, normalized, flags=re.IGNORECASE):
462
+ return pattern
463
+ return None
464
+
465
+
466
+ def _fullaccess_shell_audit_ts() -> str:
467
+ return datetime.now(UTC).isoformat()
468
+
469
+
470
+ class ToolDispatchGuard(Protocol):
471
+ """Host-supplied veto hook applied before every tool dispatch."""
472
+
473
+ def check_tool_call(
474
+ self,
475
+ tool_name: str,
476
+ arguments: dict[str, Any],
477
+ *,
478
+ resolve_rel_path: Callable[..., str] | None = None,
479
+ ) -> None: ...
480
+
481
+
482
+ @dataclass(frozen=True)
483
+ class ToolDef:
484
+ name: str
485
+ description: str
486
+ parameters: dict[str, Any]
487
+ run: Callable[[dict[str, Any]], dict[str, Any]]
488
+ metadata: dict[str, Any] = field(default_factory=dict)
489
+
490
+ def as_openai_tool(self) -> dict[str, Any]:
491
+ family = _model_schema_family(self.metadata)
492
+ description_max_chars = self.metadata.get("model_description_max_chars")
493
+ if family in CUSTOM_MCP_SCHEMA_FAMILIES:
494
+ description_max_chars = _schema_description_max_chars(description_max_chars)
495
+ description = str(self.metadata.get("model_description") or self.description)
496
+ description = _model_facing_tool_description(
497
+ description,
498
+ max_chars=description_max_chars,
499
+ )
500
+ parameters = self.parameters
501
+ if bool(self.metadata.get("compact_parameters_for_model")):
502
+ if family in CUSTOM_MCP_SCHEMA_FAMILIES:
503
+ parameters = compact_custom_mcp_tool_parameters(self.parameters)
504
+ else:
505
+ parameters = _drop_model_facing_schema_prose(self.parameters)
506
+ return {
507
+ "type": "function",
508
+ "function": {
509
+ "name": self.name,
510
+ "description": description,
511
+ "parameters": parameters,
512
+ },
513
+ }
514
+
515
+
516
+ _MODEL_FACING_SCHEMA_PROSE_KEYS = frozenset(
517
+ {
518
+ "$comment",
519
+ "description",
520
+ "example",
521
+ "examples",
522
+ "markdownDescription",
523
+ "title",
524
+ }
525
+ )
526
+
527
+
528
+ def _model_schema_family(metadata: dict[str, Any]) -> str:
529
+ tool_type = str(metadata.get("tool_type") or "").strip().lower()
530
+ if tool_type == "custom_tool":
531
+ return "custom"
532
+ if tool_type in {"mcp", "mcp_tool"}:
533
+ return "mcp"
534
+ return tool_type
535
+
536
+
537
+ def _schema_description_max_chars(value: Any) -> int:
538
+ try:
539
+ configured = int(value)
540
+ except (TypeError, ValueError):
541
+ configured = 0
542
+ if configured <= 0:
543
+ return DEFAULT_CUSTOM_MCP_DESCRIPTION_MAX_CHARS
544
+ return configured
545
+
546
+
547
+ def _model_facing_tool_description(description: str, *, max_chars: Any) -> str:
548
+ text = " ".join(str(description or "").split())
549
+ try:
550
+ limit = int(max_chars)
551
+ except (TypeError, ValueError):
552
+ limit = 0
553
+ if limit <= 0 or len(text) <= limit:
554
+ return text
555
+ return text[: max(0, limit - 3)].rstrip() + "..."
556
+
557
+
558
+ def _drop_model_facing_schema_prose(value: Any) -> Any:
559
+ if isinstance(value, dict):
560
+ reduced: dict[str, Any] = {}
561
+ for key, item in value.items():
562
+ normalized_key = str(key)
563
+ if normalized_key in _MODEL_FACING_SCHEMA_PROSE_KEYS:
564
+ continue
565
+ if normalized_key in {"const", "default", "enum"}:
566
+ reduced[key] = copy.deepcopy(item)
567
+ continue
568
+ reduced[key] = _drop_model_facing_schema_prose(item)
569
+ return reduced
570
+ if isinstance(value, list):
571
+ return [_drop_model_facing_schema_prose(item) for item in value]
572
+ return copy.deepcopy(value)
573
+
574
+
575
+ def _drop_schema_descriptions(value: Any) -> Any:
576
+ return _drop_model_facing_schema_prose(value)
577
+
578
+
579
+ _BUILTIN_MODEL_DESCRIPTIONS: dict[str, str] = {
580
+ "report_blocker": "Report a final unresolvable blocker.",
581
+ "fs_read": "Read a workspace text file.",
582
+ "fs_read_lines": "Read numbered file lines.",
583
+ "fs_edit": "Edit one UTF-8 file.",
584
+ "fs_move": "Move a file.",
585
+ "fs_copy": "Copy a file.",
586
+ "fs_delete": "Delete a file.",
587
+ "fs_write": "Write a UTF-8 text file.",
588
+ "fs_mkdir": "Create a directory.",
589
+ "fs_list": "List workspace files.",
590
+ "web_fetch": "Fetch a supplied/search URL.",
591
+ "web_search": (
592
+ "Search current sources for unstable/requested facts; includes UTC retrieved_at. "
593
+ "Fetch URLs with web_fetch."
594
+ ),
595
+ "symbol_search": "Find symbols/snippets.",
596
+ "test_discover": "Suggest tests.",
597
+ "repo_map": "Map code and tests.",
598
+ "search_rg": "Search workspace text.",
599
+ "history_search": "Search prior events.",
600
+ "session_artifact_read": "Read current-session artifact locator.",
601
+ "verify_run": "Run verification.",
602
+ "shell_run": "Run a policy-checked shell command.",
603
+ "shell_background": ("Run a session lifetime command; killed when this session ends."),
604
+ "shell_service_start": (
605
+ "Start a durable service with durable lifetime; keeps running after this session ends."
606
+ ),
607
+ "workspace_preview_start": ("Serve files without Docker; semantic access chooses a free port."),
608
+ "shell_service_status": "Check a service that outlives the session.",
609
+ "shell_service_stop": ("Stop a durable service; others keep running after the session ends."),
610
+ "shell_output": "Read process output.",
611
+ "shell_wait": "Wait for output/exit.",
612
+ "shell_kill": "Stop a background process.",
613
+ "shell_list": "List background processes.",
614
+ "session_set_workdir": "Set active_workdir.",
615
+ "switch_mode": "Propose a user-approved persona switch.",
616
+ "subagent_run": "Run child; eligible batches parallelize max4.",
617
+ "subagent_spawn": (
618
+ "Background: shared read-only, isolated writable. Example: run_id=impl; verifier "
619
+ "depends_on=[impl], workspace_from_run=impl."
620
+ ),
621
+ "subagent_send": "Message a queued/running child.",
622
+ "subagent_resume": "Resume a terminal child as a linked run.",
623
+ "subagent_status": "List children.",
624
+ "subagent_wait": "Collect children.",
625
+ "subagent_cancel": "Cancel children.",
626
+ "subagent_apply": "Apply patch.",
627
+ "subagent_discard": "Discard worktree.",
628
+ "git_status": "Read Git status.",
629
+ "git_diff": "Run git diff.",
630
+ "git_history": "Read Git history.",
631
+ "git_apply_patch": "Apply a unified Git diff.",
632
+ "browser_start": "Start an approval-gated, IDE-owned browser for public websites.",
633
+ "browser_navigate": "Navigate an owned browser to an approved public HTTP(S) URL.",
634
+ "browser_snapshot": "Read a bounded page snapshot from an owned browser.",
635
+ "browser_screenshot": "Capture a screenshot and return only its opaque artifact id.",
636
+ "browser_artifact_read": "Read a bounded base64 chunk of a browser artifact by opaque id.",
637
+ "browser_diagnostics": "Read bounded, redacted browser console and network events.",
638
+ "browser_click": "Click an approved selector in an owned browser.",
639
+ "browser_type": "Type approved text into a selector without echoing the text.",
640
+ "browser_status": "Read one owned browser session status.",
641
+ "browser_list": "List browser sessions owned by this IDE task.",
642
+ "browser_close": "Close an approved owned browser session.",
643
+ "ide_task_list": "List bounded VS Code workspace tasks exposed by the trusted IDE host.",
644
+ "ide_task_run": "Start one opaque VS Code workspace task through the trusted IDE host.",
645
+ "ide_task_status": "Read bounded status for trusted-host VS Code task executions.",
646
+ "ide_task_terminate": "Terminate one VS Code task execution by opaque execution id.",
647
+ "ide_debug_list": "List bounded VS Code debug configurations exposed by the trusted IDE host.",
648
+ "ide_debug_start": "Start one opaque VS Code debug configuration through the trusted IDE host.",
649
+ "ide_debug_stop": "Stop one VS Code debug session by opaque session id.",
650
+ "ide_debug_status": "Read bounded status for trusted-host VS Code debug sessions.",
651
+ }
652
+
653
+
654
+ def _tool_event_metadata(tool: ToolDef | None) -> dict[str, Any]:
655
+ if tool is None or not tool.metadata:
656
+ return {}
657
+ metadata = copy.deepcopy(tool.metadata)
658
+ event_metadata: dict[str, Any] = {}
659
+ tool_type = str(metadata.get("tool_type") or "").strip()
660
+ if tool_type:
661
+ event_metadata["tool_type"] = tool_type
662
+ custom_tool = metadata.get("custom_tool")
663
+ if isinstance(custom_tool, dict):
664
+ event_metadata["custom_tool"] = {
665
+ key: value for key, value in custom_tool.items() if key != "output_schema"
666
+ }
667
+ return event_metadata
668
+
669
+
670
+ def _custom_tool_capability_summary(spec: Any) -> str:
671
+ capabilities = getattr(spec, "capabilities", None)
672
+ if capabilities is None:
673
+ return "capabilities: unspecified"
674
+ secret_refs = getattr(capabilities, "secret_refs", ())
675
+ secret_summary = ", ".join(secret_refs) if secret_refs else "-"
676
+ network_hosts = getattr(capabilities, "network_hosts", ())
677
+ network_hosts_summary = ", ".join(network_hosts) if network_hosts else "-"
678
+ return (
679
+ "capabilities: "
680
+ f"read_only={bool(getattr(capabilities, 'read_only', False))}, "
681
+ f"destructive={bool(getattr(capabilities, 'destructive', False))}, "
682
+ f"network={getattr(capabilities, 'network_access', 'unspecified')}, "
683
+ f"network_hosts={network_hosts_summary}, "
684
+ f"fs_read={getattr(capabilities, 'filesystem_read_scope', 'unspecified')}, "
685
+ f"fs_write={getattr(capabilities, 'filesystem_write_scope', 'unspecified')}, "
686
+ f"process_spawn={getattr(capabilities, 'process_spawn', 'unspecified')}, "
687
+ f"secrets={secret_summary}"
688
+ )
689
+
690
+
691
+ _READONLY_MAIN_SESSION_BUILTIN_TOOL_NAMES = frozenset(
692
+ built_in_subagent_tool_names(exposure="readonly")
693
+ )
694
+
695
+
696
+ _READONLY_TOP_LEVEL_WEB_TOOL_NAMES = frozenset({"web_fetch", "web_search"})
697
+
698
+
699
+ # Out-of-band channel for handing the turn's cancellation token to the shell
700
+ # wait path, mirroring _SUBAGENT_CANCELLATION_TOKEN_ARG. The key is an object()
701
+ # rather than a string so it can never collide with a model-supplied argument,
702
+ # is skipped by the ``isinstance(key, str)`` filters that build public args, and
703
+ # never reaches the schema, the transcript, or the provider.
704
+ _SHELL_CANCELLATION_TOKEN_ARG = object()
705
+
706
+ # Tools whose dispatch can block on a running process, and which therefore need
707
+ # the cancellation token so PR2's watchdog can preempt a wait already in flight.
708
+ _SHELL_CANCELLABLE_WAIT_TOOL_NAMES = frozenset({"shell_wait"})
709
+
710
+
711
+ def _built_in_tool_exposed_in_mode(
712
+ *,
713
+ tool_name: str,
714
+ mode: str,
715
+ subagent_depth: int = 0,
716
+ readonly_child_web_tool_names: Collection[str] | None = None,
717
+ ) -> bool:
718
+ normalized_mode = str(mode or "").strip().lower()
719
+ if normalized_mode != "readonly":
720
+ return True
721
+ normalized_tool_name = str(tool_name or "").strip()
722
+ if normalized_tool_name in _READONLY_MAIN_SESSION_BUILTIN_TOOL_NAMES:
723
+ return True
724
+ if normalized_tool_name not in _READONLY_TOP_LEVEL_WEB_TOOL_NAMES:
725
+ return False
726
+ # Top-level Plan/readonly sessions can safely use bounded web discovery and
727
+ # fetch tools. At depth one, only a definition-gated research child receives
728
+ # the explicitly allowlisted web tools; helpers at depth two remain narrow.
729
+ if subagent_depth == 0:
730
+ return True
731
+ allowed_child_web_tools = {
732
+ str(name or "").strip() for name in (readonly_child_web_tool_names or ())
733
+ }
734
+ return subagent_depth == 1 and normalized_tool_name in allowed_child_web_tools
735
+
736
+
737
+ def _mcp_tool_exposed_in_mode(*, mode: str, write_scope_restricted: bool = False) -> bool:
738
+ return str(mode or "").strip().lower() != "readonly" and not write_scope_restricted
739
+
740
+
741
+ def _custom_tools_write_scope_restricted(
742
+ *,
743
+ mode: str,
744
+ deny_write_prefixes: list[str] | None,
745
+ allow_write_globs: list[str] | None,
746
+ persona_allow_write_globs: list[str] | None,
747
+ ) -> bool:
748
+ if persona_allow_write_globs is not None:
749
+ return True
750
+ if str(mode or "").strip().lower() == _MODE_FULLACCESS:
751
+ return False
752
+ if allow_write_globs is not None:
753
+ return True
754
+ always_protected = {
755
+ _normalize_rel_match_path(prefix).casefold()
756
+ for prefix in ALWAYS_PROTECTED_WRITE_PREFIXES
757
+ if _normalize_rel_match_path(prefix)
758
+ }
759
+ for raw in deny_write_prefixes or []:
760
+ cleaned = _normalize_rel_match_path(str(raw))
761
+ if cleaned and cleaned.casefold() not in always_protected:
762
+ return True
763
+ return False
764
+
765
+
766
+ def _unified_diff(old: str, new: str, path: str) -> str:
767
+ import difflib
768
+
769
+ old_lines = old.splitlines(keepends=True)
770
+ new_lines = new.splitlines(keepends=True)
771
+ diff = difflib.unified_diff(
772
+ old_lines,
773
+ new_lines,
774
+ fromfile=f"a/{path}",
775
+ tofile=f"b/{path}",
776
+ )
777
+ return "".join(diff)
778
+
779
+
780
+ def build_tools(
781
+ *,
782
+ root: Path,
783
+ console: Any | None,
784
+ surface: Surface | None = None,
785
+ store: SessionStore,
786
+ mode: str,
787
+ yes: bool,
788
+ cfg: AppConfig | None = None,
789
+ api_key: str | None = None,
790
+ max_steps: int | None = None,
791
+ no_log: bool = False,
792
+ usage_role: str = "main",
793
+ usage_summary: UsageSummary | None = None,
794
+ model_registry: ModelRegistry | None = None,
795
+ deny_write_prefixes: list[str] | None = None,
796
+ allow_write_globs: list[str] | None = None,
797
+ persona_allow_write_globs: list[str] | None = None,
798
+ non_interactive: bool = False,
799
+ shell_runner: Any | None = None,
800
+ process_group_registry: ProcessGroupRegistry | None = None,
801
+ terminal_manager: TerminalManager | None = None,
802
+ durable_service_manager: DurableServiceManager | None = None,
803
+ persistent_service_registry: PersistentServiceRegistry | None = None,
804
+ edit_discipline: EditDisciplineState | None = None,
805
+ verification_enabled: bool = True,
806
+ authoritative_verification_commands: list[str] | None = None,
807
+ effective_verification_commands: list[str] | None = None,
808
+ verify_command_selection: ResolvedVerifyCommands | None = None,
809
+ get_verify_command_selection: Callable[[], ResolvedVerifyCommands | None] | None = None,
810
+ one_shot_execution: bool = False,
811
+ completion_gate_tools_enabled: bool = False,
812
+ skills_enabled: bool = True,
813
+ skill_registry: dict[str, SkillBundle] | None = None,
814
+ subagents_enabled: bool = False,
815
+ helper_subagents_enabled: bool = False,
816
+ subagent_depth: int = 0,
817
+ subagent_registry: dict[str, SubagentDefinition] | None = None,
818
+ session_log_dir_override: Path | None = None,
819
+ step_budget_runtime: Any | None = None,
820
+ emit_web_search_runtime_diagnostics: bool = False,
821
+ runtime_kind: RuntimeKind | str = RuntimeKind.ONE_SHOT,
822
+ persona_switch_state: Any | None = None,
823
+ mcp_manager: McpManager | ForgeTaskScopedMcpManager | None = None,
824
+ custom_tool_session_state: CustomToolSessionState | None = None,
825
+ get_active_workdir_relpath: Callable[[], str] | None = None,
826
+ set_active_workdir_callback: Callable[[str, str], dict[str, Any]] | None = None,
827
+ create_session_factory: Callable[..., Any] | None = None,
828
+ prompt_cache_parent_session_id: str | None = None,
829
+ execution_deadline: ExecutionDeadline | None = None,
830
+ crash_diagnostic_log_path: str | os.PathLike[str] | None = None,
831
+ crash_diagnostics: CrashDiagnosticLogger | None = None,
832
+ tool_dispatch_guard: ToolDispatchGuard | None = None,
833
+ managed_browser_service: ManagedBrowserService | None = None,
834
+ managed_browser_owner_id: str | None = None,
835
+ managed_browser_cancel_check: Callable[[], bool] | None = None,
836
+ host_action_handler: HostActionHandler | None = None,
837
+ host_action_capabilities: Collection[str] | None = None,
838
+ child_scheduler_sink: Callable[[ChildScheduler], None] | None = None,
839
+ parent_steer_inbox: SteerInbox | None = None,
840
+ read_ledger_sink: Callable[[SessionReadLedger], None] | None = None,
841
+ readonly_child_web_tool_names: Collection[str] | None = None,
842
+ child_managed_browser_tool_names: Collection[str] | None = None,
843
+ ) -> dict[str, ToolDef]:
844
+ root = root.resolve()
845
+ workspace_context = resolve_workspace_context(root)
846
+ surface = surface or NoopSurface()
847
+ host_managed_approvals = bool(
848
+ getattr(surface, "host_managed_approvals", False)
849
+ or getattr(getattr(surface, "_parent_surface", None), "host_managed_approvals", False)
850
+ )
851
+ resolved_runtime_kind = normalize_runtime_kind(
852
+ runtime_kind, fallback=RuntimeKind.INTERACTIVE_CHAT
853
+ )
854
+ authoritative_verify_commands = _normalized_authoritative_verify_commands(
855
+ authoritative_verification_commands
856
+ )
857
+ static_verify_selection = verify_command_selection
858
+ normalized_effective_verification_commands = _normalized_verify_commands(
859
+ effective_verification_commands
860
+ or (
861
+ list(static_verify_selection.commands)
862
+ if isinstance(static_verify_selection, ResolvedVerifyCommands)
863
+ else []
864
+ )
865
+ )
866
+ effective_host_actions = normalized_host_action_capabilities(host_action_capabilities)
867
+ read_ledger = SessionReadLedger(
868
+ root=root,
869
+ enabled=bool(getattr(cfg, "read_ledger_enabled", True)),
870
+ )
871
+ if read_ledger_sink is not None:
872
+ read_ledger_sink(read_ledger)
873
+
874
+ def _deadline_payload() -> dict[str, Any]:
875
+ if execution_deadline is None:
876
+ return {
877
+ "failure_category": "deadline",
878
+ "deadline_exhausted": False,
879
+ "remaining_seconds": None,
880
+ "deadline": None,
881
+ }
882
+ remaining = execution_deadline.remaining_seconds()
883
+ return {
884
+ "failure_category": "deadline",
885
+ "deadline_exhausted": execution_deadline.is_exhausted(),
886
+ "remaining_seconds": remaining,
887
+ "deadline": execution_deadline.telemetry_snapshot(),
888
+ }
889
+
890
+ def _deadline_error(
891
+ message: str,
892
+ *,
893
+ prevented_launch: bool = True,
894
+ start_decision: dict[str, Any] | None = None,
895
+ ) -> dict[str, Any]:
896
+ payload = {
897
+ "error": message,
898
+ "deadline_prevented_launch": prevented_launch,
899
+ **_deadline_payload(),
900
+ }
901
+ if start_decision is not None:
902
+ payload["deadline_start_decision"] = start_decision
903
+ if crash_diagnostics is not None:
904
+ crash_diagnostics.event(
905
+ "deadline_exhausted",
906
+ {
907
+ "operation": "tool",
908
+ "deadline_exhausted": payload["deadline_exhausted"],
909
+ "remaining_seconds": payload["remaining_seconds"],
910
+ "deadline": payload["deadline"],
911
+ "deadline_start_decision": start_decision,
912
+ },
913
+ durable=True,
914
+ )
915
+ return payload
916
+
917
+ def _deadline_warning_fields(
918
+ message: str,
919
+ *,
920
+ start_decision: dict[str, Any] | None = None,
921
+ ) -> dict[str, Any]:
922
+ payload = {
923
+ "deadline_warning": message,
924
+ "deadline_prevented_launch": False,
925
+ **_deadline_payload(),
926
+ }
927
+ if start_decision is not None:
928
+ payload["deadline_start_decision"] = start_decision
929
+ if crash_diagnostics is not None:
930
+ crash_diagnostics.event(
931
+ "deadline_exhausted",
932
+ {
933
+ "operation": "tool",
934
+ "deadline_exhausted": payload["deadline_exhausted"],
935
+ "remaining_seconds": payload["remaining_seconds"],
936
+ "deadline": payload["deadline"],
937
+ "deadline_start_decision": start_decision,
938
+ },
939
+ durable=True,
940
+ )
941
+ return payload
942
+
943
+ def _deadline_start_decision(
944
+ operation: DeadlineOperation,
945
+ *,
946
+ minimum_remaining_seconds: float,
947
+ configured_timeout_seconds: float | None = None,
948
+ allow_during_finalization: bool = False,
949
+ ) -> dict[str, Any] | None:
950
+ if execution_deadline is None:
951
+ return None
952
+ return execution_deadline.start_decision(
953
+ operation,
954
+ minimum_remaining_seconds=minimum_remaining_seconds,
955
+ configured_timeout_seconds=configured_timeout_seconds,
956
+ allow_during_finalization=allow_during_finalization,
957
+ ).telemetry_snapshot()
958
+
959
+ def _deadline_timeout(
960
+ configured_timeout_seconds: float,
961
+ *,
962
+ operation: str,
963
+ ) -> float:
964
+ timeout = deadline_timeout_or_raise(
965
+ execution_deadline,
966
+ configured_timeout_seconds,
967
+ reserve_seconds=DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
968
+ operation=operation,
969
+ )
970
+ return float(configured_timeout_seconds if timeout is None else timeout)
971
+
972
+ def _current_verify_selection() -> ResolvedVerifyCommands | None:
973
+ if callable(get_verify_command_selection):
974
+ try:
975
+ current = get_verify_command_selection()
976
+ except Exception: # noqa: BLE001
977
+ current = None
978
+ if isinstance(current, ResolvedVerifyCommands):
979
+ return current
980
+ if isinstance(static_verify_selection, ResolvedVerifyCommands):
981
+ return static_verify_selection
982
+ if authoritative_verify_commands is not None:
983
+ return ResolvedVerifyCommands(
984
+ commands=tuple(authoritative_verify_commands),
985
+ source="environment.authoritative_verification_commands",
986
+ reason="managed runtime injected authoritative verification commands",
987
+ contract_type="authoritative_override",
988
+ )
989
+ if normalized_effective_verification_commands:
990
+ return ResolvedVerifyCommands(
991
+ commands=tuple(normalized_effective_verification_commands),
992
+ source="session.effective_verification_commands",
993
+ reason="session already resolved an effective verification contract",
994
+ contract_type="selected",
995
+ )
996
+ return None
997
+
998
+ command_mutation_tracking_enabled = bool(
999
+ resolved_runtime_kind == RuntimeKind.SUBAGENT
1000
+ or (
1001
+ subagent_depth == 0
1002
+ and (one_shot_execution or resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT)
1003
+ )
1004
+ )
1005
+ command_mutation_ignored_paths: list[Path] = []
1006
+ if command_mutation_tracking_enabled:
1007
+ command_mutation_ignored_paths = [
1008
+ candidate
1009
+ for candidate in [
1010
+ getattr(store, "path", None),
1011
+ getattr(store, "session_artifact_root", None),
1012
+ ]
1013
+ if isinstance(candidate, Path)
1014
+ ]
1015
+ history_artifact_persistence_available = bool(
1016
+ getattr(store, "enabled", False) or session_log_dir_override is not None
1017
+ )
1018
+ git_backed_workspace = workspace_context.git_root is not None
1019
+ resolved_skill_registry = dict(skill_registry or {})
1020
+ built_in_tool_names = {spec.name.casefold() for spec in iter_builtin_tool_metadata()}
1021
+ custom_tool_session_state = build_custom_tool_session_state(
1022
+ workspace_root=root,
1023
+ custom_tools_enabled=bool(getattr(cfg, "custom_tools_enabled", True)) if cfg else True,
1024
+ mode=mode,
1025
+ runtime_kind=resolved_runtime_kind,
1026
+ built_in_tool_names=built_in_tool_names,
1027
+ write_scope_restricted=_custom_tools_write_scope_restricted(
1028
+ mode=mode,
1029
+ deny_write_prefixes=deny_write_prefixes,
1030
+ allow_write_globs=allow_write_globs,
1031
+ persona_allow_write_globs=persona_allow_write_globs,
1032
+ ),
1033
+ discovery=(
1034
+ custom_tool_session_state.discovery if custom_tool_session_state is not None else None
1035
+ ),
1036
+ trust_state=(
1037
+ custom_tool_session_state.trust_state if custom_tool_session_state is not None else None
1038
+ ),
1039
+ )
1040
+
1041
+ persona_write_scope_active = persona_allow_write_globs is not None
1042
+ # A persona scope remains a real host constraint even if a caller ever
1043
+ # constructs an inconsistent fullaccess+scope session. Normal persona
1044
+ # application also clamps that combination to review.
1045
+ is_full_access_mode = mode == _MODE_FULLACCESS and not persona_write_scope_active
1046
+ deny_prefixes: list[str] = []
1047
+ if not is_full_access_mode:
1048
+ seen_deny_prefixes: set[str] = set()
1049
+ for raw in [
1050
+ *ALWAYS_PROTECTED_WRITE_PREFIXES,
1051
+ *(deny_write_prefixes or []),
1052
+ ]:
1053
+ cleaned = _normalize_rel_match_path(str(raw))
1054
+ if cleaned:
1055
+ normalized = cleaned.casefold()
1056
+ if normalized not in seen_deny_prefixes:
1057
+ seen_deny_prefixes.add(normalized)
1058
+ deny_prefixes.append(cleaned)
1059
+ deny_prefixes_cf = [pref.casefold() for pref in deny_prefixes]
1060
+ allow_pattern_groups: list[list[str]] = []
1061
+ allowed_ancestor_dir_groups_cf: list[set[str]] = []
1062
+ if not is_full_access_mode:
1063
+ for raw_group in (allow_write_globs, persona_allow_write_globs):
1064
+ if raw_group is None:
1065
+ continue
1066
+ patterns = [
1067
+ cleaned for raw in raw_group if (cleaned := _normalize_rel_match_path(str(raw)))
1068
+ ]
1069
+ allow_pattern_groups.append(patterns)
1070
+ allowed_ancestor_dir_groups_cf.append(
1071
+ {
1072
+ cleaned.casefold()
1073
+ for path in ancestor_directory_scope_patterns(raw_group)
1074
+ if (cleaned := _normalize_rel_match_path(path))
1075
+ }
1076
+ )
1077
+
1078
+ def _is_denied_path(rel_path: str) -> bool:
1079
+ if not deny_prefixes_cf:
1080
+ return False
1081
+ rel_norm = _normalize_rel_match_path(rel_path)
1082
+ rel_cf = rel_norm.casefold()
1083
+ for pref_cf in deny_prefixes_cf:
1084
+ if rel_cf == pref_cf or rel_cf.startswith(pref_cf + "/"):
1085
+ return True
1086
+ return False
1087
+
1088
+ def _path_escape_recovery_payload(
1089
+ *,
1090
+ tool_name: str,
1091
+ attempted_path: str,
1092
+ field_name: str,
1093
+ workspace_root: Path,
1094
+ path_base: str | None = None,
1095
+ ) -> dict[str, Any]:
1096
+ base_note = f" with path_base={path_base}" if path_base else ""
1097
+ normalized_tool = str(tool_name or "").strip().lower()
1098
+ write_tools = {
1099
+ "fs_write",
1100
+ "fs_edit",
1101
+ "fs_move",
1102
+ "fs_copy",
1103
+ "fs_delete",
1104
+ "fs_mkdir",
1105
+ }
1106
+ shell_cwd_tools = {"shell_run", "shell_background", "shell_service_start"}
1107
+
1108
+ if normalized_tool in write_tools:
1109
+ guidance = (
1110
+ "Use a workspace-relative path for this filesystem write. If the user "
1111
+ "explicitly requested an absolute path outside the workspace, explain that "
1112
+ "filesystem write tools cannot do that. Use shell_run only when policy and "
1113
+ "any required user approval allow the explicit external write."
1114
+ )
1115
+ suggested_next_actions = [
1116
+ {
1117
+ "action": "use_workspace_relative_path",
1118
+ "description": "Retry with a path inside the workspace.",
1119
+ "requires_user_confirmation": False,
1120
+ },
1121
+ {
1122
+ "action": "use_shell_run_if_policy_allows",
1123
+ "description": (
1124
+ "Use a specific shell command for an explicitly requested external "
1125
+ "target only when policy and approvals allow it."
1126
+ ),
1127
+ "requires_user_confirmation": True,
1128
+ },
1129
+ {
1130
+ "action": "ask_or_explain_boundary",
1131
+ "description": "Explain the workspace boundary and ask how to proceed.",
1132
+ "requires_user_confirmation": False,
1133
+ },
1134
+ ]
1135
+ can_use_other_allowed_tool = (
1136
+ "shell_run may target an absolute path only when policy and any required "
1137
+ "approval allow it"
1138
+ )
1139
+ requires_user_confirmation = True
1140
+ elif normalized_tool in shell_cwd_tools or field_name == "cwd":
1141
+ guidance = (
1142
+ "Use a cwd inside the workspace. If the command needs an external path, keep "
1143
+ "cwd workspace-relative and pass the path explicitly only when shell policy "
1144
+ "and approvals allow that operation."
1145
+ )
1146
+ suggested_next_actions = [
1147
+ {
1148
+ "action": "use_workspace_relative_cwd",
1149
+ "description": "Retry with cwd omitted or set inside the workspace.",
1150
+ "requires_user_confirmation": False,
1151
+ },
1152
+ {
1153
+ "action": "pass_external_path_as_argument_if_policy_allows",
1154
+ "description": (
1155
+ "Keep cwd inside the workspace and pass the external path explicitly "
1156
+ "only when policy and approvals allow it."
1157
+ ),
1158
+ "requires_user_confirmation": True,
1159
+ },
1160
+ {
1161
+ "action": "ask_or_explain_boundary",
1162
+ "description": "Explain the cwd boundary and ask how to proceed.",
1163
+ "requires_user_confirmation": False,
1164
+ },
1165
+ ]
1166
+ can_use_other_allowed_tool = "Shell commands must start from a workspace-relative cwd"
1167
+ requires_user_confirmation = True
1168
+ else:
1169
+ guidance = (
1170
+ "Use a workspace-relative path. This tool cannot inspect arbitrary paths "
1171
+ "outside the workspace; ask the user to move the input into the workspace or "
1172
+ "provide its contents."
1173
+ )
1174
+ suggested_next_actions = [
1175
+ {
1176
+ "action": "use_workspace_relative_path",
1177
+ "description": "Retry with a path inside the workspace.",
1178
+ "requires_user_confirmation": False,
1179
+ },
1180
+ {
1181
+ "action": "ask_user_for_accessible_input",
1182
+ "description": "Ask the user to provide the input inside the workspace.",
1183
+ "requires_user_confirmation": False,
1184
+ },
1185
+ {
1186
+ "action": "explain_boundary",
1187
+ "description": "Explain that the tool cannot access the external path.",
1188
+ "requires_user_confirmation": False,
1189
+ },
1190
+ ]
1191
+ can_use_other_allowed_tool = (
1192
+ "No filesystem read or search tool can access paths outside the workspace"
1193
+ )
1194
+ requires_user_confirmation = False
1195
+
1196
+ return {
1197
+ "error": (
1198
+ f"Path escapes root ({field_name}): {attempted_path}. Workspace path arguments "
1199
+ f"are limited to {os.fspath(workspace_root)}{base_note}. Recovery: {guidance}"
1200
+ ),
1201
+ "error_code": "path_escapes_workspace",
1202
+ "code": "path_escapes_workspace",
1203
+ "attempted_path": attempted_path,
1204
+ "path_field": field_name,
1205
+ "tool_name": normalized_tool or tool_name,
1206
+ "workspace_root": os.fspath(workspace_root),
1207
+ "rule": "workspace path arguments must resolve under workspace_root",
1208
+ "can_use_other_allowed_tool": can_use_other_allowed_tool,
1209
+ "requires_user_confirmation": requires_user_confirmation,
1210
+ "guidance": guidance,
1211
+ "suggested_next_actions": suggested_next_actions,
1212
+ }
1213
+
1214
+ def _resolve_rel_path(rel_path: str) -> str:
1215
+ root_abs = root.resolve()
1216
+ target = (root_abs / rel_path).resolve()
1217
+ try:
1218
+ normalized = target.relative_to(root_abs)
1219
+ except ValueError as e:
1220
+ payload = _path_escape_recovery_payload(
1221
+ tool_name="filesystem",
1222
+ attempted_path=rel_path,
1223
+ field_name="path",
1224
+ workspace_root=root_abs,
1225
+ )
1226
+ raise AgentRuntimeError(str(payload["error"]), result_payload=payload) from e
1227
+ return os.fspath(normalized)
1228
+
1229
+ def _resolve_rel_write_path(rel_path: str) -> str:
1230
+ return _resolve_rel_path(rel_path)
1231
+
1232
+ def _guard_write_path(rel_path: str) -> None:
1233
+ if is_full_access_mode:
1234
+ return
1235
+ if _is_denied_path(rel_path):
1236
+ raise AgentRuntimeError(f"Blocked write to protected path: {rel_path}")
1237
+ rel_norm = _normalize_rel_match_path(rel_path)
1238
+ rel_cf = rel_norm.casefold()
1239
+ for patterns in allow_pattern_groups:
1240
+ in_scope = any(
1241
+ scope_path_matches_pattern(rel_norm, pattern, root=root) for pattern in patterns
1242
+ )
1243
+ if not in_scope:
1244
+ in_scope = any(
1245
+ rel_cf == _normalize_rel_match_path(pattern).casefold()
1246
+ for pattern in patterns
1247
+ if not any(ch in pattern for ch in ["*", "?", "["])
1248
+ )
1249
+ if not in_scope:
1250
+ raise AgentRuntimeError(f"Blocked write outside allowed scope: {rel_path}")
1251
+
1252
+ def _is_allowed_ancestor_dir_creation(rel_path: str) -> bool:
1253
+ if is_full_access_mode or not allow_pattern_groups:
1254
+ return False
1255
+ rel_norm = _normalize_rel_match_path(rel_path).casefold()
1256
+ return all(rel_norm in ancestors for ancestors in allowed_ancestor_dir_groups_cf)
1257
+
1258
+ def _sensitive_path_findings(paths: list[str]) -> list[dict[str, str]]:
1259
+ findings: list[dict[str, str]] = []
1260
+ for path in paths:
1261
+ classification = classify_sensitive_path(path)
1262
+ if classification.sensitive:
1263
+ findings.append(
1264
+ {
1265
+ "path": path,
1266
+ "category": str(classification.category or "sensitive_file"),
1267
+ }
1268
+ )
1269
+ return findings
1270
+
1271
+ def guard_sensitive_files(kind: str, *, files: list[str]) -> list[dict[str, str]]:
1272
+ """Require one-time human consent that broad/session policy cannot satisfy."""
1273
+
1274
+ findings = _sensitive_path_findings(files)
1275
+ if not findings:
1276
+ return []
1277
+ if non_interactive and not host_managed_approvals:
1278
+ raise AgentRuntimeError(
1279
+ f"Explicit one-time user approval is required for {kind} on a sensitive file."
1280
+ )
1281
+ categories = sorted({finding["category"] for finding in findings})
1282
+ preview = "\n".join(
1283
+ [
1284
+ f"Sensitive file operation: {kind}",
1285
+ *(f"path: {finding['path']} ({finding['category']})" for finding in findings),
1286
+ "File contents are intentionally omitted from this approval preview.",
1287
+ ]
1288
+ )
1289
+ decision = surface.request_approval(
1290
+ ApprovalRequest(
1291
+ kind=kind,
1292
+ reason="sensitive files require an explicit one-time approval",
1293
+ preview=preview,
1294
+ files=[finding["path"] for finding in findings],
1295
+ metadata={
1296
+ "mandatory_explicit_approval": True,
1297
+ "allow_for_session_disabled": True,
1298
+ "sensitive_categories": categories,
1299
+ },
1300
+ # Deliberately no allow_for_session_scope: stored grants must
1301
+ # never authorize current or future sensitive-file access.
1302
+ allow_for_session_scope=None,
1303
+ )
1304
+ )
1305
+ if not decision.allow:
1306
+ raise ApprovalDeclinedError(kind)
1307
+ if decision.allow_for_session:
1308
+ # Auto/YOLO surfaces and cached grants identify themselves through
1309
+ # allow_for_session. Sensitive access only accepts the UI's one-time
1310
+ # allow decision.
1311
+ raise AgentRuntimeError(
1312
+ f"Automatic or session approval cannot authorize {kind} on a sensitive file. "
1313
+ "Switch approvals to ask and approve this operation once."
1314
+ )
1315
+ return findings
1316
+
1317
+ def guard_sensitive_read(kind: str, *, path: str) -> list[dict[str, str]]:
1318
+ findings = _sensitive_path_findings([path])
1319
+ if findings and not (root / path).exists():
1320
+ message = (
1321
+ f"Path does not exist: {path}. This result is terminal; do not retry this path."
1322
+ )
1323
+ raise AgentRuntimeError(
1324
+ message,
1325
+ result_payload={
1326
+ "error": message,
1327
+ "error_code": "fs_path_not_found",
1328
+ "terminal": True,
1329
+ "retryable": False,
1330
+ },
1331
+ )
1332
+ return guard_sensitive_files(kind, files=[path])
1333
+
1334
+ def _mark_sensitive_result(
1335
+ result: dict[str, Any], findings: list[dict[str, str]]
1336
+ ) -> dict[str, Any]:
1337
+ if findings:
1338
+ result["_alysis_output_policy"] = {
1339
+ "sensitive": True,
1340
+ "persist": "redact",
1341
+ "display": "redact",
1342
+ "categories": sorted({finding["category"] for finding in findings}),
1343
+ }
1344
+ return result
1345
+
1346
+ def _stale_file_result(error: StaleFileError) -> dict[str, Any]:
1347
+ return {
1348
+ "error": "The file changed after this operation was prepared; no mutation was made.",
1349
+ "error_code": "stale_file",
1350
+ "code": "stale_file",
1351
+ "path": error.path,
1352
+ "recoverable": True,
1353
+ }
1354
+
1355
+ def guard_write(kind: str, preview: str, *, files: list[str] | None = None) -> None:
1356
+ if is_full_access_mode:
1357
+ return
1358
+ if mode == "readonly":
1359
+ raise AgentRuntimeError(f"Blocked in readonly mode: {kind}")
1360
+ if mode == "review":
1361
+ if non_interactive and not host_managed_approvals:
1362
+ raise AgentRuntimeError(
1363
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1364
+ )
1365
+ decision = surface.request_approval(
1366
+ ApprovalRequest(
1367
+ kind=kind,
1368
+ reason="review mode requires confirmation for write operations",
1369
+ preview=preview,
1370
+ files=files or [],
1371
+ allow_for_session_scope=exact_file_set_scope(files or [], operation=kind)
1372
+ if files
1373
+ else None,
1374
+ )
1375
+ )
1376
+ if not decision.allow:
1377
+ raise ApprovalDeclinedError(kind)
1378
+ if mode == "auto" and kind == "fs_delete" and not yes:
1379
+ if non_interactive and not host_managed_approvals:
1380
+ raise AgentRuntimeError(
1381
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1382
+ )
1383
+ decision = surface.request_approval(
1384
+ ApprovalRequest(
1385
+ kind=kind,
1386
+ reason="file deletion requires confirmation",
1387
+ preview=preview,
1388
+ files=files or [],
1389
+ allow_for_session_scope=exact_file_set_scope(files or [], operation=kind)
1390
+ if files
1391
+ else None,
1392
+ )
1393
+ )
1394
+ if not decision.allow:
1395
+ raise ApprovalDeclinedError(kind)
1396
+
1397
+ def guard_shell(cmd: str, *, tool_name: str = "shell_run") -> None:
1398
+ if persona_write_scope_active:
1399
+ raise AgentRuntimeError(
1400
+ f"Blocked while persona write scope is active: {tool_name}. "
1401
+ "Use scoped filesystem and inspection tools instead."
1402
+ )
1403
+ if matched_pattern := _fullaccess_denylist_match(cmd):
1404
+ if tool_name != "shell_run" and not is_full_access_mode:
1405
+ raise AgentRuntimeError(f"Blocked command: denylist pattern {matched_pattern}")
1406
+ raise AgentRuntimeError(
1407
+ f"Blocked fullaccess shell command by denylist pattern: {matched_pattern}"
1408
+ )
1409
+ if is_full_access_mode:
1410
+ return
1411
+ if mode == "readonly":
1412
+ raise AgentRuntimeError(f"Blocked in readonly mode: {tool_name}")
1413
+ decision = evaluate_shell_command(cmd)
1414
+ if not decision.allowed:
1415
+ raise AgentRuntimeError(f"Blocked command: {decision.reason}")
1416
+ if mode == "review":
1417
+ if non_interactive and not host_managed_approvals:
1418
+ raise AgentRuntimeError(
1419
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1420
+ )
1421
+ decision = surface.request_approval(
1422
+ ApprovalRequest(
1423
+ kind=tool_name,
1424
+ reason="review mode requires confirmation for shell commands",
1425
+ preview=cmd,
1426
+ command=cmd,
1427
+ allow_for_session_scope=exact_command_scope(cmd, kind=tool_name),
1428
+ )
1429
+ )
1430
+ if not decision.allow:
1431
+ raise ApprovalDeclinedError(tool_name)
1432
+ return
1433
+ # auto mode
1434
+ if decision.needs_confirm and not yes:
1435
+ if non_interactive and not host_managed_approvals:
1436
+ raise AgentRuntimeError(
1437
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1438
+ )
1439
+ choice = surface.request_approval(
1440
+ ApprovalRequest(
1441
+ kind=tool_name,
1442
+ reason=f"sensitive command: {decision.reason}",
1443
+ preview=cmd,
1444
+ command=cmd,
1445
+ allow_for_session_scope=exact_command_scope(cmd, kind=tool_name),
1446
+ )
1447
+ )
1448
+ if not choice.allow:
1449
+ raise ApprovalDeclinedError(tool_name)
1450
+
1451
+ def guard_terminal_op(op_name: str) -> None:
1452
+ if is_full_access_mode:
1453
+ return
1454
+ if mode == "readonly":
1455
+ raise AgentRuntimeError(f"Blocked in readonly mode: {op_name}")
1456
+
1457
+ def guard_verify(commands: list[str]) -> None:
1458
+ if persona_write_scope_active:
1459
+ raise AgentRuntimeError(
1460
+ "Blocked while persona write scope is active: verify_run. "
1461
+ "Verification commands can write paths the persona scope cannot constrain."
1462
+ )
1463
+ if is_full_access_mode:
1464
+ return
1465
+ if mode == "readonly":
1466
+ raise AgentRuntimeError("Blocked in readonly mode: verify_run")
1467
+
1468
+ sensitive_reason: str | None = None
1469
+ for command in commands:
1470
+ decision = evaluate_shell_command(command)
1471
+ if not decision.allowed:
1472
+ raise AgentRuntimeError(f"Blocked command: {decision.reason}")
1473
+ if sensitive_reason is None and decision.needs_confirm:
1474
+ sensitive_reason = decision.reason
1475
+
1476
+ preview = "\n".join(f"$ {command}" for command in commands)
1477
+ command_label = (
1478
+ commands[0] if len(commands) == 1 else f"{len(commands)} verification commands"
1479
+ )
1480
+
1481
+ if mode == "review":
1482
+ if non_interactive and not host_managed_approvals:
1483
+ raise AgentRuntimeError(
1484
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1485
+ )
1486
+ decision = surface.request_approval(
1487
+ ApprovalRequest(
1488
+ kind="verify_run",
1489
+ reason="review mode requires confirmation for verification commands",
1490
+ preview=preview,
1491
+ command=command_label,
1492
+ allow_for_session_scope=exact_verify_command_set_scope(commands),
1493
+ )
1494
+ )
1495
+ if not decision.allow:
1496
+ raise ApprovalDeclinedError("verify_run")
1497
+ return
1498
+
1499
+ if sensitive_reason and not yes:
1500
+ if non_interactive and not host_managed_approvals:
1501
+ raise AgentRuntimeError(
1502
+ "Confirmation required for sensitive command. Re-run with --yes or adjust plan."
1503
+ )
1504
+ choice = surface.request_approval(
1505
+ ApprovalRequest(
1506
+ kind="verify_run",
1507
+ reason=f"sensitive command in verification set: {sensitive_reason}",
1508
+ preview=preview,
1509
+ command=command_label,
1510
+ allow_for_session_scope=exact_verify_command_set_scope(commands),
1511
+ )
1512
+ )
1513
+ if not choice.allow:
1514
+ raise ApprovalDeclinedError("verify_run")
1515
+
1516
+ tools: list[ToolDef] = []
1517
+
1518
+ def _default_active_workdir_relpath() -> str:
1519
+ return (
1520
+ _normalize_workspace_relpath(get_active_workdir_relpath())
1521
+ if callable(get_active_workdir_relpath)
1522
+ else "."
1523
+ )
1524
+
1525
+ def _normalize_tool_path_base(
1526
+ raw_value: Any,
1527
+ *,
1528
+ field_name: str,
1529
+ default: str = "active_workdir",
1530
+ ) -> str:
1531
+ if raw_value is None:
1532
+ return default
1533
+ text = str(raw_value).strip().lower()
1534
+ if not text:
1535
+ return default
1536
+ if text in {"active_workdir", "workspace_root"}:
1537
+ return text
1538
+ raise AgentRuntimeError(
1539
+ f"Invalid {field_name}: {raw_value!r}. Expected 'active_workdir' or 'workspace_root'."
1540
+ )
1541
+
1542
+ def _resolve_workspace_relative_path(
1543
+ *,
1544
+ tool_name: str,
1545
+ raw_path: Any,
1546
+ raw_base: Any = None,
1547
+ field_name: str,
1548
+ base_field_name: str,
1549
+ allow_empty: bool = False,
1550
+ ) -> str:
1551
+ workspace_root = root.resolve()
1552
+ base_kind = _normalize_tool_path_base(raw_base, field_name=base_field_name)
1553
+ if base_kind == "workspace_root":
1554
+ base_path = workspace_root
1555
+ else:
1556
+ base_path = resolve_workdir_relpath_within_workspace(
1557
+ workspace_root=workspace_root,
1558
+ relpath=_default_active_workdir_relpath(),
1559
+ )
1560
+
1561
+ text = "" if raw_path is None else str(raw_path).strip()
1562
+ if not text:
1563
+ if allow_empty:
1564
+ return _workspace_relpath_for_path(workspace_root=workspace_root, path=base_path)
1565
+ raise AgentRuntimeError(f"Missing required argument: {field_name}")
1566
+
1567
+ requested = Path(text)
1568
+ candidate = (
1569
+ requested.resolve() if requested.is_absolute() else (base_path / requested).resolve()
1570
+ )
1571
+ try:
1572
+ candidate.relative_to(workspace_root)
1573
+ except ValueError as e:
1574
+ payload = _path_escape_recovery_payload(
1575
+ tool_name=tool_name,
1576
+ attempted_path=text,
1577
+ field_name=field_name,
1578
+ workspace_root=workspace_root,
1579
+ path_base=base_kind,
1580
+ )
1581
+ raise AgentRuntimeError(str(payload["error"]), result_payload=payload) from e
1582
+ rel_path = _workspace_relpath_for_path(workspace_root=workspace_root, path=candidate)
1583
+ if rel_path == "README" and not (workspace_root / "README").exists():
1584
+ if (workspace_root / "README.md").exists():
1585
+ return "README.md"
1586
+ if rel_path == "README.md" and not (workspace_root / "README.md").exists():
1587
+ if (workspace_root / "README").exists():
1588
+ return "README"
1589
+ return rel_path
1590
+
1591
+ def _make_tool_def(
1592
+ name: str,
1593
+ *,
1594
+ run: Callable[[dict[str, Any]], dict[str, Any]],
1595
+ parameters: dict[str, Any] | None = None,
1596
+ ) -> ToolDef:
1597
+ metadata = require_builtin_tool_metadata(name)
1598
+ return ToolDef(
1599
+ name=metadata.name,
1600
+ description=metadata.description,
1601
+ parameters=parameters if parameters is not None else copied_tool_parameters(name),
1602
+ run=run,
1603
+ metadata={
1604
+ "tool_type": "builtin",
1605
+ "compact_parameters_for_model": True,
1606
+ "model_description": _BUILTIN_MODEL_DESCRIPTIONS.get(
1607
+ metadata.name, metadata.description
1608
+ ),
1609
+ },
1610
+ )
1611
+
1612
+ def _custom_tool_requires_approval(spec: Any) -> bool:
1613
+ if mode == "review":
1614
+ return True
1615
+ return False
1616
+
1617
+ def _run_custom_tool(spec: Any, args: dict[str, Any]) -> dict[str, Any]:
1618
+ if mode == "readonly":
1619
+ raise AgentRuntimeError(f"Blocked in readonly mode: custom tool '{spec.name}'")
1620
+ args_preview = json.dumps(args, ensure_ascii=True, indent=2, sort_keys=True)
1621
+ preview = (
1622
+ f"Run custom tool\n"
1623
+ f"name: {spec.name}\n"
1624
+ f"scope: {spec.source_scope}\n"
1625
+ f"path: {spec.source_path}\n"
1626
+ f"{_custom_tool_capability_summary(spec)}\n"
1627
+ f"args:\n{args_preview}"
1628
+ )
1629
+ if _custom_tool_requires_approval(spec):
1630
+ if non_interactive and not host_managed_approvals:
1631
+ raise AgentRuntimeError(
1632
+ "Confirmation required for custom tool execution. Re-run with --yes or adjust plan."
1633
+ )
1634
+ decision = surface.request_approval(
1635
+ ApprovalRequest(
1636
+ kind=f"custom_tool_run:{spec.name}",
1637
+ reason="review mode requires confirmation for custom tools",
1638
+ preview=preview,
1639
+ files=[spec.relative_tool_path],
1640
+ command=spec.name,
1641
+ metadata={"custom_tool": spec.metadata(include_output_schema=True)},
1642
+ )
1643
+ )
1644
+ if not decision.allow:
1645
+ raise ApprovalDeclinedError(
1646
+ f"custom tool '{spec.name}'",
1647
+ message=f"User declined: custom tool '{spec.name}'",
1648
+ )
1649
+ artifact_dir: Path | None = None
1650
+ artifact_reference_prefix: str | None = None
1651
+ if store.artifact_persistence_enabled:
1652
+ artifact_dir = store.runtime_artifact_path("tool_logs")
1653
+ artifact_reference_prefix = store.session_artifact_layout.artifact_locator("tool_logs")
1654
+ return run_custom_tool(
1655
+ spec=spec,
1656
+ args=args,
1657
+ workspace_root=root,
1658
+ session_id=store.session_id,
1659
+ artifact_dir=artifact_dir,
1660
+ artifact_reference_prefix=artifact_reference_prefix,
1661
+ )
1662
+
1663
+ def _append_builtin_tool(
1664
+ name: str,
1665
+ *,
1666
+ run: Callable[[dict[str, Any]], dict[str, Any]],
1667
+ parameters: dict[str, Any] | None = None,
1668
+ ) -> None:
1669
+ if not _built_in_tool_exposed_in_mode(
1670
+ tool_name=name,
1671
+ mode=mode,
1672
+ subagent_depth=subagent_depth,
1673
+ readonly_child_web_tool_names=readonly_child_web_tool_names,
1674
+ ):
1675
+ return
1676
+ tools.append(_make_tool_def(name, run=run, parameters=parameters))
1677
+
1678
+ def _fs_read(args: dict[str, Any]) -> dict[str, Any]:
1679
+ path = _resolve_workspace_relative_path(
1680
+ tool_name="fs_read",
1681
+ raw_path=args.get("path"),
1682
+ raw_base=args.get("path_base"),
1683
+ field_name="path",
1684
+ base_field_name="path_base",
1685
+ )
1686
+ sensitive = guard_sensitive_read("fs_read", path=path)
1687
+ content_hash_before = read_ledger.content_hash(path)
1688
+ result = _patchable("fs_read", fs_read)(
1689
+ root=root,
1690
+ path=path,
1691
+ max_bytes=int(args.get("max_bytes") or 20000),
1692
+ allow_derived=bool(args.get("allow_derived", False)),
1693
+ )
1694
+ result = read_ledger.filter_result(
1695
+ path=path,
1696
+ result=result,
1697
+ content_hash_before=content_hash_before,
1698
+ force=args.get("force") is True,
1699
+ )
1700
+ return _mark_sensitive_result(result, sensitive)
1701
+
1702
+ _append_builtin_tool("fs_read", run=_fs_read)
1703
+
1704
+ # report_blocker is a top-level completion-gate control signal, not a
1705
+ # repository action. Keep the runtime-kind check here as a fail-closed
1706
+ # boundary even though the session also computes gate eligibility.
1707
+ if (
1708
+ completion_gate_tools_enabled
1709
+ and subagent_depth == 0
1710
+ and resolved_runtime_kind
1711
+ in {
1712
+ RuntimeKind.INTERACTIVE_CHAT,
1713
+ RuntimeKind.ONE_SHOT,
1714
+ RuntimeKind.FORGE_EXEC,
1715
+ }
1716
+ ):
1717
+
1718
+ def _report_blocker(args: dict[str, Any]) -> dict[str, Any]:
1719
+ raw_message = args.get("message")
1720
+ if not isinstance(raw_message, str) or not raw_message.strip():
1721
+ return {
1722
+ "error": "message must be a non-empty string",
1723
+ "error_code": "invalid_blocker_message",
1724
+ "reported": False,
1725
+ }
1726
+ message = raw_message.strip()
1727
+ if len(message) > REPORT_BLOCKER_MAX_MESSAGE_CHARS:
1728
+ return {
1729
+ "error": (
1730
+ "message exceeds the transport limit of "
1731
+ f"{REPORT_BLOCKER_MAX_MESSAGE_CHARS} characters"
1732
+ ),
1733
+ "error_code": "blocker_message_too_long",
1734
+ "reported": False,
1735
+ }
1736
+ return {"ok": True, "reported": True, "message": message}
1737
+
1738
+ _append_builtin_tool("report_blocker", run=_report_blocker)
1739
+
1740
+ # switch_mode: model-proposed persona switch, user-approved, applied by the
1741
+ # chat loop at turn end (the tool surface is never swapped mid-turn). Only
1742
+ # the top-level interactive chat runtime provides persona_switch_state, so
1743
+ # one_shot/forge/swarm/subagent/conflict runtimes never see this tool and
1744
+ # automation can never switch personas silently.
1745
+ if (
1746
+ persona_switch_state is not None
1747
+ and resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT
1748
+ and not non_interactive
1749
+ and subagent_depth == 0
1750
+ and persona_modes_enabled(cfg)
1751
+ ):
1752
+
1753
+ def _switch_mode(args: dict[str, Any]) -> dict[str, Any]:
1754
+ persona_raw = str(args.get("persona") or "").strip().lower()
1755
+ reason = " ".join(str(args.get("reason") or "").split())[:300]
1756
+ if not is_persona_name(persona_raw):
1757
+ return {
1758
+ "ok": False,
1759
+ "applied": False,
1760
+ "error": "unknown persona; valid: code, architect, ask, debug",
1761
+ }
1762
+ if persona_switch_state.last_declined == persona_raw:
1763
+ return {
1764
+ "ok": True,
1765
+ "applied": False,
1766
+ "declined": True,
1767
+ "note": (
1768
+ "The user already declined switching to this persona in "
1769
+ "this session; continue in the current persona without "
1770
+ "asking again."
1771
+ ),
1772
+ }
1773
+ decision = surface.request_approval(
1774
+ ApprovalRequest(
1775
+ kind="persona_switch",
1776
+ reason=f"model proposes a persona switch: {reason or 'no reason given'}",
1777
+ preview=f"Switch persona to {persona_raw} for the rest of the session?",
1778
+ metadata={"persona": persona_raw},
1779
+ )
1780
+ )
1781
+ if not decision.allow:
1782
+ persona_switch_state.last_declined = persona_raw
1783
+ return {
1784
+ "ok": True,
1785
+ "applied": False,
1786
+ "declined": True,
1787
+ "note": "User declined; continue in the current persona.",
1788
+ }
1789
+ persona_switch_state.last_declined = None
1790
+ persona_switch_state.pending = (persona_raw, reason)
1791
+ return {
1792
+ "ok": True,
1793
+ "applied": False,
1794
+ "scheduled": True,
1795
+ "persona": persona_raw,
1796
+ "note": "Approved. The persona switch applies when this turn ends.",
1797
+ }
1798
+
1799
+ _append_builtin_tool("switch_mode", run=_switch_mode)
1800
+
1801
+ def _fs_read_lines(args: dict[str, Any]) -> dict[str, Any]:
1802
+ path = _resolve_workspace_relative_path(
1803
+ tool_name="fs_read_lines",
1804
+ raw_path=args.get("path"),
1805
+ raw_base=args.get("path_base"),
1806
+ field_name="path",
1807
+ base_field_name="path_base",
1808
+ )
1809
+ sensitive = guard_sensitive_read("fs_read_lines", path=path)
1810
+ content_hash_before = read_ledger.content_hash(path)
1811
+ result = _patchable("fs_read_lines", fs_read_lines)(
1812
+ root=root,
1813
+ path=path,
1814
+ start_line=int(args["start_line"]) if args.get("start_line") is not None else 0,
1815
+ end_line=(int(args["end_line"]) if args.get("end_line") is not None else None),
1816
+ max_lines=(int(args["max_lines"]) if args.get("max_lines") is not None else 200),
1817
+ include_line_numbers=bool(args.get("include_line_numbers", True)),
1818
+ max_bytes=(int(args["max_bytes"]) if args.get("max_bytes") is not None else 48_000),
1819
+ )
1820
+ result = read_ledger.filter_result(
1821
+ path=path,
1822
+ result=result,
1823
+ content_hash_before=content_hash_before,
1824
+ force=args.get("force") is True,
1825
+ )
1826
+ return _mark_sensitive_result(result, sensitive)
1827
+
1828
+ _append_builtin_tool("fs_read_lines", run=_fs_read_lines)
1829
+
1830
+ def _fs_edit(args: dict[str, Any]) -> dict[str, Any]:
1831
+ path = _resolve_workspace_relative_path(
1832
+ tool_name="fs_edit",
1833
+ raw_path=args.get("path"),
1834
+ raw_base=args.get("path_base"),
1835
+ field_name="path",
1836
+ base_field_name="path_base",
1837
+ )
1838
+ _guard_write_path(path)
1839
+ raw_edits = args.get("edits")
1840
+ if not isinstance(raw_edits, list):
1841
+ raise FsError("edits must be a non-empty array of edit objects")
1842
+ sensitive_findings = _sensitive_path_findings([path])
1843
+ stamped_precondition = (
1844
+ capture_file_precondition(root=root, path=path) if sensitive_findings else None
1845
+ )
1846
+ sensitive = guard_sensitive_files("fs_edit", files=[path])
1847
+ if stamped_precondition is not None:
1848
+ try:
1849
+ assert_file_precondition(root=root, precondition=stamped_precondition)
1850
+ except StaleFileError as error:
1851
+ return _stale_file_result(error)
1852
+ try:
1853
+ prepared = prepare_fs_edit(root=root, path=path, edits=raw_edits)
1854
+ except FsError:
1855
+ if sensitive_findings:
1856
+ try:
1857
+ assert_file_precondition(root=root, precondition=stamped_precondition)
1858
+ except StaleFileError as error:
1859
+ return _stale_file_result(error)
1860
+ raise AgentRuntimeError(
1861
+ "Sensitive file edit could not be prepared; content details were redacted."
1862
+ ) from None
1863
+ raise
1864
+ if stamped_precondition is not None:
1865
+ if prepared.precondition != stamped_precondition:
1866
+ return _stale_file_result(StaleFileError(path))
1867
+ prepared = replace(prepared, precondition=stamped_precondition)
1868
+ if sensitive:
1869
+ store.append(
1870
+ "sensitive_change_preview",
1871
+ {"path": path, "operation": "fs_edit", "content_redacted": True},
1872
+ )
1873
+ else:
1874
+ diff = _unified_diff(prepared.original_content, prepared.updated_content, path)
1875
+ store.append("diff_preview", {"path": path, "diff": diff[:20000]})
1876
+ surface.on_patch_generated(
1877
+ PatchEvent(
1878
+ files=[path],
1879
+ diff=diff,
1880
+ summary=f"1 file changed via fs_edit ({path})",
1881
+ )
1882
+ )
1883
+ guard_write("fs_edit", diff[:20000] or f"(no diff) {path}", files=[path])
1884
+ try:
1885
+ result = write_prepared_fs_edit(prepared, root=root)
1886
+ except StaleFileError as error:
1887
+ return _stale_file_result(error)
1888
+ read_ledger.invalidate(path)
1889
+ return _mark_sensitive_result(result, sensitive)
1890
+
1891
+ _append_builtin_tool("fs_edit", run=_fs_edit)
1892
+
1893
+ def _fs_move(args: dict[str, Any]) -> dict[str, Any]:
1894
+ source_path = _resolve_workspace_relative_path(
1895
+ tool_name="fs_move",
1896
+ raw_path=args.get("source_path"),
1897
+ raw_base=args.get("source_path_base"),
1898
+ field_name="source_path",
1899
+ base_field_name="source_path_base",
1900
+ )
1901
+ destination_path = _resolve_workspace_relative_path(
1902
+ tool_name="fs_move",
1903
+ raw_path=args.get("destination_path"),
1904
+ raw_base=args.get("destination_path_base"),
1905
+ field_name="destination_path",
1906
+ base_field_name="destination_path_base",
1907
+ )
1908
+ _guard_write_path(source_path)
1909
+ _guard_write_path(destination_path)
1910
+ overwrite = bool(args.get("overwrite", False))
1911
+ source_precondition = capture_file_precondition(root=root, path=source_path)
1912
+ destination_precondition = capture_file_precondition(root=root, path=destination_path)
1913
+ sensitive = guard_sensitive_files("fs_move", files=[source_path, destination_path])
1914
+ preview = (
1915
+ "Move file\n"
1916
+ f"source: {source_path}\n"
1917
+ f"destination: {destination_path}\n"
1918
+ f"overwrite: {str(overwrite).lower()}"
1919
+ )
1920
+ if not sensitive:
1921
+ guard_write("fs_move", preview, files=[source_path, destination_path])
1922
+ try:
1923
+ result = fs_move(
1924
+ root=root,
1925
+ source_path=source_path,
1926
+ destination_path=destination_path,
1927
+ overwrite=overwrite,
1928
+ source_precondition=source_precondition,
1929
+ destination_precondition=destination_precondition,
1930
+ )
1931
+ except StaleFileError as error:
1932
+ return _stale_file_result(error)
1933
+ read_ledger.invalidate(source_path, destination_path)
1934
+ return _mark_sensitive_result(result, sensitive)
1935
+
1936
+ _append_builtin_tool("fs_move", run=_fs_move)
1937
+
1938
+ def _fs_copy(args: dict[str, Any]) -> dict[str, Any]:
1939
+ source_path = _resolve_workspace_relative_path(
1940
+ tool_name="fs_copy",
1941
+ raw_path=args.get("source_path"),
1942
+ raw_base=args.get("source_path_base"),
1943
+ field_name="source_path",
1944
+ base_field_name="source_path_base",
1945
+ )
1946
+ destination_path = _resolve_workspace_relative_path(
1947
+ tool_name="fs_copy",
1948
+ raw_path=args.get("destination_path"),
1949
+ raw_base=args.get("destination_path_base"),
1950
+ field_name="destination_path",
1951
+ base_field_name="destination_path_base",
1952
+ )
1953
+ _guard_write_path(destination_path)
1954
+ overwrite = bool(args.get("overwrite", False))
1955
+ source_precondition = capture_file_precondition(root=root, path=source_path)
1956
+ destination_precondition = capture_file_precondition(root=root, path=destination_path)
1957
+ sensitive = guard_sensitive_files("fs_copy", files=[source_path, destination_path])
1958
+ preview = (
1959
+ "Copy file\n"
1960
+ f"source: {source_path}\n"
1961
+ f"destination: {destination_path}\n"
1962
+ f"overwrite: {str(overwrite).lower()}"
1963
+ )
1964
+ if not sensitive:
1965
+ guard_write("fs_copy", preview, files=[source_path, destination_path])
1966
+ try:
1967
+ result = fs_copy(
1968
+ root=root,
1969
+ source_path=source_path,
1970
+ destination_path=destination_path,
1971
+ overwrite=overwrite,
1972
+ source_precondition=source_precondition,
1973
+ destination_precondition=destination_precondition,
1974
+ )
1975
+ except StaleFileError as error:
1976
+ return _stale_file_result(error)
1977
+ read_ledger.invalidate(destination_path)
1978
+ return _mark_sensitive_result(result, sensitive)
1979
+
1980
+ _append_builtin_tool("fs_copy", run=_fs_copy)
1981
+
1982
+ def _fs_delete(args: dict[str, Any]) -> dict[str, Any]:
1983
+ path = _resolve_workspace_relative_path(
1984
+ tool_name="fs_delete",
1985
+ raw_path=args.get("path"),
1986
+ raw_base=args.get("path_base"),
1987
+ field_name="path",
1988
+ base_field_name="path_base",
1989
+ )
1990
+ try:
1991
+ _guard_write_path(path)
1992
+ except AgentRuntimeError as exc:
1993
+ if (
1994
+ persona_write_scope_active
1995
+ or "outside allowed scope" not in str(exc)
1996
+ or not is_non_material_untracked_path(path)
1997
+ ):
1998
+ raise
1999
+ precondition = capture_file_precondition(root=root, path=path)
2000
+ sensitive = guard_sensitive_files("fs_delete", files=[path])
2001
+ preview = f"Delete file\npath: {path}"
2002
+ if not sensitive:
2003
+ guard_write("fs_delete", preview, files=[path])
2004
+ try:
2005
+ result = fs_delete(root=root, path=path, precondition=precondition)
2006
+ except StaleFileError as error:
2007
+ return _stale_file_result(error)
2008
+ read_ledger.invalidate(path)
2009
+ return _mark_sensitive_result(result, sensitive)
2010
+
2011
+ _append_builtin_tool("fs_delete", run=_fs_delete)
2012
+
2013
+ def _fs_write(args: dict[str, Any]) -> dict[str, Any]:
2014
+ path = _resolve_workspace_relative_path(
2015
+ tool_name="fs_write",
2016
+ raw_path=args.get("path"),
2017
+ raw_base=args.get("path_base"),
2018
+ field_name="path",
2019
+ base_field_name="path_base",
2020
+ )
2021
+ _guard_write_path(path)
2022
+ content = str(args.get("content", ""))
2023
+ prepared = prepare_fs_write(root=root, path=path, content=content)
2024
+ sensitive = guard_sensitive_files("fs_write", files=[path])
2025
+ rewrite_warning: str | None = None
2026
+ if sensitive:
2027
+ store.append(
2028
+ "sensitive_change_preview",
2029
+ {"path": path, "operation": "fs_write", "content_redacted": True},
2030
+ )
2031
+ else:
2032
+ if prepared.precondition.exists:
2033
+ old = _patchable("fs_read", fs_read)(root=root, path=path, max_bytes=2_000_000)[
2034
+ "content"
2035
+ ]
2036
+ else:
2037
+ old = ""
2038
+ if edit_discipline is not None and old:
2039
+ # Free: `old` is already in hand for the diff preview below, so
2040
+ # scoring the overwrite costs no I/O and never scans the
2041
+ # workspace. Advisory only -- the write proceeds either way, and
2042
+ # a guard that raised would be a guard that broke writes.
2043
+ try:
2044
+ rewrite_warning = edit_discipline.warn_for_write(
2045
+ path=path, original=old, updated=content
2046
+ )
2047
+ except Exception: # noqa: BLE001 - advice must never fail a write
2048
+ rewrite_warning = None
2049
+ diff = _unified_diff(old, content, path)
2050
+ store.append("diff_preview", {"path": path, "diff": diff[:20000]})
2051
+ surface.on_patch_generated(
2052
+ PatchEvent(
2053
+ files=[path],
2054
+ diff=diff,
2055
+ summary=f"1 file changed via fs_write ({path})",
2056
+ )
2057
+ )
2058
+ guard_write("fs_write", diff[:20000] or f"(no diff) {path}", files=[path])
2059
+ try:
2060
+ result = write_prepared_fs_write(prepared, root=root)
2061
+ except StaleFileError as error:
2062
+ return _stale_file_result(error)
2063
+ read_ledger.invalidate(path)
2064
+ if rewrite_warning is not None:
2065
+ # Attached only after the write succeeded, so the model never reads
2066
+ # advice about an overwrite that did not happen.
2067
+ result["warning"] = rewrite_warning
2068
+ return _mark_sensitive_result(result, sensitive)
2069
+
2070
+ _append_builtin_tool("fs_write", run=_fs_write)
2071
+
2072
+ def _fs_mkdir(args: dict[str, Any]) -> dict[str, Any]:
2073
+ path = _resolve_workspace_relative_path(
2074
+ tool_name="fs_mkdir",
2075
+ raw_path=args.get("path"),
2076
+ raw_base=args.get("path_base"),
2077
+ field_name="path",
2078
+ base_field_name="path_base",
2079
+ )
2080
+ if _is_allowed_ancestor_dir_creation(path):
2081
+ if _is_denied_path(path):
2082
+ raise AgentRuntimeError(f"Blocked write to protected path: {path}")
2083
+ else:
2084
+ _guard_write_path(path)
2085
+ parents = bool(args.get("parents", True))
2086
+ exist_ok = bool(args.get("exist_ok", True))
2087
+ preview = (
2088
+ "Create directory\n"
2089
+ f"path: {path}\n"
2090
+ f"parents: {str(parents).lower()}\n"
2091
+ f"exist_ok: {str(exist_ok).lower()}"
2092
+ )
2093
+ guard_write("fs_mkdir", preview, files=[path])
2094
+ return fs_mkdir(
2095
+ root=root,
2096
+ path=path,
2097
+ parents=parents,
2098
+ exist_ok=exist_ok,
2099
+ )
2100
+
2101
+ _append_builtin_tool("fs_mkdir", run=_fs_mkdir)
2102
+
2103
+ if cfg is not None and cfg.image_generation.enabled:
2104
+
2105
+ def _image_generate(args: dict[str, Any]) -> dict[str, Any]:
2106
+ try:
2107
+ count = int(args.get("count", 1))
2108
+ planned = plan_image_output_paths(
2109
+ root=root,
2110
+ output_path=str(args.get("output_path") or ""),
2111
+ count=count,
2112
+ )
2113
+ except (ImageGenerationError, TypeError, ValueError) as exc:
2114
+ raise AgentRuntimeError(f"Invalid image generation request: {exc}") from exc
2115
+ relative_paths = [relative for _path, relative in planned]
2116
+ for relative_path in relative_paths:
2117
+ _guard_write_path(relative_path)
2118
+ preview = (
2119
+ "Generate image asset(s)\n"
2120
+ f"model: {cfg.image_generation.model}\n"
2121
+ f"paths: {', '.join(relative_paths)}\n"
2122
+ f"count: {count}\n"
2123
+ f"size: {str(args.get('size') or 'auto')}\n"
2124
+ f"quality: {str(args.get('quality') or 'auto')}\n"
2125
+ f"background: {str(args.get('background') or 'auto')}"
2126
+ )
2127
+ guard_write("image_generate", preview, files=relative_paths)
2128
+ try:
2129
+ result = generate_images(
2130
+ root=root,
2131
+ cfg=cfg,
2132
+ fallback_api_key=api_key,
2133
+ prompt=str(args.get("prompt") or ""),
2134
+ output_path=str(args.get("output_path") or ""),
2135
+ count=count,
2136
+ size=str(args.get("size") or "auto"),
2137
+ quality=str(args.get("quality") or "auto"),
2138
+ background=str(args.get("background") or "auto"),
2139
+ timeout_s=_deadline_timeout(
2140
+ cfg.image_generation.timeout_s,
2141
+ operation="image_generate",
2142
+ ),
2143
+ )
2144
+ except (ImageGenerationError, DeadlineExhausted) as exc:
2145
+ raise AgentRuntimeError(f"Image generation failed: {exc}") from exc
2146
+ store.append("image_generated", dict(result))
2147
+ return result
2148
+
2149
+ _append_builtin_tool("image_generate", run=_image_generate)
2150
+
2151
+ _append_builtin_tool(
2152
+ "fs_list",
2153
+ run=lambda args: _patchable("fs_list", fs_list)(
2154
+ root=root,
2155
+ root_path=_resolve_workspace_relative_path(
2156
+ tool_name="fs_list",
2157
+ raw_path=args.get("root_path"),
2158
+ raw_base=args.get("path_base"),
2159
+ field_name="root_path",
2160
+ base_field_name="path_base",
2161
+ allow_empty=True,
2162
+ ),
2163
+ globs=args.get("globs"),
2164
+ ignore=args.get("ignore"),
2165
+ ),
2166
+ )
2167
+
2168
+ def _managed_browser_cancelled() -> bool:
2169
+ if managed_browser_cancel_check is None:
2170
+ return False
2171
+ try:
2172
+ return bool(managed_browser_cancel_check())
2173
+ except Exception: # noqa: BLE001 - a broken host token fails closed
2174
+ return True
2175
+
2176
+ def _browser_public_url(raw_url: Any) -> str | None:
2177
+ value = str(raw_url or "").strip()
2178
+ if not value:
2179
+ return None
2180
+ try:
2181
+ split = urlsplit(value)
2182
+ scheme = split.scheme.lower()
2183
+ hostname = split.hostname
2184
+ if scheme not in {"http", "https"} or not hostname:
2185
+ return None
2186
+ host = f"[{hostname}]" if ":" in hostname else hostname
2187
+ if split.port is not None:
2188
+ host = f"{host}:{split.port}"
2189
+ public_url = urlunsplit((scheme, host, split.path or "/", "", ""))
2190
+ except (TypeError, ValueError):
2191
+ return None
2192
+ return str(redact_secrets(public_url))
2193
+
2194
+ def _browser_status_payload(status: BrowserSessionStatus | Any) -> dict[str, Any]:
2195
+ if isinstance(status, dict):
2196
+ source = status
2197
+ else:
2198
+ source = {
2199
+ key: getattr(status, key, None)
2200
+ for key in (
2201
+ "session_id",
2202
+ "product",
2203
+ "state",
2204
+ "created_at",
2205
+ "active_url",
2206
+ "artifact_count",
2207
+ )
2208
+ }
2209
+ return {
2210
+ "session_id": str(source.get("session_id") or ""),
2211
+ "product": str(source.get("product") or ""),
2212
+ "state": str(source.get("state") or ""),
2213
+ "created_at": source.get("created_at"),
2214
+ "active_url": _browser_public_url(source.get("active_url")),
2215
+ "artifact_count": int(source.get("artifact_count") or 0),
2216
+ }
2217
+
2218
+ def _browser_action_preview(kind: str, args: dict[str, Any]) -> str:
2219
+ session_id = str(args.get("session_id") or "").strip()
2220
+ lines = [f"Managed browser action: {kind}"]
2221
+ if session_id:
2222
+ lines.append(f"session: {session_id[:80]}")
2223
+ if kind == "browser_navigate":
2224
+ target = _browser_public_url(args.get("url"))
2225
+ lines.append(f"public target: {target or '(invalid URL)'}")
2226
+ lines.append("query parameters and fragments are omitted from this preview")
2227
+ elif kind in {"browser_click", "browser_type"}:
2228
+ selector = str(args.get("selector") or "")
2229
+ lines.append(f"selector: {selector[:300]}")
2230
+ if kind == "browser_type":
2231
+ lines.append(f"input characters: {len(str(args.get('text') or ''))}")
2232
+ lines.append("input text is intentionally omitted")
2233
+ elif kind == "browser_start":
2234
+ target = _browser_public_url(args.get("url"))
2235
+ if target:
2236
+ lines.append(f"initial target: {target}")
2237
+ lines.append("query parameters and fragments are omitted from this preview")
2238
+ lines.append("network policy: public or session-owned preview destinations")
2239
+ return "\n".join(lines)
2240
+
2241
+ def _guard_managed_browser_action(kind: str, args: dict[str, Any]) -> None:
2242
+ if not host_managed_approvals:
2243
+ raise AgentRuntimeError(
2244
+ "Managed browser state changes require an IDE host-managed approval."
2245
+ )
2246
+ decision = surface.request_approval(
2247
+ ApprovalRequest(
2248
+ kind=kind,
2249
+ reason="managed browser actions can launch processes or change remote page state",
2250
+ preview=_browser_action_preview(kind, args),
2251
+ command=kind,
2252
+ metadata={
2253
+ "managed_browser": True,
2254
+ "mandatory_explicit_approval": True,
2255
+ "allow_for_session_disabled": True,
2256
+ "public_or_owned_preview_destinations": True,
2257
+ },
2258
+ allow_for_session_scope=None,
2259
+ )
2260
+ )
2261
+ if not decision.allow:
2262
+ raise ApprovalDeclinedError(kind)
2263
+ if decision.allow_for_session:
2264
+ raise AgentRuntimeError(
2265
+ "Managed browser state changes require a one-time host approval for each action."
2266
+ )
2267
+
2268
+ def _run_managed_browser(operation: Callable[[], Any]) -> Any:
2269
+ try:
2270
+ return operation()
2271
+ except BrowserError as exc:
2272
+ raise AgentRuntimeError(str(exc)) from exc
2273
+ except Exception as exc: # noqa: BLE001 - do not expose provider or page secrets
2274
+ raise AgentRuntimeError("Managed browser operation failed safely.") from exc
2275
+
2276
+ allowed_child_browser_tools = {
2277
+ str(name or "").strip() for name in (child_managed_browser_tool_names or ())
2278
+ }
2279
+ browser_tools_enabled = managed_browser_service is not None and (
2280
+ subagent_depth == 0 or (subagent_depth == 1 and bool(allowed_child_browser_tools))
2281
+ )
2282
+
2283
+ def _session_owned_preview_urls() -> tuple[str, ...]:
2284
+ if durable_service_manager is None:
2285
+ return ()
2286
+ try:
2287
+ active_services = durable_service_manager.list_active()
2288
+ except Exception:
2289
+ return ()
2290
+ urls: list[str] = []
2291
+ for service in active_services:
2292
+ if not isinstance(service, dict):
2293
+ continue
2294
+ candidates = [service.get("preview_url")]
2295
+ raw_urls = service.get("preview_urls")
2296
+ if isinstance(raw_urls, list):
2297
+ candidates.extend(raw_urls)
2298
+ for raw in candidates:
2299
+ value = str(raw or "").strip()
2300
+ if value and value not in urls:
2301
+ urls.append(value)
2302
+ if len(urls) >= 64:
2303
+ return tuple(urls)
2304
+ return tuple(urls)
2305
+
2306
+ if browser_tools_enabled:
2307
+ browser_owner_id = str(managed_browser_owner_id or store.session_id).strip()
2308
+
2309
+ def _browser_is_direct_only(status: BrowserSessionStatus | Any) -> bool:
2310
+ if isinstance(status, dict):
2311
+ return bool(status.get("allow_local_destinations"))
2312
+ return bool(getattr(status, "allow_local_destinations", False))
2313
+
2314
+ def _browser_require_agent_visible(session_id: str) -> BrowserSessionStatus | Any:
2315
+ """Fence direct localhost sessions away from model-controlled tools.
2316
+
2317
+ ``allow_local_destinations`` is immutable for a managed-browser
2318
+ session, so checking the owner-scoped status before every action is
2319
+ a stable actor boundary rather than a best-effort URL check.
2320
+ """
2321
+
2322
+ status = _run_managed_browser(
2323
+ lambda: managed_browser_service.status(browser_owner_id, session_id)
2324
+ )
2325
+ if _browser_is_direct_only(status):
2326
+ raise AgentRuntimeError(
2327
+ "This browser session is reserved for direct IDE localhost testing."
2328
+ )
2329
+ return status
2330
+
2331
+ def _browser_start(args: dict[str, Any]) -> dict[str, Any]:
2332
+ _guard_managed_browser_action("browser_start", args)
2333
+ status = _run_managed_browser(
2334
+ lambda: managed_browser_service.start(
2335
+ browser_owner_id,
2336
+ allow_local_destinations=False,
2337
+ allowed_preview_urls_provider=_session_owned_preview_urls,
2338
+ cancel=_managed_browser_cancelled,
2339
+ )
2340
+ )
2341
+ payload = _browser_status_payload(status)
2342
+ target = str(args.get("url") or "").strip()
2343
+ if not target:
2344
+ return payload
2345
+ session_id = str(payload.get("session_id") or "")
2346
+ try:
2347
+ navigated = _run_managed_browser(
2348
+ lambda: managed_browser_service.navigate(
2349
+ browser_owner_id,
2350
+ session_id,
2351
+ target,
2352
+ timeout=args.get("timeout"),
2353
+ cancel=_managed_browser_cancelled,
2354
+ )
2355
+ )
2356
+ except Exception:
2357
+ try:
2358
+ managed_browser_service.close(
2359
+ browser_owner_id,
2360
+ session_id,
2361
+ delete_artifacts=True,
2362
+ )
2363
+ except Exception:
2364
+ pass
2365
+ raise
2366
+ navigated_payload = dict(navigated) if isinstance(navigated, dict) else {}
2367
+ payload["active_url"] = _browser_public_url(navigated_payload.get("url") or target)
2368
+ return payload
2369
+
2370
+ def _browser_navigate(args: dict[str, Any]) -> dict[str, Any]:
2371
+ session_id = str(args.get("session_id") or "")
2372
+ _browser_require_agent_visible(session_id)
2373
+ _guard_managed_browser_action("browser_navigate", args)
2374
+ result = _run_managed_browser(
2375
+ lambda: managed_browser_service.navigate(
2376
+ browser_owner_id,
2377
+ session_id,
2378
+ str(args.get("url") or ""),
2379
+ timeout=args.get("timeout"),
2380
+ cancel=_managed_browser_cancelled,
2381
+ )
2382
+ )
2383
+ payload = dict(result) if isinstance(result, dict) else {}
2384
+ payload["url"] = _browser_public_url(payload.get("url") or args.get("url"))
2385
+ return payload
2386
+
2387
+ def _browser_snapshot(args: dict[str, Any]) -> dict[str, Any]:
2388
+ session_id = str(args.get("session_id") or "")
2389
+ _browser_require_agent_visible(session_id)
2390
+ result = _run_managed_browser(
2391
+ lambda: managed_browser_service.snapshot(
2392
+ browser_owner_id,
2393
+ session_id,
2394
+ kind=str(args.get("kind") or "semantic"),
2395
+ timeout=args.get("timeout"),
2396
+ cancel=_managed_browser_cancelled,
2397
+ )
2398
+ )
2399
+ return dict(result) if isinstance(result, dict) else {"data": result}
2400
+
2401
+ def _browser_screenshot(args: dict[str, Any]) -> dict[str, Any]:
2402
+ session_id = str(args.get("session_id") or "")
2403
+ _browser_require_agent_visible(session_id)
2404
+ artifact = _run_managed_browser(
2405
+ lambda: managed_browser_service.screenshot(
2406
+ browser_owner_id,
2407
+ session_id,
2408
+ full_page=bool(args.get("full_page", False)),
2409
+ timeout=args.get("timeout"),
2410
+ cancel=_managed_browser_cancelled,
2411
+ )
2412
+ )
2413
+ if not isinstance(artifact, BrowserArtifact) and not hasattr(artifact, "artifact_id"):
2414
+ raise AgentRuntimeError("Managed browser returned an invalid artifact.")
2415
+ return {
2416
+ "artifact_id": str(artifact.artifact_id),
2417
+ "media_type": str(artifact.media_type),
2418
+ "size_bytes": int(artifact.size_bytes),
2419
+ "sha256": str(artifact.sha256),
2420
+ }
2421
+
2422
+ def _browser_artifact_read(args: dict[str, Any]) -> dict[str, Any]:
2423
+ session_id = str(args.get("session_id") or "")
2424
+ _browser_require_agent_visible(session_id)
2425
+ result = _run_managed_browser(
2426
+ lambda: managed_browser_service.read_artifact(
2427
+ browser_owner_id,
2428
+ session_id,
2429
+ str(args.get("artifact_id") or ""),
2430
+ offset=args.get("offset", 0),
2431
+ max_bytes=args.get("max_bytes", 256 * 1024),
2432
+ )
2433
+ )
2434
+ return dict(result) if isinstance(result, dict) else {"data": result}
2435
+
2436
+ def _browser_diagnostics(args: dict[str, Any]) -> dict[str, Any]:
2437
+ session_id = str(args.get("session_id") or "")
2438
+ _browser_require_agent_visible(session_id)
2439
+ result = _run_managed_browser(
2440
+ lambda: managed_browser_service.diagnostics(
2441
+ browser_owner_id,
2442
+ session_id,
2443
+ max_events=args.get("max_events"),
2444
+ timeout=args.get("timeout"),
2445
+ cancel=_managed_browser_cancelled,
2446
+ )
2447
+ )
2448
+ return dict(result) if isinstance(result, dict) else {"data": result}
2449
+
2450
+ def _browser_click(args: dict[str, Any]) -> dict[str, Any]:
2451
+ session_id = str(args.get("session_id") or "")
2452
+ _browser_require_agent_visible(session_id)
2453
+ _guard_managed_browser_action("browser_click", args)
2454
+ result = _run_managed_browser(
2455
+ lambda: managed_browser_service.click(
2456
+ browser_owner_id,
2457
+ session_id,
2458
+ str(args.get("selector") or ""),
2459
+ timeout=args.get("timeout"),
2460
+ cancel=_managed_browser_cancelled,
2461
+ )
2462
+ )
2463
+ return dict(result) if isinstance(result, dict) else {"clicked": bool(result)}
2464
+
2465
+ def _browser_type(args: dict[str, Any]) -> dict[str, Any]:
2466
+ session_id = str(args.get("session_id") or "")
2467
+ _browser_require_agent_visible(session_id)
2468
+ _guard_managed_browser_action("browser_type", args)
2469
+ result = _run_managed_browser(
2470
+ lambda: managed_browser_service.type_text(
2471
+ browser_owner_id,
2472
+ session_id,
2473
+ str(args.get("selector") or ""),
2474
+ str(args.get("text") or ""),
2475
+ replace=bool(args.get("replace", True)),
2476
+ timeout=args.get("timeout"),
2477
+ cancel=_managed_browser_cancelled,
2478
+ )
2479
+ )
2480
+ payload = dict(result) if isinstance(result, dict) else {"typed": bool(result)}
2481
+ payload.pop("text", None)
2482
+ return payload
2483
+
2484
+ def _browser_status(args: dict[str, Any]) -> dict[str, Any]:
2485
+ status = _browser_require_agent_visible(str(args.get("session_id") or ""))
2486
+ return _browser_status_payload(status)
2487
+
2488
+ def _browser_list(_args: dict[str, Any]) -> dict[str, Any]:
2489
+ statuses = _run_managed_browser(lambda: managed_browser_service.list(browser_owner_id))
2490
+ items = [
2491
+ _browser_status_payload(status)
2492
+ for status in tuple(statuses)
2493
+ if not _browser_is_direct_only(status)
2494
+ ]
2495
+ return {"sessions": items, "count": len(items)}
2496
+
2497
+ def _browser_close(args: dict[str, Any]) -> dict[str, Any]:
2498
+ session_id = str(args.get("session_id") or "")
2499
+ _browser_require_agent_visible(session_id)
2500
+ _guard_managed_browser_action("browser_close", args)
2501
+ closed = _run_managed_browser(
2502
+ lambda: managed_browser_service.close(
2503
+ browser_owner_id,
2504
+ session_id,
2505
+ # Browser screenshots are ephemeral owner-scoped artifacts.
2506
+ # The service has no safe retained-session read surface, so
2507
+ # close must not leave unreachable private files behind.
2508
+ delete_artifacts=True,
2509
+ )
2510
+ )
2511
+ return {"session_id": session_id, "closed": bool(closed)}
2512
+
2513
+ def _append_browser_tool(
2514
+ name: str,
2515
+ *,
2516
+ run: Callable[[dict[str, Any]], dict[str, Any]],
2517
+ ) -> None:
2518
+ if subagent_depth == 1 and name not in allowed_child_browser_tools:
2519
+ return
2520
+ _append_builtin_tool(name, run=run)
2521
+
2522
+ _append_browser_tool("browser_start", run=_browser_start)
2523
+ _append_browser_tool("browser_navigate", run=_browser_navigate)
2524
+ _append_browser_tool("browser_snapshot", run=_browser_snapshot)
2525
+ _append_browser_tool("browser_screenshot", run=_browser_screenshot)
2526
+ _append_browser_tool("browser_artifact_read", run=_browser_artifact_read)
2527
+ _append_browser_tool("browser_diagnostics", run=_browser_diagnostics)
2528
+ _append_browser_tool("browser_click", run=_browser_click)
2529
+ _append_browser_tool("browser_type", run=_browser_type)
2530
+ _append_browser_tool("browser_status", run=_browser_status)
2531
+ _append_browser_tool("browser_list", run=_browser_list)
2532
+ _append_browser_tool("browser_close", run=_browser_close)
2533
+
2534
+ # Master web-tools switch: when off (config field or ALYSIS_WEB_TOOLS env),
2535
+ # neither web_fetch nor web_search is registered at all — the model never sees
2536
+ # them in its tool list. Required for benchmark/offline integrity.
2537
+ web_tools_enabled = resolve_web_tools_enabled(cfg)
2538
+
2539
+ web_search_exposed_in_mode = (
2540
+ web_tools_enabled
2541
+ and _built_in_tool_exposed_in_mode(
2542
+ tool_name="web_search",
2543
+ mode=mode,
2544
+ subagent_depth=subagent_depth,
2545
+ readonly_child_web_tool_names=readonly_child_web_tool_names,
2546
+ )
2547
+ and resolve_web_search_policy(cfg) != "off"
2548
+ )
2549
+ web_search_status = (
2550
+ resolve_web_search_runtime_status(cfg=cfg, api_key=api_key)
2551
+ if web_search_exposed_in_mode
2552
+ else None
2553
+ )
2554
+
2555
+ def _web_fetch_recovery_is_public_candidate(raw_url: str) -> bool:
2556
+ normalized = normalize_web_url(raw_url)
2557
+ if normalized is None:
2558
+ return False
2559
+ try:
2560
+ split = urlsplit(normalized)
2561
+ except ValueError:
2562
+ return False
2563
+ host = (split.hostname or "").rstrip(".").lower()
2564
+ if not host or host == "localhost" or host.endswith(".localhost"):
2565
+ return False
2566
+ if split.username is not None or split.password is not None:
2567
+ return False
2568
+ try:
2569
+ ip = ipaddress.ip_address(host)
2570
+ except ValueError:
2571
+ return True
2572
+ return not (
2573
+ ip.is_loopback
2574
+ or ip.is_private
2575
+ or ip.is_link_local
2576
+ or ip.is_multicast
2577
+ or ip.is_unspecified
2578
+ or ip.is_reserved
2579
+ )
2580
+
2581
+ def _web_fetch_source_matches_requested(*, source_url: str, requested_url: str) -> bool:
2582
+ source = normalize_web_url(source_url)
2583
+ requested = normalize_web_url(requested_url)
2584
+ if source is None or requested is None:
2585
+ return False
2586
+ if source == requested:
2587
+ return True
2588
+ source_split = urlsplit(source)
2589
+ requested_split = urlsplit(requested)
2590
+ if (
2591
+ source_split.scheme,
2592
+ source_split.netloc,
2593
+ source_split.query,
2594
+ ) != (
2595
+ requested_split.scheme,
2596
+ requested_split.netloc,
2597
+ requested_split.query,
2598
+ ):
2599
+ return False
2600
+ return source_split.path.rstrip("/") == requested_split.path.rstrip("/")
2601
+
2602
+ def _web_fetch_recovery_display_url(raw_url: str) -> str:
2603
+ normalized = normalize_web_url(raw_url)
2604
+ if normalized is not None:
2605
+ return normalized
2606
+ canonical = canonicalize_web_url_input(raw_url)
2607
+ if canonical is not None:
2608
+ return canonical
2609
+ try:
2610
+ split = urlsplit(str(raw_url or "").strip())
2611
+ port = split.port
2612
+ except ValueError:
2613
+ return "[invalid URL omitted]"
2614
+ scheme = str(split.scheme or "").lower()
2615
+ host = (split.hostname or "").rstrip(".").lower()
2616
+ if scheme not in {"http", "https"} or not host:
2617
+ return "[unsupported URL omitted]"
2618
+ if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
2619
+ port = None
2620
+ netloc = host if port is None else f"{host}:{port}"
2621
+ path = split.path or "/"
2622
+ return urlunsplit((scheme, netloc, path, split.query, ""))
2623
+
2624
+ def _web_fetch_recovery_payload(
2625
+ *,
2626
+ requested_url: str,
2627
+ raw_requested_url: str,
2628
+ finalization_suppressed: bool,
2629
+ automatic_attempted: bool = False,
2630
+ search_error: str = "",
2631
+ ) -> dict[str, Any]:
2632
+ display_url = _web_fetch_recovery_display_url(requested_url)
2633
+ query = build_web_fetch_recovery_search_query(display_url)
2634
+ payload: dict[str, Any] = {
2635
+ "error": (
2636
+ "web_fetch only allows a URL explicitly provided by the user or one returned "
2637
+ "by web_search earlier in this session."
2638
+ ),
2639
+ "error_code": "web_fetch_provenance_required",
2640
+ "url": display_url,
2641
+ "allowed_provenance": [
2642
+ "user_provided",
2643
+ "returned_by_web_search",
2644
+ "fetched_page_link",
2645
+ "trusted_local_file",
2646
+ "trusted_tool_output",
2647
+ "canonical_redirect",
2648
+ "search_mediated_recovery",
2649
+ "same_origin_derived_search_result",
2650
+ ],
2651
+ "provenance_recovery": {
2652
+ "suggested_search_query": query,
2653
+ "web_search_available": bool(
2654
+ web_search_status is not None and web_search_status.registration_ready
2655
+ ),
2656
+ "automatic_recovery_attempted": automatic_attempted,
2657
+ "finalization_suppressed": finalization_suppressed,
2658
+ "search_error": search_error,
2659
+ },
2660
+ }
2661
+ canonical_raw = normalize_web_url(raw_requested_url)
2662
+ if canonical_raw is not None and canonical_raw != display_url:
2663
+ payload["raw_input_url"] = raw_requested_url
2664
+ return payload
2665
+
2666
+ def _maybe_establish_web_fetch_provenance_via_search(
2667
+ *,
2668
+ requested_url: str,
2669
+ raw_requested_url: str,
2670
+ ) -> tuple[str | None, str | None, dict[str, Any] | None]:
2671
+ finalization_suppressed = (
2672
+ execution_deadline is not None
2673
+ and execution_deadline.phase() == DeadlinePhase.FINALIZATION_WINDOW
2674
+ )
2675
+ base_payload = _web_fetch_recovery_payload(
2676
+ requested_url=requested_url,
2677
+ raw_requested_url=raw_requested_url,
2678
+ finalization_suppressed=finalization_suppressed,
2679
+ )
2680
+ if finalization_suppressed:
2681
+ return None, None, base_payload
2682
+ if web_search_status is None or not web_search_status.registration_ready:
2683
+ return None, None, base_payload
2684
+ if not _web_fetch_recovery_is_public_candidate(requested_url):
2685
+ return None, None, base_payload
2686
+ query = str(base_payload["provenance_recovery"]["suggested_search_query"] or "").strip()
2687
+ if not query:
2688
+ return None, None, base_payload
2689
+ try:
2690
+ host = (
2691
+ urlsplit(normalize_web_url(requested_url) or requested_url).hostname or ""
2692
+ ).lower()
2693
+ search_result = web_search(
2694
+ query=query,
2695
+ cfg=cfg,
2696
+ api_key=api_key,
2697
+ allowed_domains=[host] if host else None,
2698
+ max_sources=5,
2699
+ external_web_access=True,
2700
+ session_id=str(getattr(store, "session_id", "") or "") or None,
2701
+ )
2702
+ except WebSearchError as exc:
2703
+ return (
2704
+ None,
2705
+ None,
2706
+ _web_fetch_recovery_payload(
2707
+ requested_url=requested_url,
2708
+ raw_requested_url=raw_requested_url,
2709
+ finalization_suppressed=finalization_suppressed,
2710
+ automatic_attempted=True,
2711
+ search_error=str(exc),
2712
+ ),
2713
+ )
2714
+ matching_source_url = ""
2715
+ for source in list(search_result.get("sources") or []):
2716
+ if not isinstance(source, dict):
2717
+ continue
2718
+ source_url = str(source.get("url") or "").strip()
2719
+ if _web_fetch_source_matches_requested(
2720
+ source_url=source_url,
2721
+ requested_url=requested_url,
2722
+ ):
2723
+ matching_source_url = source_url
2724
+ break
2725
+ if not matching_source_url:
2726
+ payload = _web_fetch_recovery_payload(
2727
+ requested_url=requested_url,
2728
+ raw_requested_url=raw_requested_url,
2729
+ finalization_suppressed=finalization_suppressed,
2730
+ automatic_attempted=True,
2731
+ )
2732
+ payload["provenance_recovery"]["search_result_source_count"] = len(
2733
+ list(search_result.get("sources") or [])
2734
+ )
2735
+ return None, None, payload
2736
+ _changed, normalized = store.establish_search_mediated_web_fetch_url(
2737
+ raw_url=requested_url,
2738
+ query=query,
2739
+ source_url=matching_source_url,
2740
+ )
2741
+ store.append(
2742
+ "web_fetch_provenance_recovery",
2743
+ {
2744
+ "url": requested_url,
2745
+ "normalized_url": normalized,
2746
+ "query": query,
2747
+ "source_url": matching_source_url,
2748
+ "provenance_classification": "search_mediated_recovery",
2749
+ },
2750
+ )
2751
+ return store.resolve_web_fetch_url(requested_url)[0], normalized, None
2752
+
2753
+ def _web_fetch_tool(args: dict[str, Any]) -> dict[str, Any]:
2754
+ raw_requested_url = str(args.get("url", "")).strip()
2755
+ provenance_classification, resolved_requested_url = store.resolve_web_fetch_url(
2756
+ raw_requested_url
2757
+ )
2758
+ requested_url = resolved_requested_url or raw_requested_url
2759
+ recovered_via_search = False
2760
+ if provenance_classification is None:
2761
+ (
2762
+ provenance_classification,
2763
+ recovered_url,
2764
+ recovery_result,
2765
+ ) = _maybe_establish_web_fetch_provenance_via_search(
2766
+ requested_url=requested_url,
2767
+ raw_requested_url=raw_requested_url,
2768
+ )
2769
+ if provenance_classification is None:
2770
+ rejection = recovery_result or _web_fetch_recovery_payload(
2771
+ requested_url=requested_url,
2772
+ raw_requested_url=raw_requested_url,
2773
+ finalization_suppressed=False,
2774
+ )
2775
+ # Make the rejection self-correcting: tell the model exactly which
2776
+ # URLs it MAY fetch (prior trusted session evidence) so it retries
2777
+ # against a real source instead of a guessed/restated one — without
2778
+ # widening what is authorized.
2779
+ fetchable_urls = store.fetchable_web_fetch_urls()
2780
+ if fetchable_urls:
2781
+ rejection["fetchable_urls"] = fetchable_urls
2782
+ rejection["guidance"] = (
2783
+ "Do not guess or restate URLs from memory. Retry web_fetch with one of "
2784
+ "fetchable_urls (these came from prior trusted session evidence), or run "
2785
+ "web_search again to find the page."
2786
+ )
2787
+ else:
2788
+ rejection["guidance"] = (
2789
+ "No URLs are fetchable yet. Run web_search first, or ask the user for "
2790
+ "the exact URL. Do not guess URLs."
2791
+ )
2792
+ return rejection
2793
+ recovered_via_search = True
2794
+ if recovered_url:
2795
+ requested_url = recovered_url
2796
+ result = _patchable("web_fetch", web_fetch)(
2797
+ url=requested_url,
2798
+ max_chars=(args["max_chars"] if "max_chars" in args else 20000),
2799
+ )
2800
+ if recovered_via_search:
2801
+ result["provenance_classification"] = provenance_classification
2802
+ return result
2803
+
2804
+ if web_tools_enabled:
2805
+ _append_builtin_tool("web_fetch", run=_web_fetch_tool)
2806
+
2807
+ if web_search_exposed_in_mode and web_search_status is not None:
2808
+ if (
2809
+ emit_web_search_runtime_diagnostics
2810
+ and web_search_status.mode == "auto"
2811
+ and not web_search_status.registration_ready
2812
+ ):
2813
+ store.append("web_search_runtime_unavailable", web_search_status.to_payload())
2814
+
2815
+ if web_search_status.registration_ready:
2816
+ _append_builtin_tool(
2817
+ "web_search",
2818
+ run=lambda args: web_search(
2819
+ query=str(args.get("query", "")),
2820
+ cfg=cfg,
2821
+ api_key=api_key,
2822
+ allowed_domains=args.get("allowed_domains"),
2823
+ max_sources=(args["max_sources"] if "max_sources" in args else 8),
2824
+ external_web_access=(
2825
+ args["external_web_access"] if "external_web_access" in args else True
2826
+ ),
2827
+ session_id=str(getattr(store, "session_id", "") or "") or None,
2828
+ ),
2829
+ )
2830
+
2831
+ _append_builtin_tool(
2832
+ "symbol_search",
2833
+ run=lambda args: _patchable("symbol_search", symbol_search)(
2834
+ root=root,
2835
+ query=str(args.get("query", "")),
2836
+ kind=str(args["kind"]) if args.get("kind") is not None else None,
2837
+ root_path=_resolve_workspace_relative_path(
2838
+ tool_name="symbol_search",
2839
+ raw_path=args.get("root_path"),
2840
+ raw_base=args.get("path_base"),
2841
+ field_name="root_path",
2842
+ base_field_name="path_base",
2843
+ allow_empty=True,
2844
+ ),
2845
+ globs=args.get("globs"),
2846
+ max_results=(int(args["max_results"]) if args.get("max_results") is not None else 100),
2847
+ exact=bool(args.get("exact", False)),
2848
+ include_details=bool(args.get("include_details", False)),
2849
+ include_snippet=bool(args.get("include_snippet", False)),
2850
+ include_references=bool(args.get("include_references", False)),
2851
+ ),
2852
+ )
2853
+
2854
+ _append_builtin_tool(
2855
+ "test_discover",
2856
+ run=lambda args: _patchable("test_discover", test_discover)(
2857
+ root=root,
2858
+ paths=args.get("paths"),
2859
+ symbols=args.get("symbols"),
2860
+ changed_only=bool(args.get("changed_only", False)),
2861
+ include_commands=bool(args.get("include_commands", True)),
2862
+ max_results=(int(args["max_results"]) if args.get("max_results") is not None else 20),
2863
+ failure_summary=(
2864
+ args.get("failure_summary")
2865
+ if isinstance(args.get("failure_summary"), dict)
2866
+ else None
2867
+ ),
2868
+ ),
2869
+ )
2870
+
2871
+ _append_builtin_tool(
2872
+ "repo_map",
2873
+ run=lambda args: _patchable("repo_map", repo_map)(
2874
+ root=root,
2875
+ paths=args.get("paths"),
2876
+ symbols=args.get("symbols"),
2877
+ include_tests=bool(args.get("include_tests", True)),
2878
+ include_imports=bool(args.get("include_imports", True)),
2879
+ include_references=bool(args.get("include_references", False)),
2880
+ depth=(int(args["depth"]) if args.get("depth") is not None else 2),
2881
+ max_items=(int(args["max_items"]) if args.get("max_items") is not None else 80),
2882
+ ),
2883
+ )
2884
+
2885
+ _append_builtin_tool(
2886
+ "search_rg",
2887
+ run=lambda args: _patchable("search_rg", search_rg)(
2888
+ root=root,
2889
+ pattern=str(args.get("pattern", "")),
2890
+ root_path=_resolve_workspace_relative_path(
2891
+ tool_name="search_rg",
2892
+ raw_path=args.get("root_path"),
2893
+ raw_base=args.get("path_base"),
2894
+ field_name="root_path",
2895
+ base_field_name="path_base",
2896
+ allow_empty=True,
2897
+ ),
2898
+ globs=args.get("globs"),
2899
+ before_context=(
2900
+ int(args["before_context"]) if args.get("before_context") is not None else 0
2901
+ ),
2902
+ after_context=(
2903
+ int(args["after_context"]) if args.get("after_context") is not None else 0
2904
+ ),
2905
+ literal=bool(args.get("literal", False)),
2906
+ case_sensitive=bool(args.get("case_sensitive", True)),
2907
+ include_hidden=bool(args.get("include_hidden", False)),
2908
+ max_results=(int(args["max_results"]) if args.get("max_results") is not None else 200),
2909
+ ),
2910
+ )
2911
+
2912
+ if history_artifact_persistence_available:
2913
+
2914
+ def _session_artifact_read(args: dict[str, Any]) -> dict[str, Any]:
2915
+ locator = str(args.get("locator", ""))
2916
+ try:
2917
+ return session_artifact_read(
2918
+ artifact_layout=store.session_artifact_layout,
2919
+ locator=locator,
2920
+ max_bytes=args.get("max_bytes"),
2921
+ offset=args.get("offset"),
2922
+ )
2923
+ except SessionArtifactReadError as exc:
2924
+ payload = getattr(exc, "result_payload", None)
2925
+ if (
2926
+ isinstance(payload, dict)
2927
+ and payload.get("error_code") == "session_artifact_session_mismatch"
2928
+ ):
2929
+ store.append(
2930
+ "session_artifact_read_session_mismatch",
2931
+ {
2932
+ "locator": locator,
2933
+ "runtime_kind": resolved_runtime_kind.value,
2934
+ "terminal": True,
2935
+ },
2936
+ )
2937
+ raise
2938
+
2939
+ _append_builtin_tool(
2940
+ "session_artifact_read",
2941
+ run=_session_artifact_read,
2942
+ )
2943
+ _append_builtin_tool(
2944
+ "history_search",
2945
+ run=lambda args: history_search(
2946
+ root=root,
2947
+ session_id=store.session_id,
2948
+ session_artifact_root=store.session_artifact_root,
2949
+ pattern=str(args.get("pattern", "")),
2950
+ max_results=int(args.get("max_results") or 50),
2951
+ max_file_bytes=int(args.get("max_file_bytes") or 200000),
2952
+ include_history=bool(args.get("include_history", True)),
2953
+ include_tool_outputs=bool(args.get("include_tool_outputs", True)),
2954
+ include_memory=bool(args.get("include_memory", True)),
2955
+ ),
2956
+ )
2957
+
2958
+ if skills_enabled and resolved_skill_registry:
2959
+
2960
+ def _skill_read(args: dict[str, Any]) -> dict[str, Any]:
2961
+ raw_name = str(args.get("name", "")).strip()
2962
+ if not raw_name:
2963
+ return {"error": "Missing required argument: name"}
2964
+ skill = resolve_skill_by_name(resolved_skill_registry, raw_name)
2965
+ if skill is None:
2966
+ return {
2967
+ "error": f"Unknown skill: {raw_name}",
2968
+ "available_skills": sorted(
2969
+ skill.name for skill in resolved_skill_registry.values()
2970
+ ),
2971
+ }
2972
+ try:
2973
+ return read_skill_bundle_file(
2974
+ skill,
2975
+ path=(str(args["path"]) if args.get("path") is not None else None),
2976
+ )
2977
+ except SkillReadError as exc:
2978
+ return {
2979
+ "error": str(exc),
2980
+ "name": skill.name,
2981
+ "source_path": skill.source_path.as_posix(),
2982
+ }
2983
+
2984
+ _append_builtin_tool("skill_read", run=_skill_read)
2985
+
2986
+ verify_artifact_counter = 0
2987
+
2988
+ def _next_verify_artifact_path() -> Path:
2989
+ nonlocal verify_artifact_counter
2990
+ verify_artifact_counter += 1
2991
+ return store.runtime_artifact_path(
2992
+ "verify",
2993
+ f"step{verify_artifact_counter:03d}_verify_run.txt",
2994
+ )
2995
+
2996
+ def _workspace_services_before_verification() -> list[dict[str, Any]]:
2997
+ """Alysis Code-managed processes already alive in this workspace.
2998
+
2999
+ Reported, never acted on. A dev server holding a port or writing into
3000
+ the same build directory is a common source of confusing verification
3001
+ output, and the host cannot tell a genuine conflict from a deliberate
3002
+ setup. The agent decides whether it matters and stops anything through
3003
+ the normal approval path.
3004
+ """
3005
+
3006
+ services: list[dict[str, Any]] = []
3007
+ if terminal_manager is not None:
3008
+ try:
3009
+ for summary in terminal_manager.list():
3010
+ if summary.status != "running":
3011
+ continue
3012
+ services.append(
3013
+ {
3014
+ "kind": "background_process",
3015
+ "process_id": summary.process_id,
3016
+ "command": summary.cmd,
3017
+ "cwd": str(summary.cwd),
3018
+ "runtime_s": round(float(summary.runtime_s), 1),
3019
+ }
3020
+ )
3021
+ except Exception: # noqa: BLE001 - reporting must never fail verification
3022
+ pass
3023
+ if durable_service_manager is not None:
3024
+ try:
3025
+ for entry in durable_service_manager.list_active():
3026
+ services.append(
3027
+ {
3028
+ "kind": "durable_service",
3029
+ "service_id": str(entry.get("service_id") or ""),
3030
+ "command": str(entry.get("command") or ""),
3031
+ "url": str(entry.get("url") or ""),
3032
+ }
3033
+ )
3034
+ except Exception: # noqa: BLE001 - reporting must never fail verification
3035
+ pass
3036
+ return services
3037
+
3038
+ def _verify_run(args: dict[str, Any]) -> dict[str, Any]:
3039
+ deadline_decision = _deadline_start_decision(
3040
+ DeadlineOperation.VERIFICATION,
3041
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
3042
+ allow_during_finalization=True,
3043
+ )
3044
+ if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
3045
+ return _deadline_error(
3046
+ "verify_run skipped because the run deadline is exhausted or too close.",
3047
+ start_decision=deadline_decision,
3048
+ )
3049
+ try:
3050
+ verify_timeout_s = _deadline_timeout(900, operation="verify_run")
3051
+ except DeadlineExhausted:
3052
+ return _deadline_error(
3053
+ "verify_run skipped because the run deadline is exhausted or too close."
3054
+ )
3055
+ effective_cfg = cfg or AppConfig(model="")
3056
+ raw_commands = args.get("commands")
3057
+ verify_cmd: list[str] | None = None
3058
+ current_selection = _current_verify_selection()
3059
+ current_effective_verification_commands = _normalized_verify_commands(
3060
+ list(current_selection.commands) if current_selection is not None else []
3061
+ )
3062
+ unavailable_verification_contract = bool(
3063
+ current_selection is not None
3064
+ and str(current_selection.contract_type or "").strip() == "unavailable"
3065
+ and not current_effective_verification_commands
3066
+ )
3067
+ ignore_explicit_commands_for_unavailable_contract = (
3068
+ unavailable_verification_contract
3069
+ and authoritative_verify_commands is None
3070
+ and (
3071
+ (not one_shot_execution and resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT)
3072
+ or not _normalized_verify_commands(
3073
+ getattr(effective_cfg, "verify_commands", []) or []
3074
+ )
3075
+ )
3076
+ )
3077
+ trusted_shell_commands = trusted_shell_expression_command_set(current_selection)
3078
+
3079
+ def _validate_explicit_verify_candidate(command: str) -> None:
3080
+ normalized_exact = " ".join(str(command or "").strip().split())
3081
+ trusted = normalized_exact in trusted_shell_commands
3082
+ analysis = analyze_verification_command(
3083
+ command,
3084
+ trusted=trusted,
3085
+ workspace_root=root,
3086
+ )
3087
+ if analysis.rejection_reason:
3088
+ raise VerifyError("verification command is invalid: " + analysis.rejection_reason)
3089
+ if _has_disallowed_shell_control_flow(command) and not trusted:
3090
+ raise VerifyError("verification command is invalid: disallowed_shell_control_flow")
3091
+
3092
+ selection_metadata = verification_selection_payload(
3093
+ current_selection
3094
+ if current_selection is not None
3095
+ else ResolvedVerifyCommands(
3096
+ commands=tuple(current_effective_verification_commands),
3097
+ source="session.effective_verification_commands",
3098
+ reason="session already resolved an effective verification contract",
3099
+ contract_type="selected",
3100
+ ),
3101
+ authoritative=(
3102
+ is_authoritative_verify_command_selection(current_selection)
3103
+ if current_selection is not None
3104
+ else bool(authoritative_verify_commands is not None)
3105
+ ),
3106
+ )
3107
+ selection_metadata.update(verification_command_specs_payload(current_selection))
3108
+ if raw_commands is not None:
3109
+ if not isinstance(raw_commands, list):
3110
+ raise VerifyError("commands must be an array of command strings.")
3111
+ verify_cmd = []
3112
+ for item in raw_commands:
3113
+ text = str(item).strip()
3114
+ if not text:
3115
+ raise VerifyError("commands cannot contain empty values.")
3116
+ expanded_commands = _expand_simple_verify_command_chain(
3117
+ text,
3118
+ workspace_root=root,
3119
+ )
3120
+ if not ignore_explicit_commands_for_unavailable_contract:
3121
+ if len(expanded_commands) == 1 and expanded_commands[0] == text:
3122
+ _validate_explicit_verify_candidate(text)
3123
+ else:
3124
+ for command in expanded_commands:
3125
+ _validate_explicit_verify_candidate(command)
3126
+ verify_cmd.extend(expanded_commands)
3127
+ if not verify_cmd:
3128
+ raise VerifyError("commands cannot be empty.")
3129
+
3130
+ ignored_model_verification_commands: list[str] = []
3131
+ if authoritative_verify_commands is not None:
3132
+ if verify_cmd is not None:
3133
+ requested_commands = _normalized_verify_commands(verify_cmd)
3134
+ if requested_commands != authoritative_verify_commands:
3135
+ raise VerifyError(
3136
+ "Managed verification commands are locked to the authoritative Forge command set."
3137
+ )
3138
+ commands = list(authoritative_verify_commands)
3139
+ elif verify_cmd is not None and current_effective_verification_commands:
3140
+ requested_commands = _normalized_verify_commands(verify_cmd)
3141
+ incompatible_commands = _verify_run_commands_match_effective_contract(
3142
+ requested_commands=requested_commands,
3143
+ effective_verification_commands=current_effective_verification_commands,
3144
+ )
3145
+ if incompatible_commands:
3146
+ raise VerifyError(
3147
+ "verify_run commands must stay within the session's effective verification contract."
3148
+ )
3149
+ commands = requested_commands
3150
+ elif verify_cmd is not None and unavailable_verification_contract:
3151
+ requested_commands = _normalized_verify_commands(verify_cmd)
3152
+ commands = []
3153
+ for command in requested_commands:
3154
+ analysis = analyze_verification_command(command, trusted=False, workspace_root=root)
3155
+ if (
3156
+ analysis.evidentiary_capability
3157
+ == VerificationCommandEvidentiaryCapability.ASSERTIVE
3158
+ and not analysis.rejection_reason
3159
+ ):
3160
+ commands.append(command)
3161
+ else:
3162
+ ignored_model_verification_commands.append(command)
3163
+ elif verify_cmd is not None and current_selection is not None:
3164
+ commands = _normalized_verify_commands(verify_cmd)
3165
+ elif verify_cmd is None and current_selection is not None:
3166
+ commands = list(current_effective_verification_commands)
3167
+ else:
3168
+ commands = resolve_verify_commands(
3169
+ cfg=effective_cfg,
3170
+ verify_cmd=verify_cmd,
3171
+ )
3172
+ validation_errors = validation_errors_for_selection(current_selection)
3173
+ if validation_errors and (
3174
+ authoritative_verify_commands is not None
3175
+ or (
3176
+ current_selection is not None
3177
+ and is_authoritative_verify_command_selection(current_selection)
3178
+ )
3179
+ ):
3180
+ raise VerifyError(
3181
+ "authoritative verification command is invalid: " + "; ".join(validation_errors[:3])
3182
+ )
3183
+ for command in commands:
3184
+ normalized_exact = " ".join(str(command or "").strip().split())
3185
+ analysis = analyze_verification_command(
3186
+ command,
3187
+ trusted=normalized_exact in trusted_shell_commands,
3188
+ workspace_root=root,
3189
+ )
3190
+ if analysis.rejection_reason:
3191
+ raise VerifyError("verification command is invalid: " + analysis.rejection_reason)
3192
+ if (
3193
+ _has_disallowed_shell_control_flow(command)
3194
+ and normalized_exact not in trusted_shell_commands
3195
+ ):
3196
+ raise VerifyError(
3197
+ "verification commands must be single commands without shell control flow or chaining."
3198
+ )
3199
+ guard_verify(commands)
3200
+ workspace_services = _workspace_services_before_verification()
3201
+ artifact_path = _next_verify_artifact_path()
3202
+ result, touched_repo_paths = _run_with_command_mutation_detection(
3203
+ root=root,
3204
+ enabled=command_mutation_tracking_enabled,
3205
+ ignored_paths=command_mutation_ignored_paths,
3206
+ operation=lambda: _call_with_optional_kwargs(
3207
+ _patchable("run_task_verification", run_task_verification),
3208
+ required_kwargs={
3209
+ "root": root,
3210
+ "commands": commands,
3211
+ "artifact_path": artifact_path,
3212
+ "cfg": effective_cfg,
3213
+ },
3214
+ optional_kwargs={
3215
+ "timeout_s": verify_timeout_s,
3216
+ "process_group_registry": process_group_registry,
3217
+ },
3218
+ ),
3219
+ )
3220
+ payload = verify_run_result_to_payload(root=root, result=result)
3221
+ if workspace_services:
3222
+ payload = dict(payload)
3223
+ payload["workspace_services"] = workspace_services
3224
+ if ignored_model_verification_commands:
3225
+ payload["ignored_model_verification_commands"] = ignored_model_verification_commands
3226
+ payload["verification_skip_reason"] = "verification_contract_unavailable"
3227
+ material_touched_repo_paths: list[str] = []
3228
+ if touched_repo_paths:
3229
+ payload = dict(payload)
3230
+ payload["touched_repo_paths"] = touched_repo_paths
3231
+ mutation_metadata = _command_mutation_metadata(
3232
+ root=root,
3233
+ touched_repo_paths=touched_repo_paths,
3234
+ command_was_verification=True,
3235
+ )
3236
+ payload.update(mutation_metadata)
3237
+ material_touched_repo_paths = list(
3238
+ mutation_metadata.get("material_touched_repo_paths") or []
3239
+ )
3240
+ verification_relevant_material_touched_paths = _verification_relevant_material_paths(
3241
+ material_touched_repo_paths
3242
+ )
3243
+ evidence_records: list[VerificationEvidence] = []
3244
+ command_results = payload.get("command_results")
3245
+ if isinstance(command_results, list):
3246
+ for item in command_results:
3247
+ if not isinstance(item, dict):
3248
+ continue
3249
+ command = str(item.get("command") or item.get("effective_command") or "")
3250
+ if not command:
3251
+ continue
3252
+ exit_code_raw = item.get("exit_code")
3253
+ evidence_records.append(
3254
+ classify_verification_evidence(
3255
+ command,
3256
+ known_verification_commands=current_effective_verification_commands,
3257
+ authoritative=bool(selection_metadata.get("verification_authoritative")),
3258
+ material_touched_paths=verification_relevant_material_touched_paths,
3259
+ exit_code=(exit_code_raw if isinstance(exit_code_raw, int) else None),
3260
+ output=str(item.get("output_preview") or ""),
3261
+ real_execution=(
3262
+ item.get("real_execution")
3263
+ if isinstance(item.get("real_execution"), bool)
3264
+ or item.get("real_execution") is None
3265
+ else None
3266
+ ),
3267
+ root=root,
3268
+ )
3269
+ )
3270
+ payload.update(_aggregate_tool_evidence_payload(evidence_records))
3271
+ payload.update(selection_metadata)
3272
+ stored_artifact_path = (
3273
+ os.fspath(result.artifact_path.resolve())
3274
+ if result.artifact_path.exists()
3275
+ else os.fspath(result.artifact_path)
3276
+ )
3277
+ store.append(
3278
+ "verify_run",
3279
+ {
3280
+ "commands": commands,
3281
+ "all_passed": result.all_passed,
3282
+ "summary": result.summary,
3283
+ "fallback_used": payload.get("fallback_used"),
3284
+ "fallback_count": payload.get("fallback_count"),
3285
+ "fallback_details": payload.get("fallback_details"),
3286
+ "artifact_path": stored_artifact_path,
3287
+ "model_artifact_path": payload.get("artifact_path"),
3288
+ "artifact_saved": payload.get("artifact_saved"),
3289
+ "artifact_readable_via_fs": payload.get("artifact_readable_via_fs"),
3290
+ "artifact_location": payload.get("artifact_location"),
3291
+ "verification_evidence_category": payload.get("verification_evidence_category"),
3292
+ "verification_evidence_reason": payload.get("verification_evidence_reason"),
3293
+ "verification_evidence_allowed": payload.get("verification_evidence_allowed"),
3294
+ "verification_evidence_supplemental_only": payload.get(
3295
+ "verification_evidence_supplemental_only"
3296
+ ),
3297
+ "ignored_model_verification_commands": payload.get(
3298
+ "ignored_model_verification_commands", []
3299
+ ),
3300
+ "verification_skip_reason": payload.get("verification_skip_reason"),
3301
+ "material_touched_repo_paths": payload.get("material_touched_repo_paths", []),
3302
+ "benign_runtime_paths": payload.get("benign_runtime_paths", []),
3303
+ **selection_metadata,
3304
+ },
3305
+ )
3306
+ return payload
3307
+
3308
+ if verification_enabled:
3309
+ _append_builtin_tool("verify_run", run=_verify_run)
3310
+
3311
+ def _shell(args: dict[str, Any]) -> dict[str, Any]:
3312
+ dispatch_started = perf_counter()
3313
+ deadline_decision = _deadline_start_decision(
3314
+ DeadlineOperation.SHELL_TOOL,
3315
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
3316
+ allow_during_finalization=True,
3317
+ )
3318
+ if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
3319
+ return _deadline_error(
3320
+ "shell_run skipped because the run deadline is exhausted or too close.",
3321
+ start_decision=deadline_decision,
3322
+ )
3323
+ try:
3324
+ shell_timeout_s = _deadline_timeout(60, operation="shell_run")
3325
+ except DeadlineExhausted:
3326
+ return _deadline_error(
3327
+ "shell_run skipped because the run deadline is exhausted or too close."
3328
+ )
3329
+ cmd = str(args.get("cmd", ""))
3330
+ effective_cwd = _resolve_workspace_relative_path(
3331
+ tool_name="shell_run",
3332
+ raw_path=args.get("cwd"),
3333
+ raw_base=args.get("cwd_base"),
3334
+ field_name="cwd",
3335
+ base_field_name="cwd_base",
3336
+ allow_empty=True,
3337
+ )
3338
+ guard_shell(cmd)
3339
+ store.append("cmd", {"cmd": cmd, "cwd": effective_cwd})
3340
+ started = perf_counter()
3341
+ result: dict[str, Any] | None = None
3342
+ command_seconds = 0.0
3343
+
3344
+ def _run_shell_command() -> Any:
3345
+ # Timed on its own so that the workspace walks mutation detection
3346
+ # performs on either side of it are attributable to the dispatch
3347
+ # path rather than to the command the model asked for.
3348
+ nonlocal command_seconds
3349
+ command_started = perf_counter()
3350
+ try:
3351
+ return _call_with_optional_kwargs(
3352
+ _patchable("shell_run", shell_run),
3353
+ required_kwargs={
3354
+ "root": root,
3355
+ "cmd": cmd,
3356
+ "cwd": effective_cwd,
3357
+ "runner": shell_runner,
3358
+ },
3359
+ optional_kwargs={
3360
+ "timeout_s": shell_timeout_s,
3361
+ "capture_pipeline_status": _evidence_v2_enabled(cfg),
3362
+ },
3363
+ )
3364
+ finally:
3365
+ command_seconds += perf_counter() - command_started
3366
+
3367
+ try:
3368
+ result, touched_repo_paths = _run_with_command_mutation_detection(
3369
+ root=root,
3370
+ enabled=command_mutation_tracking_enabled,
3371
+ ignored_paths=command_mutation_ignored_paths,
3372
+ operation=_run_shell_command,
3373
+ )
3374
+ finally:
3375
+ if is_full_access_mode:
3376
+ duration_ms = int((perf_counter() - started) * 1000)
3377
+ store.append(
3378
+ "fullaccess_shell",
3379
+ {
3380
+ "event": "fullaccess_shell",
3381
+ "ts": _fullaccess_shell_audit_ts(),
3382
+ "command": cmd,
3383
+ "cwd": str((result or {}).get("cwd") or effective_cwd or root),
3384
+ "exit_code": int((result or {}).get("exit_code", -1)),
3385
+ "pipeline_stage_status": (result or {}).get("pipeline_stage_status"),
3386
+ "duration_ms": duration_ms,
3387
+ "mode": "fullaccess",
3388
+ },
3389
+ )
3390
+ if touched_repo_paths:
3391
+ result = dict(result)
3392
+ result["touched_repo_paths"] = touched_repo_paths
3393
+ result.update(
3394
+ _command_mutation_metadata(
3395
+ root=root,
3396
+ touched_repo_paths=touched_repo_paths,
3397
+ command_was_verification=False,
3398
+ )
3399
+ )
3400
+ current_selection = _current_verify_selection()
3401
+ current_effective_verification_commands = _normalized_verify_commands(
3402
+ list(current_selection.commands) if current_selection is not None else []
3403
+ )
3404
+ shell_exit_code = result.get("exit_code") if isinstance(result, dict) else None
3405
+ evidence_v2 = _evidence_v2_enabled(cfg)
3406
+ shell_effective_cmd = str(result.get("effective_cmd") or result.get("cmd") or cmd)
3407
+
3408
+ def _reexec_first_stage_exit(stage: str) -> int | None:
3409
+ # Bounded ground-truth fallback: when PIPESTATUS could not be observed
3410
+ # (e.g. bash was unavailable), re-run only a recognized test/execution
3411
+ # first stage unpiped, once, to learn its true exit code. Never re-run
3412
+ # an arbitrary side-effecting first stage.
3413
+ nonlocal command_seconds
3414
+ if not command_is_qualifying_execution_evidence(stage):
3415
+ return None
3416
+ rerun_started = perf_counter()
3417
+ try:
3418
+ rerun = _call_with_optional_kwargs(
3419
+ _patchable("shell_run", shell_run),
3420
+ required_kwargs={
3421
+ "root": root,
3422
+ "cmd": stage,
3423
+ "cwd": effective_cwd,
3424
+ "runner": shell_runner,
3425
+ },
3426
+ optional_kwargs={"timeout_s": shell_timeout_s},
3427
+ )
3428
+ except Exception: # noqa: BLE001
3429
+ return None
3430
+ finally:
3431
+ # Still command time, not dispatch time, even when it fails.
3432
+ command_seconds += perf_counter() - rerun_started
3433
+ code = rerun.get("exit_code") if isinstance(rerun, dict) else None
3434
+ return code if isinstance(code, int) else None
3435
+
3436
+ stage_status = result.get("pipeline_stage_status") if isinstance(result, dict) else None
3437
+ if evidence_v2 and stage_status is None:
3438
+ resolved_status = resolve_pipeline_stage_status(
3439
+ shell_effective_cmd,
3440
+ None,
3441
+ reexec=_reexec_first_stage_exit,
3442
+ )
3443
+ if resolved_status is not None:
3444
+ stage_status = resolved_status
3445
+ result["pipeline_stage_status"] = resolved_status
3446
+ result["pipeline_stage_status_source"] = "reexec"
3447
+ shell_evidence = classify_verification_evidence(
3448
+ shell_effective_cmd,
3449
+ known_verification_commands=current_effective_verification_commands,
3450
+ authoritative=(
3451
+ is_authoritative_verify_command_selection(current_selection)
3452
+ if current_selection is not None
3453
+ else bool(authoritative_verify_commands is not None)
3454
+ ),
3455
+ material_touched_paths=_verification_relevant_material_paths(
3456
+ result.get("material_touched_repo_paths", [])
3457
+ if isinstance(result.get("material_touched_repo_paths"), list)
3458
+ else [],
3459
+ ),
3460
+ exit_code=(shell_exit_code if isinstance(shell_exit_code, int) else None),
3461
+ output="\n".join(
3462
+ [
3463
+ str(result.get("stdout") or "").strip(),
3464
+ str(result.get("stderr") or "").strip(),
3465
+ ]
3466
+ ).strip(),
3467
+ root=root,
3468
+ stage_status=stage_status if isinstance(stage_status, list) else None,
3469
+ evidence_v2=evidence_v2,
3470
+ )
3471
+ result["verification_evidence_category"] = shell_evidence.category.value
3472
+ result["verification_evidence_reason"] = shell_evidence.reason
3473
+ result["verification_evidence_allowed"] = shell_evidence.allowed_to_satisfy_contract
3474
+ result["verification_evidence_supplemental_only"] = shell_evidence.supplemental_only
3475
+ result["evidence_verdict"] = shell_evidence.evidence_verdict
3476
+ result["dispatch_overhead_seconds"] = round(
3477
+ _observe_dispatch_overhead(
3478
+ dispatch_started=dispatch_started,
3479
+ command_seconds=command_seconds,
3480
+ ),
3481
+ 6,
3482
+ )
3483
+ return result
3484
+
3485
+ _append_builtin_tool("shell_run", run=_shell)
3486
+
3487
+ def _require_terminal_manager() -> TerminalManager:
3488
+ if terminal_manager is None:
3489
+ raise AgentRuntimeError("Background shell tools are unavailable in this session.")
3490
+ return terminal_manager
3491
+
3492
+ def _require_durable_service_manager() -> DurableServiceManager:
3493
+ if durable_service_manager is None:
3494
+ raise AgentRuntimeError("Durable service tools are unavailable in this session.")
3495
+ return durable_service_manager
3496
+
3497
+ def _service_event_payload(payload: dict[str, Any]) -> dict[str, Any]:
3498
+ readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
3499
+ return {
3500
+ "service_id": payload.get("service_id"),
3501
+ "ownership": payload.get("ownership") or ProcessOwnership.DURABLE_SERVICE.value,
3502
+ "status": payload.get("status"),
3503
+ "alive": bool(payload.get("alive")),
3504
+ "backend": payload.get("backend"),
3505
+ "readiness": {
3506
+ "type": readiness.get("type"),
3507
+ "status": readiness.get("status"),
3508
+ "host": readiness.get("host"),
3509
+ "port": readiness.get("port"),
3510
+ "path": readiness.get("path"),
3511
+ },
3512
+ "failure_category": payload.get("failure_category"),
3513
+ "log_paths": payload.get("log_paths"),
3514
+ "preview_url": payload.get("preview_url"),
3515
+ "startup_error": payload.get("startup_error"),
3516
+ }
3517
+
3518
+ def _guard_service_readiness_spec(raw_readiness: Any) -> dict[str, Any] | None:
3519
+ if raw_readiness is None:
3520
+ return None
3521
+ if not isinstance(raw_readiness, dict):
3522
+ raise AgentRuntimeError("readiness must be an object when provided")
3523
+ readiness = dict(raw_readiness)
3524
+ if str(readiness.get("type") or "").strip().lower() == "command":
3525
+ command = str(readiness.get("command") or "").strip()
3526
+ if not command:
3527
+ raise AgentRuntimeError("readiness.command is required for command readiness")
3528
+ guard_shell(command, tool_name="shell_service_start")
3529
+ return readiness
3530
+
3531
+ def _format_bg_snapshot(
3532
+ *,
3533
+ process_id: str,
3534
+ snapshot: ProcessOutputSnapshot,
3535
+ max_bytes: int | None = None,
3536
+ ) -> dict[str, Any]:
3537
+ lines: list[dict[str, Any]] = []
3538
+ output_truncated_by_max_bytes = False
3539
+ remaining_bytes = max_bytes if max_bytes is not None else None
3540
+ for line in snapshot.lines:
3541
+ text = line.text
3542
+ if remaining_bytes is not None:
3543
+ encoded = text.encode("utf-8", errors="replace")
3544
+ if remaining_bytes <= 0:
3545
+ output_truncated_by_max_bytes = True
3546
+ break
3547
+ if len(encoded) > remaining_bytes:
3548
+ text = encoded[:remaining_bytes].decode("utf-8", errors="replace")
3549
+ output_truncated_by_max_bytes = True
3550
+ remaining_bytes = 0
3551
+ else:
3552
+ remaining_bytes -= len(encoded)
3553
+ lines.append({"seq": line.seq, "stream": line.stream, "text": text})
3554
+ payload = {
3555
+ "process_id": process_id,
3556
+ "lifetime": "session",
3557
+ "status": snapshot.status,
3558
+ "exit_code": snapshot.exit_code,
3559
+ "failure_reason": snapshot.failure_reason,
3560
+ "lines": lines,
3561
+ "next_seq": snapshot.next_seq,
3562
+ "dropped_lines": snapshot.dropped_lines,
3563
+ "runtime_s": round(snapshot.runtime_s, 3),
3564
+ "total_bytes": snapshot.total_bytes,
3565
+ }
3566
+ if output_truncated_by_max_bytes:
3567
+ payload["output_truncated_by_max_bytes"] = True
3568
+ payload["max_bytes"] = max_bytes
3569
+ return payload
3570
+
3571
+ def _format_bg_summaries(manager: TerminalManager) -> list[dict[str, Any]]:
3572
+ return [
3573
+ {
3574
+ "process_id": summary.process_id,
3575
+ "cmd": summary.cmd,
3576
+ "cwd": str(summary.cwd),
3577
+ "status": summary.status,
3578
+ "exit_code": summary.exit_code,
3579
+ "runtime_s": round(summary.runtime_s, 3),
3580
+ "started_at_wall": summary.started_at_wall,
3581
+ }
3582
+ for summary in manager.list()
3583
+ ]
3584
+
3585
+ def _unknown_bg_process_payload(
3586
+ *,
3587
+ manager: TerminalManager,
3588
+ process_id: str,
3589
+ operation: str,
3590
+ since: int | None = None,
3591
+ ) -> dict[str, Any]:
3592
+ known_processes = _format_bg_summaries(manager)
3593
+ payload: dict[str, Any] = {
3594
+ "status": "unknown_process_id",
3595
+ "unknown_process_id": True,
3596
+ "process_id": process_id,
3597
+ "requested_process_id": process_id,
3598
+ "operation": operation,
3599
+ "exit_code": None,
3600
+ "failure_reason": "No background process with that process_id is tracked in this session.",
3601
+ "lines": [],
3602
+ "next_seq": since if since is not None else 0,
3603
+ "dropped_lines": 0,
3604
+ "runtime_s": 0.0,
3605
+ "total_bytes": 0,
3606
+ "known_processes": known_processes,
3607
+ "known_process_ids": [process["process_id"] for process in known_processes],
3608
+ "recovery": {
3609
+ "recommended_tool": "shell_list",
3610
+ "suggested_arguments": {},
3611
+ "reason": (
3612
+ "The supplied process_id is not tracked. Use shell_list or the process_id "
3613
+ "returned by shell_background; do not use a tool_call_id as process_id."
3614
+ ),
3615
+ },
3616
+ }
3617
+ if since is not None:
3618
+ payload["since"] = since
3619
+ store.append(
3620
+ "bg_unknown_process",
3621
+ {
3622
+ "operation": operation,
3623
+ "process_id": process_id,
3624
+ "known_process_count": len(known_processes),
3625
+ },
3626
+ )
3627
+ return payload
3628
+
3629
+ shell_empty_poll_counts: dict[tuple[str, int, int, str], int] = {}
3630
+
3631
+ def _coerce_shell_since(raw_since: Any) -> int:
3632
+ try:
3633
+ since = int(raw_since) if raw_since is not None else 0
3634
+ except (TypeError, ValueError) as exc:
3635
+ raise AgentRuntimeError(f"Invalid since value: {raw_since!r}") from exc
3636
+ if since < 0:
3637
+ raise AgentRuntimeError("since must be non-negative")
3638
+ return since
3639
+
3640
+ def _coerce_shell_wait_seconds(raw_wait: Any) -> float:
3641
+ try:
3642
+ wait_seconds = float(raw_wait) if raw_wait is not None else 5.0
3643
+ except (TypeError, ValueError) as exc:
3644
+ raise AgentRuntimeError(f"Invalid wait_seconds value: {raw_wait!r}") from exc
3645
+ if wait_seconds < 0:
3646
+ raise AgentRuntimeError("wait_seconds must be non-negative")
3647
+ return min(wait_seconds, 60.0)
3648
+
3649
+ def _coerce_shell_max_bytes(raw_max_bytes: Any) -> int | None:
3650
+ if raw_max_bytes is None:
3651
+ return None
3652
+ try:
3653
+ max_bytes = int(raw_max_bytes)
3654
+ except (TypeError, ValueError) as exc:
3655
+ raise AgentRuntimeError(f"Invalid max_bytes value: {raw_max_bytes!r}") from exc
3656
+ if max_bytes <= 0:
3657
+ raise AgentRuntimeError("max_bytes must be positive")
3658
+ return max_bytes
3659
+
3660
+ def _coerce_shell_wait_until(raw_until: Any) -> str:
3661
+ until = str(raw_until or "either").strip().lower()
3662
+ if until not in {"output_available", "process_exited", "either"}:
3663
+ raise AgentRuntimeError(
3664
+ "until must be one of output_available, process_exited, or either"
3665
+ )
3666
+ return until
3667
+
3668
+ def _clamp_shell_wait_seconds(wait_seconds: float) -> tuple[float, dict[str, Any] | None]:
3669
+ if execution_deadline is None:
3670
+ return wait_seconds, None
3671
+ decision = execution_deadline.start_decision(
3672
+ DeadlineOperation.SHELL_TOOL,
3673
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
3674
+ configured_timeout_seconds=wait_seconds,
3675
+ allow_during_finalization=True,
3676
+ ).telemetry_snapshot()
3677
+ if not bool(decision.get("allowed")):
3678
+ return 0.0, decision
3679
+ clamped = execution_deadline.clamp_timeout(
3680
+ wait_seconds,
3681
+ reserve_seconds=DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
3682
+ )
3683
+ if clamped is None:
3684
+ return 0.0, decision
3685
+ if execution_deadline.phase() == DeadlinePhase.FINALIZATION_WINDOW:
3686
+ clamped = min(float(clamped), 1.0)
3687
+ return float(clamped), decision
3688
+
3689
+ def _observe_dispatch_overhead(
3690
+ *,
3691
+ dispatch_started: float,
3692
+ command_seconds: float,
3693
+ ) -> float:
3694
+ """Record time spent in dispatch machinery, excluding the command itself.
3695
+
3696
+ ``shell_tool`` and ``tool_dispatch`` both time the whole dispatch, so a
3697
+ build that legitimately takes twenty minutes is indistinguishable from a
3698
+ dispatch path that has become expensive. Subtracting the command's own
3699
+ runtime leaves the number that actually describes this code, reported as
3700
+ its own ``duration_observations`` category.
3701
+ """
3702
+ account = DispatchOverheadAccount.from_totals(
3703
+ perf_counter() - dispatch_started,
3704
+ command_seconds,
3705
+ )
3706
+ if execution_deadline is not None:
3707
+ execution_deadline.observe_duration(
3708
+ DISPATCH_OVERHEAD_OPERATION,
3709
+ account.overhead_seconds,
3710
+ )
3711
+ return account.overhead_seconds
3712
+
3713
+ def _cancellation_probe(token: Any | None) -> Callable[[], bool] | None:
3714
+ """Read-only view of a cancellation token, or ``None`` when absent."""
3715
+ if token is None:
3716
+ return None
3717
+ return lambda: bool(getattr(token, "is_cancelled", False))
3718
+
3719
+ def _wait_for_output_cancellably(
3720
+ manager: TerminalManager,
3721
+ *,
3722
+ process_id: str,
3723
+ since: int,
3724
+ timeout_s: float,
3725
+ until: str,
3726
+ cancellation_token: Any | None,
3727
+ ) -> tuple[ProcessOutputSnapshot, bool, bool]:
3728
+ """Wait for background output without outliving the run budget.
3729
+
3730
+ The manager's wait is already completion-driven -- it returns the moment
3731
+ the process speaks or exits -- but it blocks on a condition variable that
3732
+ knows nothing about the budget. A ``shell_wait`` armed just before the
3733
+ deadline therefore ran to its full 60s while the run was already over,
3734
+ which is PR2's documented "a running tool cannot be preempted" gap in the
3735
+ one place the telemetry says it costs whole runs.
3736
+
3737
+ Driving the same wait in slices and re-reading the token between them
3738
+ bounds cancellation latency to a single slice. Because each slice still
3739
+ returns early on completion, nothing about the non-cancelled path gets
3740
+ slower, and no busy-polling is introduced. When no token is supplied
3741
+ there is nothing to observe, so the wait is taken in one step exactly as
3742
+ before.
3743
+
3744
+ Returns ``(snapshot, timed_out, cancelled)``.
3745
+ """
3746
+ latest_snapshot: ProcessOutputSnapshot | None = None
3747
+
3748
+ def _wait_once(step: float) -> bool:
3749
+ nonlocal latest_snapshot
3750
+ snapshot, wait_timed_out = manager.wait_for_output(
3751
+ process_id,
3752
+ since=since,
3753
+ timeout_s=step,
3754
+ until=until, # type: ignore[arg-type]
3755
+ )
3756
+ latest_snapshot = snapshot
3757
+ return not wait_timed_out
3758
+
3759
+ is_cancelled = _cancellation_probe(cancellation_token)
3760
+ result = run_cancellable_wait(
3761
+ wait_once=_wait_once,
3762
+ total_seconds=timeout_s,
3763
+ is_cancelled=is_cancelled,
3764
+ slice_seconds=None if is_cancelled is not None else 0.0,
3765
+ )
3766
+ snapshot = (
3767
+ latest_snapshot
3768
+ if latest_snapshot is not None
3769
+ # Cancelled before the first block: report the current state rather
3770
+ # than an empty one, so nothing already emitted is dropped.
3771
+ else manager.read(process_id, since=since)
3772
+ )
3773
+ return snapshot, not result.completed, result.cancelled
3774
+
3775
+ def _maybe_add_empty_poll_guidance(
3776
+ *,
3777
+ payload: dict[str, Any],
3778
+ process_id: str,
3779
+ since: int,
3780
+ snapshot: ProcessOutputSnapshot,
3781
+ ) -> None:
3782
+ if snapshot.lines or snapshot.status != "running":
3783
+ shell_empty_poll_counts.pop(
3784
+ (process_id, since, snapshot.next_seq, snapshot.status), None
3785
+ )
3786
+ return
3787
+ key = (process_id, since, snapshot.next_seq, snapshot.status)
3788
+ count = shell_empty_poll_counts.get(key, 0) + 1
3789
+ shell_empty_poll_counts[key] = count
3790
+ payload["empty_poll_count"] = count
3791
+ if count >= 2:
3792
+ payload["wait_guidance"] = {
3793
+ "recommended_tool": "shell_wait",
3794
+ "reason": "No new output or process status change was observed for repeated immediate polls.",
3795
+ "process_id": process_id,
3796
+ "since": since,
3797
+ "suggested_arguments": {
3798
+ "process_id": process_id,
3799
+ "since": since,
3800
+ "until": "either",
3801
+ "wait_seconds": 5,
3802
+ },
3803
+ }
3804
+
3805
+ def _persist_background_start(
3806
+ *,
3807
+ cmd: str,
3808
+ cwd_path: Path,
3809
+ effective_cwd_relpath: str,
3810
+ probe_port: int | None,
3811
+ deadline_warning: dict[str, Any] | None,
3812
+ ) -> dict[str, Any]:
3813
+ """Route a persist=true background start to the durable-service manager.
3814
+
3815
+ Persistence is deliberately not reimplemented here. The durable manager
3816
+ already spawns into its own session, redirects stdio to files under the
3817
+ session's service directory, and is excluded from every reaping path --
3818
+ so routing keeps one implementation and one lifecycle rather than a
3819
+ second, subtly different one.
3820
+ """
3821
+
3822
+ manager = _require_durable_service_manager()
3823
+ readiness = readiness_spec_for_port(probe_port) if probe_port is not None else None
3824
+ try:
3825
+ started = manager.start(cmd=cmd, cwd=cwd_path, readiness=readiness)
3826
+ except ValueError as exc:
3827
+ raise AgentRuntimeError(f"Invalid durable service request: {exc}") from exc
3828
+ except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
3829
+ raise AgentRuntimeError(f"Failed to start durable service: {exc}") from exc
3830
+ payload = dict(started.payload)
3831
+ payload["lifetime"] = "durable"
3832
+ payload["persist"] = True
3833
+ record = PersistentServiceRecord(
3834
+ service_id=started.service_id,
3835
+ command=cmd,
3836
+ pid=int(payload.get("pid") or 0),
3837
+ probe_port=probe_port,
3838
+ )
3839
+ # A start that silently produced nothing is the failure this PR exists
3840
+ # for, so say now whether the process is up and the port is answering.
3841
+ payload.update(check_service(record).as_payload())
3842
+ if persistent_service_registry is not None:
3843
+ persistent_service_registry.register(record)
3844
+ store.append(
3845
+ "service_start",
3846
+ {
3847
+ **_service_event_payload(payload),
3848
+ "cwd": effective_cwd_relpath,
3849
+ "persist": True,
3850
+ "probe_port": probe_port,
3851
+ },
3852
+ )
3853
+ if deadline_warning is not None:
3854
+ payload.update(deadline_warning)
3855
+ return payload
3856
+
3857
+ def _shell_background(args: dict[str, Any]) -> dict[str, Any]:
3858
+ deadline_decision = _deadline_start_decision(
3859
+ DeadlineOperation.SHELL_BACKGROUND,
3860
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
3861
+ )
3862
+ deadline_warning = None
3863
+ if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
3864
+ deadline_warning = _deadline_warning_fields(
3865
+ "Deadline policy would normally block background work; start proceeded because "
3866
+ "this is advisory and not safety.",
3867
+ start_decision=deadline_decision,
3868
+ )
3869
+ manager = _require_terminal_manager()
3870
+ cmd = str(args.get("cmd", ""))
3871
+ effective_cwd_relpath = _resolve_workspace_relative_path(
3872
+ tool_name="shell_background",
3873
+ raw_path=args.get("cwd"),
3874
+ raw_base=args.get("cwd_base"),
3875
+ field_name="cwd",
3876
+ base_field_name="cwd_base",
3877
+ allow_empty=True,
3878
+ )
3879
+ guard_shell(cmd, tool_name="shell_background")
3880
+ cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
3881
+ try:
3882
+ probe_port = resolve_probe_port(requested=args.get("probe_port"), cmd=cmd)
3883
+ except ValueError as exc:
3884
+ raise AgentRuntimeError(str(exc)) from exc
3885
+ if bool(args.get("persist")):
3886
+ return _persist_background_start(
3887
+ cmd=cmd,
3888
+ cwd_path=cwd_path,
3889
+ effective_cwd_relpath=effective_cwd_relpath,
3890
+ probe_port=probe_port,
3891
+ deadline_warning=deadline_warning,
3892
+ )
3893
+ store.append("bg_start", {"cmd": cmd, "cwd": effective_cwd_relpath})
3894
+ started = perf_counter()
3895
+ snapshot: ProcessOutputSnapshot | None = None
3896
+ try:
3897
+ try:
3898
+ process_id = manager.start(
3899
+ cmd=cmd,
3900
+ cwd=cwd_path,
3901
+ root=root,
3902
+ )
3903
+ except TerminalLimitError as exc:
3904
+ raise AgentRuntimeError(str(exc)) from exc
3905
+ except ValueError as exc:
3906
+ raise AgentRuntimeError(f"Invalid background process request: {exc}") from exc
3907
+ except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
3908
+ raise AgentRuntimeError(f"Failed to start background process: {exc}") from exc
3909
+ snapshot = manager.read(process_id)
3910
+ payload = _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
3911
+ if deadline_warning is not None:
3912
+ payload.update(deadline_warning)
3913
+ return payload
3914
+ finally:
3915
+ if is_full_access_mode:
3916
+ duration_ms = int((perf_counter() - started) * 1000)
3917
+ exit_code = snapshot.exit_code if snapshot is not None else None
3918
+ store.append(
3919
+ "fullaccess_shell",
3920
+ {
3921
+ "event": "fullaccess_shell",
3922
+ "ts": _fullaccess_shell_audit_ts(),
3923
+ "command": cmd,
3924
+ "cwd": str(cwd_path),
3925
+ "exit_code": int(exit_code if exit_code is not None else -1),
3926
+ "duration_ms": duration_ms,
3927
+ "mode": "fullaccess",
3928
+ },
3929
+ )
3930
+
3931
+ def _shell_output(args: dict[str, Any]) -> dict[str, Any]:
3932
+ manager = _require_terminal_manager()
3933
+ guard_terminal_op("shell_output")
3934
+ process_id = str(args.get("process_id", "")).strip()
3935
+ if not process_id:
3936
+ raise AgentRuntimeError("Missing required argument: process_id")
3937
+ since = _coerce_shell_since(args.get("since"))
3938
+ try:
3939
+ snapshot = manager.read(process_id, since=since)
3940
+ except KeyError:
3941
+ return _unknown_bg_process_payload(
3942
+ manager=manager,
3943
+ process_id=process_id,
3944
+ operation="shell_output",
3945
+ since=since,
3946
+ )
3947
+ payload = _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
3948
+ _maybe_add_empty_poll_guidance(
3949
+ payload=payload,
3950
+ process_id=process_id,
3951
+ since=since,
3952
+ snapshot=snapshot,
3953
+ )
3954
+ return payload
3955
+
3956
+ def _shell_wait(args: dict[str, Any]) -> dict[str, Any]:
3957
+ dispatch_started = perf_counter()
3958
+ manager = _require_terminal_manager()
3959
+ guard_terminal_op("shell_wait")
3960
+ process_id = str(args.get("process_id", "")).strip()
3961
+ if not process_id:
3962
+ raise AgentRuntimeError("Missing required argument: process_id")
3963
+ since = _coerce_shell_since(args.get("since"))
3964
+ wait_seconds = _coerce_shell_wait_seconds(args.get("wait_seconds"))
3965
+ until = _coerce_shell_wait_until(args.get("until"))
3966
+ max_bytes = _coerce_shell_max_bytes(args.get("max_bytes"))
3967
+ clamped_wait_seconds, deadline_decision = _clamp_shell_wait_seconds(wait_seconds)
3968
+ started = perf_counter()
3969
+ wait_cancelled = False
3970
+ try:
3971
+ snapshot, timed_out, wait_cancelled = _wait_for_output_cancellably(
3972
+ manager,
3973
+ process_id=process_id,
3974
+ since=since,
3975
+ timeout_s=clamped_wait_seconds,
3976
+ until=until,
3977
+ cancellation_token=args.get(_SHELL_CANCELLATION_TOKEN_ARG),
3978
+ )
3979
+ except KeyError:
3980
+ payload = _unknown_bg_process_payload(
3981
+ manager=manager,
3982
+ process_id=process_id,
3983
+ operation="shell_wait",
3984
+ since=since,
3985
+ )
3986
+ payload.update(
3987
+ {
3988
+ "waited": False,
3989
+ "timed_out": False,
3990
+ "wait_seconds_requested": wait_seconds,
3991
+ "wait_seconds_effective": 0.0,
3992
+ "until": until,
3993
+ "elapsed_ms": int((perf_counter() - started) * 1000),
3994
+ }
3995
+ )
3996
+ if deadline_decision is not None:
3997
+ payload["deadline_start_decision"] = deadline_decision
3998
+ payload["deadline_clamped"] = clamped_wait_seconds < wait_seconds
3999
+ _observe_dispatch_overhead(
4000
+ dispatch_started=dispatch_started,
4001
+ command_seconds=perf_counter() - started,
4002
+ )
4003
+ return payload
4004
+ waited_seconds = perf_counter() - started
4005
+ elapsed_ms = int(waited_seconds * 1000)
4006
+ payload = _format_bg_snapshot(
4007
+ process_id=process_id,
4008
+ snapshot=snapshot,
4009
+ max_bytes=max_bytes,
4010
+ )
4011
+ payload.update(
4012
+ {
4013
+ "waited": True,
4014
+ "timed_out": timed_out,
4015
+ "wait_seconds_requested": wait_seconds,
4016
+ "wait_seconds_effective": clamped_wait_seconds,
4017
+ "until": until,
4018
+ "elapsed_ms": elapsed_ms,
4019
+ }
4020
+ )
4021
+ if wait_cancelled:
4022
+ # The watchdog fired mid-wait. Reported rather than raised: the step
4023
+ # loop's existing cancellation checkpoint stops the run on its next
4024
+ # iteration, and returning normally keeps the output collected so
4025
+ # far instead of discarding it with an exception.
4026
+ payload["wait_interrupted_by_budget"] = True
4027
+ if deadline_decision is not None:
4028
+ payload["deadline_start_decision"] = deadline_decision
4029
+ payload["deadline_clamped"] = clamped_wait_seconds < wait_seconds
4030
+ payload["dispatch_overhead_seconds"] = round(
4031
+ _observe_dispatch_overhead(
4032
+ dispatch_started=dispatch_started,
4033
+ command_seconds=waited_seconds,
4034
+ ),
4035
+ 6,
4036
+ )
4037
+ return payload
4038
+
4039
+ def _shell_kill(args: dict[str, Any]) -> dict[str, Any]:
4040
+ manager = _require_terminal_manager()
4041
+ guard_terminal_op("shell_kill")
4042
+ process_id = str(args.get("process_id", "")).strip()
4043
+ if not process_id:
4044
+ raise AgentRuntimeError("Missing required argument: process_id")
4045
+ try:
4046
+ snapshot = manager.kill(process_id)
4047
+ except KeyError as exc:
4048
+ raise AgentRuntimeError(f"Unknown background process_id: {process_id}") from exc
4049
+ store.append(
4050
+ "bg_kill",
4051
+ {
4052
+ "process_id": process_id,
4053
+ "status": snapshot.status,
4054
+ "exit_code": snapshot.exit_code,
4055
+ },
4056
+ )
4057
+ return _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
4058
+
4059
+ def _shell_list(_args: dict[str, Any]) -> dict[str, Any]:
4060
+ manager = _require_terminal_manager()
4061
+ guard_terminal_op("shell_list")
4062
+ return {"processes": _format_bg_summaries(manager)}
4063
+
4064
+ def _shell_service_start(args: dict[str, Any]) -> dict[str, Any]:
4065
+ deadline_decision = _deadline_start_decision(
4066
+ DeadlineOperation.SHELL_BACKGROUND,
4067
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
4068
+ )
4069
+ deadline_warning = None
4070
+ if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
4071
+ deadline_warning = _deadline_warning_fields(
4072
+ "Deadline policy would normally block service work; start proceeded because "
4073
+ "this is advisory and not safety.",
4074
+ start_decision=deadline_decision,
4075
+ )
4076
+ manager = _require_durable_service_manager()
4077
+ cmd = str(args.get("cmd", ""))
4078
+ guard_shell(cmd, tool_name="shell_service_start")
4079
+ readiness = _guard_service_readiness_spec(args.get("readiness"))
4080
+ effective_cwd_relpath = _resolve_workspace_relative_path(
4081
+ tool_name="shell_service_start",
4082
+ raw_path=args.get("cwd"),
4083
+ raw_base=args.get("cwd_base"),
4084
+ field_name="cwd",
4085
+ base_field_name="cwd_base",
4086
+ allow_empty=True,
4087
+ )
4088
+ cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
4089
+ try:
4090
+ started = manager.start(cmd=cmd, cwd=cwd_path, readiness=readiness)
4091
+ except ValueError as exc:
4092
+ raise AgentRuntimeError(f"Invalid durable service request: {exc}") from exc
4093
+ except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
4094
+ raise AgentRuntimeError(f"Failed to start durable service: {exc}") from exc
4095
+ payload = dict(started.payload)
4096
+ payload["lifetime"] = "durable"
4097
+ if deadline_warning is not None:
4098
+ payload.update(deadline_warning)
4099
+ store.append(
4100
+ "service_start",
4101
+ {
4102
+ **_service_event_payload(payload),
4103
+ "cwd": effective_cwd_relpath,
4104
+ },
4105
+ )
4106
+ return payload
4107
+
4108
+ def _workspace_preview_start(args: dict[str, Any]) -> dict[str, Any]:
4109
+ deadline_decision = _deadline_start_decision(
4110
+ DeadlineOperation.SHELL_BACKGROUND,
4111
+ minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
4112
+ )
4113
+ deadline_warning = None
4114
+ if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
4115
+ deadline_warning = _deadline_warning_fields(
4116
+ "Deadline policy would normally block preview work; start proceeded because "
4117
+ "this is advisory and not safety.",
4118
+ start_decision=deadline_decision,
4119
+ )
4120
+ manager = _require_durable_service_manager()
4121
+ guard_terminal_op("workspace_preview_start")
4122
+ requested_access = str(args.get("access") or "auto").strip().lower()
4123
+ try:
4124
+ effective_access = manager.resolve_preview_access(requested_access)
4125
+ except ValueError as exc:
4126
+ raise AgentRuntimeError(f"Invalid workspace preview request: {exc}") from exc
4127
+ if effective_access == "lan" and not yes and not is_full_access_mode:
4128
+ if non_interactive and not host_managed_approvals:
4129
+ raise AgentRuntimeError(
4130
+ "LAN preview exposure requires interactive approval. Use local access or "
4131
+ "re-run in an approval-capable session."
4132
+ )
4133
+ decision = surface.request_approval(
4134
+ ApprovalRequest(
4135
+ kind="workspace_preview_lan",
4136
+ reason=(
4137
+ "LAN preview access exposes an authenticated workspace server to other "
4138
+ "devices on the current network"
4139
+ ),
4140
+ preview="Start a temporary authenticated LAN workspace preview",
4141
+ )
4142
+ )
4143
+ if not decision.allow:
4144
+ raise ApprovalDeclinedError("workspace_preview_lan")
4145
+ raw_port = args.get("port")
4146
+ if raw_port is None or raw_port == "":
4147
+ port = None
4148
+ else:
4149
+ if isinstance(raw_port, bool):
4150
+ raise AgentRuntimeError("Preview port must be an integer")
4151
+ try:
4152
+ port = int(raw_port)
4153
+ except (TypeError, ValueError) as exc:
4154
+ raise AgentRuntimeError("Preview port must be an integer") from exc
4155
+ effective_cwd_relpath = _resolve_workspace_relative_path(
4156
+ tool_name="workspace_preview_start",
4157
+ raw_path=args.get("cwd"),
4158
+ raw_base=args.get("cwd_base"),
4159
+ field_name="cwd",
4160
+ base_field_name="cwd_base",
4161
+ allow_empty=True,
4162
+ )
4163
+ cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
4164
+ try:
4165
+ started = manager.start_preview(
4166
+ cwd=cwd_path,
4167
+ access=requested_access,
4168
+ port=port,
4169
+ )
4170
+ except ValueError as exc:
4171
+ raise AgentRuntimeError(f"Invalid workspace preview request: {exc}") from exc
4172
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
4173
+ raise AgentRuntimeError(f"Failed to start workspace preview: {exc}") from exc
4174
+ payload = dict(started.payload)
4175
+ payload["lifetime"] = "durable"
4176
+ if deadline_warning is not None:
4177
+ payload.update(deadline_warning)
4178
+ store.append(
4179
+ "service_start",
4180
+ {
4181
+ **_service_event_payload(payload),
4182
+ "cwd": effective_cwd_relpath,
4183
+ "service_kind": "workspace_preview",
4184
+ },
4185
+ )
4186
+ return payload
4187
+
4188
+ def _shell_service_status(args: dict[str, Any]) -> dict[str, Any]:
4189
+ manager = _require_durable_service_manager()
4190
+ guard_terminal_op("shell_service_status")
4191
+ service_id = str(args.get("service_id", "")).strip()
4192
+ if not service_id:
4193
+ raise AgentRuntimeError("Missing required argument: service_id")
4194
+ try:
4195
+ payload = manager.status(service_id)
4196
+ except ValueError as exc:
4197
+ raise AgentRuntimeError(f"Invalid durable service_id: {exc}") from exc
4198
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
4199
+ raise AgentRuntimeError(f"Failed to inspect durable service: {exc}") from exc
4200
+ store.append("service_status", _service_event_payload(payload))
4201
+ return payload
4202
+
4203
+ def _shell_service_stop(args: dict[str, Any]) -> dict[str, Any]:
4204
+ manager = _require_durable_service_manager()
4205
+ guard_terminal_op("shell_service_stop")
4206
+ service_id = str(args.get("service_id", "")).strip()
4207
+ if not service_id:
4208
+ raise AgentRuntimeError("Missing required argument: service_id")
4209
+ try:
4210
+ payload = manager.stop(service_id)
4211
+ except ValueError as exc:
4212
+ raise AgentRuntimeError(f"Invalid durable service_id: {exc}") from exc
4213
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
4214
+ raise AgentRuntimeError(f"Failed to stop durable service: {exc}") from exc
4215
+ store.append("service_stop", _service_event_payload(payload))
4216
+ return payload
4217
+
4218
+ _append_builtin_tool("shell_background", run=_shell_background)
4219
+ _append_builtin_tool("shell_output", run=_shell_output)
4220
+ _append_builtin_tool("shell_wait", run=_shell_wait)
4221
+ _append_builtin_tool("shell_kill", run=_shell_kill)
4222
+ _append_builtin_tool("shell_list", run=_shell_list)
4223
+ _append_builtin_tool("shell_service_start", run=_shell_service_start)
4224
+ _append_builtin_tool("workspace_preview_start", run=_workspace_preview_start)
4225
+ _append_builtin_tool("shell_service_status", run=_shell_service_status)
4226
+ _append_builtin_tool("shell_service_stop", run=_shell_service_stop)
4227
+
4228
+ def _session_set_workdir(args: dict[str, Any]) -> dict[str, Any]:
4229
+ raw_path = str(args.get("path", "")).strip()
4230
+ if not raw_path:
4231
+ raise SessionWorkdirError("Missing required argument: path")
4232
+ if not callable(set_active_workdir_callback):
4233
+ raise SessionWorkdirError("session_set_workdir is unavailable in this session.")
4234
+ return set_active_workdir_callback(raw_path, "tool")
4235
+
4236
+ _append_builtin_tool("session_set_workdir", run=_session_set_workdir)
4237
+
4238
+ def _host_action(tool_name: str, action: str, args: dict[str, Any]) -> dict[str, Any]:
4239
+ if host_action_handler is None or action not in effective_host_actions:
4240
+ raise AgentRuntimeError(
4241
+ f"{tool_name} is unavailable because the IDE host did not advertise this capability."
4242
+ )
4243
+ try:
4244
+ normalized = normalize_host_action_arguments(action, args)
4245
+ if action in {"tasks.run", "debug.start"}:
4246
+ if persona_write_scope_active:
4247
+ raise AgentRuntimeError(
4248
+ f"Blocked while persona write scope is active: {tool_name}. "
4249
+ "Opaque IDE executions cannot be constrained to persona paths."
4250
+ )
4251
+ if mode == "readonly":
4252
+ raise AgentRuntimeError(f"Blocked in readonly mode: {tool_name}")
4253
+ elif not host_managed_approvals:
4254
+ raise AgentRuntimeError(
4255
+ f"Explicit one-time IDE approval is required for {tool_name}."
4256
+ )
4257
+ else:
4258
+ identifier_field = "task_id" if action == "tasks.run" else "configuration_id"
4259
+ opaque_id = normalized[identifier_field]
4260
+ decision = surface.request_approval(
4261
+ ApprovalRequest(
4262
+ kind=tool_name,
4263
+ reason=(
4264
+ "opaque IDE task and debug configurations may execute arbitrary "
4265
+ "workspace commands"
4266
+ ),
4267
+ preview=(
4268
+ f"IDE host execution: {action}\n"
4269
+ f"{identifier_field}: {opaque_id}\n"
4270
+ f"workspace: {root}"
4271
+ ),
4272
+ metadata={
4273
+ "mandatory_explicit_approval": True,
4274
+ "allow_for_session_disabled": True,
4275
+ "host_action": action,
4276
+ "opaque_id": opaque_id,
4277
+ "workspace_root": str(root),
4278
+ },
4279
+ # Opaque task/configuration ids can resolve to changed commands later.
4280
+ # A cached or broad session grant must never authorize execution.
4281
+ allow_for_session_scope=None,
4282
+ )
4283
+ )
4284
+ if not decision.allow:
4285
+ raise ApprovalDeclinedError(tool_name)
4286
+ if decision.allow_for_session:
4287
+ raise AgentRuntimeError(
4288
+ f"Session approval cannot authorize opaque IDE execution: {tool_name}."
4289
+ )
4290
+ return host_action_handler(action, normalized)
4291
+ except HostActionError as exc:
4292
+ raise AgentRuntimeError(exc.message, result_payload=exc.to_result_payload()) from exc
4293
+
4294
+ host_tool_actions = {tool_name: action for action, tool_name in HOST_ACTION_TOOL_NAMES.items()}
4295
+ for tool_name, action in host_tool_actions.items():
4296
+ if action not in effective_host_actions or host_action_handler is None:
4297
+ continue
4298
+
4299
+ def _run_host_action(
4300
+ args: dict[str, Any],
4301
+ *,
4302
+ _tool_name: str = tool_name,
4303
+ _action: str = action,
4304
+ ) -> dict[str, Any]:
4305
+ if _action in {"tasks.terminate", "debug.stop"}:
4306
+ guard_terminal_op(_tool_name)
4307
+ return _host_action(_tool_name, _action, args)
4308
+
4309
+ _append_builtin_tool(tool_name, run=_run_host_action)
4310
+
4311
+ _append_builtin_tool(
4312
+ "git_status",
4313
+ run=lambda _args: git_status(root=root),
4314
+ )
4315
+
4316
+ _append_builtin_tool(
4317
+ "git_diff",
4318
+ run=lambda _args: git_diff(root=root),
4319
+ )
4320
+
4321
+ if git_backed_workspace:
4322
+ _append_builtin_tool(
4323
+ "git_history",
4324
+ run=lambda args: git_history(
4325
+ root=root,
4326
+ mode=str(args.get("mode", "")),
4327
+ path=str(args["path"]) if args.get("path") is not None else None,
4328
+ limit=int(args["limit"]) if args.get("limit") is not None else 10,
4329
+ ref=str(args["ref"]) if args.get("ref") is not None else None,
4330
+ grep=str(args["grep"]) if args.get("grep") is not None else None,
4331
+ author=str(args["author"]) if args.get("author") is not None else None,
4332
+ commit=str(args["commit"]) if args.get("commit") is not None else None,
4333
+ start_line=(
4334
+ int(args["start_line"]) if args.get("start_line") is not None else None
4335
+ ),
4336
+ end_line=(int(args["end_line"]) if args.get("end_line") is not None else None),
4337
+ ),
4338
+ )
4339
+
4340
+ def _git_apply(args: dict[str, Any]) -> dict[str, Any]:
4341
+ patch = str(args.get("patch", ""))
4342
+ patch_paths = sorted(set(iter_patch_paths(patch)))
4343
+ for p in patch_paths:
4344
+ _guard_write_path(p)
4345
+ preconditions = [capture_file_precondition(root=root, path=p) for p in patch_paths]
4346
+ sensitive = guard_sensitive_files("git_apply_patch", files=patch_paths)
4347
+ preview = patch[:20000]
4348
+ if sensitive:
4349
+ store.append(
4350
+ "sensitive_change_preview",
4351
+ {
4352
+ "paths": patch_paths,
4353
+ "operation": "git_apply_patch",
4354
+ "content_redacted": True,
4355
+ },
4356
+ )
4357
+ else:
4358
+ store.append("diff_preview", {"patch": preview})
4359
+ surface.on_patch_generated(
4360
+ PatchEvent(
4361
+ files=patch_paths,
4362
+ diff=patch,
4363
+ summary=f"{len(patch_paths)} file(s) changed via git_apply_patch",
4364
+ )
4365
+ )
4366
+ guard_write("git_apply_patch", preview or "(empty patch)", files=patch_paths)
4367
+ try:
4368
+ for precondition in preconditions:
4369
+ assert_file_precondition(root=root, precondition=precondition)
4370
+ result = git_apply_patch(root=root, patch=patch)
4371
+ except StaleFileError as error:
4372
+ return _stale_file_result(error)
4373
+ return _mark_sensitive_result(result, sensitive)
4374
+
4375
+ _append_builtin_tool("git_apply_patch", run=_git_apply)
4376
+
4377
+ if subagents_enabled and subagent_depth == 0:
4378
+ callable_subagent_names = routable_subagent_names(
4379
+ registry=subagent_registry,
4380
+ cfg=cfg,
4381
+ available_tool_names={tool.name for tool in tools},
4382
+ )
4383
+ subagent_parameters = copied_tool_parameters("subagent_run")
4384
+ properties = subagent_parameters.get("properties")
4385
+ if not isinstance(properties, dict):
4386
+ raise AgentRuntimeError("subagent_run parameters must define properties")
4387
+ subagent_name_schema = properties.get("name")
4388
+ if not isinstance(subagent_name_schema, dict):
4389
+ raise AgentRuntimeError("subagent_run parameters must define a name property")
4390
+ if callable_subagent_names:
4391
+ subagent_name_schema["enum"] = callable_subagent_names
4392
+
4393
+ child_run_registry = ChildRunRegistry()
4394
+ workspace_provider = (
4395
+ SubagentWorkspaceProvider(root=root, store=store)
4396
+ if (
4397
+ (cfg is None or cfg.subagent_orchestration.workspace_isolation_enabled)
4398
+ and isinstance(store, SessionStore)
4399
+ )
4400
+ else None
4401
+ )
4402
+ subagent_launcher = SubagentLauncher(
4403
+ root=root,
4404
+ surface=surface,
4405
+ store=store,
4406
+ mode=mode,
4407
+ yes=yes,
4408
+ cfg=cfg,
4409
+ api_key=api_key,
4410
+ max_steps=max_steps,
4411
+ no_log=no_log,
4412
+ usage_role=usage_role,
4413
+ usage_summary=usage_summary,
4414
+ deny_write_prefixes=deny_write_prefixes,
4415
+ allow_write_globs=allow_write_globs,
4416
+ persona_allow_write_globs=persona_allow_write_globs,
4417
+ non_interactive=non_interactive,
4418
+ verification_enabled=verification_enabled,
4419
+ authoritative_verification_commands=authoritative_verify_commands,
4420
+ subagents_enabled=subagents_enabled,
4421
+ subagent_depth=subagent_depth,
4422
+ subagent_registry=subagent_registry,
4423
+ session_log_dir_override=session_log_dir_override,
4424
+ step_budget_runtime=step_budget_runtime,
4425
+ get_active_workdir_relpath=get_active_workdir_relpath,
4426
+ create_session_factory=create_session_factory,
4427
+ prompt_cache_parent_session_id=prompt_cache_parent_session_id,
4428
+ execution_deadline=execution_deadline,
4429
+ crash_diagnostic_log_path=crash_diagnostic_log_path,
4430
+ crash_diagnostics=crash_diagnostics,
4431
+ tools=tools,
4432
+ command_mutation_metadata=_command_mutation_metadata,
4433
+ workspace_provider=workspace_provider,
4434
+ child_run_registry=child_run_registry,
4435
+ helpers_enabled_for_children=(
4436
+ resolved_runtime_kind
4437
+ not in {
4438
+ RuntimeKind.FORGE_EXEC,
4439
+ RuntimeKind.SWARM_WORKER,
4440
+ }
4441
+ ),
4442
+ managed_browser_service=managed_browser_service,
4443
+ managed_browser_owner_id=managed_browser_owner_id,
4444
+ managed_browser_cancel_check=managed_browser_cancel_check,
4445
+ )
4446
+ child_scheduler = ChildScheduler(
4447
+ launcher=subagent_launcher,
4448
+ max_background_children=(
4449
+ cfg.subagent_orchestration.max_background_children if cfg is not None else 3
4450
+ ),
4451
+ parent_steer_inbox=parent_steer_inbox,
4452
+ )
4453
+ if child_scheduler_sink is not None:
4454
+ child_scheduler_sink(child_scheduler)
4455
+ _append_builtin_tool(
4456
+ "subagent_run",
4457
+ parameters=subagent_parameters,
4458
+ run=subagent_launcher.run,
4459
+ )
4460
+ background_tools_enabled = resolved_runtime_kind not in {
4461
+ RuntimeKind.FORGE_EXEC,
4462
+ RuntimeKind.SWARM_WORKER,
4463
+ }
4464
+ if background_tools_enabled:
4465
+ spawn_parameters = copied_tool_parameters("subagent_spawn")
4466
+ spawn_properties = spawn_parameters.get("properties")
4467
+ if not isinstance(spawn_properties, dict):
4468
+ raise AgentRuntimeError("subagent_spawn parameters must define properties")
4469
+ spawn_name_schema = spawn_properties.get("name")
4470
+ if not isinstance(spawn_name_schema, dict):
4471
+ raise AgentRuntimeError("subagent_spawn parameters must define a name property")
4472
+ if callable_subagent_names:
4473
+ spawn_name_schema["enum"] = callable_subagent_names
4474
+
4475
+ def _subagent_spawn(args: dict[str, Any]) -> dict[str, Any]:
4476
+ parent_token = args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG)
4477
+ public_args = {key: value for key, value in args.items() if isinstance(key, str)}
4478
+ return child_scheduler.spawn(
4479
+ public_args,
4480
+ parent_cancellation_token=parent_token,
4481
+ )
4482
+
4483
+ def _subagent_status(args: dict[str, Any]) -> dict[str, Any]:
4484
+ raw_run_id = str(args.get("run_id") or "").strip()
4485
+ return child_scheduler.status(run_id=raw_run_id or None)
4486
+
4487
+ def _subagent_send(args: dict[str, Any]) -> dict[str, Any]:
4488
+ return child_scheduler.send(
4489
+ run_id=str(args.get("run_id") or "").strip(),
4490
+ message=str(args.get("message") or ""),
4491
+ )
4492
+
4493
+ def _subagent_resume(args: dict[str, Any]) -> dict[str, Any]:
4494
+ return child_scheduler.resume(
4495
+ args,
4496
+ parent_cancellation_token=args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG),
4497
+ )
4498
+
4499
+ def _subagent_wait(args: dict[str, Any]) -> dict[str, Any]:
4500
+ raw_run_id = str(args.get("run_id") or "all").strip() or "all"
4501
+ raw_timeout = args.get("timeout_s")
4502
+ timeout_s = float(raw_timeout) if raw_timeout is not None else None
4503
+ return child_scheduler.collect(
4504
+ run_id=raw_run_id,
4505
+ timeout_s=timeout_s,
4506
+ cancellation_token=args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG),
4507
+ )
4508
+
4509
+ def _subagent_cancel(args: dict[str, Any]) -> dict[str, Any]:
4510
+ raw_run_id = str(args.get("run_id") or "all").strip() or "all"
4511
+ return child_scheduler.cancel(run_id=raw_run_id, wait_for_running=False)
4512
+
4513
+ _append_builtin_tool(
4514
+ "subagent_spawn",
4515
+ parameters=spawn_parameters,
4516
+ run=_subagent_spawn,
4517
+ )
4518
+ _append_builtin_tool("subagent_send", run=_subagent_send)
4519
+ _append_builtin_tool("subagent_resume", run=_subagent_resume)
4520
+ _append_builtin_tool("subagent_status", run=_subagent_status)
4521
+ _append_builtin_tool("subagent_wait", run=_subagent_wait)
4522
+ _append_builtin_tool("subagent_cancel", run=_subagent_cancel)
4523
+
4524
+ workspace_actions_enabled = bool(
4525
+ workspace_provider is not None
4526
+ and (cfg is None or cfg.subagent_orchestration.workspace_isolation_enabled)
4527
+ )
4528
+ if workspace_actions_enabled:
4529
+
4530
+ def _subagent_apply(args: dict[str, Any]) -> dict[str, Any]:
4531
+ run_id = str(args.get("run_id") or "").strip()
4532
+ workspace_record = workspace_provider.get(run_id)
4533
+ if workspace_record is not None and workspace_record.no_changes:
4534
+ return workspace_provider.apply(run_id)
4535
+ preflight = child_scheduler.candidate_apply_preflight(
4536
+ run_id=run_id,
4537
+ acknowledge_incomplete=args.get("acknowledge_incomplete") is True,
4538
+ )
4539
+ if not bool(preflight.get("allowed")):
4540
+ return {key: value for key, value in preflight.items() if key != "allowed"}
4541
+ result = workspace_provider.apply(run_id)
4542
+ if preflight.get("incomplete_acknowledged"):
4543
+ result.update(
4544
+ {key: value for key, value in preflight.items() if key != "allowed"}
4545
+ )
4546
+ return result
4547
+
4548
+ def _subagent_discard(args: dict[str, Any]) -> dict[str, Any]:
4549
+ return workspace_provider.release(
4550
+ str(args.get("run_id") or "").strip(),
4551
+ action="discarded",
4552
+ )
4553
+
4554
+ _append_builtin_tool("subagent_apply", run=_subagent_apply)
4555
+ _append_builtin_tool("subagent_discard", run=_subagent_discard)
4556
+
4557
+ elif (
4558
+ helper_subagents_enabled
4559
+ and subagent_depth == 1
4560
+ and (cfg is None or cfg.subagent_orchestration.helpers_enabled)
4561
+ ):
4562
+ available_names = {tool.name for tool in tools}
4563
+ helper_names = helper_subagent_names(
4564
+ registry=subagent_registry,
4565
+ cfg=cfg,
4566
+ available_tool_names=available_names,
4567
+ )
4568
+ if helper_names and EDIT_CAPABLE_SUBAGENT_TOOL_NAMES.intersection(available_names):
4569
+ helper_parameters = copied_tool_parameters("subagent_run")
4570
+ helper_properties = helper_parameters.get("properties")
4571
+ if not isinstance(helper_properties, dict):
4572
+ raise AgentRuntimeError("subagent_run parameters must define properties")
4573
+ helper_parameters["properties"] = {
4574
+ name: schema
4575
+ for name, schema in helper_properties.items()
4576
+ if name in {"name", "task", "max_steps"}
4577
+ }
4578
+ helper_name_schema = helper_parameters["properties"].get("name")
4579
+ if not isinstance(helper_name_schema, dict):
4580
+ raise AgentRuntimeError("subagent_run parameters must define a name property")
4581
+ helper_name_schema["enum"] = helper_names
4582
+ helper_launcher = SubagentLauncher(
4583
+ root=root,
4584
+ surface=surface,
4585
+ store=store,
4586
+ mode=mode,
4587
+ yes=yes,
4588
+ cfg=cfg,
4589
+ api_key=api_key,
4590
+ max_steps=max_steps,
4591
+ no_log=no_log,
4592
+ usage_role=usage_role,
4593
+ usage_summary=usage_summary,
4594
+ deny_write_prefixes=deny_write_prefixes,
4595
+ allow_write_globs=allow_write_globs,
4596
+ persona_allow_write_globs=persona_allow_write_globs,
4597
+ non_interactive=non_interactive,
4598
+ verification_enabled=verification_enabled,
4599
+ authoritative_verification_commands=authoritative_verify_commands,
4600
+ subagents_enabled=True,
4601
+ subagent_depth=subagent_depth,
4602
+ subagent_registry=subagent_registry,
4603
+ session_log_dir_override=session_log_dir_override,
4604
+ step_budget_runtime=step_budget_runtime,
4605
+ get_active_workdir_relpath=get_active_workdir_relpath,
4606
+ create_session_factory=create_session_factory,
4607
+ prompt_cache_parent_session_id=prompt_cache_parent_session_id,
4608
+ execution_deadline=execution_deadline,
4609
+ crash_diagnostic_log_path=crash_diagnostic_log_path,
4610
+ crash_diagnostics=crash_diagnostics,
4611
+ tools=tools,
4612
+ command_mutation_metadata=_command_mutation_metadata,
4613
+ helper_only=True,
4614
+ helper_allowed_names=tuple(helper_names),
4615
+ )
4616
+ helper_description = (
4617
+ "Run one bounded non-editing helper in this workspace and return its "
4618
+ "advisory report. Helpers cannot delegate further."
4619
+ )
4620
+ tools.append(
4621
+ ToolDef(
4622
+ name="subagent_run",
4623
+ description=helper_description,
4624
+ parameters=helper_parameters,
4625
+ run=helper_launcher.run,
4626
+ metadata={
4627
+ "tool_type": "builtin",
4628
+ "compact_parameters_for_model": True,
4629
+ "model_description": helper_description,
4630
+ },
4631
+ )
4632
+ )
4633
+
4634
+ for custom_tool_spec in sorted(
4635
+ custom_tool_session_state.exposed_tools_by_name.values(),
4636
+ key=lambda spec: spec.name.casefold(),
4637
+ ):
4638
+ tools.append(
4639
+ ToolDef(
4640
+ name=custom_tool_spec.name,
4641
+ description=custom_tool_spec.description,
4642
+ parameters=copy.deepcopy(custom_tool_spec.input_schema),
4643
+ run=lambda args, spec=custom_tool_spec: _run_custom_tool(spec, args),
4644
+ metadata={
4645
+ "tool_type": "custom_tool",
4646
+ "compact_parameters_for_model": True,
4647
+ "model_description_max_chars": 1200,
4648
+ "custom_tool": custom_tool_spec.metadata(include_output_schema=True),
4649
+ },
4650
+ )
4651
+ )
4652
+
4653
+ if mcp_manager is not None and _mcp_tool_exposed_in_mode(
4654
+ mode=mode,
4655
+ write_scope_restricted=persona_write_scope_active,
4656
+ ):
4657
+ for binding in mcp_manager.tool_bindings:
4658
+ bound_binding = binding.bind_session_mode(mode)
4659
+ tools.append(
4660
+ ToolDef(
4661
+ name=bound_binding.tool_alias,
4662
+ description=bound_binding.description,
4663
+ parameters=bound_binding.parameters,
4664
+ run=bound_binding.run,
4665
+ metadata={
4666
+ "tool_type": "mcp",
4667
+ "compact_parameters_for_model": True,
4668
+ "model_description_max_chars": 1000,
4669
+ },
4670
+ )
4671
+ )
4672
+ active_tools = {t.name: t for t in tools}
4673
+ if tool_dispatch_guard is not None:
4674
+
4675
+ def _resolve_guard_rel_path(
4676
+ *,
4677
+ raw_path: Any,
4678
+ raw_base: Any = None,
4679
+ field_name: str,
4680
+ base_field_name: str,
4681
+ ) -> str:
4682
+ return _resolve_workspace_relative_path(
4683
+ tool_name="tool_dispatch_guard",
4684
+ raw_path=raw_path,
4685
+ raw_base=raw_base,
4686
+ field_name=field_name,
4687
+ base_field_name=base_field_name,
4688
+ )
4689
+
4690
+ def _wrap_with_dispatch_guard(tool: ToolDef) -> ToolDef:
4691
+ original_run = tool.run
4692
+
4693
+ def guarded_run(
4694
+ arguments: dict[str, Any],
4695
+ *,
4696
+ _original_run: Callable[[dict[str, Any]], dict[str, Any]] = original_run,
4697
+ _tool_name: str = tool.name,
4698
+ ) -> dict[str, Any]:
4699
+ tool_dispatch_guard.check_tool_call(
4700
+ _tool_name,
4701
+ arguments if isinstance(arguments, dict) else {},
4702
+ resolve_rel_path=_resolve_guard_rel_path,
4703
+ )
4704
+ return _original_run(arguments)
4705
+
4706
+ return replace(tool, run=guarded_run)
4707
+
4708
+ active_tools = {
4709
+ name: _wrap_with_dispatch_guard(tool) for name, tool in active_tools.items()
4710
+ }
4711
+ for metadata in iter_builtin_tool_metadata():
4712
+ register_tool_availability(metadata.name, optional=metadata.optional)
4713
+ if metadata.name in active_tools:
4714
+ mark_available(metadata.name)
4715
+ elif metadata.optional:
4716
+ if metadata.name == "image_generate" and (
4717
+ cfg is None or not cfg.image_generation.enabled
4718
+ ):
4719
+ reason = "image_generation.enabled is false"
4720
+ elif metadata.name == "image_generate":
4721
+ reason = f"image generation is not exposed in mode={mode}"
4722
+ else:
4723
+ reason = metadata.optional_unavailable_reason or (
4724
+ "not registered in active tool registry "
4725
+ f"for mode={mode} runtime_kind={resolved_runtime_kind.value}"
4726
+ )
4727
+ mark_unavailable(metadata.name, reason)
4728
+ return active_tools