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,3218 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import dataclasses
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import threading
10
+ import warnings
11
+ from collections import Counter
12
+ from collections.abc import Callable, Collection, Iterable
13
+ from dataclasses import dataclass, field, replace
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from .. import __version__
19
+ from ..agent import _patchable
20
+ from ..agentbox_integration import AgentBoxTelemetry
21
+ from ..background_runner import (
22
+ DisabledBackgroundRunner,
23
+ LazyBackgroundShellRunner,
24
+ build_background_shell_runner_from_settings,
25
+ )
26
+ from ..branding import env_get
27
+ from ..budget_policy import (
28
+ BUDGET_STOP_EXIT_CODE,
29
+ STOP_REASON_RUN_BUDGET_EXHAUSTED,
30
+ is_budget_cancellation,
31
+ resolve_budget_grace_seconds,
32
+ )
33
+ from ..build_identity import load_build_info
34
+ from ..cancellation import CooperativeCancellationError, EventCancellationToken
35
+ from ..compaction.conversation_compactor import ConversationCompactor
36
+ from ..compaction.settings import resolve_compaction_settings
37
+ from ..compaction.tool_output_offload import ToolOutputOffloader
38
+ from ..config import (
39
+ AppConfig,
40
+ ConfigError,
41
+ get_api_key,
42
+ resolve_api_key,
43
+ resolve_crash_diagnostic_log_path,
44
+ resolve_llm_enable_thinking,
45
+ resolve_llm_reasoning_effort,
46
+ resolve_llm_timeout_s,
47
+ resolve_prompt_cache_key,
48
+ resolve_prompt_cache_retention,
49
+ resolve_role_temperature,
50
+ )
51
+ from ..crash_diagnostics import (
52
+ CrashDiagnosticLogger,
53
+ build_crash_diagnostic_logger,
54
+ build_error_event_fields,
55
+ )
56
+ from ..custom_tools import CustomToolSessionState, build_custom_tool_session_state
57
+ from ..durable_service_manager import DurableServiceManager
58
+ from ..edit_discipline import EditDisciplineState
59
+ from ..error_text import sanitize_error_text_for_output
60
+ from ..execution_deadline import (
61
+ MINIMUM_FORCED_SUMMARY_SECONDS,
62
+ DeadlineExhausted,
63
+ ExecutionDeadline,
64
+ temporarily_clamp_client_timeout,
65
+ )
66
+ from ..extensions.activation import (
67
+ ActivationDecision,
68
+ WorkspaceTrustPromptFn,
69
+ WorkspaceTrustPromptRequest,
70
+ )
71
+ from ..hooks import (
72
+ HOOK_AUDIT_ARTIFACT_PARTS,
73
+ HookDispatcher,
74
+ HookDispatchResult,
75
+ ResolvedHookConfig,
76
+ load_resolved_hooks_config,
77
+ )
78
+ from ..host_actions import HostActionHandler
79
+ from ..internal_artifacts import (
80
+ ArtifactVisibility,
81
+ mark_message_internal,
82
+ summary_input_messages,
83
+ )
84
+ from ..llm.base import (
85
+ ChatClient,
86
+ count_input_tokens_if_supported,
87
+ effective_tools_for_client,
88
+ )
89
+ from ..llm.cache_policy import build_prompt_cache_namespace, derive_prompt_cache_stream_key
90
+ from ..llm.factory import _resolve_base_url, make_llm_client
91
+ from ..llm.metadata import (
92
+ PROVIDER_METADATA_KEY,
93
+ assistant_message_from_response,
94
+ endpoint_descriptor,
95
+ )
96
+ from ..llm.openai_compat import OpenAICompatClient as _OpenAICompatClient
97
+ from ..llm.protocols import OPENAI_COMPAT_PROTOCOL, get_provider_protocol_capabilities
98
+ from ..llm.types import UsageConfidence, UsageSource
99
+ from ..mcp.config import load_resolved_mcp_config
100
+ from ..mcp.manager import ForgeTaskScopedMcpManager, McpManager, create_mcp_manager
101
+ from ..model_metadata_policy import ActiveModelRef, evaluate_active_model_metadata_policy
102
+ from ..model_registry import ModelRegistry, resolve_model_provider_key
103
+ from ..model_router import ROLE_CODING, ROLE_COMPACTOR, resolve_model_for_role
104
+ from ..personas import (
105
+ PersonaSwitchState,
106
+ load_custom_personas,
107
+ normalize_persona,
108
+ persona_modes_enabled,
109
+ )
110
+ from ..process_reaping import (
111
+ ProcessGroupRegistry,
112
+ ReapAction,
113
+ ReapEvent,
114
+ _process_reaping_enabled,
115
+ reap_tracked_groups,
116
+ resolve_reap_decision,
117
+ survivor_payloads,
118
+ )
119
+ from ..profile_presets import find_preset_for_profile
120
+ from ..profiles import get_active_profile, resolve_effective_base_url
121
+ from ..provider_telemetry import (
122
+ ProviderCallTelemetryRecorder,
123
+ provider_telemetry_operation,
124
+ provider_telemetry_record_count,
125
+ set_provider_telemetry_sink,
126
+ )
127
+ from ..repo_scan import scan_workspace as scan_workspace
128
+ from ..request_estimation import (
129
+ estimate_request_token_breakdown,
130
+ estimate_request_tokens,
131
+ request_contains_media,
132
+ request_message_signatures,
133
+ tool_schema_signature,
134
+ )
135
+ from ..run_provenance import (
136
+ CONFIG_SNAPSHOT_EVENT,
137
+ config_snapshot_payload,
138
+ resolve_sampling_settings,
139
+ set_active_sampling_settings,
140
+ )
141
+ from ..runtime_context_features import resolve_runtime_context_features
142
+ from ..runtime_kind import RuntimeKind, resolve_session_runtime_kind
143
+ from ..sandbox_runner import (
144
+ DisabledShellRunner,
145
+ LazyShellRunner,
146
+ build_shell_runner,
147
+ build_shell_runner_from_settings,
148
+ with_closed_stdin,
149
+ )
150
+ from ..sandbox_settings import resolve_shell_sandbox_settings
151
+ from ..service_persistence import PersistentServiceRegistry
152
+ from ..session_store import SessionStore, make_session_id, resolve_sessions_dir
153
+ from ..skills import ConventionDocument, SkillBundle, SkillCatalogEntry
154
+ from ..step_budget import StepBudgetRuntime, normalize_step_budget_policy
155
+ from ..subagents import SubagentDefinition, unavailable_builtin_subagents
156
+ from ..surface import ApprovalRequest, NoopSurface, StatusEvent
157
+ from ..surface.base import Surface
158
+ from ..terminal_manager import TerminalManager
159
+ from ..tools.registry import iter_builtin_tool_metadata
160
+ from ..usage_tracker import (
161
+ ContextLeft,
162
+ RequestContextMeasurement,
163
+ UsageSummary,
164
+ build_usage_record,
165
+ compute_context_left,
166
+ usage_context_from_client_response,
167
+ )
168
+ from ..verify_gate import (
169
+ ResolvedVerifyCommands,
170
+ is_authoritative_verify_command_selection,
171
+ verification_selection_payload,
172
+ )
173
+ from ..workspace_binding import WorkspaceBinding
174
+ from ..workspace_provisioning import (
175
+ ProvisioningAction,
176
+ ShellCommandRunner,
177
+ ShellProbeResult,
178
+ _workspace_provisioning_enabled,
179
+ detect_declared_test_runner,
180
+ probe_runner_importable,
181
+ provision_test_runner,
182
+ provisioning_already_attempted,
183
+ resolve_provisioning_decision,
184
+ )
185
+ from .cache_keepalive import (
186
+ CacheKeepaliveRequest,
187
+ ParentCacheKeepalive,
188
+ cache_keepalive_unsupported_reason,
189
+ )
190
+ from .empty_response_stall import EmptyResponseStallTracker
191
+ from .errors import SessionWorkdirError
192
+ from .llm_calls import _main_agent_chat, _rewrite_final_summary_for_language
193
+ from .prompt_context import (
194
+ _build_plugin_activation_index,
195
+ _component_plugin_allowed,
196
+ _merge_dropped_counts,
197
+ _normalize_workspace_relpath,
198
+ _PluginActivationIndex,
199
+ _repo_summary_data,
200
+ _resolve_requested_workdir_within_workspace,
201
+ _session_verify_command_selection,
202
+ _subagent_context_message,
203
+ _workspace_binding_context_message,
204
+ _workspace_relpath_for_path,
205
+ _WorkspaceGroundingDescriptor,
206
+ prepare_session_prompt_context,
207
+ resolve_session_active_workdir_path,
208
+ resolve_session_active_workdir_relpath,
209
+ resolve_workdir_relpath_within_workspace,
210
+ set_session_active_workdir,
211
+ )
212
+ from .read_ledger import SessionReadLedger
213
+ from .tools_assembly import (
214
+ ToolDef,
215
+ ToolDispatchGuard,
216
+ _custom_tools_write_scope_restricted,
217
+ _filter_custom_tool_session_state_for_plugins,
218
+ _filter_mcp_config_for_plugins,
219
+ build_tools,
220
+ )
221
+ from .turn import (
222
+ _FORCED_FINAL_SUMMARY_SYSTEM_PROMPT_TEMPLATE,
223
+ _looks_like_unexecuted_tool_call_markup,
224
+ )
225
+ from .turn import run_turn as _run_turn
226
+ from .turn.events import (
227
+ _emit_assistant_message_events,
228
+ _legacy_message_tool_events_required,
229
+ )
230
+
231
+ if TYPE_CHECKING:
232
+ from ..ide.managed_browser import ManagedBrowserService
233
+ from .steering import SteerInbox
234
+ from .subagent_execution import ChildScheduler
235
+
236
+ OpenAICompatClient = _OpenAICompatClient
237
+ _DEFAULT_CREATE_MCP_MANAGER = create_mcp_manager
238
+
239
+
240
+ def _build_workspace_trust_prompt(
241
+ *,
242
+ surface: Surface,
243
+ non_interactive: bool,
244
+ ) -> WorkspaceTrustPromptFn | None:
245
+ if non_interactive or env_get("ALYSIS_CI") == "1":
246
+ return None
247
+
248
+ def prompt(request: WorkspaceTrustPromptRequest) -> bool:
249
+ preview = (
250
+ f"Workspace: {request.repo_root}\n"
251
+ f"Overrides SHA-256: {request.overrides_sha256}\n"
252
+ f"Project enables: {', '.join(request.plugins_added) or '-'}\n"
253
+ f"Project disables: {', '.join(request.plugins_removed) or '-'}"
254
+ )
255
+ decision = surface.request_approval(
256
+ ApprovalRequest(
257
+ kind="workspace_trust",
258
+ reason="Trust this workspace's plugin enable/disable overrides?",
259
+ preview=preview,
260
+ files=[request.repo_root],
261
+ metadata=request.model_dump(mode="json"),
262
+ )
263
+ )
264
+ return bool(decision.allow)
265
+
266
+ return prompt
267
+
268
+
269
+ def _hook_plugin_id(hook_id: str | None, index: _PluginActivationIndex) -> str | None:
270
+ raw = str(hook_id or "").strip()
271
+ if "." not in raw:
272
+ return None
273
+ return index.slug_to_plugin_id.get(raw.split(".", 1)[0])
274
+
275
+
276
+ def _filter_hooks_config_for_plugins(
277
+ *,
278
+ config: ResolvedHookConfig,
279
+ activation_decision: ActivationDecision,
280
+ index: _PluginActivationIndex,
281
+ ) -> tuple[ResolvedHookConfig, Counter[str]]:
282
+ dropped_counts: Counter[str] = Counter()
283
+ groups_by_event: dict[str, tuple[Any, ...]] = {}
284
+ for event_name, groups in config.groups_by_event.items():
285
+ kept_groups = []
286
+ for group in groups:
287
+ kept_hooks = tuple(
288
+ hook
289
+ for hook in group.hooks
290
+ if _component_plugin_allowed(
291
+ _hook_plugin_id(hook.id, index),
292
+ activation_decision,
293
+ dropped_counts,
294
+ )
295
+ )
296
+ if kept_hooks:
297
+ kept_groups.append(replace(group, hooks=kept_hooks))
298
+ groups_by_event[event_name] = tuple(kept_groups)
299
+ return (
300
+ ResolvedHookConfig(
301
+ groups_by_event=groups_by_event,
302
+ loaded_paths=config.loaded_paths,
303
+ untrusted_project_paths=config.untrusted_project_paths,
304
+ ),
305
+ dropped_counts,
306
+ )
307
+
308
+
309
+ def _make_session_llm_client(
310
+ *,
311
+ cfg: AppConfig,
312
+ api_key: str,
313
+ model: str,
314
+ timeout_s: float | None,
315
+ temperature: float,
316
+ prompt_cache_key: str | None,
317
+ prompt_cache_retention: str | None,
318
+ prompt_cache_namespace: str | None,
319
+ enable_thinking: bool | None,
320
+ reasoning_effort: str | None,
321
+ session_id: str | None,
322
+ ) -> ChatClient:
323
+ openai_client_cls = _patchable("OpenAICompatClient", OpenAICompatClient)
324
+ if openai_client_cls is _OpenAICompatClient:
325
+ return make_llm_client(
326
+ cfg=cfg,
327
+ api_key=api_key,
328
+ model=model,
329
+ timeout_s=timeout_s,
330
+ temperature=temperature,
331
+ prompt_cache_key=prompt_cache_key,
332
+ prompt_cache_retention=prompt_cache_retention,
333
+ prompt_cache_namespace=prompt_cache_namespace,
334
+ enable_thinking=enable_thinking,
335
+ reasoning_effort=reasoning_effort,
336
+ session_id=session_id,
337
+ )
338
+
339
+ profile = get_active_profile(cfg)
340
+ return openai_client_cls(
341
+ base_url=_resolve_base_url(cfg=cfg, profile=profile),
342
+ api_key=api_key,
343
+ model=model,
344
+ timeout_s=60.0 if timeout_s is None else timeout_s,
345
+ temperature=temperature,
346
+ prompt_cache_key=prompt_cache_key,
347
+ prompt_cache_retention=prompt_cache_retention,
348
+ enable_thinking=enable_thinking,
349
+ reasoning_effort=reasoning_effort,
350
+ extra_headers=profile.extra_headers,
351
+ )
352
+
353
+
354
+ def _git_branch(root: Path) -> str:
355
+ try:
356
+ proc = subprocess.run(
357
+ ["git", "-C", os.fspath(root), "rev-parse", "--abbrev-ref", "HEAD"],
358
+ check=False,
359
+ capture_output=True,
360
+ text=True,
361
+ )
362
+ except OSError:
363
+ return "-"
364
+ if proc.returncode != 0:
365
+ return "-"
366
+ branch = proc.stdout.strip()
367
+ return branch or "-"
368
+
369
+
370
+ def _git_is_dirty(root: Path) -> bool:
371
+ try:
372
+ proc = subprocess.run(
373
+ ["git", "-C", os.fspath(root), "status", "--porcelain"],
374
+ check=False,
375
+ capture_output=True,
376
+ text=True,
377
+ )
378
+ except OSError:
379
+ return False
380
+ if proc.returncode != 0:
381
+ return False
382
+ return bool(proc.stdout.strip())
383
+
384
+
385
+ def _surface_needs_startup_git_status(surface: Surface) -> bool:
386
+ if isinstance(surface, NoopSurface):
387
+ return False
388
+ # RichSurface stores this flag internally; when hidden, skip expensive startup git probes.
389
+ show_status_line = getattr(surface, "_show_status_line", None)
390
+ if isinstance(show_status_line, bool):
391
+ return show_status_line
392
+ return True
393
+
394
+
395
+ def _meaningful_surface_warning_handler(surface: Surface | object) -> Callable[[str], None] | None:
396
+ surface_cls = getattr(surface, "__class__", None)
397
+ handler = getattr(surface, "emit_warning", None)
398
+ if callable(handler):
399
+ cls_handler = getattr(surface_cls, "emit_warning", None)
400
+ if cls_handler is not getattr(NoopSurface, "emit_warning", None):
401
+ return handler
402
+ return _meaningful_surface_legacy_warning_handler(surface)
403
+
404
+
405
+ def _meaningful_surface_legacy_warning_handler(
406
+ surface: Surface | object,
407
+ ) -> Callable[[str], None] | None:
408
+ surface_cls = getattr(surface, "__class__", None)
409
+ handler = getattr(surface, "on_warning", None)
410
+ if not callable(handler):
411
+ return None
412
+ cls_handler = getattr(surface_cls, "on_warning", None)
413
+ if cls_handler is getattr(NoopSurface, "on_warning", None):
414
+ return None
415
+ return handler
416
+
417
+
418
+ def _repo_summary(root: Path) -> str:
419
+ return _repo_summary_data(root).text
420
+
421
+
422
+ def _disable_unsupported_native_streaming(
423
+ *,
424
+ cfg: AppConfig,
425
+ ) -> tuple[AppConfig, str | None]:
426
+ if not bool(getattr(cfg, "stream", False)):
427
+ return cfg, None
428
+ profile = get_active_profile(cfg)
429
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
430
+ if protocol == OPENAI_COMPAT_PROTOCOL:
431
+ return cfg, None
432
+ base_url = _resolve_base_url(cfg=cfg, profile=profile)
433
+ provider_key = resolve_model_provider_key(
434
+ cfg=cfg,
435
+ model_name=cfg.model,
436
+ base_url=base_url,
437
+ profile_name=profile.name,
438
+ )
439
+ capabilities = get_provider_protocol_capabilities(
440
+ provider_key=provider_key,
441
+ protocol=protocol,
442
+ )
443
+ # Unknown provider capabilities must not disable streaming: assume it works
444
+ # and rely on the per-step stream-unsupported fallback in the turn loop to
445
+ # downgrade at runtime if the provider rejects it.
446
+ streaming_supported = capabilities.supports_streaming if capabilities is not None else True
447
+ if streaming_supported:
448
+ return cfg, None
449
+ warning = (
450
+ f"Streaming requested but profile {profile.name!r} uses protocol={protocol!r}, "
451
+ "which does not support streaming yet in Alysis Code; streaming is disabled for this run."
452
+ )
453
+ return cfg.model_copy(update={"stream": False}, deep=True), warning
454
+
455
+
456
+ class ForcedFinalSummaryTerminationKind(StrEnum):
457
+ STEP_BUDGET_EXHAUSTED = "step_budget_exhausted"
458
+ COMPLETION_GATE_STAGNATION = "completion_gate_stagnation"
459
+ EXECUTION_GUARD_STAGNATION = "execution_guard_stagnation"
460
+ DEADLINE_EXHAUSTED = "deadline_exhausted"
461
+ OTHER = "other"
462
+
463
+
464
+ def _normalize_forced_summary_termination_kind(
465
+ value: str | ForcedFinalSummaryTerminationKind,
466
+ ) -> ForcedFinalSummaryTerminationKind:
467
+ if isinstance(value, ForcedFinalSummaryTerminationKind):
468
+ return value
469
+ normalized = str(value or "").strip().lower()
470
+ for item in ForcedFinalSummaryTerminationKind:
471
+ if normalized == item.value:
472
+ return item
473
+ return ForcedFinalSummaryTerminationKind.OTHER
474
+
475
+
476
+ def _add_event_diagnostics(
477
+ payload: dict[str, Any],
478
+ diagnostics: dict[str, Any] | None,
479
+ ) -> dict[str, Any]:
480
+ if not diagnostics:
481
+ return payload
482
+ for key, value in diagnostics.items():
483
+ payload.setdefault(key, value)
484
+ return payload
485
+
486
+
487
+ @dataclass
488
+ class AgentSession:
489
+ cfg: AppConfig
490
+ root: Path
491
+ mode: str
492
+ yes: bool
493
+ stream: bool
494
+ routing_mode: str
495
+ max_steps: int | None
496
+ console: Any | None
497
+ surface: Surface
498
+ store: SessionStore
499
+ client: ChatClient
500
+ model_registry: ModelRegistry
501
+ usage_summary: UsageSummary
502
+ usage_role: str
503
+ tool_output_offloader: ToolOutputOffloader | None
504
+ conversation_compactor: ConversationCompactor | None
505
+ tool_output_offload_enabled: bool
506
+ conversation_summarization_enabled: bool
507
+ compaction_profile: str
508
+ tools: dict[str, ToolDef]
509
+ tool_list: list[dict[str, Any]]
510
+ messages: list[dict[str, Any]]
511
+ _cache_efficiency_summary_recorded: bool = field(
512
+ default=False,
513
+ init=False,
514
+ repr=False,
515
+ )
516
+ # Machine-readable reason this run stopped, set by whichever path
517
+ # terminates the turn. Read by close() so the run_finished crash event
518
+ # carries it, which is how a harness tells a budget stop from a crash.
519
+ stop_reason: str | None = field(default=None, init=False, repr=False)
520
+ startup_messages: list[dict[str, Any]] = field(default_factory=list)
521
+ runtime_kind: RuntimeKind = RuntimeKind.INTERACTIVE_CHAT
522
+ prompt_cache_stream_key: str | None = None
523
+ mcp_manager: McpManager | ForgeTaskScopedMcpManager | None = None
524
+ terminal_manager: TerminalManager | None = None
525
+ durable_service_manager: DurableServiceManager | None = None
526
+ persistent_service_registry: PersistentServiceRegistry | None = None
527
+ edit_discipline: EditDisciplineState | None = None
528
+ managed_browser_service: ManagedBrowserService | None = None
529
+ managed_browser_owner_id: str | None = None
530
+ managed_browser_cancel_check: Callable[[], bool] | None = None
531
+ router_client: Any | None = None
532
+ _semantic_router_bound_client: Any | None = None
533
+ _provisioned_router_client: Any | None = None
534
+ api_key: str = ""
535
+ api_key_source: str = "missing"
536
+ shell_runner: Any | None = None
537
+ no_log: bool = False
538
+ non_interactive: bool = False
539
+ one_shot_execution: bool = False
540
+ # Persona mode (code|architect|ask|debug). A convention layered on the
541
+ # execution-mode gate, never an enforcement layer; "code" is the no-op
542
+ # persona. See docs/persona_modes_design.md.
543
+ persona: str = "code"
544
+ # The user's chosen execution mode remembered while a narrowing persona
545
+ # (architect/ask) is active, so switching back to code/debug restores it.
546
+ # None when the active persona narrows nothing. An explicit /mode <exec>
547
+ # always wins and clears the restore point (and restores the write scope).
548
+ persona_restore_mode: str | None = None
549
+ # The user's base allow_write_globs snapshotted alongside
550
+ # persona_restore_mode (may legitimately be None = unrestricted; the
551
+ # restore-active signal is persona_restore_mode itself).
552
+ persona_restore_write_globs: list[str] | None = None
553
+ # Active persona scope, enforced independently from the user's base
554
+ # allow_write_globs. A path must satisfy both scopes when both exist.
555
+ persona_allow_write_globs: list[str] | None = None
556
+ # Coordination cell for the switch_mode tool (interactive chat only; None
557
+ # everywhere else, which also keeps the tool unregistered).
558
+ persona_switch_state: PersonaSwitchState | None = None
559
+ # Persona clients are keyed by the resolved client configuration that can
560
+ # vary by role. Model-only caching would reuse the wrong temperature when
561
+ # two roles intentionally share a model.
562
+ persona_client_cache: dict[tuple[str, float], Any] | None = None
563
+ persona_client_key: tuple[str, float] | None = None
564
+ # Custom personas loaded from .alysis_personas / <user-config>/personas
565
+ # (interactive chat only; builtins always win on name collisions).
566
+ persona_registry: dict[str, Any] | None = None
567
+ persona_registry_warnings: tuple[str, ...] = ()
568
+ enable_chat_turn_step_budget: bool = False
569
+ chat_turn_fixed_override: int | None = None
570
+ verification_enabled: bool = True
571
+ effective_verification_commands: list[str] = field(default_factory=list)
572
+ authoritative_verification_commands: list[str] | None = None
573
+ verification_selection_source: str = ""
574
+ verification_selection_reason: str = ""
575
+ verification_contract_type: str = ""
576
+ verification_authoritative: bool = False
577
+ verification_best_effort: bool = False
578
+ deny_write_prefixes: list[str] | None = None
579
+ allow_write_globs: list[str] | None = None
580
+ session_log_dir_override: Path | None = None
581
+ skills_enabled: bool = True
582
+ skills_auto_invoke: bool = True
583
+ skill_registry: dict[str, SkillBundle] | None = None
584
+ skills_ordered: tuple[SkillBundle, ...] = ()
585
+ skill_discovery_issues: tuple[Any, ...] = ()
586
+ skill_catalog_entries: tuple[SkillCatalogEntry, ...] = ()
587
+ repo_conventions: tuple[ConventionDocument, ...] = ()
588
+ subagents_enabled: bool = False
589
+ enforce_explicit_subagent_requests: bool = True
590
+ subagent_depth: int = 0
591
+ subagent_registry: dict[str, SubagentDefinition] | None = None
592
+ child_scheduler: ChildScheduler | None = None
593
+ steer_inbox: SteerInbox = field(default_factory=SteerInbox)
594
+ child_repetition_signal: Callable[[dict[str, Any]], bool] | None = None
595
+ read_ledger: SessionReadLedger | None = None
596
+ step_system_message_provider: Callable[[], list[str]] | None = None
597
+ step_system_message_delivery_observer: Callable[[int], None] | None = None
598
+ step_budget_runtime: StepBudgetRuntime | None = None
599
+ planner_workspace_context: dict[str, Any] | None = None
600
+ workspace_grounding: _WorkspaceGroundingDescriptor | None = None
601
+ focus_dir: Path | None = None
602
+ focus_relpath: str = "."
603
+ workspace_kind: str = "plain_dir"
604
+ binding_requested_path: str | None = None
605
+ binding_source: str | None = None
606
+ binding_risk_level: str | None = None
607
+ binding_created_path: bool | None = None
608
+ active_workdir_relpath: str = "."
609
+ session_source: str = "startup"
610
+ session_source_metadata: dict[str, Any] = field(default_factory=dict)
611
+ pinned_prefix_len: int = 0
612
+ startup_context_baseline_tokens: int = 0
613
+ request_context_measurement: RequestContextMeasurement | None = None
614
+ workspace_touched_paths: set[str] = field(default_factory=set)
615
+ workspace_writes_allowed: bool | None = None
616
+ custom_tool_session_state: CustomToolSessionState | None = None
617
+ hook_dispatcher: HookDispatcher | None = None
618
+ execution_deadline: ExecutionDeadline | None = None
619
+ cache_keepalive: ParentCacheKeepalive | None = None
620
+ crash_diagnostics: CrashDiagnosticLogger | None = None
621
+ crash_diagnostic_log_path: str | None = None
622
+ agentbox_telemetry: AgentBoxTelemetry | None = None
623
+ process_group_registry: ProcessGroupRegistry | None = None
624
+ # Empty-response handling is budgeted per session, not per turn: two failed
625
+ # recovery cycles mean the endpoint is not answering, and re-spending the
626
+ # budget every turn would reintroduce the unbounded retrying this bounds.
627
+ empty_response_stall_tracker: EmptyResponseStallTracker | None = None
628
+
629
+ def workspace_write_contract_allows_writes(self) -> bool:
630
+ """Return the effective write capability after role/tool filtering."""
631
+ writes_allowed = self.workspace_writes_allowed
632
+ if writes_allowed is None:
633
+ writes_allowed = str(self.mode or "").strip().lower() != "readonly"
634
+ return writes_allowed
635
+
636
+ def initial_outstanding_action(self) -> str:
637
+ """Describe the first unmet outcome allowed by this session's contract."""
638
+ if self.workspace_write_contract_allows_writes():
639
+ return "edit a relevant path"
640
+ return "deliver the requested analysis or report"
641
+
642
+ def __post_init__(self) -> None:
643
+ self._bind_provider_retry_observer(self.client)
644
+ if self.child_scheduler is None:
645
+ subagent_tool = self.tools.get("subagent_run")
646
+ launcher = getattr(getattr(subagent_tool, "run", None), "__self__", None)
647
+ scheduler = getattr(launcher, "child_scheduler", None)
648
+ if scheduler is not None:
649
+ self.child_scheduler = scheduler
650
+ if self.child_scheduler is not None:
651
+ self.child_scheduler.set_parent_steer_inbox(self.steer_inbox)
652
+ if self.cache_keepalive is None and self.subagent_depth == 0:
653
+ cache_config = self.cfg.cache
654
+ unsupported_reason = cache_keepalive_unsupported_reason(self.client)
655
+ self.cache_keepalive = ParentCacheKeepalive(
656
+ enabled=cache_config.keepalive_enabled,
657
+ idle_threshold_s=cache_config.keepalive_idle_threshold_s,
658
+ send_ping=self._send_cache_keepalive,
659
+ on_disabled=self._on_cache_keepalive_disabled,
660
+ on_unsupported=self._on_cache_keepalive_unsupported,
661
+ unsupported_reason=unsupported_reason,
662
+ deadline=self.execution_deadline,
663
+ )
664
+
665
+ def _bind_provider_retry_observer(self, client: Any) -> None:
666
+ if client is None:
667
+ return
668
+
669
+ def _record_retry(payload: dict[str, object]) -> None:
670
+ safe_payload = {
671
+ "provider": str(payload.get("provider") or "unknown"),
672
+ "attempt": max(1, int(payload.get("attempt") or 1)),
673
+ "reason": str(payload.get("reason") or "provider_retry"),
674
+ "elapsed_ms": max(0, int(payload.get("elapsed_ms") or 0)),
675
+ }
676
+ self.store.append("llm_call_retry", safe_payload)
677
+ set_activity = getattr(self.surface, "set_model_retry_activity", None)
678
+ if callable(set_activity):
679
+ set_activity(safe_payload["attempt"])
680
+
681
+ try:
682
+ client._provider_retry_event_observer = _record_retry
683
+ except Exception: # noqa: BLE001 - diagnostics must not break a provider.
684
+ pass
685
+
686
+ def _reap_tracked_process_groups(self, *, event: ReapEvent) -> None:
687
+ """Terminate or report the process groups this session's runner started.
688
+
689
+ Runs on every turn exit path (success, honest-unverified, error,
690
+ cancellation) and again at session close. Only groups the runner itself
691
+ created and recorded are ever signalled; nothing is discovered by name
692
+ or by scanning the process table.
693
+ """
694
+ registry = self.process_group_registry
695
+ if registry is None:
696
+ return
697
+ try:
698
+ decision = resolve_reap_decision(
699
+ runtime_kind=self.runtime_kind,
700
+ event=event,
701
+ enabled=_process_reaping_enabled(self.cfg),
702
+ )
703
+ if decision.action is ReapAction.SKIP:
704
+ return
705
+ if decision.action is ReapAction.REPORT:
706
+ survivors = survivor_payloads(registry)
707
+ if survivors:
708
+ self.store.append(
709
+ "process_survivors",
710
+ {
711
+ "event": str(event),
712
+ "runtime_kind": self.runtime_kind.value,
713
+ "reason": decision.reason,
714
+ "count": len(survivors),
715
+ "groups": list(survivors),
716
+ },
717
+ )
718
+ return
719
+ for outcome in reap_tracked_groups(registry):
720
+ self.store.append(
721
+ "process_reaped",
722
+ {
723
+ "event": str(event),
724
+ "runtime_kind": self.runtime_kind.value,
725
+ "reason": decision.reason,
726
+ **outcome.payload(),
727
+ },
728
+ )
729
+ except Exception as exc: # noqa: BLE001 - hygiene must never break the turn
730
+ try:
731
+ self.store.append(
732
+ "warning",
733
+ {"warning": "process_reaping_failed", "error": str(exc)},
734
+ )
735
+ except Exception: # noqa: BLE001 - best-effort diagnostic
736
+ pass
737
+
738
+ def close(self, *, reason: str = "session_close") -> None:
739
+ self._reap_tracked_process_groups(event=ReapEvent.SESSION_CLOSE)
740
+ if self.cache_keepalive is not None:
741
+ self.cache_keepalive.close()
742
+ if self.child_scheduler is not None:
743
+ try:
744
+ self.child_scheduler.shutdown(cancel_pending=True)
745
+ except Exception as exc: # noqa: BLE001 - session teardown must continue
746
+ self._hook_warning(
747
+ f"Child scheduler shutdown failed: {exc}",
748
+ code="child_scheduler_shutdown_failed",
749
+ )
750
+ if self.subagent_depth == 0:
751
+ # Release the process-wide telemetry sink registered for this top-level run.
752
+ set_provider_telemetry_sink(None)
753
+ if self.terminal_manager is not None:
754
+ try:
755
+ self.terminal_manager.shutdown_all()
756
+ except Exception as exc: # noqa: BLE001
757
+ # Session teardown must continue even if terminal shutdown hits an unexpected bug.
758
+ self._hook_warning(
759
+ f"Terminal manager shutdown failed: {exc}",
760
+ code="terminal_shutdown_failed",
761
+ )
762
+ if self.durable_service_manager is not None:
763
+ try:
764
+ active_services = self.durable_service_manager.list_active()
765
+ except Exception as exc: # noqa: BLE001
766
+ self._hook_warning(
767
+ f"Durable service status check failed during close: {exc}",
768
+ code="durable_service_status_failed",
769
+ )
770
+ else:
771
+ if active_services:
772
+ self.store.append(
773
+ "durable_services_left_active",
774
+ {
775
+ "count": len(active_services),
776
+ "services": active_services,
777
+ },
778
+ )
779
+ if not self._cache_efficiency_summary_recorded:
780
+ cache_summary = self.usage_summary.cache_efficiency_summary()
781
+ if int(cache_summary.get("reported_calls") or 0) > 0:
782
+ self.store.append("cache_efficiency_summary", cache_summary)
783
+ self._cache_efficiency_summary_recorded = True
784
+ try:
785
+ if self.crash_diagnostics is not None:
786
+ run_finished_payload: dict[str, Any] = {
787
+ "status": reason,
788
+ "runtime_kind": self.runtime_kind.value,
789
+ "deadline": (
790
+ self.execution_deadline.telemetry_snapshot()
791
+ if self.execution_deadline is not None
792
+ else None
793
+ ),
794
+ }
795
+ if self.stop_reason:
796
+ # Only present when a path actually claimed a reason, so an
797
+ # ordinary run's event is byte-identical to before.
798
+ run_finished_payload["stop_reason"] = self.stop_reason
799
+ self.crash_diagnostics.event(
800
+ "run_finished",
801
+ run_finished_payload,
802
+ durable=True,
803
+ )
804
+ cwd, active_workdir_relpath = self._hook_runtime_context()
805
+ self._safe_dispatch_hooks(
806
+ lambda: self.hook_dispatcher.fire_session_end(
807
+ cwd=cwd,
808
+ active_workdir_relpath=active_workdir_relpath,
809
+ payload={
810
+ "reason": reason,
811
+ "mode": self.mode,
812
+ "runtime_kind": self.runtime_kind.value,
813
+ "session_source": self.session_source,
814
+ "session_source_metadata": copy.deepcopy(self.session_source_metadata),
815
+ "workspace_root": os.fspath(self.root),
816
+ "focus_dir": os.fspath(self.focus_dir or self.root),
817
+ "focus_relpath": self.focus_relpath,
818
+ "active_workdir": os.fspath(cwd),
819
+ "active_workdir_relpath": active_workdir_relpath,
820
+ "workspace_kind": self.workspace_kind,
821
+ "usage_role": self.usage_role,
822
+ "message_count": len(self.messages),
823
+ "pinned_prefix_len": self.pinned_prefix_len,
824
+ "subagent_depth": self.subagent_depth,
825
+ "skills_enabled": self.skills_enabled,
826
+ "subagents_enabled": self.subagents_enabled,
827
+ },
828
+ )
829
+ )
830
+ if self.mcp_manager is not None:
831
+ self.mcp_manager.close()
832
+ finally:
833
+ if self.agentbox_telemetry is not None:
834
+ self.agentbox_telemetry.close(error=reason not in {"session_close", "completed"})
835
+ self.store.close()
836
+
837
+ def _hook_warning(self, message: str, *, code: str = "hook_warning") -> None:
838
+ clean = str(message or "").strip()
839
+ if not clean:
840
+ return
841
+ self.store.append("warning", {"warning": code, "message": clean})
842
+ surface_on_warning = _meaningful_surface_warning_handler(self.surface)
843
+ if callable(surface_on_warning):
844
+ surface_on_warning(clean)
845
+ else:
846
+ warnings.warn(clean, stacklevel=2)
847
+
848
+ def _safe_dispatch_hooks(
849
+ self,
850
+ dispatcher_call: Callable[[], HookDispatchResult],
851
+ ) -> HookDispatchResult:
852
+ if self.hook_dispatcher is None:
853
+ return HookDispatchResult()
854
+ try:
855
+ result = dispatcher_call()
856
+ except Exception as exc: # noqa: BLE001
857
+ self._hook_warning(
858
+ f"Lifecycle hook dispatch failed: {exc}",
859
+ code="hook_dispatch_failed",
860
+ )
861
+ return HookDispatchResult()
862
+ for notice in result.system_notices:
863
+ self._hook_notice(notice)
864
+ return result
865
+
866
+ def _hook_notice(self, message: str) -> None:
867
+ clean = str(message or "").strip()
868
+ if not clean:
869
+ return
870
+ self.store.append("hook_notice", {"message": clean})
871
+ handler = getattr(self.surface, "on_notice", None)
872
+ if callable(handler):
873
+ handler(clean)
874
+ return
875
+ fallback = _meaningful_surface_legacy_warning_handler(self.surface)
876
+ if callable(fallback):
877
+ fallback(clean)
878
+
879
+ def _hook_runtime_context(self) -> tuple[Path, str]:
880
+ return (
881
+ resolve_session_active_workdir_path(self),
882
+ resolve_session_active_workdir_relpath(self),
883
+ )
884
+
885
+ def _append_hook_messages(
886
+ self,
887
+ *,
888
+ event_name: str,
889
+ system_messages: Iterable[str] = (),
890
+ user_messages: Iterable[str] = (),
891
+ pinned: bool = False,
892
+ ) -> int:
893
+ appended_count = 0
894
+ for role, messages in (("system", system_messages), ("user", user_messages)):
895
+ for raw_message in messages:
896
+ text = str(raw_message or "").strip()
897
+ if not text:
898
+ continue
899
+ self.messages.append({"role": role, "content": text})
900
+ appended_count += 1
901
+ self.store.append(
902
+ "hook_message_added",
903
+ {
904
+ "event_name": event_name,
905
+ "role": role,
906
+ "chars": len(text),
907
+ "pinned": pinned,
908
+ },
909
+ )
910
+ if pinned and appended_count > 0:
911
+ self.pinned_prefix_len += appended_count
912
+ return appended_count
913
+
914
+ def context_left(self) -> ContextLeft:
915
+ compaction_settings = resolve_compaction_settings(self.cfg)
916
+ effective_tool_list = effective_tools_for_client(self.client, self.tool_list)
917
+ startup_baseline_tokens = self.startup_context_baseline_tokens
918
+ if self.startup_messages:
919
+ # Tool support can change after the provider rejects a tool-bearing
920
+ # request. Keep the dynamic HUD baseline aligned with what the
921
+ # client's current transport state will actually send.
922
+ startup_baseline_tokens = estimate_request_token_breakdown(
923
+ messages=self.startup_messages,
924
+ tool_list=effective_tool_list,
925
+ pinned_prefix_len=self.pinned_prefix_len,
926
+ ).total_tokens
927
+ usage_context = usage_context_from_client_response(
928
+ client=self.client,
929
+ response=None,
930
+ operation="main_llm",
931
+ )
932
+ request_measurement = self.request_context_measurement
933
+ if request_measurement is not None and not request_measurement.matches_route(
934
+ requested_model=self.client.model,
935
+ provider_key=usage_context.get("provider_key"),
936
+ protocol=usage_context.get("protocol"),
937
+ base_url_host=usage_context.get("base_url_host"),
938
+ ):
939
+ request_measurement = None
940
+ calibration = self.usage_summary.recent_calibration_snapshot(
941
+ requested_model=self.client.model,
942
+ provider_key=usage_context.get("provider_key"),
943
+ protocol=usage_context.get("protocol"),
944
+ base_url_host=usage_context.get("base_url_host"),
945
+ operation="main_llm",
946
+ request_mode=(request_measurement.request_mode if request_measurement else None),
947
+ cache_strategy=(request_measurement.cache_strategy if request_measurement else None),
948
+ limit=20,
949
+ )
950
+ estimate_multiplier = calibration.get("prompt_estimate_error_ratio_p90")
951
+ return compute_context_left(
952
+ messages=self.messages,
953
+ model_name=self.client.model,
954
+ registry=self.model_registry,
955
+ tool_list=effective_tool_list,
956
+ pinned_prefix_len=self.pinned_prefix_len,
957
+ safety_margin_tokens=compaction_settings.safety_margin_tokens,
958
+ startup_baseline_tokens=startup_baseline_tokens,
959
+ prompt_estimate_multiplier=(
960
+ float(estimate_multiplier) if isinstance(estimate_multiplier, int | float) else None
961
+ ),
962
+ request_measurement=request_measurement,
963
+ )
964
+
965
+ def refresh_compactor_calibration_filters(self) -> None:
966
+ compactor = self.conversation_compactor
967
+ updater = getattr(compactor, "update_calibration_filters", None)
968
+ if not callable(updater):
969
+ return
970
+ updater(
971
+ usage_context_from_client_response(
972
+ client=self.client,
973
+ response=None,
974
+ operation="main_llm",
975
+ )
976
+ )
977
+
978
+ def invalidate_request_context(self, *, reason: str) -> None:
979
+ if self.request_context_measurement is None:
980
+ return
981
+ self.request_context_measurement = None
982
+ self.store.append(
983
+ "request_context_invalidated",
984
+ {"reason": str(reason or "request_shape_changed")},
985
+ )
986
+
987
+ @staticmethod
988
+ def _normalize_visible_assistant_text(text: str) -> str:
989
+ return str(text or "").strip()
990
+
991
+ def _emit_assistant_message_if_changed(
992
+ self,
993
+ *,
994
+ text: str,
995
+ prior_visible_text: str = "",
996
+ extra_payload: dict[str, Any] | None = None,
997
+ streamed_text_emitted: bool = False,
998
+ ) -> str:
999
+ normalized_text = self._normalize_visible_assistant_text(text)
1000
+ if not normalized_text:
1001
+ if extra_payload:
1002
+ payload = {"content": text}
1003
+ payload.update(extra_payload)
1004
+ self.store.append("assistant_message", payload)
1005
+ return self._normalize_visible_assistant_text(prior_visible_text)
1006
+ if normalized_text == self._normalize_visible_assistant_text(prior_visible_text):
1007
+ if extra_payload:
1008
+ payload = {"content": text}
1009
+ payload.update(extra_payload)
1010
+ self.store.append("assistant_message", payload)
1011
+ return normalized_text
1012
+ payload = {"content": text}
1013
+ if extra_payload:
1014
+ payload.update(extra_payload)
1015
+ self.store.append("assistant_message", payload)
1016
+ _emit_assistant_message_events(
1017
+ self.surface,
1018
+ text,
1019
+ streamed_text_emitted=streamed_text_emitted,
1020
+ )
1021
+ if _legacy_message_tool_events_required(self.surface):
1022
+ self.surface.on_assistant_message_done(text)
1023
+ return normalized_text
1024
+
1025
+ def _record_llm_usage(
1026
+ self,
1027
+ *,
1028
+ client: Any,
1029
+ response: Any,
1030
+ messages: list[dict[str, Any]],
1031
+ tool_list: list[dict[str, Any]] | None,
1032
+ operation: str,
1033
+ role_override: str | None = None,
1034
+ ) -> Any | None:
1035
+ """Record one provider call without allowing telemetry to break the turn."""
1036
+ if response is None:
1037
+ return None
1038
+ try:
1039
+ tool_list = effective_tools_for_client(client, tool_list)
1040
+ usage = getattr(response, "usage", None)
1041
+ prompt_tokens = getattr(usage, "prompt_tokens", None) if usage else None
1042
+ usage_context = usage_context_from_client_response(
1043
+ client=client,
1044
+ response=response,
1045
+ operation=operation,
1046
+ )
1047
+ prompt_token_source = str(
1048
+ usage_context.get("api_usage_source_detail") or UsageSource.PROVIDER_RESPONSE.value
1049
+ )
1050
+ prompt_token_confidence = str(
1051
+ usage_context.get("api_usage_confidence") or UsageConfidence.REPORTED.value
1052
+ )
1053
+ if prompt_tokens is None:
1054
+ try:
1055
+ counted_input = count_input_tokens_if_supported(
1056
+ client=client,
1057
+ messages=list(messages or []),
1058
+ tools=tool_list,
1059
+ )
1060
+ except Exception as exc: # noqa: BLE001 -- fallback must not break a turn
1061
+ self.store.append(
1062
+ "warning",
1063
+ {
1064
+ "warning": "provider_input_token_count_failed",
1065
+ "operation": operation,
1066
+ "error": str(exc),
1067
+ },
1068
+ )
1069
+ else:
1070
+ if counted_input is not None:
1071
+ prompt_tokens = counted_input.input_tokens
1072
+ usage_context["api_usage_source_detail"] = counted_input.source.value
1073
+ usage_context["api_usage_confidence"] = counted_input.confidence.value
1074
+ usage_context["api_prompt_tokens_authoritative"] = (
1075
+ counted_input.confidence.value == "authoritative"
1076
+ )
1077
+ prompt_token_source = counted_input.source.value
1078
+ prompt_token_confidence = counted_input.confidence.value
1079
+ response_tool_calls = [
1080
+ {
1081
+ "id": getattr(tool_call, "id", ""),
1082
+ "name": getattr(tool_call, "name", ""),
1083
+ "arguments": getattr(tool_call, "arguments", {}),
1084
+ }
1085
+ for tool_call in (getattr(response, "tool_calls", None) or [])
1086
+ ]
1087
+ usage_record = build_usage_record(
1088
+ role=role_override or self.usage_role,
1089
+ requested_model=getattr(client, "model", None) or self.client.model,
1090
+ response_model=getattr(response, "response_model", None),
1091
+ messages=list(messages or []),
1092
+ response_content=str(getattr(response, "content", "") or ""),
1093
+ response_tool_calls=response_tool_calls,
1094
+ api_prompt_tokens=prompt_tokens,
1095
+ api_completion_tokens=(
1096
+ getattr(usage, "completion_tokens", None) if usage else None
1097
+ ),
1098
+ api_total_tokens=(getattr(usage, "total_tokens", None) if usage else None),
1099
+ api_usage=usage,
1100
+ api_cached_prompt_tokens=(
1101
+ getattr(usage, "cached_prompt_tokens", None) if usage else None
1102
+ ),
1103
+ tool_list=tool_list,
1104
+ pinned_prefix_len=self.pinned_prefix_len,
1105
+ registry=self.model_registry,
1106
+ **usage_context,
1107
+ )
1108
+ self.usage_summary.add_record(usage_record)
1109
+ if operation in {"main_llm", "context_overflow_retry"}:
1110
+ if prompt_tokens is None or usage_record.prompt_tokens != prompt_tokens:
1111
+ prompt_token_source = UsageSource.LOCAL_ESTIMATE.value
1112
+ prompt_token_confidence = UsageConfidence.ESTIMATED.value
1113
+ measurement_tools = tool_list
1114
+ request_plan = usage_record.request_plan or {}
1115
+ try:
1116
+ planned_tool_count = int(request_plan.get("tool_count"))
1117
+ except (TypeError, ValueError):
1118
+ planned_tool_count = -1
1119
+ if planned_tool_count == 0:
1120
+ measurement_tools = None
1121
+ self.request_context_measurement = RequestContextMeasurement(
1122
+ input_tokens=max(0, usage_record.prompt_tokens),
1123
+ anchor_estimate_tokens=estimate_request_tokens(
1124
+ list(messages or []),
1125
+ measurement_tools,
1126
+ ),
1127
+ persistent_anchor_estimate_tokens=estimate_request_tokens(
1128
+ self.messages,
1129
+ measurement_tools,
1130
+ ),
1131
+ source=prompt_token_source,
1132
+ confidence=prompt_token_confidence,
1133
+ requested_model=usage_record.requested_model,
1134
+ provider_key=usage_record.provider_key,
1135
+ protocol=usage_record.protocol,
1136
+ base_url_host=usage_record.base_url_host,
1137
+ operation=usage_record.operation,
1138
+ request_mode=usage_record.request_mode,
1139
+ cache_strategy=usage_record.cache_strategy,
1140
+ request_message_signatures=request_message_signatures(list(messages or [])),
1141
+ persistent_message_signatures=request_message_signatures(self.messages),
1142
+ tool_schema_signature=tool_schema_signature(measurement_tools),
1143
+ request_has_media=request_contains_media(list(messages or [])),
1144
+ persistent_has_media=request_contains_media(self.messages),
1145
+ )
1146
+ self.store.append("llm_usage", usage_record.to_payload())
1147
+ if self.agentbox_telemetry is not None:
1148
+ self.agentbox_telemetry.record_usage(usage_record)
1149
+ return usage_record
1150
+ except Exception as exc: # noqa: BLE001 -- accounting cannot break the agent turn
1151
+ self.store.append(
1152
+ "warning",
1153
+ {
1154
+ "warning": "llm_usage_record_failed",
1155
+ "operation": operation,
1156
+ "error": str(exc),
1157
+ },
1158
+ )
1159
+ return None
1160
+
1161
+ def _send_cache_keepalive(
1162
+ self,
1163
+ request: CacheKeepaliveRequest,
1164
+ stop_event: Any,
1165
+ ) -> bool:
1166
+ """Replay the exact parent prefix and discard the bounded response."""
1167
+ tools = effective_tools_for_client(self.client, request.tools)
1168
+ tool_choice = (
1169
+ copy.deepcopy(request.tool_choice)
1170
+ if tools is not None and request.tool_choice is not None
1171
+ else None
1172
+ )
1173
+ telemetry_count_before = provider_telemetry_record_count()
1174
+ try:
1175
+ with provider_telemetry_operation("cache_keepalive"):
1176
+ response = _main_agent_chat(
1177
+ client=self.client,
1178
+ messages=copy.deepcopy(request.messages),
1179
+ tools=copy.deepcopy(tools),
1180
+ stream=False,
1181
+ on_text_delta=None,
1182
+ temperature=0.0,
1183
+ max_tokens=16,
1184
+ cancellation_token=EventCancellationToken(stop_event),
1185
+ tool_choice=tool_choice,
1186
+ )
1187
+ except Exception as exc: # noqa: BLE001 - affinity is best-effort only
1188
+ if provider_telemetry_record_count() == telemetry_count_before:
1189
+ ProviderCallTelemetryRecorder(
1190
+ provider_key=getattr(self.client, "provider_key", None),
1191
+ protocol=str(getattr(self.client, "protocol", "unknown") or "unknown"),
1192
+ model=str(getattr(self.client, "model", "") or "unknown"),
1193
+ base_url=str(getattr(self.client, "base_url", "") or ""),
1194
+ stream=False,
1195
+ tools=tools,
1196
+ operation="cache_keepalive",
1197
+ ).record_error(exc)
1198
+ self.store.append(
1199
+ "cache_keepalive",
1200
+ {
1201
+ "status": "failed",
1202
+ "error_class": type(exc).__name__,
1203
+ "error": sanitize_error_text_for_output(exc),
1204
+ "request": {
1205
+ "message_count": len(request.messages),
1206
+ "tool_count": len(tools or []),
1207
+ "stream": False,
1208
+ "temperature": 0.0,
1209
+ "max_tokens": 16,
1210
+ "tool_choice_present": tool_choice is not None,
1211
+ },
1212
+ },
1213
+ )
1214
+ return False
1215
+ usage_record = self._record_llm_usage(
1216
+ client=self.client,
1217
+ response=response,
1218
+ messages=request.messages,
1219
+ tool_list=tools,
1220
+ operation="cache_keepalive",
1221
+ role_override=f"{self.usage_role}:cache_keepalive",
1222
+ )
1223
+ self.store.append(
1224
+ "cache_keepalive",
1225
+ {
1226
+ "status": "completed",
1227
+ "request": {
1228
+ "message_count": len(request.messages),
1229
+ "tool_count": len(tools or []),
1230
+ "stream": False,
1231
+ "temperature": 0.0,
1232
+ "max_tokens": 16,
1233
+ "tool_choice_present": tool_choice is not None,
1234
+ },
1235
+ "usage": usage_record.to_payload() if usage_record is not None else None,
1236
+ },
1237
+ )
1238
+ return True
1239
+
1240
+ def _on_cache_keepalive_disabled(self, consecutive_failures: int) -> None:
1241
+ self.store.append(
1242
+ "warning",
1243
+ {
1244
+ "warning": "cache_keepalive_disabled_after_failures",
1245
+ "consecutive_failures": consecutive_failures,
1246
+ "message": (
1247
+ "Cache keepalive disabled for this session after repeated failures; "
1248
+ "child execution continues unchanged."
1249
+ ),
1250
+ },
1251
+ )
1252
+
1253
+ def _on_cache_keepalive_unsupported(self, reason: str) -> None:
1254
+ self.store.append(
1255
+ "warning",
1256
+ {
1257
+ "warning": "keepalive_unsupported_transport",
1258
+ "reason": reason,
1259
+ "message": (
1260
+ "Cache keepalive is disabled because this transport does not replay the "
1261
+ "parent's active prompt-cache stream."
1262
+ ),
1263
+ },
1264
+ )
1265
+
1266
+ def _emit_final_assistant_text(
1267
+ self,
1268
+ *,
1269
+ final_text: str,
1270
+ assistant_response: Any | None = None,
1271
+ language: str = "",
1272
+ script: str = "",
1273
+ explicit_language_override: bool = False,
1274
+ prior_visible_text: str = "",
1275
+ streamed_text_emitted: bool = False,
1276
+ final_event_payload: dict[str, Any] | None = None,
1277
+ internal_fallback: bool = False,
1278
+ internal_fallback_kind: str = "",
1279
+ ) -> str:
1280
+ emitted_text = str(final_text or "").strip()
1281
+ emitted_text, rewrite_payload = _rewrite_final_summary_for_language(
1282
+ client=self.client,
1283
+ final_text=emitted_text,
1284
+ language=language,
1285
+ script=script,
1286
+ explicit_language_override=explicit_language_override,
1287
+ record_usage=lambda **kw: self._record_llm_usage(client=self.client, **kw),
1288
+ )
1289
+ if rewrite_payload is not None:
1290
+ self.store.append("final_summary_rewrite", rewrite_payload)
1291
+ assistant_message = None
1292
+ if assistant_response is not None:
1293
+ candidate_message = assistant_message_from_response(
1294
+ assistant_response,
1295
+ content=emitted_text,
1296
+ )
1297
+ if PROVIDER_METADATA_KEY in candidate_message:
1298
+ assistant_message = candidate_message
1299
+ extra_payload = {"message": assistant_message} if assistant_message is not None else None
1300
+ if internal_fallback and self.subagent_depth > 0:
1301
+ # A nested run's locally generated stop report is internal state. The
1302
+ # nested surface forwards assistant messages up to the parent's panel,
1303
+ # so emitting it here would render the dump to the user even though the
1304
+ # tool result never carries it. Record it; do not show it.
1305
+ self.store.append(
1306
+ "assistant_message",
1307
+ {"content": emitted_text, "internal_fallback": True},
1308
+ )
1309
+ else:
1310
+ self._emit_assistant_message_if_changed(
1311
+ text=emitted_text,
1312
+ prior_visible_text=prior_visible_text,
1313
+ extra_payload=extra_payload,
1314
+ streamed_text_emitted=streamed_text_emitted,
1315
+ )
1316
+ if assistant_message is not None:
1317
+ if internal_fallback:
1318
+ # Today a fallback always arrives with assistant_response=None, so
1319
+ # nothing is appended and this does not fire. It is here because the
1320
+ # invariant is "an internal artifact that enters the transcript is
1321
+ # marked", and that has to hold at the point of entry, not by anyone
1322
+ # remembering to re-check later.
1323
+ mark_message_internal(
1324
+ assistant_message,
1325
+ kind=internal_fallback_kind or "forced_final_summary_fallback",
1326
+ )
1327
+ self.messages.append(assistant_message)
1328
+ final_payload: dict[str, Any] = {"content": emitted_text}
1329
+ if internal_fallback:
1330
+ # A locally generated stop report, not a model answer. Recorded as a
1331
+ # fact here so the subagent boundary can refuse to hand it to a parent
1332
+ # as a deliverable, without inspecting the text.
1333
+ final_payload["internal_fallback"] = True
1334
+ final_payload["artifact_visibility"] = ArtifactVisibility.INTERNAL.value
1335
+ if internal_fallback_kind:
1336
+ final_payload["internal_fallback_kind"] = internal_fallback_kind
1337
+ self.store.append(
1338
+ "final",
1339
+ _add_event_diagnostics(final_payload, final_event_payload),
1340
+ )
1341
+ return emitted_text
1342
+
1343
+ def _forced_final_summary_activity_snapshot(self) -> dict[str, Any]:
1344
+ tool_calls_by_id: dict[str, tuple[str, dict[str, Any]]] = {}
1345
+ read_paths: list[str] = []
1346
+ listed_paths: list[str] = []
1347
+ edited_paths: list[str] = []
1348
+ verification_commands: list[str] = []
1349
+ shell_commands: list[str] = []
1350
+ other_actions: list[str] = []
1351
+ failed_actions: list[str] = []
1352
+
1353
+ def _append_unique(items: list[str], value: str) -> None:
1354
+ clean = str(value or "").strip()
1355
+ if clean and clean not in items:
1356
+ items.append(clean)
1357
+
1358
+ def _path_arg(args: dict[str, Any]) -> str:
1359
+ for key in ("path", "file", "target", "target_path"):
1360
+ value = args.get(key)
1361
+ if isinstance(value, str) and value.strip():
1362
+ return value.strip()
1363
+ return ""
1364
+
1365
+ def _command_arg(args: dict[str, Any]) -> str:
1366
+ for key in ("command", "cmd"):
1367
+ value = args.get(key)
1368
+ if isinstance(value, str) and value.strip():
1369
+ return value.strip()
1370
+ return ""
1371
+
1372
+ for message in self.messages:
1373
+ role = str(message.get("role") or "")
1374
+ if role == "assistant":
1375
+ for raw_call in message.get("tool_calls") or []:
1376
+ if not isinstance(raw_call, dict):
1377
+ continue
1378
+ call_id = str(raw_call.get("id") or "").strip()
1379
+ function = raw_call.get("function")
1380
+ if not call_id or not isinstance(function, dict):
1381
+ continue
1382
+ name = str(function.get("name") or "").strip()
1383
+ raw_args = function.get("arguments")
1384
+ args: dict[str, Any] = {}
1385
+ if isinstance(raw_args, str) and raw_args.strip():
1386
+ try:
1387
+ parsed_args = json.loads(raw_args)
1388
+ except Exception: # noqa: BLE001
1389
+ parsed_args = None
1390
+ if isinstance(parsed_args, dict):
1391
+ args = parsed_args
1392
+ if name:
1393
+ tool_calls_by_id[call_id] = (name, args)
1394
+ continue
1395
+ if role != "tool":
1396
+ continue
1397
+ call_id = str(message.get("tool_call_id") or "").strip()
1398
+ name, args = tool_calls_by_id.get(call_id, ("", {}))
1399
+ if not name:
1400
+ continue
1401
+ content = message.get("content")
1402
+ result: Any = None
1403
+ if isinstance(content, str) and content.strip():
1404
+ try:
1405
+ result = json.loads(content)
1406
+ except Exception: # noqa: BLE001
1407
+ result = None
1408
+ failed = isinstance(result, dict) and "error" in result
1409
+ path = _path_arg(args)
1410
+ command = _command_arg(args)
1411
+ if failed:
1412
+ label = f"{name} {path}".strip() if path else name
1413
+ _append_unique(failed_actions, label)
1414
+ continue
1415
+ if name == "fs_read" and path:
1416
+ _append_unique(read_paths, path)
1417
+ elif name == "fs_list" and path:
1418
+ _append_unique(listed_paths, path)
1419
+ elif name in {"fs_write", "fs_edit", "apply_patch"} and path:
1420
+ _append_unique(edited_paths, path)
1421
+ elif name in {"shell", "shell_command", "shell_run", "verify_run"} and command:
1422
+ if name == "verify_run":
1423
+ _append_unique(verification_commands, command)
1424
+ else:
1425
+ _append_unique(shell_commands, command)
1426
+ else:
1427
+ label = f"{name} {path}".strip() if path else name
1428
+ _append_unique(other_actions, label)
1429
+
1430
+ return {
1431
+ "read_paths": read_paths,
1432
+ "listed_paths": listed_paths,
1433
+ "edited_paths": edited_paths,
1434
+ "verification_commands": verification_commands,
1435
+ "shell_commands": shell_commands,
1436
+ "other_actions": other_actions,
1437
+ "failed_actions": failed_actions,
1438
+ }
1439
+
1440
+ def _forced_final_summary_fallback_text(
1441
+ self,
1442
+ *,
1443
+ termination_cause: str,
1444
+ termination_kind: str = "step_budget_exhausted",
1445
+ max_steps: int | None,
1446
+ fallback_reason: str,
1447
+ latest_assistant_text: str = "",
1448
+ ) -> str:
1449
+ snapshot = self._forced_final_summary_activity_snapshot()
1450
+
1451
+ def _join_limited(items: list[str], *, limit: int = 8) -> str:
1452
+ visible = items[:limit]
1453
+ text = ", ".join(visible)
1454
+ remaining = len(items) - len(visible)
1455
+ if remaining > 0:
1456
+ text += f", and {remaining} more"
1457
+ return text
1458
+
1459
+ completed: list[str] = []
1460
+ read_paths = snapshot["read_paths"]
1461
+ listed_paths = snapshot["listed_paths"]
1462
+ edited_paths = snapshot["edited_paths"]
1463
+ verification_commands = snapshot["verification_commands"]
1464
+ shell_commands = snapshot["shell_commands"]
1465
+ other_actions = snapshot["other_actions"]
1466
+ failed_actions = snapshot["failed_actions"]
1467
+
1468
+ if read_paths:
1469
+ completed.append(f"- Read files: {_join_limited(read_paths)}.")
1470
+ if listed_paths:
1471
+ completed.append(f"- Listed directories: {_join_limited(listed_paths)}.")
1472
+ if edited_paths:
1473
+ completed.append(f"- Edited files: {_join_limited(edited_paths)}.")
1474
+ if verification_commands:
1475
+ completed.append(
1476
+ f"- Ran verification: {_join_limited(verification_commands, limit=4)}."
1477
+ )
1478
+ if shell_commands:
1479
+ completed.append(f"- Ran shell commands: {_join_limited(shell_commands, limit=4)}.")
1480
+ if other_actions:
1481
+ completed.append(f"- Ran tools: {_join_limited(other_actions)}.")
1482
+ latest = str(latest_assistant_text or "").strip()
1483
+ if latest:
1484
+ latest = " ".join(latest.split())
1485
+ if len(latest) > 180:
1486
+ latest = latest[:177].rstrip() + "..."
1487
+ completed.append(f"- Last assistant progress note: {latest}")
1488
+ if not completed:
1489
+ completed.append(
1490
+ "- No durable repository change was completed before the turn stopped."
1491
+ )
1492
+
1493
+ remaining = [
1494
+ "- Continue from the recorded tool results instead of restarting from scratch.",
1495
+ ]
1496
+ writes_allowed = self.workspace_write_contract_allows_writes()
1497
+ if not edited_paths and writes_allowed:
1498
+ remaining.append(
1499
+ "- Implementation has not started yet; identify the smallest safe fix first."
1500
+ )
1501
+ elif not edited_paths:
1502
+ remaining.append("- Deliver the requested analysis or report.")
1503
+ if edited_paths and not verification_commands:
1504
+ remaining.append("- Run focused verification for the edited files before finalizing.")
1505
+ if failed_actions:
1506
+ remaining.append(
1507
+ f"- Resolve failed tool calls: {_join_limited(failed_actions, limit=5)}."
1508
+ )
1509
+ if writes_allowed:
1510
+ remaining.append("- Finish the requested implementation or report a concrete blocker.")
1511
+ else:
1512
+ remaining.append(
1513
+ "- Report a concrete blocker if the requested result cannot be delivered."
1514
+ )
1515
+
1516
+ kind = _normalize_forced_summary_termination_kind(termination_kind)
1517
+ if kind == ForcedFinalSummaryTerminationKind.STEP_BUDGET_EXHAUSTED:
1518
+ if max_steps is None:
1519
+ stop_risk = "- The turn stopped before completion."
1520
+ else:
1521
+ stop_risk = f"- The turn exhausted its {max_steps}-step limit before completion."
1522
+ elif kind == ForcedFinalSummaryTerminationKind.COMPLETION_GATE_STAGNATION:
1523
+ stop_risk = (
1524
+ "- Execution stopped after repeated invalid finalization attempts without "
1525
+ "new implementation or verification progress."
1526
+ )
1527
+ elif kind == ForcedFinalSummaryTerminationKind.EXECUTION_GUARD_STAGNATION:
1528
+ stop_risk = (
1529
+ "- Execution stopped after a runtime guard observed repeated no-progress behavior."
1530
+ )
1531
+ elif kind == ForcedFinalSummaryTerminationKind.DEADLINE_EXHAUSTED:
1532
+ stop_risk = "- The run deadline was exhausted before the turn could finish."
1533
+ else:
1534
+ stop_risk = "- The turn stopped before completion for the reported reason."
1535
+ risks = [
1536
+ stop_risk,
1537
+ "- This fallback was generated from runtime state before the turn terminated.",
1538
+ ]
1539
+ if not verification_commands:
1540
+ risks.append("- No verification result was recorded in this turn.")
1541
+
1542
+ return (
1543
+ f"The turn stopped before it could finish ({termination_cause}).\n\n"
1544
+ "Completed work:\n" + "\n".join(completed) + "\n\n"
1545
+ "Remaining work:\n" + "\n".join(remaining) + "\n\n"
1546
+ "Known issues or risks:\n" + "\n".join(risks)
1547
+ )
1548
+
1549
+ def _emit_forced_final_summary_before_termination(
1550
+ self,
1551
+ *,
1552
+ reason: str,
1553
+ termination_cause: str,
1554
+ termination_kind: str = "step_budget_exhausted",
1555
+ max_steps: int | None,
1556
+ language: str = "",
1557
+ script: str = "",
1558
+ explicit_language_override: bool = False,
1559
+ latest_assistant_text: str = "",
1560
+ allow_llm_summary: bool = True,
1561
+ local_summary_override: str = "",
1562
+ final_event_payload: dict[str, Any] | None = None,
1563
+ ) -> str:
1564
+ normalized_termination_kind = _normalize_forced_summary_termination_kind(
1565
+ termination_kind
1566
+ ).value
1567
+ # Internal artifacts (a previous turn's locally generated stop report) are
1568
+ # excluded here by marker, so a summary can never be built by re-narrating
1569
+ # one. Marker-based, never a scan of the text.
1570
+ request_messages = summary_input_messages(self.messages)
1571
+ latest_assistant_text = str(latest_assistant_text or "").strip()
1572
+ if latest_assistant_text:
1573
+ request_messages.append({"role": "assistant", "content": latest_assistant_text})
1574
+ request_messages.append(
1575
+ {
1576
+ "role": "system",
1577
+ "content": _FORCED_FINAL_SUMMARY_SYSTEM_PROMPT_TEMPLATE.format(
1578
+ termination_cause=termination_cause
1579
+ ),
1580
+ }
1581
+ )
1582
+ self.store.append(
1583
+ "forced_final_summary_requested",
1584
+ {
1585
+ "reason": reason,
1586
+ "termination_cause": termination_cause,
1587
+ "termination_kind": normalized_termination_kind,
1588
+ "max_steps": max_steps,
1589
+ },
1590
+ )
1591
+
1592
+ final_text = ""
1593
+ fallback_reason: str | None = None
1594
+ fallback_error: str | None = None
1595
+ resp = None
1596
+ deadline = self.execution_deadline
1597
+ if not allow_llm_summary or (
1598
+ deadline is not None and not deadline.can_start(MINIMUM_FORCED_SUMMARY_SECONDS)
1599
+ ):
1600
+ fallback_reason = "local_summary_due_to_deadline"
1601
+ else:
1602
+ try:
1603
+ with temporarily_clamp_client_timeout(
1604
+ self.client,
1605
+ deadline,
1606
+ operation="forced_final_summary_llm",
1607
+ ):
1608
+ resp = _main_agent_chat(
1609
+ client=self.client,
1610
+ messages=request_messages,
1611
+ tools=None,
1612
+ stream=False,
1613
+ on_text_delta=None,
1614
+ )
1615
+ except DeadlineExhausted:
1616
+ fallback_reason = "local_summary_due_to_deadline"
1617
+ except Exception as exc: # noqa: BLE001
1618
+ fallback_reason = "finalization_error"
1619
+ fallback_error = str(exc)
1620
+ else:
1621
+ self._record_llm_usage(
1622
+ client=self.client,
1623
+ response=resp,
1624
+ messages=request_messages,
1625
+ tool_list=None,
1626
+ operation="forced_final_summary_llm",
1627
+ )
1628
+ final_text = str(resp.content or "").strip()
1629
+ if resp.tool_calls:
1630
+ fallback_reason = "tool_call_response"
1631
+ elif not final_text:
1632
+ fallback_reason = "blank_response"
1633
+ elif _looks_like_unexecuted_tool_call_markup(final_text):
1634
+ fallback_reason = "tool_call_markup_response"
1635
+
1636
+ if fallback_reason is not None:
1637
+ # A caller that already built a factual account of what it salvaged
1638
+ # supplies it here, so the local summary reports real state instead
1639
+ # of the generic termination notice.
1640
+ final_text = str(local_summary_override or "").strip()
1641
+ if not final_text:
1642
+ final_text = self._forced_final_summary_fallback_text(
1643
+ termination_cause=termination_cause,
1644
+ termination_kind=normalized_termination_kind,
1645
+ max_steps=max_steps,
1646
+ fallback_reason=fallback_reason,
1647
+ latest_assistant_text=latest_assistant_text,
1648
+ )
1649
+ fallback_payload: dict[str, Any] = {
1650
+ "reason": reason,
1651
+ "termination_cause": termination_cause,
1652
+ "termination_kind": normalized_termination_kind,
1653
+ "max_steps": max_steps,
1654
+ "fallback_reason": fallback_reason,
1655
+ }
1656
+ if fallback_error:
1657
+ fallback_payload["error"] = fallback_error
1658
+ _add_event_diagnostics(fallback_payload, final_event_payload)
1659
+ self.store.append("forced_final_summary_fallback", fallback_payload)
1660
+
1661
+ emitted_text = self._emit_final_assistant_text(
1662
+ final_text=final_text,
1663
+ assistant_response=resp if fallback_reason is None else None,
1664
+ internal_fallback=fallback_reason is not None,
1665
+ internal_fallback_kind=normalized_termination_kind,
1666
+ language=language,
1667
+ script=script,
1668
+ explicit_language_override=explicit_language_override,
1669
+ # Thread the turn's last-shown answer so the change-dedup can suppress a
1670
+ # forced summary that merely repeats it (otherwise prior_visible_text
1671
+ # defaults to "" and on_assistant_message_done always re-fires).
1672
+ prior_visible_text=latest_assistant_text,
1673
+ final_event_payload=final_event_payload,
1674
+ )
1675
+ if fallback_reason is None:
1676
+ completed_payload = {
1677
+ "reason": reason,
1678
+ "termination_cause": termination_cause,
1679
+ "termination_kind": normalized_termination_kind,
1680
+ "max_steps": max_steps,
1681
+ "content_length": len(emitted_text),
1682
+ }
1683
+ _add_event_diagnostics(completed_payload, final_event_payload)
1684
+ self.store.append("forced_final_summary_completed", completed_payload)
1685
+ return emitted_text
1686
+
1687
+ def run_turn(
1688
+ self,
1689
+ instruction: str,
1690
+ *,
1691
+ image_paths: list[str] | None = None,
1692
+ routing_mode_override: str | None = None,
1693
+ ephemeral_system_messages: list[str] | tuple[str, ...] | None = None,
1694
+ ephemeral_user_messages: list[str] | tuple[str, ...] | None = None,
1695
+ cancellation_token: Any | None = None,
1696
+ chat_only: bool = False,
1697
+ ) -> int:
1698
+ turn_thread_id = threading.get_ident()
1699
+ self._turn_owner_thread_id = turn_thread_id
1700
+ self._bind_provider_retry_observer(self.client)
1701
+ try:
1702
+ if self.agentbox_telemetry is None:
1703
+ return _run_turn(
1704
+ self,
1705
+ instruction,
1706
+ image_paths=image_paths,
1707
+ routing_mode_override=routing_mode_override,
1708
+ ephemeral_system_messages=ephemeral_system_messages,
1709
+ ephemeral_user_messages=ephemeral_user_messages,
1710
+ cancellation_token=cancellation_token,
1711
+ chat_only=chat_only,
1712
+ )
1713
+ self.agentbox_telemetry.task(instruction)
1714
+ with self.agentbox_telemetry.turn():
1715
+ return _run_turn(
1716
+ self,
1717
+ instruction,
1718
+ image_paths=image_paths,
1719
+ routing_mode_override=routing_mode_override,
1720
+ ephemeral_system_messages=ephemeral_system_messages,
1721
+ ephemeral_user_messages=ephemeral_user_messages,
1722
+ cancellation_token=cancellation_token,
1723
+ chat_only=chat_only,
1724
+ )
1725
+ except CooperativeCancellationError as exc:
1726
+ # The budget watchdog cancels through the same cooperative channel a
1727
+ # user does, so the two are told apart by reason. A budget stop is a
1728
+ # normal outcome and is finalized cleanly; a user cancellation and
1729
+ # every other cooperative stop fall through to the failure boundary
1730
+ # below, unchanged.
1731
+ if not is_budget_cancellation(exc):
1732
+ self._emit_terminal_error(exc)
1733
+ raise
1734
+ return self._finalize_run_budget_stop()
1735
+ except Exception as exc:
1736
+ # Single authoritative terminal-failure boundary: every caller (one-shot
1737
+ # run, interactive chat, Forge workers, subagents) routes turns through
1738
+ # here, so one durable, redacted record makes a crashed build
1739
+ # reconstructable from artifacts alone. Re-raise unchanged afterwards.
1740
+ self._emit_terminal_error(exc)
1741
+ raise
1742
+ finally:
1743
+ # Same reasoning as the boundary above: this is the only per-turn point
1744
+ # that sees normal returns, exceptions, and cancellation, so turn-level
1745
+ # process hygiene belongs here rather than in the loop's success path.
1746
+ try:
1747
+ if (
1748
+ cancellation_token is not None
1749
+ and bool(getattr(cancellation_token, "is_cancelled", False))
1750
+ and self.child_scheduler is not None
1751
+ ):
1752
+ pending_run_ids = self.child_scheduler.pending_run_ids()
1753
+ if pending_run_ids:
1754
+ self.child_scheduler.cancel(
1755
+ run_id=pending_run_ids,
1756
+ wait_for_running=True,
1757
+ # Unbounded while there is budget left, as before.
1758
+ # Once it is gone this join has nothing to wait
1759
+ # with, and a child that ignores its cancellation
1760
+ # would otherwise pin the run open indefinitely --
1761
+ # on a path that only became reachable now that a
1762
+ # budget stop cancels cooperatively.
1763
+ wait_timeout_s=(
1764
+ resolve_budget_grace_seconds()
1765
+ if (
1766
+ self.execution_deadline is not None
1767
+ and self.execution_deadline.is_exhausted()
1768
+ )
1769
+ else None
1770
+ ),
1771
+ )
1772
+ self.store.append(
1773
+ "subagent_turn_end_enforcement",
1774
+ {
1775
+ "policy": str(self.cfg.subagent_orchestration.turn_end_policy),
1776
+ "action": "parent_cancel",
1777
+ "run_ids": pending_run_ids,
1778
+ },
1779
+ )
1780
+ finally:
1781
+ try:
1782
+ self._reap_tracked_process_groups(event=ReapEvent.TURN_FINALIZATION)
1783
+ finally:
1784
+ if getattr(self, "_turn_owner_thread_id", None) == turn_thread_id:
1785
+ self._turn_owner_thread_id = None
1786
+
1787
+ def _finalize_run_budget_stop(self) -> int:
1788
+ """Finalize a watchdog-cancelled run as a clean budget stop.
1789
+
1790
+ This is the backstop, not the common path. The turn engine's
1791
+ ``_deadline_exhausted_result`` handles a budget stop the run noticed
1792
+ itself and is richer, because it can still salvage workspace state.
1793
+ This runs only when the run was blocked somewhere no cooperative
1794
+ checkpoint could reach and had to be cancelled from outside: record the
1795
+ same markers, emit the same local (never model-generated) summary, and
1796
+ exit zero, because running out of time is an outcome and not a crash.
1797
+ """
1798
+ self.stop_reason = STOP_REASON_RUN_BUDGET_EXHAUSTED
1799
+ payload: dict[str, Any] = {
1800
+ "operation": "budget_watchdog",
1801
+ "stop_reason": STOP_REASON_RUN_BUDGET_EXHAUSTED,
1802
+ "deadline_exhausted": True,
1803
+ "deadline": (
1804
+ self.execution_deadline.telemetry_snapshot()
1805
+ if self.execution_deadline is not None
1806
+ else None
1807
+ ),
1808
+ }
1809
+ self.store.append("deadline_exhausted", payload)
1810
+ if self.crash_diagnostics is not None:
1811
+ self.crash_diagnostics.event("deadline_exhausted", payload, durable=True)
1812
+ try:
1813
+ self._emit_forced_final_summary_before_termination(
1814
+ reason="deadline_exhausted",
1815
+ termination_cause="the run deadline is exhausted",
1816
+ termination_kind="deadline_exhausted",
1817
+ max_steps=None,
1818
+ # No LLM call: the budget is already gone, and this PR adds no
1819
+ # model-visible traffic on the stop path.
1820
+ allow_llm_summary=False,
1821
+ final_event_payload={
1822
+ "degraded": False,
1823
+ "degraded_reason": STOP_REASON_RUN_BUDGET_EXHAUSTED,
1824
+ "stop_reason": STOP_REASON_RUN_BUDGET_EXHAUSTED,
1825
+ },
1826
+ )
1827
+ except Exception as exc: # noqa: BLE001 - a clean stop must stay clean
1828
+ self.store.append(
1829
+ "warning",
1830
+ {"warning": "budget_stop_summary_failed", "error": str(exc)},
1831
+ )
1832
+ return BUDGET_STOP_EXIT_CODE
1833
+
1834
+ def _emit_terminal_error(
1835
+ self,
1836
+ error: BaseException,
1837
+ *,
1838
+ operation: str = "run_turn",
1839
+ ) -> None:
1840
+ """Record one redacted, joinable terminal-failure record for ``error``.
1841
+
1842
+ Written to the per-run store (default-on; suppressed only by ``--no-log``) and,
1843
+ when the opt-in crash-diagnostic log is enabled, as the durable ``terminal_error``
1844
+ event. Never raises — a diagnostic failure here must not mask the user's error.
1845
+ """
1846
+ try:
1847
+ fields = build_error_event_fields(error, operation=operation)
1848
+ except Exception: # noqa: BLE001 - diagnostics must never mask the real failure
1849
+ fields = {"error_type": type(error).__name__, "operation": operation}
1850
+ try:
1851
+ self.store.append("terminal_error", dict(fields))
1852
+ except Exception: # noqa: BLE001 - best-effort durable record
1853
+ pass
1854
+ if self.crash_diagnostics is not None:
1855
+ try:
1856
+ self.crash_diagnostics.event("terminal_error", dict(fields), durable=True)
1857
+ except Exception: # noqa: BLE001 - best-effort diagnostic event
1858
+ pass
1859
+
1860
+
1861
+ def _shell_command_probe(*, shell_runner: Any, root: Path) -> ShellCommandRunner:
1862
+ """Adapt the session's shell runner to the provisioning probe interface.
1863
+
1864
+ Everything provisioning does goes through here, so it inherits the session's
1865
+ sandbox, PATH, and environment -- the same ones the agent's own test command
1866
+ will resolve. A disabled or unbuildable runner yields "could not run", which
1867
+ the decision reads as unknown rather than as a missing package.
1868
+ """
1869
+
1870
+ def _run(command: str, timeout_s: float) -> ShellProbeResult:
1871
+ if shell_runner is None:
1872
+ return ShellProbeResult(exit_code=None, stderr="shell execution is unavailable")
1873
+ try:
1874
+ completed = shell_runner.run(
1875
+ root=root,
1876
+ cwd=root,
1877
+ cmd=command,
1878
+ timeout_s=int(timeout_s),
1879
+ )
1880
+ except Exception as exc: # noqa: BLE001 - readonly mode, sandbox failure, timeout
1881
+ return ShellProbeResult(exit_code=None, stderr=str(exc))
1882
+ return ShellProbeResult(
1883
+ exit_code=int(getattr(completed, "returncode", 1) or 0),
1884
+ stderr=str(getattr(completed, "stderr", "") or ""),
1885
+ )
1886
+
1887
+ return _run
1888
+
1889
+
1890
+ def _pre_provision_declared_test_runner(
1891
+ *,
1892
+ store: SessionStore,
1893
+ root: Path,
1894
+ cfg: AppConfig,
1895
+ runtime_kind: RuntimeKind,
1896
+ subagent_depth: int,
1897
+ shell_runner: Any,
1898
+ ) -> None:
1899
+ """Close a declared-but-missing test runner gap once, during warmup.
1900
+
1901
+ A top-level autonomous run installs the runner the repo's own config
1902
+ declares; an interactive run only records that the gap exists so the agent
1903
+ or the user can decide. Never raises: startup must survive a broken package
1904
+ index, a disabled shell, or an unreachable sandbox.
1905
+ """
1906
+ try:
1907
+ enabled = _workspace_provisioning_enabled(cfg)
1908
+ declared = detect_declared_test_runner(root) if enabled else None
1909
+ importable: bool | None = None
1910
+ if declared is not None:
1911
+ importable = probe_runner_importable(
1912
+ declared.package,
1913
+ run_command=_shell_command_probe(shell_runner=shell_runner, root=root),
1914
+ )
1915
+ decision = resolve_provisioning_decision(
1916
+ declared=declared,
1917
+ importable=importable,
1918
+ runtime_kind=runtime_kind,
1919
+ enabled=enabled,
1920
+ subagent_depth=subagent_depth,
1921
+ already_attempted=(
1922
+ provisioning_already_attempted(declared.package) if declared is not None else False
1923
+ ),
1924
+ )
1925
+ if decision.action is ProvisioningAction.SKIP:
1926
+ return
1927
+ if decision.action is ProvisioningAction.REPORT_GAP:
1928
+ store.append(
1929
+ "env_gap_detected",
1930
+ {
1931
+ "package": decision.package,
1932
+ "trigger_config_file": decision.trigger_config_file,
1933
+ "runtime_kind": runtime_kind.value,
1934
+ "reason": decision.reason,
1935
+ },
1936
+ )
1937
+ return
1938
+ outcome = provision_test_runner(
1939
+ decision,
1940
+ run_command=_shell_command_probe(shell_runner=shell_runner, root=root),
1941
+ )
1942
+ if outcome is None:
1943
+ # Another session in this process claimed the one attempt; nothing
1944
+ # happened here, so nothing is reported here.
1945
+ return
1946
+ store.append(
1947
+ "env_provisioned",
1948
+ {**outcome.payload(), "runtime_kind": runtime_kind.value},
1949
+ )
1950
+ except Exception as exc: # noqa: BLE001 - provisioning must never break startup
1951
+ try:
1952
+ store.append(
1953
+ "warning",
1954
+ {"warning": "workspace_provisioning_failed", "error": str(exc)},
1955
+ )
1956
+ except Exception: # noqa: BLE001 - best-effort diagnostic
1957
+ pass
1958
+
1959
+
1960
+ def create_session(
1961
+ *,
1962
+ cfg: AppConfig,
1963
+ root: Path,
1964
+ mode: str,
1965
+ yes: bool,
1966
+ max_steps: int | None,
1967
+ no_log: bool,
1968
+ api_key_override: str | None = None,
1969
+ console: Any | None = None,
1970
+ deny_write_prefixes: list[str] | None = None,
1971
+ allow_write_globs: list[str] | None = None,
1972
+ persona_allow_write_globs: list[str] | None = None,
1973
+ non_interactive: bool = False,
1974
+ one_shot_execution: bool = False,
1975
+ enable_chat_turn_step_budget: bool = False,
1976
+ chat_turn_fixed_override: int | None = None,
1977
+ session_log_dir_override: Path | None = None,
1978
+ session_id_override: str | None = None,
1979
+ prompt_cache_parent_session_id: str | None = None,
1980
+ surface: Surface | None = None,
1981
+ usage_role: str = "main",
1982
+ trusted_system_prompt_override: str | None = None,
1983
+ trusted_system_prompt_append: str | None = None,
1984
+ untrusted_prompt_prelude: str | None = None,
1985
+ enable_compaction: bool = True,
1986
+ enable_tool_output_offload: bool | None = None,
1987
+ enable_conversation_summarization: bool | None = None,
1988
+ compaction_profile: str = "chat",
1989
+ verification_enabled: bool = True,
1990
+ authoritative_verification_commands: list[str] | None = None,
1991
+ verify_cmd: list[str] | None = None,
1992
+ subagents_enabled: bool | None = None,
1993
+ helper_subagents_enabled: bool = False,
1994
+ enforce_explicit_subagent_requests: bool = True,
1995
+ subagent_depth: int = 0,
1996
+ subagent_registry: dict[str, SubagentDefinition] | None = None,
1997
+ workspace_binding: WorkspaceBinding | None = None,
1998
+ active_workdir_relpath_override: str | None = None,
1999
+ runtime_kind: RuntimeKind | str | None = None,
2000
+ mcp_manager: McpManager | ForgeTaskScopedMcpManager | None = None,
2001
+ session_source: str = "startup",
2002
+ session_source_metadata: dict[str, Any] | None = None,
2003
+ execution_deadline: ExecutionDeadline | None = None,
2004
+ crash_diagnostic_log_path: str | Path | None = None,
2005
+ crash_diagnostic_logger: CrashDiagnosticLogger | None = None,
2006
+ tool_dispatch_guard: ToolDispatchGuard | None = None,
2007
+ managed_browser_service: ManagedBrowserService | None = None,
2008
+ managed_browser_owner_id: str | None = None,
2009
+ managed_browser_cancel_check: Callable[[], bool] | None = None,
2010
+ host_action_handler: HostActionHandler | None = None,
2011
+ host_action_capabilities: Collection[str] | None = None,
2012
+ readonly_child_web_tool_names: Collection[str] | None = None,
2013
+ child_managed_browser_tool_names: Collection[str] | None = None,
2014
+ ) -> AgentSession:
2015
+ surface = surface or NoopSurface()
2016
+ resolved_runtime_kind = resolve_session_runtime_kind(
2017
+ runtime_kind=runtime_kind,
2018
+ one_shot_execution=one_shot_execution,
2019
+ subagent_depth=subagent_depth,
2020
+ )
2021
+ parent_steer_inbox = SteerInbox()
2022
+ workspace_trust_prompt = _build_workspace_trust_prompt(
2023
+ surface=surface,
2024
+ non_interactive=non_interactive,
2025
+ )
2026
+ prompt_context = prepare_session_prompt_context(
2027
+ cfg=cfg,
2028
+ root=root,
2029
+ mode=mode,
2030
+ yes=yes,
2031
+ deny_write_prefixes=deny_write_prefixes,
2032
+ allow_write_globs=allow_write_globs,
2033
+ persona_allow_write_globs=persona_allow_write_globs,
2034
+ non_interactive=non_interactive,
2035
+ one_shot_execution=one_shot_execution,
2036
+ verification_enabled=verification_enabled,
2037
+ authoritative_verification_commands=authoritative_verification_commands,
2038
+ verify_cmd=verify_cmd,
2039
+ trusted_system_prompt_override=trusted_system_prompt_override,
2040
+ trusted_system_prompt_append=trusted_system_prompt_append,
2041
+ untrusted_prompt_prelude=untrusted_prompt_prelude,
2042
+ subagents_enabled=subagents_enabled,
2043
+ subagent_depth=subagent_depth,
2044
+ subagent_registry=subagent_registry,
2045
+ workspace_binding=workspace_binding,
2046
+ workspace_trust_prompt=workspace_trust_prompt,
2047
+ )
2048
+ root = prompt_context.root
2049
+ workspace_context = prompt_context.workspace_context
2050
+ binding_requested_path = prompt_context.binding_requested_path
2051
+ binding_source = prompt_context.binding_source
2052
+ binding_risk_level = prompt_context.binding_risk_level
2053
+ binding_created_path = prompt_context.binding_created_path
2054
+ authoritative_verify_commands = prompt_context.authoritative_verify_commands
2055
+ session_cfg = prompt_context.session_cfg
2056
+ session_cfg.step_budget_policy = normalize_step_budget_policy(session_cfg.step_budget_policy)
2057
+ session_cfg, native_streaming_warning_message = _disable_unsupported_native_streaming(
2058
+ cfg=session_cfg
2059
+ )
2060
+ normalized_session_source = str(session_source or "startup").strip().lower() or "startup"
2061
+ if normalized_session_source not in {"startup", "resume", "fork"}:
2062
+ raise ConfigError("session_source must be one of: startup, resume, fork.")
2063
+ if session_source_metadata is not None and not isinstance(session_source_metadata, dict):
2064
+ raise ConfigError("session_source_metadata must be an object.")
2065
+ normalized_session_source_metadata = (
2066
+ copy.deepcopy(session_source_metadata) if session_source_metadata is not None else {}
2067
+ )
2068
+ if active_workdir_relpath_override is None:
2069
+ initial_active_workdir_relpath = _normalize_workspace_relpath(
2070
+ workspace_context.focus_relpath
2071
+ )
2072
+ else:
2073
+ initial_active_workdir_relpath = _normalize_workspace_relpath(
2074
+ active_workdir_relpath_override
2075
+ )
2076
+ initial_active_workdir = resolve_workdir_relpath_within_workspace(
2077
+ workspace_root=workspace_context.workspace_root,
2078
+ relpath=initial_active_workdir_relpath,
2079
+ )
2080
+ if not initial_active_workdir.exists():
2081
+ raise SessionWorkdirError(f"Directory does not exist: {initial_active_workdir}")
2082
+ if not initial_active_workdir.is_dir():
2083
+ raise SessionWorkdirError(f"Path is not a directory: {initial_active_workdir}")
2084
+
2085
+ active_profile = get_active_profile(session_cfg)
2086
+ if api_key_override is None:
2087
+ if active_profile.auth_provider:
2088
+ api_key = ""
2089
+ api_key_source = f"provider-auth:{active_profile.auth_provider}"
2090
+ else:
2091
+ api_key_resolution = resolve_api_key(session_cfg)
2092
+ if api_key_resolution.key is None:
2093
+ api_key = get_api_key(session_cfg)
2094
+ api_key_source = "missing"
2095
+ else:
2096
+ api_key = api_key_resolution.key
2097
+ api_key_source = api_key_resolution.source
2098
+ else:
2099
+ api_key = api_key_override.strip()
2100
+ if not api_key:
2101
+ raise ConfigError("API key is empty.")
2102
+ api_key_source = "override"
2103
+ coding_temperature = resolve_role_temperature(session_cfg, role="coding")
2104
+ review_temperature = resolve_role_temperature(session_cfg, role="review")
2105
+ planner_temperature = resolve_role_temperature(session_cfg, role="planner")
2106
+ conflict_review_temperature = resolve_role_temperature(session_cfg, role="conflict_review")
2107
+ compactor_temperature = resolve_role_temperature(session_cfg, role="compactor")
2108
+ chat_temperature = resolve_role_temperature(session_cfg, role="chat")
2109
+ llm_timeout_s = resolve_llm_timeout_s(session_cfg)
2110
+ llm_enable_thinking = resolve_llm_enable_thinking(session_cfg)
2111
+ llm_reasoning_effort = resolve_llm_reasoning_effort(session_cfg)
2112
+ active_profile_name = active_profile.name
2113
+ active_profile_base_url = resolve_effective_base_url(
2114
+ cfg=session_cfg,
2115
+ profile=active_profile,
2116
+ )
2117
+ cache_affinity_preset = find_preset_for_profile(active_profile)
2118
+ session_scoped_cache_affinity = (
2119
+ cache_affinity_preset is not None
2120
+ and str(cache_affinity_preset.provider_key or "").strip().lower() == "moonshot"
2121
+ )
2122
+
2123
+ def _prompt_cache_namespace(role: str) -> str | None:
2124
+ return build_prompt_cache_namespace(
2125
+ workspace_root=workspace_context.workspace_root,
2126
+ role=role,
2127
+ profile_name=active_profile_name,
2128
+ session_id=session_id if session_scoped_cache_affinity else None,
2129
+ )
2130
+
2131
+ registry = ModelRegistry(cfg=session_cfg, api_key=api_key)
2132
+ # Deprecated no-op: recorded for observability and the (ignored)
2133
+ # AgentSession.routing_mode field; nothing consults it.
2134
+ routing_mode = str(getattr(session_cfg, "routing_mode", "auto") or "auto")
2135
+ resolved_subagents_enabled = prompt_context.resolved_subagents_enabled
2136
+ resolved_skills_enabled = prompt_context.resolved_skills_enabled
2137
+ skills_auto_invoke = prompt_context.skills_auto_invoke
2138
+ activation_decision = prompt_context.activation_decision
2139
+ plugin_activation_index = _build_plugin_activation_index(root)
2140
+ plugin_activation_dropped_counts: Counter[str] = Counter(
2141
+ prompt_context.plugin_activation_dropped_counts
2142
+ )
2143
+ discovered_skills = prompt_context.discovered_skills
2144
+ repo_conventions = prompt_context.repo_conventions
2145
+ effective_one_shot_execution = prompt_context.effective_one_shot_execution
2146
+ resolved_subagent_registry = prompt_context.resolved_subagent_registry
2147
+ step_budget_runtime = StepBudgetRuntime()
2148
+
2149
+ session_id = session_id_override.strip() if session_id_override else make_session_id()
2150
+ if not session_id:
2151
+ session_id = make_session_id()
2152
+ prompt_cache_stream_key = resolve_prompt_cache_key(session_cfg)
2153
+ if prompt_cache_stream_key is None and session_cfg.cache.prompt_cache_key_enabled:
2154
+ prompt_cache_stream_key = derive_prompt_cache_stream_key(
2155
+ session_id=session_id,
2156
+ parent_session_id=prompt_cache_parent_session_id,
2157
+ )
2158
+ if mcp_manager is None:
2159
+ create_mcp_manager_fn = _patchable("create_mcp_manager", create_mcp_manager)
2160
+ if create_mcp_manager_fn is not _DEFAULT_CREATE_MCP_MANAGER:
2161
+ mcp_manager = create_mcp_manager_fn(
2162
+ workspace_root=workspace_context.workspace_root,
2163
+ runtime_kind=resolved_runtime_kind,
2164
+ session_id=session_id,
2165
+ )
2166
+ else:
2167
+ resolved_mcp_config = load_resolved_mcp_config(
2168
+ workspace_root=workspace_context.workspace_root
2169
+ )
2170
+ resolved_mcp_config, mcp_dropped_counts = _filter_mcp_config_for_plugins(
2171
+ config=resolved_mcp_config,
2172
+ activation_decision=activation_decision,
2173
+ )
2174
+ plugin_activation_dropped_counts.update(mcp_dropped_counts)
2175
+ mcp_manager = McpManager(
2176
+ resolved_config=resolved_mcp_config,
2177
+ workspace_root=workspace_context.workspace_root,
2178
+ runtime_kind=resolved_runtime_kind,
2179
+ session_id=session_id,
2180
+ )
2181
+ compaction_settings = resolve_compaction_settings(session_cfg)
2182
+ runtime_context_features = resolve_runtime_context_features(
2183
+ settings=compaction_settings,
2184
+ enable_compaction=enable_compaction,
2185
+ enable_tool_output_offload=enable_tool_output_offload,
2186
+ enable_conversation_summarization=enable_conversation_summarization,
2187
+ logging_enabled=not no_log,
2188
+ explicit_session_artifact_root=session_log_dir_override is not None,
2189
+ )
2190
+ compaction_enabled = runtime_context_features.any_enabled
2191
+ tool_output_offload_enabled = runtime_context_features.tool_output_offload_enabled
2192
+ conversation_summarization_enabled = runtime_context_features.conversation_summarization_enabled
2193
+ compactor_model_name: str | None = None
2194
+ if conversation_summarization_enabled:
2195
+ compactor_model_name = resolve_model_for_role(
2196
+ cfg=session_cfg,
2197
+ role=ROLE_COMPACTOR,
2198
+ plan=None,
2199
+ )
2200
+ active_model_refs = [ActiveModelRef(role=ROLE_CODING, model_name=session_cfg.model)]
2201
+ if compactor_model_name:
2202
+ active_model_refs.append(
2203
+ ActiveModelRef(role=ROLE_COMPACTOR, model_name=compactor_model_name)
2204
+ )
2205
+ model_metadata_policy_result = evaluate_active_model_metadata_policy(
2206
+ cfg=session_cfg,
2207
+ registry=registry,
2208
+ active_models=active_model_refs,
2209
+ )
2210
+ client = _make_session_llm_client(
2211
+ cfg=session_cfg,
2212
+ api_key=api_key,
2213
+ model=session_cfg.model,
2214
+ timeout_s=llm_timeout_s,
2215
+ temperature=coding_temperature,
2216
+ prompt_cache_key=prompt_cache_stream_key,
2217
+ prompt_cache_retention=resolve_prompt_cache_retention(session_cfg),
2218
+ prompt_cache_namespace=_prompt_cache_namespace(ROLE_CODING),
2219
+ enable_thinking=llm_enable_thinking,
2220
+ reasoning_effort=llm_reasoning_effort,
2221
+ session_id=session_id,
2222
+ )
2223
+ sessions_dir = (
2224
+ session_log_dir_override
2225
+ if session_log_dir_override is not None
2226
+ else resolve_sessions_dir(session_cfg)
2227
+ )
2228
+ store = SessionStore(
2229
+ enabled=not no_log,
2230
+ artifact_persistence_enabled=(not no_log) or session_log_dir_override is not None,
2231
+ sessions_dir=sessions_dir,
2232
+ session_id=session_id,
2233
+ cwd=str(initial_active_workdir),
2234
+ repo_root=str(root),
2235
+ workspace_root=str(workspace_context.workspace_root),
2236
+ focus_dir=str(workspace_context.focus_path),
2237
+ git_root=(
2238
+ str(workspace_context.git_root) if workspace_context.git_root is not None else None
2239
+ ),
2240
+ workspace_kind=workspace_context.workspace_kind,
2241
+ binding_source=binding_source,
2242
+ binding_requested_path=binding_requested_path,
2243
+ binding_risk_level=binding_risk_level,
2244
+ binding_created_path=binding_created_path,
2245
+ runtime_kind=resolved_runtime_kind.value,
2246
+ active_workdir=str(initial_active_workdir),
2247
+ active_workdir_relpath=initial_active_workdir_relpath,
2248
+ )
2249
+ resolved_crash_diagnostic_log_path = resolve_crash_diagnostic_log_path(
2250
+ session_cfg,
2251
+ cli_diagnostic_log_path=crash_diagnostic_log_path,
2252
+ )
2253
+ crash_diagnostics = crash_diagnostic_logger or build_crash_diagnostic_logger(
2254
+ path=resolved_crash_diagnostic_log_path,
2255
+ run_id=session_id,
2256
+ session_id=session_id,
2257
+ runtime_kind=resolved_runtime_kind.value,
2258
+ )
2259
+ crash_diagnostics.event(
2260
+ "run_started",
2261
+ {
2262
+ "runtime_kind": resolved_runtime_kind.value,
2263
+ "model": session_cfg.model,
2264
+ "max_steps": max_steps,
2265
+ "session_source": normalized_session_source,
2266
+ "deadline": (
2267
+ execution_deadline.telemetry_snapshot() if execution_deadline is not None else None
2268
+ ),
2269
+ },
2270
+ durable=True,
2271
+ )
2272
+ surface_on_warning = _meaningful_surface_warning_handler(surface)
2273
+
2274
+ def _emit_hook_warning(message: str) -> None:
2275
+ clean = str(message or "").strip()
2276
+ if not clean:
2277
+ return
2278
+ store.append("warning", {"warning": "hook_warning", "message": clean})
2279
+ if callable(surface_on_warning):
2280
+ surface_on_warning(clean)
2281
+ else:
2282
+ warnings.warn(clean, stacklevel=2)
2283
+
2284
+ tool_output_offloader: ToolOutputOffloader | None = None
2285
+ if tool_output_offload_enabled:
2286
+ workspace_artifacts_enabled = resolved_runtime_kind != RuntimeKind.SWARM_WORKER
2287
+ tool_output_offloader = ToolOutputOffloader(
2288
+ artifact_layout=store.session_artifact_layout,
2289
+ workspace_root=root,
2290
+ threshold_chars=compaction_settings.tool_output_offload_threshold_chars,
2291
+ preview_chars=compaction_settings.tool_output_preview_chars,
2292
+ workspace_artifacts_enabled=workspace_artifacts_enabled,
2293
+ )
2294
+ system_prompt = prompt_context.system_prompt
2295
+ system_prompt_sha256 = hashlib.sha256(system_prompt.encode("utf-8")).hexdigest()
2296
+ effective_deny_write_prefixes = prompt_context.effective_deny_write_prefixes
2297
+ effective_allow_write_globs = prompt_context.effective_allow_write_globs
2298
+ effective_verification_selection = prompt_context.effective_verification_selection
2299
+ effective_verification_commands = prompt_context.effective_verification_commands
2300
+ recommended_verification_commands = prompt_context.recommended_verification_commands
2301
+ verification_selection_metadata = verification_selection_payload(
2302
+ effective_verification_selection,
2303
+ authoritative=is_authoritative_verify_command_selection(effective_verification_selection),
2304
+ )
2305
+
2306
+ usage_summary = UsageSummary()
2307
+ custom_tool_session_state = build_custom_tool_session_state(
2308
+ workspace_root=root,
2309
+ custom_tools_enabled=bool(getattr(session_cfg, "custom_tools_enabled", True)),
2310
+ mode=mode,
2311
+ runtime_kind=resolved_runtime_kind,
2312
+ built_in_tool_names={spec.name.casefold() for spec in iter_builtin_tool_metadata()},
2313
+ write_scope_restricted=_custom_tools_write_scope_restricted(
2314
+ mode=mode,
2315
+ deny_write_prefixes=deny_write_prefixes,
2316
+ allow_write_globs=allow_write_globs,
2317
+ persona_allow_write_globs=persona_allow_write_globs,
2318
+ ),
2319
+ )
2320
+ (
2321
+ custom_tool_session_state,
2322
+ custom_tool_dropped_counts,
2323
+ ) = _filter_custom_tool_session_state_for_plugins(
2324
+ state=custom_tool_session_state,
2325
+ activation_decision=activation_decision,
2326
+ index=plugin_activation_index,
2327
+ )
2328
+ plugin_activation_dropped_counts.update(custom_tool_dropped_counts)
2329
+ if activation_decision.untrusted_project_plugin_ids:
2330
+ ids = ", ".join(sorted(activation_decision.untrusted_project_plugin_ids))
2331
+ warning_message = f"Ignoring untrusted project plugin overrides: {ids}"
2332
+ store.append(
2333
+ "workspace_trust_untrusted_overrides",
2334
+ {"plugin_ids": sorted(activation_decision.untrusted_project_plugin_ids)},
2335
+ )
2336
+ if callable(surface_on_warning):
2337
+ surface_on_warning(warning_message)
2338
+ else:
2339
+ warnings.warn(warning_message, stacklevel=2)
2340
+
2341
+ if native_streaming_warning_message:
2342
+ store.append(
2343
+ "warning",
2344
+ {
2345
+ "warning": "native_streaming_disabled",
2346
+ "message": native_streaming_warning_message,
2347
+ },
2348
+ )
2349
+ if callable(surface_on_warning):
2350
+ surface_on_warning(native_streaming_warning_message)
2351
+ else:
2352
+ warnings.warn(native_streaming_warning_message, stacklevel=2)
2353
+
2354
+ for verification_warning_message in prompt_context.verification_selection_warnings:
2355
+ store.append(
2356
+ "warning",
2357
+ {
2358
+ "warning": "verification_selection_degraded",
2359
+ "message": verification_warning_message,
2360
+ **verification_selection_metadata,
2361
+ },
2362
+ )
2363
+ if callable(surface_on_warning):
2364
+ surface_on_warning(verification_warning_message)
2365
+ else:
2366
+ warnings.warn(verification_warning_message, stacklevel=2)
2367
+
2368
+ # Determinism controls and the effective-configuration snapshot, both
2369
+ # resolved before the first provider call so nothing is recorded against a
2370
+ # configuration that was not yet in force.
2371
+ #
2372
+ # Only the top-level session does this. A subagent shares the process, so
2373
+ # it shares the installed sampling settings; re-resolving per subagent
2374
+ # would just rewrite the same values, and re-snapshotting would bury the
2375
+ # run's one authoritative config record under near-duplicates.
2376
+ if subagent_depth == 0:
2377
+ sampling_settings = resolve_sampling_settings(
2378
+ config_values=cfg.model_dump(),
2379
+ )
2380
+ set_active_sampling_settings(sampling_settings)
2381
+ for sampling_warning in sampling_settings.warnings:
2382
+ # An invalid sampling value is ignored rather than fatal (a typo in
2383
+ # a benchmark harness must not abort a campaign), so it has to be
2384
+ # visible in the log or it is indistinguishable from never having
2385
+ # been set at all.
2386
+ store.append(
2387
+ "warning",
2388
+ {
2389
+ "warning": "sampling_setting_ignored",
2390
+ "message": sampling_warning.message(),
2391
+ **sampling_warning.payload(),
2392
+ },
2393
+ )
2394
+ store.append(
2395
+ CONFIG_SNAPSHOT_EVENT,
2396
+ config_snapshot_payload(
2397
+ config_values=cfg.model_dump(),
2398
+ version=__version__,
2399
+ build_info=load_build_info().telemetry_payload(),
2400
+ sampling=sampling_settings,
2401
+ ),
2402
+ )
2403
+
2404
+ store.append(
2405
+ "session_start",
2406
+ {
2407
+ "session_source": normalized_session_source,
2408
+ "session_source_metadata": normalized_session_source_metadata,
2409
+ "mode": mode,
2410
+ "runtime_kind": resolved_runtime_kind.value,
2411
+ "max_steps": max_steps,
2412
+ "step_budget_policy": session_cfg.step_budget_policy,
2413
+ "task_max_steps": session_cfg.task_max_steps,
2414
+ "subagent_max_steps": session_cfg.subagent_max_steps,
2415
+ "model": session_cfg.model,
2416
+ "router_model": "",
2417
+ "base_url_descriptor": endpoint_descriptor(session_cfg.base_url),
2418
+ "profile_name": active_profile.name,
2419
+ "protocol": active_profile.protocol,
2420
+ "provider_base_url_descriptor": endpoint_descriptor(active_profile_base_url),
2421
+ "auth_provider": active_profile.auth_provider,
2422
+ "reasoning_trace_adapter": active_profile.reasoning_trace_adapter,
2423
+ "api_key_source": api_key_source,
2424
+ "temperature": session_cfg.temperature,
2425
+ "coding_temperature": coding_temperature,
2426
+ "review_temperature": review_temperature,
2427
+ "planner_temperature": planner_temperature,
2428
+ "conflict_review_temperature": conflict_review_temperature,
2429
+ "compactor_temperature": compactor_temperature,
2430
+ "chat_temperature": chat_temperature,
2431
+ "llm_enable_thinking": llm_enable_thinking,
2432
+ "llm_reasoning_effort": llm_reasoning_effort,
2433
+ "stream": session_cfg.stream,
2434
+ "routing_mode": routing_mode,
2435
+ "subagents_enabled": resolved_subagents_enabled,
2436
+ "skills_enabled": resolved_skills_enabled,
2437
+ "skills_auto_invoke": skills_auto_invoke,
2438
+ "custom_tools_enabled": bool(getattr(session_cfg, "custom_tools_enabled", True)),
2439
+ "custom_tool_count": len(custom_tool_session_state.discovery.effective_tools),
2440
+ "custom_tool_issue_count": len(custom_tool_session_state.discovery.issues),
2441
+ "discovered_skill_count": len(discovered_skills.ordered),
2442
+ "repo_convention_count": len(repo_conventions),
2443
+ "skill_discovery_issues": [
2444
+ {
2445
+ "source_path": issue.source_path.as_posix(),
2446
+ "message": issue.message,
2447
+ }
2448
+ for issue in discovered_skills.issues
2449
+ ],
2450
+ "subagent_depth": subagent_depth,
2451
+ "subagent_count": len(resolved_subagent_registry),
2452
+ "root": str(root),
2453
+ "workspace_root": str(workspace_context.workspace_root),
2454
+ "focus_dir": str(workspace_context.focus_path),
2455
+ "focus_relpath": workspace_context.focus_relpath,
2456
+ "active_workdir": str(initial_active_workdir),
2457
+ "active_workdir_relpath": initial_active_workdir_relpath,
2458
+ "workspace_kind": workspace_context.workspace_kind,
2459
+ "git_root": (
2460
+ str(workspace_context.git_root) if workspace_context.git_root is not None else None
2461
+ ),
2462
+ "has_head_commit": workspace_context.has_head_commit,
2463
+ "current_branch": workspace_context.current_branch,
2464
+ "binding_requested_path": binding_requested_path,
2465
+ "binding_source": binding_source,
2466
+ "binding_risk_level": binding_risk_level,
2467
+ "binding_created_path": binding_created_path,
2468
+ "usage_role": usage_role,
2469
+ "yes": yes,
2470
+ "non_interactive": non_interactive,
2471
+ "one_shot_execution": effective_one_shot_execution,
2472
+ "enable_chat_turn_step_budget": enable_chat_turn_step_budget,
2473
+ "workspace_grounding": prompt_context.workspace_grounding.to_payload(),
2474
+ "chat_turn_fixed_override": chat_turn_fixed_override,
2475
+ "verification_enabled": verification_enabled,
2476
+ "effective_verification_commands": effective_verification_commands,
2477
+ **verification_selection_metadata,
2478
+ "model_metadata_policy": model_metadata_policy_result.policy,
2479
+ "model_metadata_diagnostics": [
2480
+ diagnostic.as_payload() for diagnostic in model_metadata_policy_result.diagnostics
2481
+ ],
2482
+ "deny_write_prefixes": effective_deny_write_prefixes,
2483
+ "allow_write_globs": effective_allow_write_globs,
2484
+ "recommended_verification_commands": recommended_verification_commands,
2485
+ "authoritative_verification_commands": authoritative_verify_commands,
2486
+ "system_prompt_sha256": system_prompt_sha256,
2487
+ "requested_enable_compaction": runtime_context_features.requested_enable_compaction,
2488
+ "requested_tool_output_offload": (
2489
+ runtime_context_features.requested_tool_output_offload
2490
+ ),
2491
+ "requested_conversation_summarization": (
2492
+ runtime_context_features.requested_conversation_summarization
2493
+ ),
2494
+ "logging_enabled": runtime_context_features.logging_enabled,
2495
+ "explicit_session_artifact_root": (
2496
+ runtime_context_features.explicit_session_artifact_root
2497
+ ),
2498
+ "tool_output_offload_artifact_persistence_available": (
2499
+ runtime_context_features.tool_output_offload_artifact_persistence_available
2500
+ ),
2501
+ "compaction_enabled": compaction_enabled,
2502
+ "compaction_settings_enabled": runtime_context_features.settings_enabled,
2503
+ "tool_output_offload_enabled": tool_output_offload_enabled,
2504
+ "compaction_settings_offload_tool_outputs": (
2505
+ runtime_context_features.settings_offload_tool_outputs
2506
+ ),
2507
+ "tool_output_offload_threshold_chars": (
2508
+ compaction_settings.tool_output_offload_threshold_chars
2509
+ ),
2510
+ "tool_output_preview_chars": compaction_settings.tool_output_preview_chars,
2511
+ "conversation_summarization_enabled": conversation_summarization_enabled,
2512
+ "compaction_profile": compaction_profile,
2513
+ "compaction_settings_summarize_conversation": (
2514
+ runtime_context_features.settings_summarize_conversation
2515
+ ),
2516
+ "compaction_recent_user_turns_to_keep": (compaction_settings.recent_user_turns_to_keep),
2517
+ "compaction_trigger_ratio": compaction_settings.trigger_ratio,
2518
+ "compaction_target_ratio": compaction_settings.target_ratio,
2519
+ "compaction_max_chunk_messages": compaction_settings.max_chunk_messages,
2520
+ "compaction_safety_margin_tokens": compaction_settings.safety_margin_tokens,
2521
+ "compactor_model": compactor_model_name,
2522
+ "mcp": mcp_manager.startup_metadata(),
2523
+ },
2524
+ )
2525
+
2526
+ if callable(surface_on_warning):
2527
+ for warning_message in model_metadata_policy_result.warning_messages:
2528
+ surface_on_warning(warning_message)
2529
+ else:
2530
+ for warning_message in model_metadata_policy_result.warning_messages:
2531
+ warnings.warn(warning_message, stacklevel=2)
2532
+
2533
+ if conversation_summarization_enabled and compactor_model_name == session_cfg.model:
2534
+ store.append(
2535
+ "warning",
2536
+ {
2537
+ "warning": "compactor_model_equals_main_model",
2538
+ "model": session_cfg.model,
2539
+ },
2540
+ )
2541
+ for issue in discovered_skills.issues:
2542
+ store.append(
2543
+ "warning",
2544
+ {
2545
+ "warning": "skill_discovery_issue",
2546
+ "source_path": issue.source_path.as_posix(),
2547
+ "message": issue.message,
2548
+ },
2549
+ )
2550
+ for issue in custom_tool_session_state.discovery.issues:
2551
+ store.append(
2552
+ "warning",
2553
+ {
2554
+ "warning": "custom_tool_discovery_issue",
2555
+ "source_scope": issue.source_scope,
2556
+ "source_path": issue.source_path.as_posix(),
2557
+ "tool_name": issue.tool_name,
2558
+ "code": issue.code,
2559
+ "message": issue.message,
2560
+ },
2561
+ )
2562
+
2563
+ resolved_sandbox_settings = resolve_shell_sandbox_settings(session_cfg)
2564
+ process_group_registry = ProcessGroupRegistry()
2565
+
2566
+ def _sandbox_warning_callback(message: str) -> None:
2567
+ store.append("sandbox_warning", {"message": message})
2568
+
2569
+ def _with_process_group_registry(built_runner: Any) -> Any:
2570
+ # Attach the session's registry to whichever runner the (possibly patched)
2571
+ # builder produced. Backends that cannot be reaped by process group -- the
2572
+ # Docker runner tears its container down through the docker CLI instead --
2573
+ # simply do not declare the field and are left untouched.
2574
+ if not dataclasses.is_dataclass(built_runner):
2575
+ return built_runner
2576
+ field_names = {f.name for f in dataclasses.fields(built_runner)}
2577
+ if "process_group_registry" not in field_names:
2578
+ return built_runner
2579
+ return dataclasses.replace(built_runner, process_group_registry=process_group_registry)
2580
+
2581
+ def _load_shell_runner_from_resolved_settings() -> Any:
2582
+ # stdin is closed for every foreground command, not just verification:
2583
+ # nobody is watching the terminal, so a tool that stops to ask a
2584
+ # question (ssh-keygen's "Overwrite (y/n)?", apt's config prompts) would
2585
+ # otherwise hold the pipe open until the hard kill.
2586
+ patched_build_shell_runner = _patchable("build_shell_runner", build_shell_runner)
2587
+ if patched_build_shell_runner is not build_shell_runner:
2588
+ return with_closed_stdin(
2589
+ _with_process_group_registry(
2590
+ patched_build_shell_runner(
2591
+ cfg=session_cfg,
2592
+ root=root,
2593
+ warning_callback=_sandbox_warning_callback,
2594
+ )
2595
+ )
2596
+ )
2597
+ return with_closed_stdin(
2598
+ _with_process_group_registry(
2599
+ _patchable(
2600
+ "build_shell_runner_from_settings",
2601
+ build_shell_runner_from_settings,
2602
+ )(
2603
+ resolved_sandbox_settings,
2604
+ root,
2605
+ warning_callback=_sandbox_warning_callback,
2606
+ )
2607
+ )
2608
+ )
2609
+
2610
+ if mode == "readonly":
2611
+ runner = DisabledShellRunner(reason="shell_run is disabled in readonly mode.")
2612
+ bg_runner = DisabledBackgroundRunner(
2613
+ reason="Background shell tools are disabled in readonly mode."
2614
+ )
2615
+ else:
2616
+ runner = LazyShellRunner(_load_shell_runner_from_resolved_settings)
2617
+ bg_runner = LazyBackgroundShellRunner(
2618
+ lambda: _patchable(
2619
+ "build_background_shell_runner_from_settings",
2620
+ build_background_shell_runner_from_settings,
2621
+ )(
2622
+ resolved_sandbox_settings,
2623
+ root,
2624
+ warning_callback=_sandbox_warning_callback,
2625
+ )
2626
+ )
2627
+
2628
+ # Warmup: close a declared-but-missing test-runner gap before the model
2629
+ # discovers it by failing a test run. Placed here because it must go through
2630
+ # the session's own shell runner -- the environment the agent's commands
2631
+ # resolve, and the one the operator's sandbox policy governs.
2632
+ _pre_provision_declared_test_runner(
2633
+ store=store,
2634
+ root=workspace_context.workspace_root,
2635
+ cfg=session_cfg,
2636
+ runtime_kind=resolved_runtime_kind,
2637
+ subagent_depth=subagent_depth,
2638
+ shell_runner=runner,
2639
+ )
2640
+
2641
+ terminal_manager = TerminalManager(
2642
+ runner=bg_runner,
2643
+ settings=resolved_sandbox_settings,
2644
+ )
2645
+ # Session-scoped: only the persist starts this run made, so the
2646
+ # finalization re-check cannot report a neighbouring session's service.
2647
+ persistent_service_registry = PersistentServiceRegistry()
2648
+ # Session-scoped for the same reason, and shared between the tool layer
2649
+ # (which scores overwrites) and the turn controller (which counts repeated
2650
+ # failures and reports scratch files) so both halves see one run's history.
2651
+ edit_discipline_state = EditDisciplineState()
2652
+ durable_service_manager = DurableServiceManager(
2653
+ root=root,
2654
+ state_dir=store.sessions_dir / "durable_services",
2655
+ settings=resolved_sandbox_settings,
2656
+ )
2657
+
2658
+ try:
2659
+ active_workdir_state: dict[str, Any] = {
2660
+ "relpath": initial_active_workdir_relpath,
2661
+ "session": None,
2662
+ }
2663
+
2664
+ def _get_active_workdir_relpath() -> str:
2665
+ session_obj = active_workdir_state.get("session")
2666
+ if session_obj is not None:
2667
+ return resolve_session_active_workdir_relpath(session_obj)
2668
+ return _normalize_workspace_relpath(active_workdir_state.get("relpath"))
2669
+
2670
+ def _set_active_workdir(raw_path: str, source: str) -> dict[str, Any]:
2671
+ session_obj = active_workdir_state.get("session")
2672
+ if session_obj is None:
2673
+ workspace_root = workspace_context.workspace_root.resolve()
2674
+ current_relpath = _normalize_workspace_relpath(active_workdir_state.get("relpath"))
2675
+ current_path = resolve_workdir_relpath_within_workspace(
2676
+ workspace_root=workspace_root,
2677
+ relpath=current_relpath,
2678
+ )
2679
+ next_path = _resolve_requested_workdir_within_workspace(
2680
+ workspace_root=workspace_root,
2681
+ current_workdir=current_path,
2682
+ requested_path=raw_path,
2683
+ )
2684
+ next_relpath = _workspace_relpath_for_path(
2685
+ workspace_root=workspace_root,
2686
+ path=next_path,
2687
+ )
2688
+ changed = next_relpath != current_relpath
2689
+ active_workdir_state["relpath"] = next_relpath
2690
+ store.update_active_workdir(
2691
+ cwd=os.fspath(next_path),
2692
+ active_workdir_relpath=next_relpath,
2693
+ )
2694
+ payload = {
2695
+ "source": source,
2696
+ "workspace_root": os.fspath(workspace_root),
2697
+ "focus_dir": os.fspath(workspace_context.focus_path),
2698
+ "focus_relpath": workspace_context.focus_relpath,
2699
+ "previous_active_workdir": os.fspath(current_path),
2700
+ "previous_active_workdir_relpath": current_relpath,
2701
+ "active_workdir": os.fspath(next_path),
2702
+ "active_workdir_relpath": next_relpath,
2703
+ "changed": changed,
2704
+ }
2705
+ if payload["changed"]:
2706
+ store.append("session_workdir_changed", payload)
2707
+ return payload
2708
+ return set_session_active_workdir(session_obj, raw_path, source=source)
2709
+
2710
+ def _get_verify_command_selection() -> ResolvedVerifyCommands | None:
2711
+ session_obj = active_workdir_state.get("session")
2712
+ if session_obj is not None:
2713
+ return _session_verify_command_selection(session_obj)
2714
+ return prompt_context.effective_verification_selection
2715
+
2716
+ persona_switch_state = (
2717
+ PersonaSwitchState()
2718
+ if (
2719
+ resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT
2720
+ and not non_interactive
2721
+ and persona_modes_enabled(session_cfg)
2722
+ )
2723
+ else None
2724
+ )
2725
+ persona_registry: dict[str, Any] | None = None
2726
+ persona_registry_warnings: tuple[str, ...] = ()
2727
+ if persona_switch_state is not None:
2728
+ try:
2729
+ loaded_personas, persona_registry_warnings = load_custom_personas(root)
2730
+ persona_registry = loaded_personas or None
2731
+ except Exception: # noqa: BLE001 - custom personas must not break startup
2732
+ persona_registry = None
2733
+ persona_registry_warnings = ()
2734
+ completion_gate_tools_enabled = bool(
2735
+ subagent_depth == 0
2736
+ and str(mode or "").strip().lower() != "readonly"
2737
+ and (
2738
+ effective_one_shot_execution
2739
+ or (
2740
+ resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT
2741
+ and enable_chat_turn_step_budget
2742
+ )
2743
+ )
2744
+ )
2745
+ child_scheduler_holder: dict[str, ChildScheduler] = {}
2746
+ read_ledger_holder: dict[str, SessionReadLedger] = {}
2747
+
2748
+ def _capture_child_scheduler(scheduler: ChildScheduler) -> None:
2749
+ child_scheduler_holder["scheduler"] = scheduler
2750
+
2751
+ def _capture_read_ledger(ledger: SessionReadLedger) -> None:
2752
+ read_ledger_holder["ledger"] = ledger
2753
+
2754
+ tools = build_tools(
2755
+ root=root,
2756
+ console=console,
2757
+ surface=surface,
2758
+ persona_switch_state=persona_switch_state,
2759
+ store=store,
2760
+ process_group_registry=process_group_registry,
2761
+ mode=mode,
2762
+ yes=yes,
2763
+ cfg=session_cfg,
2764
+ api_key=api_key,
2765
+ max_steps=max_steps,
2766
+ no_log=no_log,
2767
+ usage_role=usage_role,
2768
+ usage_summary=usage_summary,
2769
+ model_registry=registry,
2770
+ deny_write_prefixes=deny_write_prefixes,
2771
+ allow_write_globs=allow_write_globs,
2772
+ persona_allow_write_globs=persona_allow_write_globs,
2773
+ non_interactive=non_interactive,
2774
+ shell_runner=runner,
2775
+ terminal_manager=terminal_manager,
2776
+ durable_service_manager=durable_service_manager,
2777
+ persistent_service_registry=persistent_service_registry,
2778
+ edit_discipline=edit_discipline_state,
2779
+ managed_browser_service=managed_browser_service,
2780
+ managed_browser_owner_id=managed_browser_owner_id,
2781
+ managed_browser_cancel_check=managed_browser_cancel_check,
2782
+ verification_enabled=verification_enabled,
2783
+ authoritative_verification_commands=authoritative_verify_commands,
2784
+ effective_verification_commands=effective_verification_commands,
2785
+ verify_command_selection=prompt_context.effective_verification_selection,
2786
+ get_verify_command_selection=_get_verify_command_selection,
2787
+ one_shot_execution=effective_one_shot_execution,
2788
+ completion_gate_tools_enabled=completion_gate_tools_enabled,
2789
+ skills_enabled=resolved_skills_enabled,
2790
+ skill_registry=discovered_skills.skills,
2791
+ subagents_enabled=resolved_subagents_enabled,
2792
+ helper_subagents_enabled=helper_subagents_enabled,
2793
+ subagent_depth=subagent_depth,
2794
+ subagent_registry=resolved_subagent_registry,
2795
+ session_log_dir_override=session_log_dir_override,
2796
+ step_budget_runtime=step_budget_runtime,
2797
+ emit_web_search_runtime_diagnostics=(subagent_depth == 0),
2798
+ runtime_kind=resolved_runtime_kind,
2799
+ mcp_manager=mcp_manager,
2800
+ custom_tool_session_state=custom_tool_session_state,
2801
+ get_active_workdir_relpath=_get_active_workdir_relpath,
2802
+ set_active_workdir_callback=_set_active_workdir,
2803
+ create_session_factory=create_session,
2804
+ prompt_cache_parent_session_id=(prompt_cache_parent_session_id or session_id),
2805
+ execution_deadline=execution_deadline,
2806
+ crash_diagnostic_log_path=resolved_crash_diagnostic_log_path,
2807
+ crash_diagnostics=crash_diagnostics,
2808
+ tool_dispatch_guard=tool_dispatch_guard,
2809
+ host_action_handler=host_action_handler,
2810
+ host_action_capabilities=host_action_capabilities,
2811
+ child_scheduler_sink=_capture_child_scheduler,
2812
+ parent_steer_inbox=parent_steer_inbox,
2813
+ read_ledger_sink=_capture_read_ledger,
2814
+ readonly_child_web_tool_names=readonly_child_web_tool_names,
2815
+ child_managed_browser_tool_names=child_managed_browser_tool_names,
2816
+ )
2817
+ if mcp_manager is not None and mcp_manager.resolved_config.has_any_config:
2818
+ store.append("mcp_catalog_snapshot", mcp_manager.catalog_snapshot_metadata())
2819
+ tool_list = [t.as_openai_tool() for t in tools.values()]
2820
+ messages: list[dict[str, Any]] = list(prompt_context.messages)
2821
+ if active_workdir_relpath_override is not None:
2822
+ binding_context = _workspace_binding_context_message(
2823
+ workspace_root=workspace_context.workspace_root,
2824
+ focus_dir=workspace_context.focus_path,
2825
+ focus_relpath=workspace_context.focus_relpath,
2826
+ workspace_kind=workspace_context.workspace_kind,
2827
+ active_workdir=initial_active_workdir,
2828
+ active_workdir_relpath=initial_active_workdir_relpath,
2829
+ binding_requested_path=binding_requested_path,
2830
+ binding_source=binding_source,
2831
+ binding_risk_level=binding_risk_level,
2832
+ binding_created_path=binding_created_path,
2833
+ )
2834
+ for index, message in enumerate(messages):
2835
+ if str(message.get("role") or "") != "user":
2836
+ continue
2837
+ if (
2838
+ not str(message.get("content") or "")
2839
+ .lstrip()
2840
+ .startswith("<workspace_binding_context>")
2841
+ ):
2842
+ continue
2843
+ messages[index] = {**message, "content": binding_context}
2844
+ break
2845
+ pinned_prefix_len = prompt_context.pinned_prefix_len
2846
+ if resolved_subagents_enabled and subagent_depth == 0:
2847
+ effective_subagent_context = _subagent_context_message(
2848
+ subagent_registry=resolved_subagent_registry,
2849
+ unavailable_subagents=unavailable_builtin_subagents(
2850
+ registry=resolved_subagent_registry,
2851
+ cfg=session_cfg,
2852
+ available_tool_names=set(tools),
2853
+ ),
2854
+ max_background_children=(
2855
+ session_cfg.subagent_orchestration.max_background_children
2856
+ ),
2857
+ )
2858
+ replaced_subagent_context = False
2859
+ for message_index, message in enumerate(messages):
2860
+ if str(message.get("role") or "") == "user" and "<subagent_context>" in str(
2861
+ message.get("content") or ""
2862
+ ):
2863
+ if effective_subagent_context is not None:
2864
+ messages[message_index] = {
2865
+ **message,
2866
+ "content": effective_subagent_context,
2867
+ }
2868
+ replaced_subagent_context = True
2869
+ break
2870
+ if effective_subagent_context is not None and not replaced_subagent_context:
2871
+ insert_at = next(
2872
+ (
2873
+ index
2874
+ for index, message in enumerate(messages)
2875
+ if "<environment_context>" in str(message.get("content") or "")
2876
+ ),
2877
+ len(messages),
2878
+ )
2879
+ messages.insert(
2880
+ insert_at,
2881
+ {"role": "user", "content": effective_subagent_context},
2882
+ )
2883
+ pinned_prefix_len += 1
2884
+ hooks_config = load_resolved_hooks_config(workspace_context.workspace_root)
2885
+ hooks_config, hook_dropped_counts = _filter_hooks_config_for_plugins(
2886
+ config=hooks_config,
2887
+ activation_decision=activation_decision,
2888
+ index=plugin_activation_index,
2889
+ )
2890
+ plugin_activation_dropped_counts.update(hook_dropped_counts)
2891
+ dropped_counts_payload = _merge_dropped_counts(plugin_activation_dropped_counts)
2892
+ if dropped_counts_payload:
2893
+ store.append(
2894
+ "plugin_activation_filter",
2895
+ {
2896
+ "enabled_plugin_ids": sorted(activation_decision.enabled_plugin_ids),
2897
+ "dropped_component_counts": dropped_counts_payload,
2898
+ },
2899
+ )
2900
+ hook_dispatcher: HookDispatcher | None = None
2901
+ if hooks_config.untrusted_project_paths:
2902
+ untrusted_paths = [os.fspath(path) for path in hooks_config.untrusted_project_paths]
2903
+ store.append("hook_config_untrusted", {"paths": untrusted_paths})
2904
+ for path_text in untrusted_paths:
2905
+ _emit_hook_warning(
2906
+ "Ignoring untrusted project hooks config: "
2907
+ f"{path_text}. Run `alysis hooks trust --path "
2908
+ f"{os.fspath(workspace_context.workspace_root)}` to allow it."
2909
+ )
2910
+ if hooks_config.has_any_hooks:
2911
+ hook_audit_artifact = (
2912
+ store.runtime_artifact_path(*HOOK_AUDIT_ARTIFACT_PARTS) if store.enabled else None
2913
+ )
2914
+ hook_dispatcher = HookDispatcher(
2915
+ config=hooks_config,
2916
+ workspace_root=workspace_context.workspace_root,
2917
+ repo_root=root,
2918
+ session_id=session_id,
2919
+ mode=mode,
2920
+ runtime_kind=resolved_runtime_kind.value,
2921
+ warning_callback=_emit_hook_warning,
2922
+ log_callback=store.append,
2923
+ audit_callback=(
2924
+ (
2925
+ lambda payload: store.append_artifact_jsonl(
2926
+ *HOOK_AUDIT_ARTIFACT_PARTS, payload=payload
2927
+ )
2928
+ )
2929
+ if store.enabled
2930
+ else None
2931
+ ),
2932
+ )
2933
+ store.append(
2934
+ "hook_config_loaded",
2935
+ {
2936
+ "loaded_paths": [os.fspath(path) for path in hooks_config.loaded_paths],
2937
+ "events": {
2938
+ event_name: len(groups)
2939
+ for event_name, groups in hooks_config.groups_by_event.items()
2940
+ },
2941
+ "hook_audit_artifact": os.fspath(hook_audit_artifact)
2942
+ if hook_audit_artifact is not None
2943
+ else None,
2944
+ },
2945
+ )
2946
+ try:
2947
+ session_start_hook_result = hook_dispatcher.fire_session_start(
2948
+ cwd=initial_active_workdir,
2949
+ active_workdir_relpath=initial_active_workdir_relpath,
2950
+ session_source=normalized_session_source,
2951
+ payload={
2952
+ "session_source": normalized_session_source,
2953
+ "session_source_metadata": copy.deepcopy(
2954
+ normalized_session_source_metadata
2955
+ ),
2956
+ "mode": mode,
2957
+ "runtime_kind": resolved_runtime_kind.value,
2958
+ "workspace_root": os.fspath(workspace_context.workspace_root),
2959
+ "focus_dir": os.fspath(workspace_context.focus_path),
2960
+ "focus_relpath": workspace_context.focus_relpath,
2961
+ "active_workdir": os.fspath(initial_active_workdir),
2962
+ "active_workdir_relpath": initial_active_workdir_relpath,
2963
+ "workspace_kind": workspace_context.workspace_kind,
2964
+ "current_branch": workspace_context.current_branch,
2965
+ "max_steps": max_steps,
2966
+ "non_interactive": non_interactive,
2967
+ "one_shot_execution": effective_one_shot_execution,
2968
+ },
2969
+ )
2970
+ except Exception as exc: # noqa: BLE001
2971
+ _emit_hook_warning(f"Lifecycle hook dispatch failed: {exc}")
2972
+ session_start_hook_result = HookDispatchResult()
2973
+ for notice in session_start_hook_result.system_notices:
2974
+ _emit_hook_warning(notice)
2975
+ if session_start_hook_result.blocked:
2976
+ blocked_reason = session_start_hook_result.reason or "session start blocked by hook"
2977
+ raise ConfigError(f"Session blocked by hook: {blocked_reason}")
2978
+ for hook_message in session_start_hook_result.additional_system_messages:
2979
+ messages.append({"role": "system", "content": hook_message})
2980
+ pinned_prefix_len += 1
2981
+ store.append(
2982
+ "hook_message_added",
2983
+ {
2984
+ "event_name": "SessionStart",
2985
+ "role": "system",
2986
+ "chars": len(hook_message),
2987
+ "pinned": True,
2988
+ },
2989
+ )
2990
+ for hook_message in session_start_hook_result.additional_user_messages:
2991
+ messages.append({"role": "user", "content": hook_message})
2992
+ pinned_prefix_len += 1
2993
+ store.append(
2994
+ "hook_message_added",
2995
+ {
2996
+ "event_name": "SessionStart",
2997
+ "role": "user",
2998
+ "chars": len(hook_message),
2999
+ "pinned": True,
3000
+ },
3001
+ )
3002
+
3003
+ startup_messages = copy.deepcopy(messages)
3004
+
3005
+ conversation_compactor: ConversationCompactor | None = None
3006
+ if conversation_summarization_enabled and compactor_model_name:
3007
+ compactor_client = _make_session_llm_client(
3008
+ cfg=session_cfg,
3009
+ api_key=api_key,
3010
+ model=compactor_model_name,
3011
+ timeout_s=llm_timeout_s,
3012
+ temperature=compactor_temperature,
3013
+ prompt_cache_key=prompt_cache_stream_key,
3014
+ prompt_cache_retention=resolve_prompt_cache_retention(session_cfg),
3015
+ prompt_cache_namespace=_prompt_cache_namespace(ROLE_COMPACTOR),
3016
+ enable_thinking=llm_enable_thinking,
3017
+ reasoning_effort=llm_reasoning_effort,
3018
+ session_id=session_id,
3019
+ )
3020
+ conversation_compactor = ConversationCompactor(
3021
+ root=root,
3022
+ artifact_layout=store.session_artifact_layout,
3023
+ store=store,
3024
+ settings=compaction_settings,
3025
+ compactor_client=compactor_client,
3026
+ model_registry=registry,
3027
+ usage_summary=usage_summary,
3028
+ usage_role=usage_role,
3029
+ pinned_prefix_len=pinned_prefix_len,
3030
+ profile=("execution" if compaction_profile == "execution" else "chat"),
3031
+ input_token_counter=(
3032
+ lambda count_messages, count_tools: count_input_tokens_if_supported(
3033
+ client=client,
3034
+ messages=count_messages,
3035
+ tools=effective_tools_for_client(client, count_tools),
3036
+ )
3037
+ ),
3038
+ calibration_filters=usage_context_from_client_response(
3039
+ client=client,
3040
+ response=None,
3041
+ operation="main_llm",
3042
+ ),
3043
+ )
3044
+
3045
+ if _surface_needs_startup_git_status(surface):
3046
+ git_branch = _patchable("_git_branch", _git_branch)
3047
+ git_is_dirty = _patchable("_git_is_dirty", _git_is_dirty)
3048
+ startup_branch = git_branch(root)
3049
+ startup_dirty = git_is_dirty(root)
3050
+ else:
3051
+ startup_branch = "-"
3052
+ startup_dirty = False
3053
+
3054
+ startup_context_baseline_tokens = estimate_request_token_breakdown(
3055
+ messages=messages,
3056
+ tool_list=effective_tools_for_client(client, tool_list),
3057
+ pinned_prefix_len=pinned_prefix_len,
3058
+ ).total_tokens
3059
+
3060
+ surface.on_status_update(
3061
+ StatusEvent(
3062
+ mode=mode,
3063
+ model=session_cfg.model,
3064
+ workspace=os.fspath(root),
3065
+ session_id=session_id,
3066
+ branch=startup_branch,
3067
+ dirty=startup_dirty,
3068
+ stream=session_cfg.stream,
3069
+ task="-",
3070
+ )
3071
+ )
3072
+
3073
+ session = AgentSession(
3074
+ cfg=session_cfg,
3075
+ root=root,
3076
+ mode=mode,
3077
+ persona=(
3078
+ normalize_persona(getattr(session_cfg, "default_persona", "code"), persona_registry)
3079
+ if persona_modes_enabled(session_cfg)
3080
+ else "code"
3081
+ ),
3082
+ persona_switch_state=persona_switch_state,
3083
+ persona_registry=persona_registry,
3084
+ persona_registry_warnings=persona_registry_warnings,
3085
+ yes=yes,
3086
+ stream=session_cfg.stream,
3087
+ routing_mode=routing_mode,
3088
+ max_steps=max_steps,
3089
+ api_key=api_key,
3090
+ api_key_source=api_key_source,
3091
+ no_log=no_log,
3092
+ non_interactive=non_interactive,
3093
+ one_shot_execution=effective_one_shot_execution,
3094
+ enable_chat_turn_step_budget=enable_chat_turn_step_budget,
3095
+ chat_turn_fixed_override=chat_turn_fixed_override,
3096
+ verification_enabled=verification_enabled,
3097
+ effective_verification_commands=list(effective_verification_commands),
3098
+ authoritative_verification_commands=(
3099
+ list(authoritative_verify_commands)
3100
+ if authoritative_verify_commands is not None
3101
+ else None
3102
+ ),
3103
+ verification_selection_source=str(
3104
+ verification_selection_metadata.get("verification_selection_source") or ""
3105
+ ),
3106
+ verification_selection_reason=str(
3107
+ verification_selection_metadata.get("verification_selection_reason") or ""
3108
+ ),
3109
+ verification_contract_type=str(
3110
+ verification_selection_metadata.get("verification_contract_type") or ""
3111
+ ),
3112
+ verification_authoritative=bool(
3113
+ verification_selection_metadata.get("verification_authoritative", False)
3114
+ ),
3115
+ verification_best_effort=bool(
3116
+ verification_selection_metadata.get("verification_best_effort", False)
3117
+ ),
3118
+ deny_write_prefixes=(
3119
+ list(deny_write_prefixes) if deny_write_prefixes is not None else None
3120
+ ),
3121
+ allow_write_globs=(list(allow_write_globs) if allow_write_globs is not None else None),
3122
+ persona_allow_write_globs=(
3123
+ list(persona_allow_write_globs) if persona_allow_write_globs is not None else None
3124
+ ),
3125
+ session_log_dir_override=session_log_dir_override,
3126
+ skills_enabled=resolved_skills_enabled,
3127
+ skills_auto_invoke=skills_auto_invoke,
3128
+ skill_registry=dict(discovered_skills.skills),
3129
+ skills_ordered=tuple(discovered_skills.ordered),
3130
+ skill_discovery_issues=tuple(discovered_skills.issues),
3131
+ skill_catalog_entries=tuple(prompt_context.skill_catalog_entries),
3132
+ repo_conventions=tuple(repo_conventions),
3133
+ console=console,
3134
+ surface=surface,
3135
+ store=store,
3136
+ client=client,
3137
+ persona_client_cache={(session_cfg.model, coding_temperature): client},
3138
+ persona_client_key=(session_cfg.model, coding_temperature),
3139
+ model_registry=registry,
3140
+ usage_summary=usage_summary,
3141
+ usage_role=usage_role,
3142
+ tool_output_offloader=tool_output_offloader,
3143
+ conversation_compactor=conversation_compactor,
3144
+ tool_output_offload_enabled=tool_output_offload_enabled,
3145
+ conversation_summarization_enabled=conversation_summarization_enabled,
3146
+ compaction_profile=compaction_profile,
3147
+ tools=tools,
3148
+ tool_list=tool_list,
3149
+ messages=messages,
3150
+ startup_messages=startup_messages,
3151
+ runtime_kind=resolved_runtime_kind,
3152
+ prompt_cache_stream_key=prompt_cache_stream_key,
3153
+ mcp_manager=mcp_manager,
3154
+ terminal_manager=terminal_manager,
3155
+ durable_service_manager=durable_service_manager,
3156
+ persistent_service_registry=persistent_service_registry,
3157
+ edit_discipline=edit_discipline_state,
3158
+ subagents_enabled=resolved_subagents_enabled,
3159
+ enforce_explicit_subagent_requests=bool(enforce_explicit_subagent_requests),
3160
+ subagent_depth=subagent_depth,
3161
+ subagent_registry=resolved_subagent_registry,
3162
+ child_scheduler=child_scheduler_holder.get("scheduler"),
3163
+ steer_inbox=parent_steer_inbox,
3164
+ read_ledger=read_ledger_holder.get("ledger"),
3165
+ shell_runner=runner,
3166
+ step_budget_runtime=step_budget_runtime,
3167
+ planner_workspace_context=prompt_context.planner_workspace_context,
3168
+ workspace_grounding=prompt_context.workspace_grounding,
3169
+ focus_dir=workspace_context.focus_path,
3170
+ focus_relpath=workspace_context.focus_relpath,
3171
+ workspace_kind=workspace_context.workspace_kind,
3172
+ binding_requested_path=binding_requested_path,
3173
+ binding_source=binding_source,
3174
+ binding_risk_level=binding_risk_level,
3175
+ binding_created_path=binding_created_path,
3176
+ active_workdir_relpath=initial_active_workdir_relpath,
3177
+ session_source=normalized_session_source,
3178
+ session_source_metadata=copy.deepcopy(normalized_session_source_metadata),
3179
+ pinned_prefix_len=pinned_prefix_len,
3180
+ startup_context_baseline_tokens=startup_context_baseline_tokens,
3181
+ custom_tool_session_state=custom_tool_session_state,
3182
+ hook_dispatcher=hook_dispatcher,
3183
+ execution_deadline=execution_deadline,
3184
+ crash_diagnostics=crash_diagnostics,
3185
+ crash_diagnostic_log_path=resolved_crash_diagnostic_log_path,
3186
+ agentbox_telemetry=AgentBoxTelemetry.from_env(
3187
+ root=root,
3188
+ runtime_version=f"alysis-{__version__}",
3189
+ ),
3190
+ process_group_registry=process_group_registry,
3191
+ )
3192
+ if subagent_depth == 0 and store.enabled:
3193
+ # Persist the process's provider/web-search telemetry to the run's artifact
3194
+ # dir so retry/throttle/latency history survives exit. Only the top-level
3195
+ # session registers the sink; nested subagent/candidate calls in the same
3196
+ # process flow into it. Released on close().
3197
+ set_provider_telemetry_sink(
3198
+ store.runtime_artifact_path("diagnostics", "provider_telemetry.jsonl")
3199
+ )
3200
+ active_workdir_state["session"] = session
3201
+ return session
3202
+ except Exception:
3203
+ try:
3204
+ terminal_manager.shutdown_all()
3205
+ except Exception as exc: # noqa: BLE001
3206
+ # Startup failure cleanup is best-effort; still close MCP/store below.
3207
+ store.append(
3208
+ "warning",
3209
+ {
3210
+ "warning": "terminal_shutdown_failed",
3211
+ "message": f"Terminal manager shutdown failed: {exc}",
3212
+ },
3213
+ )
3214
+ try:
3215
+ mcp_manager.close()
3216
+ finally:
3217
+ store.close()
3218
+ raise