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
alysis_code/config.py ADDED
@@ -0,0 +1,3509 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ import shlex
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Literal, Self
12
+ from urllib.parse import urlsplit
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
15
+
16
+ from .branding import (
17
+ canonical_user_config_dir,
18
+ canonical_user_data_dir,
19
+ env_get,
20
+ )
21
+ from .llm.provider_limits import (
22
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS,
23
+ DEFAULT_PROVIDER_RETRY_BASE_DELAY_SECONDS,
24
+ DEFAULT_PROVIDER_RETRY_MAX_DELAY_SECONDS,
25
+ DEFAULT_PROVIDER_RETRY_MAX_RETRIES,
26
+ )
27
+ from .step_budget import (
28
+ AUTONOMOUS_STEP_BUDGET_POLICY,
29
+ DEFAULT_CHAT_MAX_STEPS,
30
+ DEFAULT_SUBAGENT_MAX_STEPS,
31
+ DEFAULT_TASK_MAX_STEPS,
32
+ normalize_step_budget_policy,
33
+ )
34
+ from .web_search_adapters import normalize_web_search_adapter
35
+ from .web_search_policy import normalize_web_search_policy
36
+
37
+
38
+ class ConfigError(RuntimeError):
39
+ pass
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ApiKeyResolution:
44
+ key: str | None
45
+ source: str
46
+
47
+
48
+ _VALID_TOOLBAR_ITEMS: set[str] = {
49
+ "mode",
50
+ "model",
51
+ "stream",
52
+ "trace",
53
+ "images",
54
+ "temp",
55
+ "ctx",
56
+ "subagents",
57
+ "tokens",
58
+ "cost",
59
+ "forge",
60
+ "plan",
61
+ }
62
+ _DEFAULT_TOOLBAR_ITEMS: tuple[str, ...] = ("mode", "model", "ctx", "subagents")
63
+ DEFAULT_SUBAGENT_TIMEOUT_S = 900.0
64
+ DEFAULT_VERIFY_COMMANDS: tuple[str, ...] = ("pytest -q",)
65
+ VERIFY_RUNNER_PREFIXES: frozenset[tuple[str, str]] = frozenset(
66
+ {
67
+ ("poetry", "run"),
68
+ ("uv", "run"),
69
+ ("pipenv", "run"),
70
+ }
71
+ )
72
+ VERIFY_PY_LAUNCHERS: frozenset[str] = frozenset({"py", "py.exe"})
73
+ VERIFY_PYTHON_LAUNCHER_RE = re.compile(r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?$")
74
+ VERIFY_MODULE_NAMES: frozenset[str] = frozenset({"pytest", "ruff", "unittest"})
75
+ _WEB_SEARCH_MODES: set[str] = {"off", "auto", "native", "external"}
76
+ _REASONING_EFFORTS: set[str] = {
77
+ "none",
78
+ "minimal",
79
+ "low",
80
+ "medium",
81
+ "high",
82
+ "xhigh",
83
+ "max",
84
+ "ultra",
85
+ }
86
+ _PROMPT_CACHE_MODES: set[str] = {"off", "manual", "auto"}
87
+ _ANTHROPIC_PROMPT_CACHE_TTLS: set[str] = {"5m", "1h"}
88
+ DEFAULT_FEEDBACK_GITHUB_REPO = "AlysisAi/alysis-code"
89
+ _GITHUB_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$")
90
+
91
+ # Profile name for the hosted Pro provider. Duplicated from ``alysis_cloud``
92
+ # rather than imported, to keep config.py free of intra-package imports at
93
+ # module scope. The legacy spelling is what pre-rebrand installs wrote into
94
+ # config.json and credentials.json.
95
+ CLOUD_PROFILE_KEY = "alysis"
96
+ LEGACY_CLOUD_PROFILE_KEY = "sylliptor"
97
+
98
+ # A config written before the rename pins the feedback repo to its old name.
99
+ # GitHub redirects the rename, so feedback still lands, but the value is stale
100
+ # and would outlive the redirect if the old name were ever reused. Anything
101
+ # still pointing at the pre-rebrand repo is read as the current one.
102
+ LEGACY_FEEDBACK_GITHUB_REPOS = frozenset(
103
+ {
104
+ "alysisai/sylliptor",
105
+ "apfivos/sylliptor",
106
+ }
107
+ )
108
+
109
+
110
+ def _redirect_legacy_feedback_repo(repo: str) -> str:
111
+ """Map a pre-rebrand feedback repo onto the current one."""
112
+ if repo.strip().lower() in LEGACY_FEEDBACK_GITHUB_REPOS:
113
+ return DEFAULT_FEEDBACK_GITHUB_REPO
114
+ return repo
115
+
116
+
117
+ class AssetsComprehensionConfig(BaseModel):
118
+ role: str = "comprehension"
119
+ vision_fallback_profile: str | None = None
120
+ vision_with_ocr_when_available: bool = True
121
+ ocr_enabled: Literal["auto", "always", "never"] = "auto"
122
+ ocr_provider: str = "tesseract"
123
+ ocr_timeout_seconds: int = 30
124
+ image_max_edge_pixels: int = 2048
125
+ questioning_mode: Literal["assertive", "balanced", "assumption_friendly"] = "balanced"
126
+ schema_version: int = 1
127
+
128
+
129
+ class AssetsPlannerConfig(BaseModel):
130
+ inline_images: bool = True
131
+ max_inline_images: int = 8
132
+ readiness_policy: Literal["soft", "block", "partial"] = "soft"
133
+ readiness_timeout_seconds: float = 60.0
134
+ max_chars_per_asset: int = 2000
135
+ max_primary_per_task: int = 8
136
+
137
+
138
+ class AssetsWorkerConfig(BaseModel):
139
+ inline_images: bool = True
140
+ max_inline_images: int = 8
141
+ fail_on_mirror_error: bool = False
142
+ allocator_role: str = "comprehension"
143
+ allocator_timeout_seconds: int = 30
144
+ max_chars_per_asset_block: int = 4000
145
+ max_focused_extract_chars: int = 4000
146
+ schema_version: int = 1
147
+
148
+
149
+ class AssetsConfig(BaseModel):
150
+ enabled: bool = True
151
+ comprehension: AssetsComprehensionConfig = Field(default_factory=AssetsComprehensionConfig)
152
+ planner: AssetsPlannerConfig = Field(default_factory=AssetsPlannerConfig)
153
+ worker: AssetsWorkerConfig = Field(default_factory=AssetsWorkerConfig)
154
+
155
+
156
+ class ImageGenerationConfig(BaseModel):
157
+ """Opt-in OpenAI-compatible image generation capability.
158
+
159
+ Generation is disabled by default because every successful call can incur a
160
+ separate provider charge. When no image-specific endpoint or credential is
161
+ configured, the active profile endpoint and resolved API key are reused.
162
+ """
163
+
164
+ enabled: bool = False
165
+ model: str = "gpt-image-1"
166
+ base_url: str | None = None
167
+ api_key_env: str | None = None
168
+ timeout_s: float = Field(default=180.0, gt=0.0, le=900.0)
169
+ max_images_per_call: int = Field(default=4, ge=1, le=4)
170
+ max_image_bytes: int = Field(default=25_000_000, ge=1024, le=100_000_000)
171
+ max_pixels: int = Field(default=16_777_216, ge=1_000_000, le=67_108_864)
172
+
173
+ @model_validator(mode="after")
174
+ def validate_provider_settings(self) -> Self:
175
+ self.model = str(self.model or "").strip()
176
+ if not self.model:
177
+ raise ValueError("Image generation model cannot be empty.")
178
+ self.api_key_env = str(self.api_key_env or "").strip() or None
179
+ if (
180
+ self.api_key_env
181
+ and re.fullmatch(
182
+ r"[A-Za-z_][A-Za-z0-9_]*",
183
+ self.api_key_env,
184
+ )
185
+ is None
186
+ ):
187
+ raise ValueError(
188
+ "Image generation api_key_env must be a valid environment variable name."
189
+ )
190
+ self.base_url = str(self.base_url or "").strip() or None
191
+ if self.base_url:
192
+ parsed = urlsplit(self.base_url)
193
+ if (
194
+ parsed.scheme.lower() not in {"http", "https"}
195
+ or not parsed.netloc
196
+ or parsed.username
197
+ or parsed.password
198
+ or parsed.query
199
+ or parsed.fragment
200
+ ):
201
+ raise ValueError(
202
+ "Image generation base_url must be a credential-free HTTP(S) URL "
203
+ "without a query or fragment."
204
+ )
205
+ return self
206
+
207
+
208
+ _RUNTIME_SECRET_FIELD_NAMES = frozenset(
209
+ {
210
+ "authorization",
211
+ "bearer",
212
+ "cookie",
213
+ "cookies",
214
+ "credential",
215
+ "credentials",
216
+ "id_token",
217
+ "oauth",
218
+ "password",
219
+ "passwd",
220
+ "secret",
221
+ "token",
222
+ }
223
+ )
224
+ _RUNTIME_SECRET_FIELD_MARKERS = (
225
+ "access_token",
226
+ "api_key",
227
+ "auth_token",
228
+ "client_secret",
229
+ "private_key",
230
+ "refresh_token",
231
+ )
232
+ _RUNTIME_SECRET_FIELD_SUFFIXES = (
233
+ "_authorization",
234
+ "_credential",
235
+ "_credentials",
236
+ "_password",
237
+ "_passwd",
238
+ "_secret",
239
+ "_token",
240
+ )
241
+ _RUNTIME_SECRET_FIELD_SEGMENTS = frozenset(
242
+ {
243
+ "authorization",
244
+ "bearer",
245
+ "cookie",
246
+ "cookies",
247
+ "credential",
248
+ "credentials",
249
+ "oauth",
250
+ "password",
251
+ "passwd",
252
+ "secret",
253
+ "token",
254
+ }
255
+ )
256
+ _RUNTIME_NON_SECRET_FIELD_ALLOWLIST = frozenset(
257
+ {
258
+ # Describes which provider-owned store is used; it is not a credential,
259
+ # credential path, or credential value.
260
+ "credential_store_backend",
261
+ }
262
+ )
263
+
264
+
265
+ def _runtime_secret_field(value: object) -> bool:
266
+ normalized = str(value or "").strip().lower().replace("-", "_")
267
+ if normalized in _RUNTIME_NON_SECRET_FIELD_ALLOWLIST:
268
+ return False
269
+ segments = frozenset(segment for segment in normalized.split("_") if segment)
270
+ return (
271
+ normalized in _RUNTIME_SECRET_FIELD_NAMES
272
+ or any(marker in normalized for marker in _RUNTIME_SECRET_FIELD_MARKERS)
273
+ or normalized.endswith(_RUNTIME_SECRET_FIELD_SUFFIXES)
274
+ or bool(segments & _RUNTIME_SECRET_FIELD_SEGMENTS)
275
+ )
276
+
277
+
278
+ def _find_runtime_secret_field(value: object, path: str = "") -> str | None:
279
+ if isinstance(value, dict):
280
+ for key, nested in value.items():
281
+ current = f"{path}.{key}" if path else str(key)
282
+ if _runtime_secret_field(key):
283
+ return current
284
+ found = _find_runtime_secret_field(nested, current)
285
+ if found:
286
+ return found
287
+ elif isinstance(value, (list, tuple)):
288
+ for index, nested in enumerate(value):
289
+ found = _find_runtime_secret_field(nested, f"{path}[{index}]")
290
+ if found:
291
+ return found
292
+ return None
293
+
294
+
295
+ def _reject_runtime_secret_fields(value: object, *, label: str) -> None:
296
+ secret_path = _find_runtime_secret_field(value)
297
+ if secret_path:
298
+ raise ValueError(
299
+ f"{label} {secret_path!r} looks secret. "
300
+ "Provider credentials must remain owned by the provider runtime."
301
+ )
302
+
303
+
304
+ class ExecutionConfig(BaseModel):
305
+ """Select the native loop or a provider-managed delegated runtime."""
306
+
307
+ model_config = ConfigDict(extra="allow")
308
+
309
+ backend: Literal["native", "delegated"] = "native"
310
+ runtime: str | None = None
311
+
312
+ @model_validator(mode="after")
313
+ def validate_backend_runtime_pair(self) -> Self:
314
+ _reject_runtime_secret_fields(
315
+ self.model_extra or {},
316
+ label="Execution setting",
317
+ )
318
+ runtime = str(self.runtime or "").strip() or None
319
+ self.runtime = runtime
320
+ if self.backend == "delegated" and runtime is None:
321
+ raise ValueError("Delegated execution requires a selected agent runtime.")
322
+ if self.backend == "native" and runtime is not None:
323
+ raise ValueError("Native execution cannot select a delegated agent runtime.")
324
+ return self
325
+
326
+
327
+ class AgentRuntimeSettings(BaseModel):
328
+ """Provider-neutral settings for one delegated agent runtime."""
329
+
330
+ model_config = ConfigDict(extra="allow")
331
+
332
+ adapter: str
333
+ executable: str
334
+ provider_managed_auth: bool = True
335
+ model: str | None = None
336
+ reasoning_effort: str | None = None
337
+ timeout_seconds: float = Field(default=3600.0, gt=0, allow_inf_nan=False)
338
+
339
+ @model_validator(mode="after")
340
+ def reject_persisted_secrets(self) -> Self:
341
+ _reject_runtime_secret_fields(
342
+ self.model_extra or {},
343
+ label="Agent runtime setting",
344
+ )
345
+ return self
346
+
347
+
348
+ class SubagentOrchestrationConfig(BaseModel):
349
+ max_background_children: int = Field(default=3, ge=1)
350
+ turn_end_policy: Literal["wait", "cancel"] = "wait"
351
+ workspace_isolation_enabled: bool = True
352
+ parallel_nonwriting_shared: bool = False
353
+ helpers_enabled: bool = True
354
+ helper_max_total_per_child: int = Field(default=2, ge=0)
355
+ helper_timeout_s: float = Field(default=120.0, gt=0, allow_inf_nan=False)
356
+ helper_max_steps: int = Field(default=20, ge=1)
357
+ # Match the established exploration-stagnation threshold: three identical
358
+ # outcomes are enough to identify repetition without treating one retry as a loop.
359
+ repetition_signal_threshold: int = Field(default=3, ge=2)
360
+ # The second occurrence is cheap enough for a child-local advisory; this is
361
+ # deliberately below the parent wake threshold.
362
+ repetition_nudge_occurrence_threshold: int = Field(default=2, ge=2)
363
+ # Five occurrences inside the retained outcome window catch alternating
364
+ # loops while leaving several ordinary retries quiet.
365
+ repetition_occurrence_threshold: int = Field(default=5, ge=3)
366
+ # Emergency containment only: ten warning windows give an available parent
367
+ # ample time to react before a demonstrably stagnant child is stopped.
368
+ repetition_backstop_threshold: int = Field(default=30, ge=3)
369
+ model_response_activity_after_s: float = Field(
370
+ default=15.0,
371
+ gt=0,
372
+ allow_inf_nan=False,
373
+ )
374
+ inactivity_signal_after_s: float = Field(
375
+ default=180.0,
376
+ gt=0,
377
+ allow_inf_nan=False,
378
+ )
379
+ inflight_deadline_grace_s: float = Field(
380
+ default=10.0,
381
+ ge=0,
382
+ allow_inf_nan=False,
383
+ )
384
+
385
+ @model_validator(mode="after")
386
+ def validate_repetition_thresholds(self) -> Self:
387
+ if self.repetition_backstop_threshold <= self.repetition_signal_threshold:
388
+ raise ValueError(
389
+ "repetition_backstop_threshold must exceed repetition_signal_threshold"
390
+ )
391
+ if self.repetition_nudge_occurrence_threshold >= self.repetition_occurrence_threshold:
392
+ raise ValueError(
393
+ "repetition_nudge_occurrence_threshold must be lower than "
394
+ "repetition_occurrence_threshold"
395
+ )
396
+ if self.repetition_occurrence_threshold > self.repetition_backstop_threshold:
397
+ raise ValueError(
398
+ "repetition_occurrence_threshold must not exceed repetition_backstop_threshold"
399
+ )
400
+ return self
401
+
402
+
403
+ class CacheConfig(BaseModel):
404
+ prompt_cache_key_enabled: bool = True
405
+ keepalive_enabled: bool = False
406
+ keepalive_idle_threshold_s: float = Field(default=240.0, gt=0, allow_inf_nan=False)
407
+
408
+
409
+ class AppConfig(BaseModel):
410
+ execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
411
+ cache: CacheConfig = Field(default_factory=CacheConfig)
412
+ read_ledger_enabled: bool = True
413
+ agent_runtimes: dict[str, AgentRuntimeSettings] = Field(default_factory=dict)
414
+ base_url: str = "https://api.openai.com/v1"
415
+ model: str = ""
416
+ llm_timeout_s: float = 60.0
417
+ llm_stream_no_progress_timeout_s: float = Field(
418
+ default=240.0,
419
+ gt=0,
420
+ allow_inf_nan=False,
421
+ )
422
+ run_deadline_seconds: float | None = None
423
+ run_deadline_unlimited: bool = False
424
+ run_deadline_degradation_enabled: bool = True
425
+ run_deadline_convergence_fraction: float = 0.75
426
+ run_deadline_wrap_up_fraction: float = 0.90
427
+ llm_enable_thinking: bool | None = None
428
+ llm_reasoning_effort: str | None = None
429
+ provider_concurrency_caps: dict[str, int] = Field(
430
+ default_factory=lambda: dict(DEFAULT_PROVIDER_CONCURRENCY_CAPS),
431
+ )
432
+ provider_retry_max_retries: int = DEFAULT_PROVIDER_RETRY_MAX_RETRIES
433
+ provider_retry_base_delay_seconds: float = DEFAULT_PROVIDER_RETRY_BASE_DELAY_SECONDS
434
+ provider_retry_max_delay_seconds: float = DEFAULT_PROVIDER_RETRY_MAX_DELAY_SECONDS
435
+ model_metadata_policy: str = "warn"
436
+ default_mode: str = "review" # review|auto|readonly|fullaccess
437
+ # Default persona for interactive chat: code|architect|ask|debug. Personas
438
+ # are conventions (prompt overlay, model role, default execution mode)
439
+ # layered on the execution-mode gate; "code" is the no-op persona and
440
+ # preserves pre-persona behavior exactly. See docs/persona_modes_design.md.
441
+ default_persona: str = "code"
442
+ # Kill-switch for persona modes; env override ALYSIS_PERSONA_MODES=off
443
+ # wins over the config value. Off: /mode accepts only execution modes,
444
+ # the switch_mode tool is not registered, and personas stay at Code.
445
+ persona_modes_enabled: bool = True
446
+ max_steps: int = DEFAULT_CHAT_MAX_STEPS
447
+ temperature: float = 0.2 # legacy global override
448
+ coding_temperature: float = 0.2
449
+ review_temperature: float = 0.0
450
+ planner_temperature: float = 0.2
451
+ conflict_review_temperature: float = 0.0
452
+ compactor_temperature: float = 0.2
453
+ chat_temperature: float = 0.7
454
+ # Sampling determinism controls. Unset (the default) means "do not pin",
455
+ # and the request is built exactly as it was before these existed: nothing
456
+ # is added to the payload and the per-role temperatures above still apply.
457
+ # Set, they pin sampling for every chat-completions request in the run so
458
+ # two runs of one build can be compared. Env overrides
459
+ # ALYSIS_SAMPLING_TEMPERATURE / _TOP_P / _SEED win over these values;
460
+ # bounds are shared with run_provenance so both paths accept the same set.
461
+ sampling_temperature: float | None = None
462
+ sampling_top_p: float | None = None
463
+ sampling_seed: int | None = None
464
+ stream: bool = True
465
+ routing_mode: str = "auto" # auto|code_only
466
+ # Kill-switch for capability arbitration over router verdicts; env override
467
+ # ALYSIS_ROUTE_ARBITRATION=off wins over the config value.
468
+ route_arbitration_enabled: bool = True
469
+ # Kill-switch for the router's semantic turn contract; env override
470
+ # ALYSIS_SEMANTIC_TURN_CONTRACT=off keeps the router's legacy posture-only
471
+ # path without restoring language-specific controller classification.
472
+ semantic_turn_contract_enabled: bool = True
473
+ # The router-free unified turn path: no router client is provisioned,
474
+ # every text turn goes straight to the main model with the full per-mode
475
+ # agent surface, and execution posture derives from the execution mode.
476
+ # The legacy pre-turn semantic-router path has been removed; this key
477
+ # (and ALYSIS_UNIFIED_TURN_PATH) stays accepted for one release but is
478
+ # ignored — every turn takes the unified path.
479
+ unified_turn_path_enabled: bool = True
480
+ # Optional fixed reply language (e.g. "Greek") for turns that run without
481
+ # the router. Empty means the model answers in the user's language
482
+ # naturally. When set, the host injects a reply-language directive and
483
+ # keys the final-summary language rewrite to it.
484
+ reply_language: str = ""
485
+ # Kill-switch for the fact-based completion-evidence classifier (evidence v2);
486
+ # env override ALYSIS_EVIDENCE_V2=off reverts to legacy string-shape evidence.
487
+ evidence_v2_enabled: bool = True
488
+ # Kill-switch for the baseline-first regression protocol (step 3); env override
489
+ # ALYSIS_REGRESSION_BASELINE=off keeps capture/telemetry but reverts the
490
+ # completion-gate policy to legacy (no regression/unattributed attribution).
491
+ regression_baseline_enabled: bool = True
492
+ # Kill-switch for turn-contract v2 (step 4: apply-don't-advise + spec literalism);
493
+ # env override ALYSIS_TURN_CONTRACT_V2=off keeps expectation extraction/telemetry
494
+ # but reverts the completion-gate policy (no expectations_unaddressed / advisory
495
+ # completion enforcement). Prompt additions are unconditional either way.
496
+ turn_contract_v2_enabled: bool = True
497
+ # Kill-switch for the reproduction-first protocol on bug-fix-shaped tasks
498
+ # (step 7); env override ALYSIS_REPRODUCTION_FIRST=off keeps repro-run
499
+ # capture/telemetry but reverts the turn directives and the completion-gate
500
+ # policy (no repro_unconfirmed / repro_artifacts_present enforcement).
501
+ reproduction_first_enabled: bool = True
502
+ # Kill-switch for the blast-radius regression gate (a correct fix that breaks
503
+ # neighbouring tests is a failed task); env override ALYSIS_BLAST_RADIUS=off
504
+ # keeps scope-run capture/telemetry but reverts the turn directives and the
505
+ # completion-gate policy (no blast_radius_regressions / blast_radius_unverified).
506
+ blast_radius_gate_enabled: bool = True
507
+ # Ceiling on how many test files one blast-radius scope may name. The scope is a
508
+ # safety net around the change, not a suite run.
509
+ blast_radius_max_scope_files: int = Field(default=40, gt=0)
510
+ # Wall-clock ceiling for one scope run. A run that exceeds it shrinks the scope
511
+ # (nearest tests kept) for the next run; it never disables the gate.
512
+ blast_radius_scope_seconds_cap: float = Field(default=300.0, gt=0, allow_inf_nan=False)
513
+ # Newly-broken test count past which the change is treated as over-broad and the
514
+ # repair directive switches from "fix each failure" to "rewrite the patch narrowly".
515
+ blast_radius_over_broad_threshold: int = Field(default=20, gt=0)
516
+ # Kill-switch for reaping agent-started process groups (step 5); env override
517
+ # ALYSIS_PROCESS_REAPING=off keeps commands in their own process group but
518
+ # never signals one, restoring the legacy leave-it-running behaviour.
519
+ process_reaping_enabled: bool = True
520
+ # Kill-switch for workspace test-runner pre-provisioning (step 5); env override
521
+ # ALYSIS_WORKSPACE_PROVISIONING=off suppresses both the one-shot install in
522
+ # autonomous runs and the env_gap_detected telemetry.
523
+ workspace_provisioning_enabled: bool = True
524
+ # Kill-switch for bounded empty-response handling; env override
525
+ # ALYSIS_EMPTY_RESPONSE_STALL=off restores the legacy behaviour of retrying
526
+ # an endpoint that returns contentless responses until the attempt cap, then
527
+ # terminating non-zero regardless of what the working tree already holds.
528
+ empty_response_stall_guard_enabled: bool = True
529
+ # A stall is declared after this many consecutive responses with no text and
530
+ # no tool calls, or after this many seconds of an unbroken contentless streak,
531
+ # whichever comes first.
532
+ empty_response_stall_threshold: int = Field(default=3, gt=0)
533
+ empty_response_stall_seconds: float = Field(default=300.0, gt=0, allow_inf_nan=False)
534
+ # Ceilings on stall handling: recovery cycles per session, and total wall-clock
535
+ # seconds the session may spend on empty-response handling before salvaging.
536
+ empty_response_max_recovery_cycles: int = Field(default=2, gt=0)
537
+ empty_response_handling_budget_seconds: float = Field(default=600.0, gt=0, allow_inf_nan=False)
538
+ step_budget_policy: str = AUTONOMOUS_STEP_BUDGET_POLICY
539
+ task_max_steps: int = DEFAULT_TASK_MAX_STEPS
540
+ subagent_max_steps: int = DEFAULT_SUBAGENT_MAX_STEPS
541
+ subagent_timeout_s: float = Field(
542
+ default=DEFAULT_SUBAGENT_TIMEOUT_S,
543
+ gt=0,
544
+ allow_inf_nan=False,
545
+ description="Fallback wall-clock ceiling in seconds for each ordinary subagent run.",
546
+ )
547
+ subagent_orchestration: SubagentOrchestrationConfig = Field(
548
+ default_factory=SubagentOrchestrationConfig,
549
+ )
550
+ subagents_enabled: bool = True
551
+ skills_enabled: bool = True
552
+ skills_auto_invoke: bool = Field(
553
+ default=True,
554
+ description=(
555
+ "Enable model-decided skill activation from discovered skill descriptions; "
556
+ "explicit false preserves manual/discovery-only behavior."
557
+ ),
558
+ )
559
+ experimental_gemini_interactions_enabled: bool = False
560
+ custom_tools_enabled: bool = True
561
+ web_tools_enabled: bool = Field(
562
+ default=True,
563
+ description=(
564
+ "Master switch for ALL web tools (web_fetch and web_search). When false, "
565
+ "web tools are never registered in the model's tool list and any runtime "
566
+ "web call hard-errors. Overridable via ALYSIS_WEB_TOOLS env var; used "
567
+ "by benchmark/offline runs to guarantee no network-mediated contamination."
568
+ ),
569
+ )
570
+ web_search_mode: str = "auto"
571
+ web_search_policy: str = "auto"
572
+ web_search_adapter: str = "auto"
573
+ web_search_base_url: str | None = None
574
+ web_search_model: str | None = None
575
+ web_search_timeout_s: float = 45.0
576
+ update_check_enabled: bool = True
577
+ update_check_interval_hours: int = 24
578
+ update_check_timeout_s: float = 3.0
579
+ update_prompt_enabled: bool = True
580
+ feedback_github_enabled: bool = True
581
+ feedback_github_repo: str = DEFAULT_FEEDBACK_GITHUB_REPO
582
+ feedback_open_browser: bool = True
583
+ session_log_dir: str | None = None
584
+ crash_diagnostic_log_path: str | None = None
585
+ prompt_cache_mode: str = "manual"
586
+ prompt_cache_key: str | None = None
587
+ prompt_cache_retention: str | None = None
588
+ anthropic_prompt_cache_enabled: bool = False
589
+ anthropic_prompt_cache_ttl: str = "5m"
590
+ verify_commands: list[str] = Field(
591
+ default_factory=lambda: list(DEFAULT_VERIFY_COMMANDS),
592
+ )
593
+ integration_verify_mode: str = "warn"
594
+ integration_verify_commands: list[str] = Field(default_factory=list)
595
+ replanning_mode: str = "off"
596
+ toolbar_items: list[str] = Field(
597
+ default_factory=lambda: list(_DEFAULT_TOOLBAR_ITEMS),
598
+ )
599
+ assets: AssetsConfig = Field(default_factory=AssetsConfig)
600
+ image_generation: ImageGenerationConfig = Field(default_factory=ImageGenerationConfig)
601
+
602
+ # Internal: allow future keys without crashing older clients.
603
+ extra_fields: dict[str, Any] = Field(default_factory=dict, exclude=True)
604
+
605
+ @model_validator(mode="after")
606
+ def validate_delegated_runtime_settings(self) -> Self:
607
+ self.step_budget_policy = normalize_step_budget_policy(self.step_budget_policy)
608
+ if self.execution.backend != "delegated":
609
+ return self
610
+ runtime_id = str(self.execution.runtime or "").strip()
611
+ if runtime_id not in self.agent_runtimes:
612
+ raise ValueError(f"Delegated runtime {runtime_id!r} has no agent_runtimes settings.")
613
+ return self
614
+
615
+
616
+ _ROLE_TEMPERATURE_FIELDS: dict[str, str] = {
617
+ "coding": "coding_temperature",
618
+ "review": "review_temperature",
619
+ "planner": "planner_temperature",
620
+ "conflict_review": "conflict_review_temperature",
621
+ "compactor": "compactor_temperature",
622
+ "chat": "chat_temperature",
623
+ }
624
+
625
+ _ROLE_TEMPERATURE_DEFAULTS: dict[str, float] = {
626
+ "coding": 0.2,
627
+ "review": 0.0,
628
+ "planner": 0.2,
629
+ "conflict_review": 0.0,
630
+ "compactor": 0.2,
631
+ "chat": 0.7,
632
+ }
633
+
634
+
635
+ def clone_cfg(cfg: AppConfig) -> AppConfig:
636
+ """Return a deep copy of AppConfig while preserving extra_fields."""
637
+ return cfg.model_copy(deep=True)
638
+
639
+
640
+ def normalize_verify_command_list(commands: Sequence[str] | None) -> tuple[str, ...]:
641
+ if not commands:
642
+ return ()
643
+ normalized: list[str] = []
644
+ for item in commands:
645
+ text = str(item).strip()
646
+ if text:
647
+ normalized.append(text)
648
+ return tuple(normalized)
649
+
650
+
651
+ def is_generic_verify_command_fallback(commands: Sequence[str] | None) -> bool:
652
+ return normalize_verify_command_list(commands) == DEFAULT_VERIFY_COMMANDS
653
+
654
+
655
+ def is_generic_configured_verify_preset(commands: Sequence[str] | None) -> bool:
656
+ normalized = normalize_verify_command_list(commands)
657
+ if not normalized:
658
+ return False
659
+ return all(_is_generic_configured_verify_command(command) for command in normalized)
660
+
661
+
662
+ def split_verify_command_parts(command: str) -> list[str] | None:
663
+ text = str(command or "")
664
+ launcher_path_parts = _split_supported_launcher_path_command_parts(text)
665
+ if launcher_path_parts is not None:
666
+ return launcher_path_parts
667
+ try:
668
+ return shlex.split(text, posix=True)
669
+ except ValueError:
670
+ return None
671
+
672
+
673
+ def strip_verify_runner_prefix(parts: Sequence[str]) -> list[str] | None:
674
+ tokens = list(parts)
675
+ if len(tokens) >= 2 and (tokens[0].casefold(), tokens[1].casefold()) in VERIFY_RUNNER_PREFIXES:
676
+ return tokens[2:] or None
677
+ return tokens
678
+
679
+
680
+ def verify_launcher_basename(token: str) -> str:
681
+ normalized = _strip_matching_shell_quotes(str(token).strip()).replace("\\", "/")
682
+ if not normalized:
683
+ return ""
684
+ return normalized.rsplit("/", 1)[-1].casefold()
685
+
686
+
687
+ def normalize_verify_module_invocation(parts: Sequence[str]) -> list[str]:
688
+ tokens = list(parts)
689
+ if len(tokens) < 3 or tokens[1] != "-m":
690
+ return tokens
691
+ launcher = verify_launcher_basename(tokens[0])
692
+ if launcher not in VERIFY_PY_LAUNCHERS and not VERIFY_PYTHON_LAUNCHER_RE.fullmatch(launcher):
693
+ return tokens
694
+ module = tokens[2].casefold()
695
+ if module not in VERIFY_MODULE_NAMES:
696
+ return tokens
697
+ return [module, *tokens[3:]]
698
+
699
+
700
+ def _is_generic_configured_verify_command(command: str) -> bool:
701
+ tokens = split_verify_command_parts(command)
702
+ if not tokens:
703
+ return False
704
+ if any(token in {"||", "&&", ";", "|", "&"} for token in tokens):
705
+ return False
706
+ if tokens[0] == "env" or _looks_like_env_assignment(tokens[0]):
707
+ return False
708
+ lowered = [token.casefold() for token in tokens]
709
+ if _is_generic_pytest_or_ruff_command(lowered):
710
+ return True
711
+ runner_stripped = strip_verify_runner_prefix(lowered)
712
+ if runner_stripped is None:
713
+ return False
714
+ return runner_stripped != lowered and _is_generic_pytest_or_ruff_command(runner_stripped)
715
+
716
+
717
+ def _is_generic_pytest_or_ruff_command(tokens: list[str]) -> bool:
718
+ normalized = normalize_verify_module_invocation(tokens)
719
+ return _is_generic_pytest_command(normalized) or _is_generic_ruff_check_command(normalized)
720
+
721
+
722
+ def _is_generic_pytest_command(tokens: list[str]) -> bool:
723
+ if tokens in (["pytest"], ["pytest", "-q"], ["py.test"], ["py.test", "-q"]):
724
+ return True
725
+ if len(tokens) in {3, 4} and tokens[0] in {"python", "python3"}:
726
+ if tokens[1:3] == ["-m", "pytest"]:
727
+ return len(tokens) == 3 or tokens[3] == "-q"
728
+ return False
729
+
730
+
731
+ def _is_generic_ruff_check_command(tokens: list[str]) -> bool:
732
+ if tokens[:2] != ["ruff", "check"]:
733
+ return False
734
+ targets = tokens[2:]
735
+ if not targets:
736
+ return True
737
+ if len(targets) != 1:
738
+ return False
739
+ return _normalize_generic_ruff_check_target(targets[0]) in {".", "src"}
740
+
741
+
742
+ def _normalize_generic_ruff_check_target(target: str) -> str:
743
+ normalized = target.strip().replace("\\", "/")
744
+ while normalized.endswith("/") and normalized not in {"/", "./"}:
745
+ normalized = normalized[:-1]
746
+ if normalized == "./":
747
+ return "."
748
+ if normalized.startswith("./"):
749
+ normalized = normalized[2:]
750
+ return normalized or "."
751
+
752
+
753
+ def _looks_like_env_assignment(token: str) -> bool:
754
+ if "=" not in token:
755
+ return False
756
+ name, _value = token.split("=", 1)
757
+ if not name:
758
+ return False
759
+ return all(ch == "_" or ch.isalnum() for ch in name)
760
+
761
+
762
+ def _split_supported_launcher_path_command_parts(command: str) -> list[str] | None:
763
+ try:
764
+ raw_parts = shlex.split(command, posix=False)
765
+ except ValueError:
766
+ return None
767
+ if not raw_parts:
768
+ return None
769
+
770
+ parts = [_strip_matching_shell_quotes(token) for token in raw_parts]
771
+ candidate = parts
772
+ runner_stripped = strip_verify_runner_prefix(candidate)
773
+ if runner_stripped is None:
774
+ return None
775
+ if runner_stripped != candidate:
776
+ candidate = runner_stripped
777
+
778
+ if len(candidate) < 3 or candidate[1] != "-m":
779
+ return None
780
+ launcher = candidate[0]
781
+ if "\\" not in launcher and "/" not in launcher:
782
+ return None
783
+
784
+ launcher_basename = verify_launcher_basename(launcher)
785
+ if launcher_basename not in VERIFY_PY_LAUNCHERS and not VERIFY_PYTHON_LAUNCHER_RE.fullmatch(
786
+ launcher_basename
787
+ ):
788
+ return None
789
+ return parts
790
+
791
+
792
+ def _strip_matching_shell_quotes(token: str) -> str:
793
+ if len(token) >= 2 and token[0] == token[-1] and token[0] in {"'", '"'}:
794
+ return token[1:-1]
795
+ return token
796
+
797
+
798
+ def _config_dir() -> Path:
799
+ override = env_get("ALYSIS_CONFIG_DIR")
800
+ if override:
801
+ return Path(override)
802
+ return canonical_user_config_dir()
803
+
804
+
805
+ def _data_dir() -> Path:
806
+ override = env_get("ALYSIS_DATA_DIR")
807
+ if override:
808
+ return Path(override)
809
+ return canonical_user_data_dir()
810
+
811
+
812
+ def config_path() -> Path:
813
+ return _config_dir() / "config.json"
814
+
815
+
816
+ def credentials_path() -> Path:
817
+ return _config_dir() / "credentials.json"
818
+
819
+
820
+ def default_sessions_dir() -> Path:
821
+ return _data_dir() / "sessions"
822
+
823
+
824
+ def default_chat_history_path() -> Path:
825
+ return _data_dir() / "chat_history.txt"
826
+
827
+
828
+ def _migrate_legacy_agent_runtime_config(
829
+ raw: dict[str, Any],
830
+ known: dict[str, Any],
831
+ ) -> set[str]:
832
+ """Translate the discarded ``acli`` prototype shape without writing it yet."""
833
+
834
+ if "execution" in raw:
835
+ return set()
836
+ raw_connection = raw.get("connection")
837
+ connection = raw_connection if isinstance(raw_connection, dict) else {}
838
+ connection_kind = str(connection.get("kind") or "").strip().lower()
839
+ connection_provider = str(connection.get("provider") or "").strip().lower()
840
+ legacy_backend = str(raw.get("llm_backend") or "").strip().lower()
841
+ delegated = (
842
+ connection_kind in {"subscription", "account", "subscription-account"}
843
+ and connection_provider in {"", "chatgpt", "chat-gpt", "codex"}
844
+ ) or legacy_backend in {"codex", "codex_cli", "codex-cli"}
845
+ if not delegated:
846
+ return set()
847
+
848
+ raw_subscription = raw.get("subscription")
849
+ subscription = raw_subscription if isinstance(raw_subscription, dict) else {}
850
+ raw_chatgpt = subscription.get("chatgpt")
851
+ chatgpt = raw_chatgpt if isinstance(raw_chatgpt, dict) else {}
852
+ raw_nested_codex = chatgpt.get("codex_cli")
853
+ nested_codex = raw_nested_codex if isinstance(raw_nested_codex, dict) else {}
854
+ raw_legacy_codex = raw.get("codex_cli")
855
+ legacy_codex = raw_legacy_codex if isinstance(raw_legacy_codex, dict) else {}
856
+ settings_source = {**legacy_codex, **nested_codex}
857
+ executable = str(settings_source.get("executable") or settings_source.get("path") or "codex")
858
+ settings: dict[str, Any] = {
859
+ "adapter": "codex-cli",
860
+ "executable": executable,
861
+ "provider_managed_auth": True,
862
+ }
863
+ model = str(settings_source.get("model") or "").strip()
864
+ if model:
865
+ settings["model"] = model
866
+ timeout = settings_source.get("timeout_seconds")
867
+ if timeout is not None and str(timeout).strip():
868
+ settings["timeout_seconds"] = timeout
869
+ known["execution"] = {"backend": "delegated", "runtime": "openai-codex"}
870
+ existing_runtimes = raw.get("agent_runtimes")
871
+ runtimes = dict(existing_runtimes) if isinstance(existing_runtimes, dict) else {}
872
+ runtimes.setdefault("openai-codex", settings)
873
+ known["agent_runtimes"] = runtimes
874
+ return {"connection", "subscription", "codex_cli", "llm_backend"}
875
+
876
+
877
+ def load_config() -> AppConfig:
878
+ path = config_path()
879
+ if not path.exists():
880
+ cfg = AppConfig()
881
+ from .profiles import migrate_legacy_to_profiles, sync_active_profile_to_config
882
+
883
+ migrate_legacy_to_profiles(cfg)
884
+ sync_active_profile_to_config(cfg)
885
+ return cfg
886
+
887
+ try:
888
+ raw = json.loads(path.read_text(encoding="utf-8"))
889
+ except Exception as e: # noqa: BLE001 - user-controlled file
890
+ raise ConfigError(f"Failed to read config: {path}") from e
891
+
892
+ if not isinstance(raw, dict):
893
+ raise ConfigError(f"Invalid config format (expected JSON object): {path}")
894
+
895
+ # Allow unknown keys; stash them so we can round-trip. Map deprecated
896
+ # web_search keys into the legacy auto/off values when older configs
897
+ # are loaded.
898
+ known = {k: v for k, v in raw.items() if k in AppConfig.model_fields}
899
+ migrated_runtime_keys = _migrate_legacy_agent_runtime_config(raw, known)
900
+ if "web_search_mode" in raw:
901
+ known["web_search_mode"] = _normalize_web_search_mode(
902
+ raw.get("web_search_mode"),
903
+ allow_legacy_on=True,
904
+ )
905
+ elif "web_search_enabled" in raw:
906
+ known["web_search_mode"] = _coerce_legacy_web_search_mode(raw.get("web_search_enabled"))
907
+ if "web_search_adapter" in raw:
908
+ known["web_search_adapter"] = _normalize_web_search_adapter(raw.get("web_search_adapter"))
909
+ if "web_search_policy" in raw:
910
+ known["web_search_policy"] = _normalize_web_search_policy(raw.get("web_search_policy"))
911
+ if "temperature" in raw:
912
+ legacy_temperature = raw.get("temperature")
913
+ for field in _ROLE_TEMPERATURE_FIELDS.values():
914
+ if field not in raw:
915
+ known[field] = legacy_temperature
916
+ unknown = {
917
+ k: v
918
+ for k, v in raw.items()
919
+ if k not in AppConfig.model_fields
920
+ and k != "web_search_enabled"
921
+ and k not in migrated_runtime_keys
922
+ }
923
+ cfg = AppConfig(**known)
924
+ cfg.extra_fields = unknown
925
+ from .profiles import migrate_legacy_to_profiles, sync_active_profile_to_config
926
+
927
+ migrate_legacy_to_profiles(cfg)
928
+ # Applied on the loaded object, not just at use time, so `config show`
929
+ # reports the repo that feedback actually goes to. Redirecting only inside
930
+ # resolve_feedback_github_repo() left the two disagreeing in the same
931
+ # output: the migrated profile name beside the stale repo name.
932
+ cfg.feedback_github_repo = _redirect_legacy_feedback_repo(
933
+ str(getattr(cfg, "feedback_github_repo", DEFAULT_FEEDBACK_GITHUB_REPO))
934
+ )
935
+ _migrate_delegated_codex_subscription(
936
+ cfg,
937
+ had_non_default_profile=(
938
+ isinstance(raw.get("profiles"), dict)
939
+ and any(str(name) != "default" for name in raw["profiles"])
940
+ ),
941
+ )
942
+ sync_active_profile_to_config(cfg)
943
+ _canonicalize_active_profile_model(cfg)
944
+ return cfg
945
+
946
+
947
+ def _migrate_delegated_codex_subscription(
948
+ cfg: AppConfig,
949
+ *,
950
+ had_non_default_profile: bool,
951
+ ) -> bool:
952
+ """Move the original Codex subscription choice onto the native transport.
953
+
954
+ The first implementation used ``execution.backend=delegated`` and therefore
955
+ replaced the Alysis Code agent with ``codex exec``. Preserve those runtime
956
+ settings as an inactive fallback, but make the user-facing subscription
957
+ selection a native profile so existing installations receive the corrected
958
+ behavior without repeating setup.
959
+ """
960
+
961
+ _ = had_non_default_profile # Retained for callers from the first migration draft.
962
+ if cfg.extra_fields.get("onboarded") is not True:
963
+ return False
964
+ if cfg.extra_fields.get("preserve_delegated_runtime") is True:
965
+ return False
966
+ if cfg.execution.backend != "delegated" or cfg.execution.runtime != "openai-codex":
967
+ return False
968
+ settings = cfg.agent_runtimes.get("openai-codex")
969
+ if settings is None or str(settings.adapter or "").strip() != "codex-cli":
970
+ return False
971
+ if str(settings.executable or "").strip() not in {"", "codex"}:
972
+ return False
973
+ from .profiles import (
974
+ SUBSCRIPTION_SELECTION_REQUIRED_KEY,
975
+ ProfileSpec,
976
+ add_profile,
977
+ set_active_profile,
978
+ )
979
+
980
+ model = str(settings.model or "").strip()
981
+ reasoning_effort = str(settings.reasoning_effort or "").strip() or None
982
+ profile = ProfileSpec(
983
+ name="chatgpt-codex",
984
+ protocol="openai_responses",
985
+ base_url="https://chatgpt.com/backend-api/codex",
986
+ auth_provider="openai-codex",
987
+ default_model=model,
988
+ reasoning_effort=reasoning_effort,
989
+ notes=(
990
+ "ChatGPT Codex subscription compatibility transport. "
991
+ "Migrated from the delegated Codex runtime."
992
+ ),
993
+ )
994
+ add_profile(cfg, profile, allow_auth_profile_update=True)
995
+ set_active_profile(cfg, profile.name)
996
+ cfg.execution.backend = "native"
997
+ cfg.execution.runtime = None
998
+ cfg.model = model
999
+ effective_effort = None if reasoning_effort in {None, "auto"} else reasoning_effort
1000
+ cfg.llm_reasoning_effort = effective_effort
1001
+ cfg.llm_enable_thinking = None if effective_effort is None else effective_effort != "none"
1002
+ # The delegated CLI owned credentials, so native Alysis Code must explicitly
1003
+ # connect its own encrypted provider vault before chat starts.
1004
+ cfg.extra_fields["onboarded"] = False
1005
+ cfg.extra_fields["subscription_reconnect_required"] = True
1006
+ # Preserve any explicit legacy values only as a pre-filled suggestion. Migration
1007
+ # itself is not a /config confirmation, so execution remains blocked until the
1008
+ # user saves a model + effort from Default Model.
1009
+ cfg.extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = "openai-codex"
1010
+ return True
1011
+
1012
+
1013
+ def save_config(cfg: AppConfig) -> None:
1014
+ path = config_path()
1015
+ path.parent.mkdir(parents=True, exist_ok=True)
1016
+
1017
+ try:
1018
+ validated = AppConfig.model_validate(cfg.model_dump())
1019
+ except ValidationError as exc:
1020
+ raise ConfigError(f"Invalid configuration: {exc.errors()[0]['msg']}") from exc
1021
+ data = validated.model_dump()
1022
+ # Preserve unknown keys.
1023
+ if cfg.extra_fields:
1024
+ extra_fields = dict(cfg.extra_fields)
1025
+ extra_fields.pop("web_search_enabled", None)
1026
+ data.update(extra_fields)
1027
+
1028
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1029
+
1030
+
1031
+ def _canonicalize_active_profile_model(cfg: AppConfig) -> None:
1032
+ active_profile = str((cfg.extra_fields or {}).get("active_profile") or "").strip()
1033
+ model = str(getattr(cfg, "model", "") or "").strip()
1034
+ if not active_profile or not model:
1035
+ return
1036
+
1037
+ from .profile_presets import find_preset_for_profile, get_preset
1038
+ from .profiles import get_profile, update_active_profile_defaults
1039
+
1040
+ profile = get_profile(cfg, active_profile)
1041
+ if profile is not None and profile.auth_provider:
1042
+ return
1043
+ preset = find_preset_for_profile(profile) if profile is not None else None
1044
+ preset = preset or get_preset(active_profile)
1045
+ canonical = _canonicalize_model_for_config(cfg, model, active_preset=preset)
1046
+ if canonical == model:
1047
+ return
1048
+ cfg.model = canonical
1049
+ update_active_profile_defaults(cfg, default_model=canonical)
1050
+
1051
+
1052
+ def _canonicalize_model_for_config(
1053
+ cfg: AppConfig,
1054
+ model: str,
1055
+ *,
1056
+ active_preset: Any | None = None,
1057
+ ) -> str:
1058
+ raw = str(model or "").strip()
1059
+ if not raw:
1060
+ return raw
1061
+
1062
+ active_profile = str((cfg.extra_fields or {}).get("active_profile") or "").strip()
1063
+ active_profile_obj = None
1064
+ if active_profile:
1065
+ from .profiles import get_profile
1066
+
1067
+ active_profile_obj = get_profile(cfg, active_profile)
1068
+
1069
+ if active_preset is None and active_profile:
1070
+ from .profile_presets import find_preset_for_profile, get_preset
1071
+
1072
+ active_preset = (
1073
+ find_preset_for_profile(active_profile_obj) if active_profile_obj is not None else None
1074
+ )
1075
+ active_preset = active_preset or get_preset(active_profile)
1076
+
1077
+ if active_preset is not None:
1078
+ active_model = _canonicalize_model_for_preset(raw, active_preset)
1079
+ if active_model is not None:
1080
+ return active_model
1081
+ if _provider_switch_is_unsafe(active_preset):
1082
+ return raw
1083
+ elif active_profile_obj is not None:
1084
+ from .profiles import DEFAULT_OPENAI_BASE_URL
1085
+
1086
+ base_url = str(getattr(active_profile_obj, "base_url", "") or "").strip().rstrip("/")
1087
+ default_base_url = DEFAULT_OPENAI_BASE_URL.strip().rstrip("/")
1088
+ if base_url and base_url != default_base_url:
1089
+ return raw
1090
+
1091
+ matches = _matching_model_presets(raw)
1092
+ direct_matches = tuple(
1093
+ (preset, canonical)
1094
+ for preset, canonical in matches
1095
+ if not _provider_switch_is_unsafe(preset)
1096
+ )
1097
+ if len(direct_matches) == 1:
1098
+ preset, canonical = direct_matches[0]
1099
+ _align_active_profile_to_preset(cfg, preset)
1100
+ return canonical
1101
+ if len(matches) == 1:
1102
+ preset, canonical = matches[0]
1103
+ _align_active_profile_to_preset(cfg, preset)
1104
+ return canonical
1105
+ return raw
1106
+
1107
+
1108
+ def _canonicalize_model_for_preset(model: str, preset: Any) -> str | None:
1109
+ lookup = _build_profile_model_lookup_index(preset)
1110
+ for alias in _iter_model_lookup_aliases(model):
1111
+ canonical = lookup.get(alias.casefold())
1112
+ if canonical:
1113
+ return canonical
1114
+ return None
1115
+
1116
+
1117
+ def _build_profile_model_lookup_index(preset: Any) -> dict[str, str]:
1118
+ index: dict[str, str] = {}
1119
+ for model in getattr(preset, "suggested_models", ()):
1120
+ canonical = str(model or "").strip()
1121
+ if not canonical:
1122
+ continue
1123
+ for alias in _iter_model_lookup_aliases(canonical):
1124
+ index.setdefault(alias.casefold(), canonical)
1125
+ model_aliases = getattr(preset, "model_aliases", {}) or {}
1126
+ if isinstance(model_aliases, dict):
1127
+ for raw_alias, raw_target in model_aliases.items():
1128
+ alias_text = str(raw_alias or "").strip()
1129
+ target_text = str(raw_target or "").strip()
1130
+ if not alias_text or not target_text:
1131
+ continue
1132
+ canonical = next(
1133
+ (
1134
+ index[target_alias.casefold()]
1135
+ for target_alias in _iter_model_lookup_aliases(target_text)
1136
+ if target_alias.casefold() in index
1137
+ ),
1138
+ target_text,
1139
+ )
1140
+ for alias in _iter_model_lookup_aliases(alias_text):
1141
+ index.setdefault(alias.casefold(), canonical)
1142
+ return index
1143
+
1144
+
1145
+ def _iter_model_lookup_aliases(raw: str) -> tuple[str, ...]:
1146
+ value = str(raw or "").strip()
1147
+ if not value:
1148
+ return ()
1149
+
1150
+ candidates: list[str] = [value]
1151
+ if "/" in value:
1152
+ provider_stripped = value.rsplit("/", 1)[-1].strip()
1153
+ if provider_stripped:
1154
+ candidates.append(provider_stripped)
1155
+
1156
+ out: list[str] = []
1157
+ seen: set[str] = set()
1158
+ for candidate in candidates:
1159
+ normalized_separators = re.sub(r"[\s_]+", "-", candidate.strip())
1160
+ variants = (
1161
+ candidate.strip(),
1162
+ normalized_separators,
1163
+ re.sub(r"(?<=\d)-(?=\d)", ".", normalized_separators),
1164
+ re.sub(r"(?<=\d)\.(?=\d)", "-", normalized_separators),
1165
+ )
1166
+ for variant in variants:
1167
+ clean = variant.strip()
1168
+ if not clean:
1169
+ continue
1170
+ folded = clean.casefold()
1171
+ if folded in seen:
1172
+ continue
1173
+ seen.add(folded)
1174
+ out.append(clean)
1175
+ return tuple(out)
1176
+
1177
+
1178
+ def _matching_model_presets(model: str) -> tuple[tuple[Any, str], ...]:
1179
+ from .profile_presets import PROFILE_PRESETS
1180
+
1181
+ matches: list[tuple[Any, str]] = []
1182
+ for preset in PROFILE_PRESETS:
1183
+ if preset.key in {
1184
+ "custom",
1185
+ "openai-responses",
1186
+ "anthropic-compat",
1187
+ "anthropic-native",
1188
+ "gemini-compat",
1189
+ "gemini-native",
1190
+ }:
1191
+ continue
1192
+ canonical = _canonicalize_model_for_preset(model, preset)
1193
+ if canonical is not None:
1194
+ matches.append((preset, canonical))
1195
+ return tuple(matches)
1196
+
1197
+
1198
+ def _provider_switch_is_unsafe(preset: Any) -> bool:
1199
+ return str(getattr(preset, "key", "") or "").strip().lower() in {"openrouter", "custom"}
1200
+
1201
+
1202
+ def _align_active_profile_to_preset(
1203
+ cfg: AppConfig,
1204
+ preset: Any,
1205
+ *,
1206
+ base_url: str | None = None,
1207
+ default_model: str | None = None,
1208
+ ) -> None:
1209
+ from .profile_presets import make_profile_from_preset
1210
+ from .profiles import (
1211
+ add_profile,
1212
+ set_active_profile,
1213
+ update_active_profile_defaults,
1214
+ )
1215
+
1216
+ profile_name = _find_profile_name_for_preset(cfg, preset)
1217
+ if profile_name is None:
1218
+ profile_name = _next_profile_name_for_preset(cfg, preset)
1219
+ add_profile(cfg, make_profile_from_preset(preset, name=profile_name))
1220
+ set_active_profile(cfg, profile_name)
1221
+ update_active_profile_defaults(
1222
+ cfg,
1223
+ base_url=base_url,
1224
+ default_model=default_model,
1225
+ )
1226
+
1227
+
1228
+ def _find_profile_name_for_preset(cfg: AppConfig, preset: Any) -> str | None:
1229
+ from .profile_presets import find_preset_for_profile
1230
+ from .profiles import list_profiles
1231
+
1232
+ active = str((cfg.extra_fields or {}).get("active_profile") or "").strip()
1233
+ profiles = list_profiles(cfg)
1234
+ for profile in profiles:
1235
+ if profile.name != active:
1236
+ continue
1237
+ matched = find_preset_for_profile(profile)
1238
+ if matched is not None and matched.key == preset.key:
1239
+ return profile.name
1240
+ for profile in profiles:
1241
+ matched = find_preset_for_profile(profile)
1242
+ if matched is not None and matched.key == preset.key:
1243
+ return profile.name
1244
+ return None
1245
+
1246
+
1247
+ def _next_profile_name_for_preset(cfg: AppConfig, preset: Any) -> str:
1248
+ from .profiles import list_profiles
1249
+
1250
+ existing = {profile.name for profile in list_profiles(cfg)}
1251
+ base = str(getattr(preset, "key", "") or "provider").strip().lower()
1252
+ if base not in existing:
1253
+ return base
1254
+ for suffix in range(2, 100):
1255
+ candidate = f"{base}-{suffix}"
1256
+ if candidate not in existing:
1257
+ return candidate
1258
+ raise ConfigError(f"Could not allocate a profile name for provider preset {base!r}.")
1259
+
1260
+
1261
+ def _load_credentials_data() -> dict[str, Any]:
1262
+ path = credentials_path()
1263
+ if not path.exists():
1264
+ return {}
1265
+ try:
1266
+ raw = json.loads(path.read_text(encoding="utf-8"))
1267
+ except Exception as e: # noqa: BLE001 - user-controlled file
1268
+ raise ConfigError(f"Failed to read persisted API key: {path}") from e
1269
+ if not isinstance(raw, dict):
1270
+ raise ConfigError(f"Invalid persisted API key format (expected JSON object): {path}")
1271
+ return dict(raw)
1272
+
1273
+
1274
+ def _save_credentials_data(data: dict[str, Any]) -> None:
1275
+ path = credentials_path()
1276
+ clean_data = dict(data)
1277
+ if not clean_data:
1278
+ if path.exists():
1279
+ path.unlink()
1280
+ return
1281
+ path.parent.mkdir(parents=True, exist_ok=True)
1282
+ path.write_text(json.dumps(clean_data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1283
+ try:
1284
+ os.chmod(path, 0o600)
1285
+ except OSError:
1286
+ pass
1287
+
1288
+
1289
+ def load_persisted_api_key() -> str | None:
1290
+ raw = _load_credentials_data()
1291
+ value = str(raw.get("api_key") or "").strip()
1292
+ return value or None
1293
+
1294
+
1295
+ def save_persisted_api_key(api_key: str) -> None:
1296
+ normalized = str(api_key or "").strip()
1297
+ if not normalized:
1298
+ raise ConfigError("API key is empty.")
1299
+ data = _load_credentials_data()
1300
+ data["api_key"] = normalized
1301
+ _save_credentials_data(data)
1302
+
1303
+
1304
+ def clear_persisted_api_key() -> bool:
1305
+ data = _load_credentials_data()
1306
+ if "api_key" not in data:
1307
+ return False
1308
+ data.pop("api_key", None)
1309
+ _save_credentials_data(data)
1310
+ return True
1311
+
1312
+
1313
+ def load_persisted_profile_keys() -> dict[str, str]:
1314
+ raw = _load_credentials_data().get("profile_keys")
1315
+ if not isinstance(raw, dict):
1316
+ return {}
1317
+ keys: dict[str, str] = {}
1318
+ for name, value in raw.items():
1319
+ profile_name = str(name or "").strip().lower()
1320
+ key = str(value or "").strip()
1321
+ if profile_name and key:
1322
+ keys[profile_name] = key
1323
+ # The hosted Pro provider's profile was named "sylliptor" before the
1324
+ # rename. Surface the stored gateway key under the current name so an
1325
+ # existing Pro user is not asked to log in again.
1326
+ if LEGACY_CLOUD_PROFILE_KEY in keys and CLOUD_PROFILE_KEY not in keys:
1327
+ keys[CLOUD_PROFILE_KEY] = keys[LEGACY_CLOUD_PROFILE_KEY]
1328
+ return keys
1329
+
1330
+
1331
+ def save_persisted_profile_key(profile_name: str, value: str) -> None:
1332
+ from .profiles import ProfileSpec
1333
+
1334
+ normalized_name = ProfileSpec(name=str(profile_name or "").strip().lower()).name
1335
+ normalized_key = str(value or "").strip()
1336
+ if not normalized_key:
1337
+ raise ConfigError("API key is empty.")
1338
+ data = _load_credentials_data()
1339
+ profile_keys = data.get("profile_keys")
1340
+ if not isinstance(profile_keys, dict):
1341
+ profile_keys = {}
1342
+ profile_keys[normalized_name] = normalized_key
1343
+ data["profile_keys"] = dict(sorted(profile_keys.items()))
1344
+ _save_credentials_data(data)
1345
+
1346
+
1347
+ def clear_persisted_profile_key(profile_name: str) -> bool:
1348
+ from .profiles import ProfileSpec
1349
+
1350
+ normalized_name = ProfileSpec(name=str(profile_name or "").strip().lower()).name
1351
+ data = _load_credentials_data()
1352
+ profile_keys = data.get("profile_keys")
1353
+ if not isinstance(profile_keys, dict) or normalized_name not in profile_keys:
1354
+ return False
1355
+ profile_keys.pop(normalized_name, None)
1356
+ if profile_keys:
1357
+ data["profile_keys"] = dict(sorted(profile_keys.items()))
1358
+ else:
1359
+ data.pop("profile_keys", None)
1360
+ _save_credentials_data(data)
1361
+ return True
1362
+
1363
+
1364
+ def rename_persisted_profile_key(old_name: str, new_name: str) -> bool:
1365
+ from .profiles import ProfileSpec
1366
+
1367
+ old_profile = ProfileSpec(name=str(old_name or "").strip().lower()).name
1368
+ new_profile = ProfileSpec(name=str(new_name or "").strip().lower()).name
1369
+ data = _load_credentials_data()
1370
+ profile_keys = data.get("profile_keys")
1371
+ if not isinstance(profile_keys, dict) or old_profile not in profile_keys:
1372
+ return False
1373
+ profile_keys[new_profile] = profile_keys.pop(old_profile)
1374
+ data["profile_keys"] = dict(sorted(profile_keys.items()))
1375
+ _save_credentials_data(data)
1376
+ return True
1377
+
1378
+
1379
+ def resolve_profile_api_key(
1380
+ cfg: AppConfig,
1381
+ profile_name: str,
1382
+ ) -> ApiKeyResolution:
1383
+ from .profiles import get_profile
1384
+
1385
+ profile = get_profile(cfg, profile_name)
1386
+ if profile is None:
1387
+ return ApiKeyResolution(key=None, source="missing")
1388
+ stored_profile_key = _resolve_stored_profile_api_key(cfg, profile_name)
1389
+ if stored_profile_key.key:
1390
+ return stored_profile_key
1391
+ env_name = str(profile.api_key_env or "").strip()
1392
+ if env_name:
1393
+ env_key = str(env_get(env_name) or "").strip()
1394
+ if env_key:
1395
+ return ApiKeyResolution(key=env_key, source=f"env:{env_name}")
1396
+ active_profile = str((cfg.extra_fields or {}).get("active_profile") or "").strip()
1397
+ if profile.name == active_profile:
1398
+ legacy_key = load_persisted_api_key()
1399
+ if legacy_key:
1400
+ return ApiKeyResolution(key=legacy_key, source="stored:legacy")
1401
+ if profile.name == "openai":
1402
+ openai_key = str(os.environ.get("OPENAI_API_KEY") or "").strip()
1403
+ if openai_key:
1404
+ return ApiKeyResolution(key=openai_key, source="env:OPENAI_API_KEY")
1405
+ return ApiKeyResolution(key=None, source="missing")
1406
+
1407
+
1408
+ def _resolve_stored_profile_api_key(cfg: AppConfig, profile_name: str) -> ApiKeyResolution:
1409
+ from .profiles import get_profile
1410
+
1411
+ profile = get_profile(cfg, profile_name)
1412
+ if profile is None:
1413
+ return ApiKeyResolution(key=None, source="missing")
1414
+ profile_key = load_persisted_profile_keys().get(profile.name)
1415
+ if profile_key:
1416
+ return ApiKeyResolution(key=profile_key, source=f"stored:profile={profile.name}")
1417
+ return ApiKeyResolution(key=None, source="missing")
1418
+
1419
+
1420
+ def resolve_api_key(
1421
+ cfg: AppConfig | None = None,
1422
+ *,
1423
+ profile_name: str | None = None,
1424
+ ) -> ApiKeyResolution:
1425
+ effective_cfg = cfg or load_config()
1426
+ if profile_name is None:
1427
+ profile_name = str((effective_cfg.extra_fields or {}).get("active_profile") or "").strip()
1428
+ if profile_name:
1429
+ stored_profile_key = _resolve_stored_profile_api_key(effective_cfg, profile_name)
1430
+ if stored_profile_key.key:
1431
+ return stored_profile_key
1432
+ prefer_profile_scoped = bool(
1433
+ profile_name and _should_prefer_profile_scoped_api_key(effective_cfg, profile_name)
1434
+ )
1435
+ if profile_name and prefer_profile_scoped:
1436
+ resolved = resolve_profile_api_key(effective_cfg, profile_name)
1437
+ if resolved.key and _is_profile_scoped_api_key_source(resolved.source):
1438
+ return resolved
1439
+ alysis_key = str(env_get("ALYSIS_API_KEY") or "").strip()
1440
+ if alysis_key and not prefer_profile_scoped:
1441
+ return ApiKeyResolution(key=alysis_key, source="env:ALYSIS_API_KEY")
1442
+ if profile_name:
1443
+ resolved = resolve_profile_api_key(effective_cfg, profile_name)
1444
+ if resolved.key and (not prefer_profile_scoped or resolved.source != "env:OPENAI_API_KEY"):
1445
+ return resolved
1446
+ legacy_key = load_persisted_api_key()
1447
+ if legacy_key:
1448
+ return ApiKeyResolution(key=legacy_key, source="stored:legacy")
1449
+ if prefer_profile_scoped:
1450
+ return ApiKeyResolution(key=None, source="missing")
1451
+ openai_key = str(os.environ.get("OPENAI_API_KEY") or "").strip()
1452
+ if openai_key:
1453
+ return ApiKeyResolution(key=openai_key, source="env:OPENAI_API_KEY")
1454
+ return ApiKeyResolution(key=None, source="missing")
1455
+
1456
+
1457
+ def _should_prefer_profile_scoped_api_key(cfg: AppConfig, profile_name: str) -> bool:
1458
+ from .profiles import DEFAULT_OPENAI_BASE_URL, get_profile, resolve_effective_base_url
1459
+
1460
+ profile = get_profile(cfg, profile_name)
1461
+ if profile is None:
1462
+ return False
1463
+ if profile.name not in {"default", "openai"}:
1464
+ return True
1465
+ effective_base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
1466
+ return effective_base_url.rstrip("/") != DEFAULT_OPENAI_BASE_URL.rstrip("/")
1467
+
1468
+
1469
+ def _is_profile_scoped_api_key_source(source: str) -> bool:
1470
+ normalized = str(source or "").strip()
1471
+ if normalized.startswith("stored:profile="):
1472
+ return True
1473
+ if not normalized.startswith("env:"):
1474
+ return False
1475
+ env_name = normalized.removeprefix("env:")
1476
+ return env_name not in {"ALYSIS_API_KEY", "OPENAI_API_KEY"}
1477
+
1478
+
1479
+ def get_api_key(cfg: AppConfig | None = None) -> str:
1480
+ resolved = resolve_api_key(cfg)
1481
+ if not resolved.key:
1482
+ raise ConfigError(_missing_api_key_message(cfg or load_config()))
1483
+ return resolved.key
1484
+
1485
+
1486
+ def resolve_model_access_api_key(
1487
+ cfg: AppConfig,
1488
+ *,
1489
+ override: str | None = None,
1490
+ legacy_resolver: Callable[[], str] | None = None,
1491
+ ) -> str:
1492
+ """Resolve a static key, or an empty placeholder for an auth-backed profile."""
1493
+
1494
+ if override is not None:
1495
+ normalized = override.strip()
1496
+ if normalized:
1497
+ return normalized
1498
+ try:
1499
+ from .profiles import get_active_profile
1500
+
1501
+ if get_active_profile(cfg).auth_provider:
1502
+ return ""
1503
+ except ConfigError:
1504
+ pass
1505
+ if override is not None:
1506
+ raise ConfigError("API key is empty.")
1507
+ if legacy_resolver is not None:
1508
+ return legacy_resolver()
1509
+ try:
1510
+ return get_api_key(cfg)
1511
+ except ConfigError:
1512
+ # ``ALYSIS_API_KEY`` is an explicit key for the selected Alysis Code
1513
+ # connection. Preserve its historical use for custom-base profiles
1514
+ # without falling back to a possibly unrelated ``OPENAI_API_KEY``.
1515
+ alysis_key = str(env_get("ALYSIS_API_KEY") or "").strip()
1516
+ if alysis_key:
1517
+ return alysis_key
1518
+ raise
1519
+
1520
+
1521
+ def ensure_subscription_menu_managed_key(cfg: AppConfig, key: str) -> None:
1522
+ """Reject raw edits that would split an auth profile's model/effort selection."""
1523
+
1524
+ normalized = str(key or "").strip().lower()
1525
+ if normalized not in {
1526
+ "model",
1527
+ "base_url",
1528
+ "llm_reasoning_effort",
1529
+ "llm_enable_thinking",
1530
+ }:
1531
+ return
1532
+ try:
1533
+ from .profiles import get_active_profile
1534
+
1535
+ auth_provider = get_active_profile(cfg).auth_provider
1536
+ except ConfigError:
1537
+ auth_provider = None
1538
+ if auth_provider:
1539
+ raise ConfigError(
1540
+ "Subscription model, reasoning effort, and endpoint settings are managed in "
1541
+ "/config → Default Model."
1542
+ )
1543
+
1544
+
1545
+ def _missing_api_key_message(cfg: AppConfig) -> str:
1546
+ suggestions: list[str] = ["set ALYSIS_API_KEY"]
1547
+ try:
1548
+ from .profiles import (
1549
+ DEFAULT_OPENAI_BASE_URL,
1550
+ get_active_profile,
1551
+ resolve_effective_base_url,
1552
+ )
1553
+
1554
+ profile = get_active_profile(cfg)
1555
+ effective_base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
1556
+ except ConfigError:
1557
+ profile = None
1558
+ effective_base_url = ""
1559
+ is_openai_endpoint = effective_base_url.rstrip("/") == DEFAULT_OPENAI_BASE_URL.rstrip("/")
1560
+ if profile is not None:
1561
+ if profile.api_key_env and (profile.api_key_env != "OPENAI_API_KEY" or is_openai_endpoint):
1562
+ suggestions.insert(0, f"set {profile.api_key_env}")
1563
+ suggestions.append(f"run `alysis profile set-key {profile.name} --key <key>`")
1564
+ if is_openai_endpoint:
1565
+ suggestions.append("set OPENAI_API_KEY")
1566
+ suggestions.append("run `alysis config set-api-key`")
1567
+ sentences = [suggestion[0].upper() + suggestion[1:] for suggestion in suggestions]
1568
+ return "Missing API key. " + "; or ".join(sentences) + "."
1569
+
1570
+
1571
+ _SETTABLE_KEYS: set[str] = {
1572
+ "execution.backend",
1573
+ "execution.runtime",
1574
+ "base_url",
1575
+ "model",
1576
+ "llm_timeout_s",
1577
+ "llm_stream_no_progress_timeout_s",
1578
+ "run_deadline_seconds",
1579
+ "llm_enable_thinking",
1580
+ "llm_reasoning_effort",
1581
+ "provider_concurrency_caps",
1582
+ "provider_retry_max_retries",
1583
+ "provider_retry_base_delay_seconds",
1584
+ "provider_retry_max_delay_seconds",
1585
+ "model_metadata_policy",
1586
+ "default_mode",
1587
+ "default_persona",
1588
+ "persona_modes_enabled",
1589
+ "max_steps",
1590
+ "temperature",
1591
+ "coding_temperature",
1592
+ "review_temperature",
1593
+ "planner_temperature",
1594
+ "conflict_review_temperature",
1595
+ "compactor_temperature",
1596
+ "chat_temperature",
1597
+ "sampling_temperature",
1598
+ "sampling_top_p",
1599
+ "sampling_seed",
1600
+ "stream",
1601
+ "routing_mode",
1602
+ "route_arbitration_enabled",
1603
+ "semantic_turn_contract_enabled",
1604
+ "unified_turn_path_enabled",
1605
+ "reply_language",
1606
+ "evidence_v2_enabled",
1607
+ "regression_baseline_enabled",
1608
+ "turn_contract_v2_enabled",
1609
+ "reproduction_first_enabled",
1610
+ "blast_radius_gate_enabled",
1611
+ "blast_radius_max_scope_files",
1612
+ "blast_radius_scope_seconds_cap",
1613
+ "blast_radius_over_broad_threshold",
1614
+ "process_reaping_enabled",
1615
+ "workspace_provisioning_enabled",
1616
+ "step_budget_policy",
1617
+ "subagents_enabled",
1618
+ "skills_enabled",
1619
+ "skills_auto_invoke",
1620
+ "experimental_gemini_interactions_enabled",
1621
+ "custom_tools_enabled",
1622
+ "task_max_steps",
1623
+ "subagent_max_steps",
1624
+ "subagent_timeout_s",
1625
+ "subagent_orchestration.max_background_children",
1626
+ "subagent_orchestration.turn_end_policy",
1627
+ "subagent_orchestration.workspace_isolation_enabled",
1628
+ "subagent_orchestration.parallel_nonwriting_shared",
1629
+ "subagent_orchestration.helpers_enabled",
1630
+ "subagent_orchestration.helper_max_total_per_child",
1631
+ "subagent_orchestration.helper_timeout_s",
1632
+ "subagent_orchestration.helper_max_steps",
1633
+ "subagent_orchestration.repetition_signal_threshold",
1634
+ "subagent_orchestration.repetition_nudge_occurrence_threshold",
1635
+ "subagent_orchestration.repetition_occurrence_threshold",
1636
+ "subagent_orchestration.repetition_backstop_threshold",
1637
+ "subagent_orchestration.model_response_activity_after_s",
1638
+ "subagent_orchestration.inactivity_signal_after_s",
1639
+ "subagent_orchestration.inflight_deadline_grace_s",
1640
+ "cache.prompt_cache_key_enabled",
1641
+ "cache.keepalive_enabled",
1642
+ "cache.keepalive_idle_threshold_s",
1643
+ "read_ledger_enabled",
1644
+ "web_search_mode",
1645
+ "web_search_policy",
1646
+ "web_search_enabled",
1647
+ "web_search_adapter",
1648
+ "web_search_base_url",
1649
+ "web_search_model",
1650
+ "web_search_timeout_s",
1651
+ "image_generation.enabled",
1652
+ "image_generation.model",
1653
+ "image_generation.base_url",
1654
+ "image_generation.api_key_env",
1655
+ "image_generation.timeout_s",
1656
+ "image_generation.max_images_per_call",
1657
+ "image_generation.max_image_bytes",
1658
+ "image_generation.max_pixels",
1659
+ "update_check_enabled",
1660
+ "update_check_interval_hours",
1661
+ "update_check_timeout_s",
1662
+ "update_prompt_enabled",
1663
+ "feedback_github_enabled",
1664
+ "feedback_github_repo",
1665
+ "feedback_open_browser",
1666
+ "session_log_dir",
1667
+ "crash_diagnostic_log_path",
1668
+ "prompt_cache_mode",
1669
+ "prompt_cache_key",
1670
+ "prompt_cache_retention",
1671
+ "anthropic_prompt_cache_enabled",
1672
+ "anthropic_prompt_cache_ttl",
1673
+ "verify_commands",
1674
+ "integration_verify_mode",
1675
+ "integration_verify_commands",
1676
+ "replanning_mode",
1677
+ "toolbar_items",
1678
+ "assets.enabled",
1679
+ "assets.comprehension.role",
1680
+ "assets.comprehension.vision_fallback_profile",
1681
+ "assets.comprehension.vision_with_ocr_when_available",
1682
+ "assets.comprehension.ocr_enabled",
1683
+ "assets.comprehension.ocr_provider",
1684
+ "assets.comprehension.ocr_timeout_seconds",
1685
+ "assets.comprehension.image_max_edge_pixels",
1686
+ "assets.comprehension.questioning_mode",
1687
+ "assets.comprehension.schema_version",
1688
+ "assets.planner.inline_images",
1689
+ "assets.planner.max_inline_images",
1690
+ "assets.planner.readiness_policy",
1691
+ "assets.planner.readiness_timeout_seconds",
1692
+ "assets.planner.max_chars_per_asset",
1693
+ "assets.planner.max_primary_per_task",
1694
+ "assets.worker.inline_images",
1695
+ "assets.worker.max_inline_images",
1696
+ "assets.worker.fail_on_mirror_error",
1697
+ "assets.worker.allocator_role",
1698
+ "assets.worker.allocator_timeout_seconds",
1699
+ "assets.worker.max_chars_per_asset_block",
1700
+ "assets.worker.max_focused_extract_chars",
1701
+ "assets.worker.schema_version",
1702
+ }
1703
+
1704
+ _VERIFY_LIKE_MODES: set[str] = {"off", "warn", "strict"}
1705
+ _MODEL_METADATA_POLICIES: set[str] = {"warn", "strict"}
1706
+ _ROLE_MODEL_NAMES: set[str] = {
1707
+ "coding",
1708
+ "planner",
1709
+ "review",
1710
+ "compactor",
1711
+ "conflict_review",
1712
+ "conflict_resolve",
1713
+ "comprehension",
1714
+ "router",
1715
+ }
1716
+ _ROLE_MODEL_CONFIG_PREFIXES: set[str] = {"role_models", "forge_role_models"}
1717
+ # Persona vocabulary for persona_models.<persona> keys and default_persona
1718
+ # validation. Kept as a local literal set like _ROLE_MODEL_NAMES (the runtime
1719
+ # registry lives in personas.BUILTIN_PERSONAS).
1720
+ _PERSONA_NAMES: set[str] = {"code", "architect", "ask", "debug"}
1721
+ _AGENT_RUNTIME_CONFIG_FIELDS: set[str] = {
1722
+ "adapter",
1723
+ "executable",
1724
+ "provider_managed_auth",
1725
+ "model",
1726
+ "reasoning_effort",
1727
+ "timeout_seconds",
1728
+ }
1729
+
1730
+
1731
+ def _parse_command_list(value: str, *, key: str, allow_empty: bool) -> list[str]:
1732
+ raw = value.strip()
1733
+ if not raw:
1734
+ raise ConfigError(f"{key} must be a JSON array of command strings")
1735
+ try:
1736
+ parsed = json.loads(raw)
1737
+ except json.JSONDecodeError as e:
1738
+ raise ConfigError(f"{key} must be valid JSON array") from e
1739
+ if not isinstance(parsed, list):
1740
+ raise ConfigError(f"{key} must be a JSON array")
1741
+ commands: list[str] = []
1742
+ for item in parsed:
1743
+ cmd = str(item).strip()
1744
+ if cmd:
1745
+ commands.append(cmd)
1746
+ if not commands and not allow_empty:
1747
+ raise ConfigError(f"{key} cannot be empty")
1748
+ return commands
1749
+
1750
+
1751
+ def _parse_provider_concurrency_caps(value: str, *, key: str) -> dict[str, int]:
1752
+ raw = value.strip()
1753
+ if not raw:
1754
+ raise ConfigError(f"{key} must be a JSON object mapping provider keys to integer caps")
1755
+ try:
1756
+ parsed = json.loads(raw)
1757
+ except json.JSONDecodeError as e:
1758
+ raise ConfigError(f"{key} must be valid JSON object") from e
1759
+ if not isinstance(parsed, dict):
1760
+ raise ConfigError(f"{key} must be a JSON object")
1761
+
1762
+ caps: dict[str, int] = {}
1763
+ for raw_provider, raw_cap in parsed.items():
1764
+ provider = str(raw_provider or "").strip().lower()
1765
+ if not provider:
1766
+ raise ConfigError(f"{key} provider keys must be non-empty strings")
1767
+ try:
1768
+ cap = int(raw_cap if raw_cap is not None else 0)
1769
+ except (TypeError, ValueError) as e:
1770
+ raise ConfigError(f"{key}.{provider} must be an integer >= 0") from e
1771
+ if cap < 0:
1772
+ raise ConfigError(f"{key}.{provider} must be an integer >= 0")
1773
+ caps[provider] = cap
1774
+ return caps
1775
+
1776
+
1777
+ def _coerce_non_negative_float(value: str, *, key: str) -> float:
1778
+ try:
1779
+ parsed = float(value)
1780
+ except ValueError as e:
1781
+ raise ConfigError(f"{key} must be a number") from e
1782
+ if parsed < 0:
1783
+ raise ConfigError(f"{key} must be >= 0")
1784
+ return parsed
1785
+
1786
+
1787
+ def _coerce_positive_float(value: str, *, key: str) -> float:
1788
+ parsed = _coerce_non_negative_float(value, key=key)
1789
+ if parsed <= 0 or not math.isfinite(parsed):
1790
+ raise ConfigError(f"{key} must be > 0")
1791
+ return parsed
1792
+
1793
+
1794
+ def _coerce_optional_positive_float(value: Any, *, key: str) -> float | None:
1795
+ if value is None:
1796
+ return None
1797
+ normalized = str(value).strip()
1798
+ if normalized.lower() in {"", "none", "null", "default", "off", "unlimited", "never"}:
1799
+ return None
1800
+ try:
1801
+ parsed = float(normalized)
1802
+ except (TypeError, ValueError) as e:
1803
+ raise ConfigError(f"{key} must be a finite number > 0") from e
1804
+ if parsed <= 0 or not math.isfinite(parsed):
1805
+ raise ConfigError(f"{key} must be a finite number > 0")
1806
+ return parsed
1807
+
1808
+
1809
+ def _coerce_optional_sampling_value(value: Any, *, key: str) -> float | int | None:
1810
+ """Validate one sampling determinism control for ``config set``.
1811
+
1812
+ Bounds are imported from :mod:`run_provenance` rather than restated, so a
1813
+ value an operator can persist is exactly a value the runtime will honour;
1814
+ two copies of these ranges would drift and produce a setting that saves
1815
+ cleanly and is then silently ignored at request time.
1816
+
1817
+ Unlike the environment path -- which ignores a bad value and warns, because
1818
+ a typo in a benchmark harness must not abort a campaign -- this raises.
1819
+ ``config set`` is interactive, and swallowing a value the operator just
1820
+ typed would be the worse failure.
1821
+ """
1822
+ from .run_provenance import SEED_RANGE, TEMPERATURE_RANGE, TOP_P_RANGE
1823
+
1824
+ normalized = "" if value is None else str(value).strip()
1825
+ if normalized.lower() in {"", "none", "null", "default", "off", "unset"}:
1826
+ return None
1827
+ if key == "sampling_seed":
1828
+ try:
1829
+ parsed_seed = int(normalized, 10)
1830
+ except (TypeError, ValueError) as e:
1831
+ raise ConfigError(f"{key} must be an integer") from e
1832
+ if not SEED_RANGE[0] <= parsed_seed <= SEED_RANGE[1]:
1833
+ raise ConfigError(f"{key} must fit in a signed 64-bit integer")
1834
+ return parsed_seed
1835
+ low, high = TEMPERATURE_RANGE if key == "sampling_temperature" else TOP_P_RANGE
1836
+ try:
1837
+ parsed = float(normalized)
1838
+ except (TypeError, ValueError) as e:
1839
+ raise ConfigError(f"{key} must be a number between {low} and {high}") from e
1840
+ if not math.isfinite(parsed) or parsed < low or parsed > high:
1841
+ raise ConfigError(f"{key} must be a number between {low} and {high}")
1842
+ return parsed
1843
+
1844
+
1845
+ def _coerce_optional_bool(value: str, *, key: str) -> bool | None:
1846
+ normalized = value.strip().lower()
1847
+ if normalized in {"", "auto", "default", "none", "null"}:
1848
+ return None
1849
+ if normalized in {"1", "true", "yes", "on"}:
1850
+ return True
1851
+ if normalized in {"0", "false", "no", "off"}:
1852
+ return False
1853
+ raise ConfigError(f"{key} must be true/false or auto")
1854
+
1855
+
1856
+ def _coerce_reasoning_effort(value: Any, *, key: str) -> str | None:
1857
+ normalized = str(value or "").strip().lower()
1858
+ if normalized in {"", "auto", "default"}:
1859
+ return None
1860
+ if normalized in _REASONING_EFFORTS:
1861
+ return normalized
1862
+ allowed = ", ".join(sorted((*_REASONING_EFFORTS, "auto")))
1863
+ raise ConfigError(f"{key} must be one of: {allowed}")
1864
+
1865
+
1866
+ def _coerce_bool(value: str, *, key: str) -> bool:
1867
+ normalized = value.strip().lower()
1868
+ if normalized in {"1", "true", "yes", "on"}:
1869
+ return True
1870
+ if normalized in {"0", "false", "no", "off"}:
1871
+ return False
1872
+ raise ConfigError(f"{key} must be true/false")
1873
+
1874
+
1875
+ def _coerce_github_repo(value: str, *, key: str) -> str:
1876
+ raw = str(value or "").strip()
1877
+ if not raw:
1878
+ raise ConfigError(f"{key} must be a GitHub repo in owner/name form")
1879
+ if raw.startswith("https://") or raw.startswith("http://"):
1880
+ try:
1881
+ parsed = urlsplit(raw)
1882
+ except ValueError as e:
1883
+ raise ConfigError(f"{key} must be a GitHub repo or GitHub URL") from e
1884
+ hostname = (parsed.hostname or "").rstrip(".").lower()
1885
+ if hostname != "github.com":
1886
+ raise ConfigError(f"{key} URL must use github.com")
1887
+ parts = [part for part in parsed.path.split("/") if part]
1888
+ if len(parts) < 2:
1889
+ raise ConfigError(f"{key} GitHub URL must include owner and repo")
1890
+ raw = f"{parts[0]}/{parts[1]}"
1891
+ raw = raw.removesuffix(".git")
1892
+ if not _GITHUB_REPO_RE.fullmatch(raw):
1893
+ raise ConfigError(f"{key} must be a GitHub repo in owner/name form")
1894
+ return raw
1895
+
1896
+
1897
+ def _coerce_positive_int(value: str, *, key: str) -> int:
1898
+ try:
1899
+ parsed = int(value)
1900
+ except ValueError as e:
1901
+ raise ConfigError(f"{key} must be an integer") from e
1902
+ if parsed <= 0:
1903
+ raise ConfigError(f"{key} must be > 0")
1904
+ return parsed
1905
+
1906
+
1907
+ def _coerce_non_negative_int(value: str, *, key: str) -> int:
1908
+ try:
1909
+ parsed = int(value)
1910
+ except ValueError as e:
1911
+ raise ConfigError(f"{key} must be an integer") from e
1912
+ if parsed < 0:
1913
+ raise ConfigError(f"{key} must be >= 0")
1914
+ return parsed
1915
+
1916
+
1917
+ def _resolve_positive_timeout(raw: Any) -> float | None:
1918
+ try:
1919
+ parsed = float(raw)
1920
+ except (TypeError, ValueError):
1921
+ return None
1922
+ if parsed <= 0 or not math.isfinite(parsed):
1923
+ return None
1924
+ return parsed
1925
+
1926
+
1927
+ def _is_dashscope_base_url(base_url: str | None) -> bool:
1928
+ normalized = str(base_url or "").strip()
1929
+ if not normalized:
1930
+ return False
1931
+ try:
1932
+ parsed = urlsplit(normalized)
1933
+ except ValueError:
1934
+ return False
1935
+ if parsed.scheme.lower() != "https":
1936
+ return False
1937
+ hostname = (parsed.hostname or "").rstrip(".").lower()
1938
+ return (
1939
+ hostname == "dashscope.aliyuncs.com"
1940
+ or hostname == "dashscope-intl.aliyuncs.com"
1941
+ or hostname == "dashscope-us.aliyuncs.com"
1942
+ or hostname.endswith(".dashscope.aliyuncs.com")
1943
+ or hostname.endswith(".dashscope-intl.aliyuncs.com")
1944
+ or hostname.endswith(".dashscope-us.aliyuncs.com")
1945
+ or (hostname.endswith(".aliyuncs.com") and ".dashscope-" in f".{hostname}")
1946
+ )
1947
+
1948
+
1949
+ def _is_qwen_model(model: str | None) -> bool:
1950
+ normalized = str(model or "").strip().lower()
1951
+ if "/" in normalized:
1952
+ normalized = normalized.rsplit("/", 1)[-1]
1953
+ return normalized.startswith("qwen")
1954
+
1955
+
1956
+ def _normalize_web_search_mode(raw: Any, *, allow_legacy_on: bool = False) -> str:
1957
+ value = str(raw or "").strip().lower()
1958
+ if allow_legacy_on and value == "on":
1959
+ return "auto"
1960
+ if value in _WEB_SEARCH_MODES:
1961
+ return value
1962
+ allowed = ", ".join(sorted(_WEB_SEARCH_MODES))
1963
+ raise ConfigError(f"web_search_mode must be one of: {allowed}")
1964
+
1965
+
1966
+ def _normalize_web_search_adapter(raw: Any) -> str:
1967
+ try:
1968
+ return normalize_web_search_adapter(raw)
1969
+ except ValueError as exc:
1970
+ raise ConfigError(str(exc)) from exc
1971
+
1972
+
1973
+ def _normalize_web_search_policy(raw: Any) -> str:
1974
+ try:
1975
+ return normalize_web_search_policy(raw)
1976
+ except ValueError as exc:
1977
+ raise ConfigError(str(exc)) from exc
1978
+
1979
+
1980
+ def _coerce_legacy_web_search_mode(raw: Any) -> str:
1981
+ if isinstance(raw, bool):
1982
+ return "auto" if raw else "off"
1983
+ value = str(raw or "").strip().lower()
1984
+ if value in {"1", "true", "yes", "on"}:
1985
+ return "auto"
1986
+ if value in {"0", "false", "no", "off"}:
1987
+ return "off"
1988
+ raise ConfigError("web_search_enabled must be true/false")
1989
+
1990
+
1991
+ def _apply_legacy_temperature_override(cfg: AppConfig, temperature: float) -> None:
1992
+ cfg.temperature = temperature
1993
+ cfg.coding_temperature = temperature
1994
+ cfg.review_temperature = temperature
1995
+ cfg.planner_temperature = temperature
1996
+ cfg.conflict_review_temperature = temperature
1997
+ cfg.compactor_temperature = temperature
1998
+ cfg.chat_temperature = temperature
1999
+
2000
+
2001
+ def resolve_llm_timeout_s(cfg: AppConfig | None) -> float:
2002
+ env_timeout = _resolve_positive_timeout(env_get("ALYSIS_LLM_TIMEOUT_S"))
2003
+ if env_timeout is not None:
2004
+ return env_timeout
2005
+ cfg_timeout = _resolve_positive_timeout(getattr(cfg, "llm_timeout_s", None))
2006
+ if cfg_timeout is not None:
2007
+ return cfg_timeout
2008
+ return 60.0
2009
+
2010
+
2011
+ @dataclass(frozen=True)
2012
+ class ResolvedRunDeadline:
2013
+ seconds: float | None
2014
+ source: str
2015
+
2016
+ @property
2017
+ def unlimited(self) -> bool:
2018
+ """True when no finite budget applies, however that was decided."""
2019
+ return self.seconds is None
2020
+
2021
+
2022
+ def resolve_run_deadline(
2023
+ cfg: AppConfig | None,
2024
+ *,
2025
+ cli_deadline_seconds: float | None = None,
2026
+ cli_no_deadline: bool = False,
2027
+ default_seconds: float | None = None,
2028
+ ) -> ResolvedRunDeadline:
2029
+ """Resolve the wall-clock budget for one run.
2030
+
2031
+ Precedence is CLI, environment, config, then ``default_seconds``. Each of
2032
+ the first three can select unlimited explicitly, and doing so stops the
2033
+ search rather than falling through to the default -- otherwise there would
2034
+ be no way to turn the default off. Unlimited is spelled with a word
2035
+ (``unlimited``, ``never``, ``off``, ``none``); ``0`` remains invalid, since
2036
+ a zero-second budget is exhausted before it starts.
2037
+ """
2038
+ if cli_no_deadline:
2039
+ return ResolvedRunDeadline(seconds=None, source="explicit_cli")
2040
+
2041
+ if cli_deadline_seconds is not None:
2042
+ return ResolvedRunDeadline(
2043
+ seconds=_coerce_optional_positive_float(
2044
+ cli_deadline_seconds,
2045
+ key="--deadline-seconds",
2046
+ ),
2047
+ source="explicit_cli",
2048
+ )
2049
+
2050
+ env_value = env_get("ALYSIS_RUN_DEADLINE_SECONDS")
2051
+ if env_value is not None and str(env_value).strip() != "":
2052
+ # Presence of the variable is itself the explicit choice, so a word form
2053
+ # here means unlimited rather than "unset".
2054
+ return ResolvedRunDeadline(
2055
+ seconds=_coerce_optional_positive_float(
2056
+ env_value,
2057
+ key="ALYSIS_RUN_DEADLINE_SECONDS",
2058
+ ),
2059
+ source="environment",
2060
+ )
2061
+
2062
+ cfg_value = _coerce_optional_positive_float(
2063
+ getattr(cfg, "run_deadline_seconds", None),
2064
+ key="run_deadline_seconds",
2065
+ )
2066
+ if cfg_value is not None:
2067
+ return ResolvedRunDeadline(seconds=cfg_value, source="config")
2068
+ if bool(getattr(cfg, "run_deadline_unlimited", False)):
2069
+ return ResolvedRunDeadline(seconds=None, source="config")
2070
+
2071
+ if default_seconds is not None:
2072
+ return ResolvedRunDeadline(
2073
+ seconds=_coerce_optional_positive_float(
2074
+ default_seconds,
2075
+ key="run_deadline_seconds default",
2076
+ ),
2077
+ source="runtime_default",
2078
+ )
2079
+ return ResolvedRunDeadline(seconds=None, source="absent")
2080
+
2081
+
2082
+ def resolve_run_deadline_seconds(
2083
+ cfg: AppConfig | None,
2084
+ *,
2085
+ cli_deadline_seconds: float | None = None,
2086
+ cli_no_deadline: bool = False,
2087
+ default_seconds: float | None = None,
2088
+ ) -> float | None:
2089
+ return resolve_run_deadline(
2090
+ cfg,
2091
+ cli_deadline_seconds=cli_deadline_seconds,
2092
+ cli_no_deadline=cli_no_deadline,
2093
+ default_seconds=default_seconds,
2094
+ ).seconds
2095
+
2096
+
2097
+ def resolve_crash_diagnostic_log_path(
2098
+ cfg: AppConfig | None,
2099
+ *,
2100
+ cli_diagnostic_log_path: str | os.PathLike[str] | None = None,
2101
+ ) -> str | None:
2102
+ if cli_diagnostic_log_path is not None:
2103
+ raw = str(cli_diagnostic_log_path).strip()
2104
+ return raw or None
2105
+
2106
+ env_value = env_get("ALYSIS_CRASH_DIAGNOSTIC_LOG_PATH")
2107
+ if env_value is not None:
2108
+ raw = str(env_value).strip()
2109
+ return raw or None
2110
+
2111
+ raw = str(getattr(cfg, "crash_diagnostic_log_path", "") or "").strip()
2112
+ return raw or None
2113
+
2114
+
2115
+ def resolve_llm_enable_thinking(cfg: AppConfig | None) -> bool | None:
2116
+ active_subscription, subscription_effort = _active_subscription_reasoning_effort(cfg)
2117
+ if active_subscription:
2118
+ return None if subscription_effort is None else subscription_effort != "none"
2119
+
2120
+ env_value = env_get("ALYSIS_LLM_ENABLE_THINKING")
2121
+ if env_value is not None:
2122
+ return _coerce_optional_bool(str(env_value), key="ALYSIS_LLM_ENABLE_THINKING")
2123
+
2124
+ cfg_value = getattr(cfg, "llm_enable_thinking", None)
2125
+ if isinstance(cfg_value, bool):
2126
+ return cfg_value
2127
+ if cfg_value is not None:
2128
+ return _coerce_optional_bool(str(cfg_value), key="llm_enable_thinking")
2129
+
2130
+ if cfg is not None and _is_dashscope_base_url(getattr(cfg, "base_url", None)):
2131
+ if _is_qwen_model(getattr(cfg, "model", None)):
2132
+ return False
2133
+ return None
2134
+
2135
+
2136
+ def _legacy_reasoning_effort_hint(cfg: AppConfig | None) -> str | None:
2137
+ if cfg is None:
2138
+ return None
2139
+ extra_fields = getattr(cfg, "extra_fields", None)
2140
+ if not isinstance(extra_fields, dict):
2141
+ return None
2142
+ try:
2143
+ return _coerce_reasoning_effort(
2144
+ extra_fields.get("llm_thinking_label"),
2145
+ key="llm_thinking_label",
2146
+ )
2147
+ except ConfigError:
2148
+ return None
2149
+
2150
+
2151
+ def resolve_llm_reasoning_effort(cfg: AppConfig | None) -> str | None:
2152
+ active_subscription, subscription_effort = _active_subscription_reasoning_effort(cfg)
2153
+ if active_subscription:
2154
+ return subscription_effort
2155
+
2156
+ env_value = env_get("ALYSIS_LLM_REASONING_EFFORT")
2157
+ if env_value is not None:
2158
+ return _coerce_reasoning_effort(env_value, key="ALYSIS_LLM_REASONING_EFFORT")
2159
+
2160
+ cfg_value = getattr(cfg, "llm_reasoning_effort", None)
2161
+ if cfg_value is not None:
2162
+ return _coerce_reasoning_effort(cfg_value, key="llm_reasoning_effort")
2163
+
2164
+ return _legacy_reasoning_effort_hint(cfg)
2165
+
2166
+
2167
+ def _active_subscription_reasoning_effort(
2168
+ cfg: AppConfig | None,
2169
+ ) -> tuple[bool, str | None]:
2170
+ if cfg is None:
2171
+ return False, None
2172
+ try:
2173
+ from .profiles import get_active_profile
2174
+
2175
+ profile = get_active_profile(cfg)
2176
+ except ConfigError:
2177
+ return False, None
2178
+ if not profile.auth_provider:
2179
+ return False, None
2180
+ return (
2181
+ True,
2182
+ _coerce_reasoning_effort(
2183
+ profile.reasoning_effort,
2184
+ key="subscription profile reasoning_effort",
2185
+ ),
2186
+ )
2187
+
2188
+
2189
+ def resolve_web_search_mode(cfg: AppConfig | None) -> str:
2190
+ if cfg is None:
2191
+ return "auto"
2192
+ return _normalize_web_search_mode(
2193
+ getattr(cfg, "web_search_mode", "auto"),
2194
+ allow_legacy_on=True,
2195
+ )
2196
+
2197
+
2198
+ def resolve_web_search_policy(cfg: AppConfig | None) -> str:
2199
+ env_policy = str(env_get("ALYSIS_WEB_SEARCH_POLICY") or "").strip()
2200
+ if env_policy:
2201
+ return _normalize_web_search_policy(env_policy)
2202
+ if cfg is None:
2203
+ return "auto"
2204
+ return _normalize_web_search_policy(getattr(cfg, "web_search_policy", "auto"))
2205
+
2206
+
2207
+ _WEB_TOOLS_DISABLED_ENV_VALUES = frozenset({"0", "false", "off", "no", "disabled"})
2208
+
2209
+
2210
+ def resolve_web_tools_enabled(cfg: AppConfig | None) -> bool:
2211
+ """Master switch for all web tools (web_fetch AND web_search).
2212
+
2213
+ Precedence: ALYSIS_WEB_TOOLS env var (any of 0/false/off/no/disabled turns
2214
+ web tools off; any other non-empty value turns them on) over the
2215
+ ``web_tools_enabled`` config field. Benchmark/offline harnesses should set
2216
+ ``ALYSIS_WEB_TOOLS=off`` so the process itself guarantees no web tool is
2217
+ exposed or executed, independent of harness-side settings.
2218
+ """
2219
+ env_value = str(env_get("ALYSIS_WEB_TOOLS") or "").strip().lower()
2220
+ if env_value:
2221
+ return env_value not in _WEB_TOOLS_DISABLED_ENV_VALUES
2222
+ if cfg is None:
2223
+ return True
2224
+ return bool(getattr(cfg, "web_tools_enabled", True))
2225
+
2226
+
2227
+ def resolve_web_search_enabled(cfg: AppConfig | None) -> bool:
2228
+ return resolve_web_search_mode(cfg) != "off"
2229
+
2230
+
2231
+ def resolve_web_search_adapter(cfg: AppConfig | None) -> str:
2232
+ env_adapter = str(env_get("ALYSIS_WEB_SEARCH_ADAPTER") or "").strip()
2233
+ if env_adapter:
2234
+ return _normalize_web_search_adapter(env_adapter)
2235
+
2236
+ cfg_adapter = _normalize_web_search_adapter(getattr(cfg, "web_search_adapter", "auto"))
2237
+ if cfg_adapter != "auto":
2238
+ return cfg_adapter
2239
+
2240
+ if cfg is not None:
2241
+ try:
2242
+ from .profiles import get_active_profile
2243
+
2244
+ profile = get_active_profile(cfg)
2245
+ except Exception:
2246
+ profile = None
2247
+ if profile is not None:
2248
+ profile_adapter = _normalize_web_search_adapter(profile.web_search_adapter)
2249
+ if profile_adapter != "auto":
2250
+ return profile_adapter
2251
+
2252
+ return "auto"
2253
+
2254
+
2255
+ def resolve_web_search_api_key(
2256
+ cfg: AppConfig | None,
2257
+ *,
2258
+ api_key_fallback: str | None = None,
2259
+ ) -> str | None:
2260
+ _ = cfg
2261
+ env_key = str(env_get("ALYSIS_WEB_SEARCH_API_KEY") or "").strip()
2262
+ if env_key:
2263
+ return env_key
2264
+ fallback = str(api_key_fallback or "").strip()
2265
+ return fallback or None
2266
+
2267
+
2268
+ def resolve_web_search_explicit_base_url(cfg: AppConfig | None) -> str | None:
2269
+ env_base_url = str(env_get("ALYSIS_WEB_SEARCH_BASE_URL") or "").strip()
2270
+ if env_base_url:
2271
+ return env_base_url.rstrip("/")
2272
+
2273
+ cfg_base_url = str(getattr(cfg, "web_search_base_url", "") or "").strip()
2274
+ if cfg_base_url:
2275
+ return cfg_base_url.rstrip("/")
2276
+ return None
2277
+
2278
+
2279
+ def resolve_web_search_base_url(cfg: AppConfig | None) -> str | None:
2280
+ explicit_base_url = resolve_web_search_explicit_base_url(cfg)
2281
+ if explicit_base_url:
2282
+ return explicit_base_url
2283
+
2284
+ fallback_base_url = str(getattr(cfg, "base_url", "") or "").strip()
2285
+ if cfg is not None and not fallback_base_url:
2286
+ return None
2287
+
2288
+ if cfg is not None:
2289
+ try:
2290
+ from .profiles import DEFAULT_OPENAI_BASE_URL, get_active_profile
2291
+
2292
+ profile = get_active_profile(cfg)
2293
+ except Exception:
2294
+ profile = None
2295
+ if profile is not None:
2296
+ profile_base_url = str(profile.base_url or "").strip().rstrip("/")
2297
+ cfg_base_url = str(getattr(cfg, "base_url", "") or "").strip().rstrip("/")
2298
+ default_base_url = DEFAULT_OPENAI_BASE_URL.rstrip("/")
2299
+ if cfg_base_url and cfg_base_url not in {profile_base_url, default_base_url}:
2300
+ return cfg_base_url
2301
+ if profile_base_url:
2302
+ return profile_base_url
2303
+
2304
+ if fallback_base_url:
2305
+ return fallback_base_url.rstrip("/")
2306
+ return None
2307
+
2308
+
2309
+ def is_first_party_openai_base_url(base_url: str | None) -> bool:
2310
+ normalized = str(base_url or "").strip()
2311
+ if not normalized:
2312
+ return False
2313
+ try:
2314
+ parsed = urlsplit(normalized)
2315
+ except ValueError:
2316
+ return False
2317
+ if parsed.scheme.lower() != "https":
2318
+ return False
2319
+ hostname = (parsed.hostname or "").rstrip(".").lower()
2320
+ return hostname == "api.openai.com"
2321
+
2322
+
2323
+ def resolve_web_search_model(cfg: AppConfig | None) -> str | None:
2324
+ env_model = str(env_get("ALYSIS_WEB_SEARCH_MODEL") or "").strip()
2325
+ if env_model:
2326
+ return env_model
2327
+ cfg_model = str(getattr(cfg, "web_search_model", "") or "").strip()
2328
+ if cfg_model:
2329
+ return cfg_model
2330
+ if cfg is not None:
2331
+ try:
2332
+ from .profiles import get_active_profile
2333
+
2334
+ profile = get_active_profile(cfg)
2335
+ except Exception:
2336
+ profile = None
2337
+ if profile is not None:
2338
+ profile_model = str(profile.web_search_model or "").strip()
2339
+ if profile_model:
2340
+ return profile_model
2341
+ fallback_model = str(getattr(cfg, "model", "") or "").strip()
2342
+ return fallback_model or None
2343
+
2344
+
2345
+ def resolve_web_search_timeout_s(cfg: AppConfig | None) -> float:
2346
+ env_timeout_raw = env_get("ALYSIS_WEB_SEARCH_TIMEOUT_S")
2347
+ if env_timeout_raw is not None:
2348
+ env_timeout = _resolve_positive_timeout(env_timeout_raw)
2349
+ if env_timeout is None:
2350
+ raise ConfigError("ALYSIS_WEB_SEARCH_TIMEOUT_S must be > 0")
2351
+ return env_timeout
2352
+
2353
+ cfg_timeout = _resolve_positive_timeout(getattr(cfg, "web_search_timeout_s", None))
2354
+ if cfg_timeout is None:
2355
+ raise ConfigError("web_search_timeout_s must be > 0")
2356
+ return cfg_timeout
2357
+
2358
+
2359
+ def resolve_feedback_github_enabled(cfg: AppConfig | None) -> bool:
2360
+ env_value = env_get("ALYSIS_FEEDBACK_GITHUB_ENABLED")
2361
+ if env_value is not None:
2362
+ return _coerce_bool(str(env_value), key="ALYSIS_FEEDBACK_GITHUB_ENABLED")
2363
+ return bool(getattr(cfg, "feedback_github_enabled", True))
2364
+
2365
+
2366
+ def resolve_feedback_github_repo(cfg: AppConfig | None) -> str:
2367
+ env_value = env_get("ALYSIS_FEEDBACK_GITHUB_REPO")
2368
+ if env_value is not None:
2369
+ return _redirect_legacy_feedback_repo(
2370
+ _coerce_github_repo(str(env_value), key="ALYSIS_FEEDBACK_GITHUB_REPO")
2371
+ )
2372
+ raw = getattr(cfg, "feedback_github_repo", DEFAULT_FEEDBACK_GITHUB_REPO)
2373
+ return _redirect_legacy_feedback_repo(_coerce_github_repo(str(raw), key="feedback_github_repo"))
2374
+
2375
+
2376
+ def resolve_feedback_open_browser(cfg: AppConfig | None) -> bool:
2377
+ env_value = env_get("ALYSIS_FEEDBACK_OPEN_BROWSER")
2378
+ if env_value is not None:
2379
+ return _coerce_bool(str(env_value), key="ALYSIS_FEEDBACK_OPEN_BROWSER")
2380
+ return bool(getattr(cfg, "feedback_open_browser", True))
2381
+
2382
+
2383
+ def resolve_model_metadata_policy(cfg: AppConfig | None) -> str:
2384
+ env_policy = str(env_get("ALYSIS_MODEL_METADATA_POLICY") or "").strip().lower()
2385
+ if env_policy:
2386
+ if env_policy not in _MODEL_METADATA_POLICIES:
2387
+ allowed = ", ".join(sorted(_MODEL_METADATA_POLICIES))
2388
+ raise ConfigError(f"ALYSIS_MODEL_METADATA_POLICY must be one of: {allowed}")
2389
+ return env_policy
2390
+
2391
+ cfg_policy = str(getattr(cfg, "model_metadata_policy", "warn") or "").strip().lower()
2392
+ if not cfg_policy:
2393
+ return "warn"
2394
+ if cfg_policy not in _MODEL_METADATA_POLICIES:
2395
+ allowed = ", ".join(sorted(_MODEL_METADATA_POLICIES))
2396
+ raise ConfigError(f"model_metadata_policy must be one of: {allowed}")
2397
+ return cfg_policy
2398
+
2399
+
2400
+ def _normalize_prompt_cache_mode(value: str | None, *, key: str) -> str:
2401
+ normalized = str(value or "").strip().lower() or "manual"
2402
+ if normalized not in _PROMPT_CACHE_MODES:
2403
+ allowed = ", ".join(sorted(_PROMPT_CACHE_MODES))
2404
+ raise ConfigError(f"{key} must be one of: {allowed}")
2405
+ return normalized
2406
+
2407
+
2408
+ def resolve_prompt_cache_mode(cfg: AppConfig | None) -> str:
2409
+ env_mode = str(env_get("ALYSIS_PROMPT_CACHE_MODE") or "").strip()
2410
+ if env_mode:
2411
+ return _normalize_prompt_cache_mode(env_mode, key="ALYSIS_PROMPT_CACHE_MODE")
2412
+ return _normalize_prompt_cache_mode(
2413
+ str(getattr(cfg, "prompt_cache_mode", "manual") or "manual"),
2414
+ key="prompt_cache_mode",
2415
+ )
2416
+
2417
+
2418
+ def resolve_prompt_cache_key(cfg: AppConfig | None) -> str | None:
2419
+ cache_config = getattr(cfg, "cache", None)
2420
+ if cache_config is not None and not bool(
2421
+ getattr(cache_config, "prompt_cache_key_enabled", True)
2422
+ ):
2423
+ return None
2424
+ env_key = str(env_get("ALYSIS_PROMPT_CACHE_KEY") or "").strip()
2425
+ if env_key:
2426
+ return env_key
2427
+ cfg_key = str(getattr(cfg, "prompt_cache_key", "") or "").strip()
2428
+ return cfg_key or None
2429
+
2430
+
2431
+ def resolve_prompt_cache_retention(cfg: AppConfig | None) -> str | None:
2432
+ env_retention = str(env_get("ALYSIS_PROMPT_CACHE_RETENTION") or "").strip()
2433
+ if env_retention:
2434
+ return env_retention
2435
+ cfg_retention = str(getattr(cfg, "prompt_cache_retention", "") or "").strip()
2436
+ return cfg_retention or None
2437
+
2438
+
2439
+ def _normalize_anthropic_prompt_cache_ttl(value: str | None, *, key: str) -> str:
2440
+ normalized = str(value or "").strip().lower() or "5m"
2441
+ if normalized not in _ANTHROPIC_PROMPT_CACHE_TTLS:
2442
+ allowed = ", ".join(sorted(_ANTHROPIC_PROMPT_CACHE_TTLS))
2443
+ raise ConfigError(f"{key} must be one of: {allowed}")
2444
+ return normalized
2445
+
2446
+
2447
+ def resolve_anthropic_prompt_cache_enabled(cfg: AppConfig | None) -> bool:
2448
+ env_value = env_get("ALYSIS_ANTHROPIC_PROMPT_CACHE_ENABLED")
2449
+ if env_value is not None:
2450
+ return _coerce_bool(str(env_value), key="ALYSIS_ANTHROPIC_PROMPT_CACHE_ENABLED")
2451
+ return bool(getattr(cfg, "anthropic_prompt_cache_enabled", False))
2452
+
2453
+
2454
+ def resolve_anthropic_prompt_cache_ttl(cfg: AppConfig | None) -> str:
2455
+ env_ttl = str(env_get("ALYSIS_ANTHROPIC_PROMPT_CACHE_TTL") or "").strip()
2456
+ if env_ttl:
2457
+ return _normalize_anthropic_prompt_cache_ttl(
2458
+ env_ttl,
2459
+ key="ALYSIS_ANTHROPIC_PROMPT_CACHE_TTL",
2460
+ )
2461
+ return _normalize_anthropic_prompt_cache_ttl(
2462
+ str(getattr(cfg, "anthropic_prompt_cache_ttl", "5m") or "5m"),
2463
+ key="anthropic_prompt_cache_ttl",
2464
+ )
2465
+
2466
+
2467
+ def resolve_role_temperature(cfg: AppConfig, *, role: str) -> float:
2468
+ default = _ROLE_TEMPERATURE_DEFAULTS.get(role, 0.2)
2469
+ legacy_raw = getattr(cfg, "temperature", default)
2470
+ try:
2471
+ legacy_temperature = float(legacy_raw)
2472
+ except (TypeError, ValueError):
2473
+ legacy_temperature = default
2474
+ if legacy_temperature < 0:
2475
+ legacy_temperature = default
2476
+
2477
+ field = _ROLE_TEMPERATURE_FIELDS.get(role)
2478
+ if field is None:
2479
+ return legacy_temperature
2480
+
2481
+ role_raw = getattr(cfg, field, legacy_temperature)
2482
+ try:
2483
+ role_temperature = float(role_raw)
2484
+ except (TypeError, ValueError):
2485
+ return legacy_temperature
2486
+ if role_temperature < 0:
2487
+ return legacy_temperature
2488
+ return role_temperature
2489
+
2490
+
2491
+ def _role_model_key_parts(key: str) -> tuple[str, str] | None:
2492
+ namespace, separator, role = str(key or "").partition(".")
2493
+ if not separator or namespace not in _ROLE_MODEL_CONFIG_PREFIXES:
2494
+ return None
2495
+ role_key = role.strip().lower()
2496
+ if not role_key:
2497
+ raise ConfigError(f"{namespace} config key must include a role name")
2498
+ if role_key not in _ROLE_MODEL_NAMES:
2499
+ raise ConfigError(
2500
+ f"{namespace}.{role_key} is not supported. "
2501
+ f"Supported roles: {', '.join(sorted(_ROLE_MODEL_NAMES))}"
2502
+ )
2503
+ return namespace, role_key
2504
+
2505
+
2506
+ def _set_role_model_config_value(
2507
+ cfg: AppConfig,
2508
+ *,
2509
+ namespace: str,
2510
+ role: str,
2511
+ value: str,
2512
+ ) -> AppConfig:
2513
+ extra_fields = dict(cfg.extra_fields or {})
2514
+ raw_models = extra_fields.get(namespace)
2515
+ models = dict(raw_models) if isinstance(raw_models, dict) else {}
2516
+ normalized = str(value or "").strip()
2517
+ if normalized:
2518
+ models[role] = normalized
2519
+ else:
2520
+ models.pop(role, None)
2521
+ if models:
2522
+ extra_fields[namespace] = dict(sorted(models.items()))
2523
+ else:
2524
+ extra_fields.pop(namespace, None)
2525
+ cfg.extra_fields = extra_fields
2526
+ return cfg
2527
+
2528
+
2529
+ def _persona_model_key_parts(key: str) -> str | None:
2530
+ namespace, separator, persona = str(key or "").partition(".")
2531
+ if not separator or namespace != "persona_models":
2532
+ return None
2533
+ persona_key = persona.strip().lower()
2534
+ if not persona_key:
2535
+ raise ConfigError("persona_models config key must include a persona name")
2536
+ if persona_key not in _PERSONA_NAMES:
2537
+ raise ConfigError(
2538
+ f"persona_models.{persona_key} is not supported. "
2539
+ f"Supported personas: {', '.join(sorted(_PERSONA_NAMES))}"
2540
+ )
2541
+ return persona_key
2542
+
2543
+
2544
+ def _set_persona_model_config_value(
2545
+ cfg: AppConfig,
2546
+ *,
2547
+ persona: str,
2548
+ value: str,
2549
+ ) -> AppConfig:
2550
+ normalized = str(value or "").strip().lower()
2551
+ if normalized and normalized not in _ROLE_MODEL_NAMES:
2552
+ raise ConfigError(
2553
+ f"persona_models.{persona} must name a model role. "
2554
+ f"Supported roles: {', '.join(sorted(_ROLE_MODEL_NAMES))}"
2555
+ )
2556
+ extra_fields = dict(cfg.extra_fields or {})
2557
+ raw_map = extra_fields.get("persona_models")
2558
+ personas = dict(raw_map) if isinstance(raw_map, dict) else {}
2559
+ if normalized:
2560
+ personas[persona] = normalized
2561
+ else:
2562
+ personas.pop(persona, None)
2563
+ if personas:
2564
+ extra_fields["persona_models"] = dict(sorted(personas.items()))
2565
+ else:
2566
+ extra_fields.pop("persona_models", None)
2567
+ cfg.extra_fields = extra_fields
2568
+ return cfg
2569
+
2570
+
2571
+ def _agent_runtime_key_parts(key: str) -> tuple[str, str] | None:
2572
+ parts = str(key or "").split(".")
2573
+ if not parts or parts[0] != "agent_runtimes":
2574
+ return None
2575
+ if len(parts) != 3 or not parts[1].strip():
2576
+ raise ConfigError("Agent runtime keys must use agent_runtimes.<runtime-id>.<field> syntax.")
2577
+ field = parts[2].strip()
2578
+ if field not in _AGENT_RUNTIME_CONFIG_FIELDS:
2579
+ allowed = ", ".join(sorted(_AGENT_RUNTIME_CONFIG_FIELDS))
2580
+ raise ConfigError(f"Unsupported agent runtime field {field!r}. Supported fields: {allowed}")
2581
+ return parts[1].strip(), field
2582
+
2583
+
2584
+ def _set_agent_runtime_config_value(
2585
+ cfg: AppConfig,
2586
+ *,
2587
+ runtime_id: str,
2588
+ field: str,
2589
+ value: str,
2590
+ ) -> AppConfig:
2591
+ current = cfg.agent_runtimes.get(runtime_id)
2592
+ values = (
2593
+ current.model_dump() if current is not None else _default_agent_runtime_values(runtime_id)
2594
+ )
2595
+ normalized = str(value or "").strip()
2596
+ if field in {"adapter", "executable"}:
2597
+ if not normalized:
2598
+ raise ConfigError(f"agent_runtimes.{runtime_id}.{field} cannot be empty")
2599
+ values[field] = normalized
2600
+ elif field in {"model", "reasoning_effort"}:
2601
+ values[field] = normalized or None
2602
+ elif field == "timeout_seconds":
2603
+ values[field] = _coerce_positive_float(value, key=f"agent_runtimes.{runtime_id}.{field}")
2604
+ elif field == "provider_managed_auth":
2605
+ values[field] = _coerce_bool(value, key=f"agent_runtimes.{runtime_id}.{field}")
2606
+ cfg.agent_runtimes[runtime_id] = AgentRuntimeSettings(**values)
2607
+ return cfg
2608
+
2609
+
2610
+ def _default_agent_runtime_values(runtime_id: str) -> dict[str, object]:
2611
+ # Local import avoids coupling the core config model to optional runtime
2612
+ # discovery during module import, while still using registered defaults for
2613
+ # direct `config set agent_runtimes.<id>...` operations.
2614
+ from .agent_runtimes.builtins import runtime_setup_option
2615
+
2616
+ option = runtime_setup_option(runtime_id)
2617
+ return {
2618
+ "adapter": option.adapter if option is not None else runtime_id,
2619
+ "executable": option.default_executable if option is not None else runtime_id,
2620
+ "provider_managed_auth": True,
2621
+ "model": None,
2622
+ "reasoning_effort": None,
2623
+ "timeout_seconds": 3600.0,
2624
+ }
2625
+
2626
+
2627
+ def _ensure_selected_runtime_settings(cfg: AppConfig, runtime_id: str) -> None:
2628
+ if runtime_id not in cfg.agent_runtimes:
2629
+ cfg.agent_runtimes[runtime_id] = AgentRuntimeSettings(
2630
+ **_default_agent_runtime_values(runtime_id)
2631
+ )
2632
+
2633
+
2634
+ def set_config_value(
2635
+ cfg: AppConfig,
2636
+ key: str,
2637
+ value: str,
2638
+ *,
2639
+ allow_subscription_selection: bool = False,
2640
+ ) -> AppConfig:
2641
+ if not allow_subscription_selection or str(key or "").strip().lower() == "base_url":
2642
+ ensure_subscription_menu_managed_key(cfg, key)
2643
+ role_model_parts = _role_model_key_parts(key)
2644
+ if role_model_parts is not None:
2645
+ namespace, role = role_model_parts
2646
+ return _set_role_model_config_value(
2647
+ cfg,
2648
+ namespace=namespace,
2649
+ role=role,
2650
+ value=value,
2651
+ )
2652
+
2653
+ persona_model_persona = _persona_model_key_parts(key)
2654
+ if persona_model_persona is not None:
2655
+ return _set_persona_model_config_value(
2656
+ cfg,
2657
+ persona=persona_model_persona,
2658
+ value=value,
2659
+ )
2660
+
2661
+ agent_runtime_parts = _agent_runtime_key_parts(key)
2662
+ if agent_runtime_parts is not None:
2663
+ runtime_id, field = agent_runtime_parts
2664
+ return _set_agent_runtime_config_value(
2665
+ cfg,
2666
+ runtime_id=runtime_id,
2667
+ field=field,
2668
+ value=value,
2669
+ )
2670
+
2671
+ if key not in _SETTABLE_KEYS:
2672
+ raise ConfigError(
2673
+ f"Unknown/unsupported key: {key}. Supported keys: "
2674
+ f"{', '.join(sorted(_SETTABLE_KEYS))}, role_models.<role>, "
2675
+ "forge_role_models.<role>, persona_models.<persona>, "
2676
+ "agent_runtimes.<runtime-id>.<field>"
2677
+ )
2678
+
2679
+ if key == "execution.backend":
2680
+ backend = str(value or "").strip().lower()
2681
+ if backend not in {"native", "delegated"}:
2682
+ raise ConfigError("execution.backend must be one of: native, delegated")
2683
+ if backend == "native":
2684
+ cfg.execution.backend = "native"
2685
+ cfg.execution.runtime = None
2686
+ return cfg
2687
+ runtime_id = str(cfg.execution.runtime or "").strip()
2688
+ if not runtime_id:
2689
+ configured = tuple(sorted(cfg.agent_runtimes))
2690
+ if len(configured) == 1:
2691
+ runtime_id = configured[0]
2692
+ elif len(configured) > 1:
2693
+ raise ConfigError(
2694
+ "Multiple agent runtimes are configured. Set execution.runtime to choose one."
2695
+ )
2696
+ else:
2697
+ from .agent_runtimes.builtins import runtime_setup_options
2698
+
2699
+ options = runtime_setup_options()
2700
+ if len(options) != 1:
2701
+ raise ConfigError(
2702
+ "Choose an agent runtime with `alysis config set execution.runtime "
2703
+ "<runtime-id>`."
2704
+ )
2705
+ runtime_id = options[0].id
2706
+ _ensure_selected_runtime_settings(cfg, runtime_id)
2707
+ cfg.execution.runtime = runtime_id
2708
+ cfg.execution.backend = "delegated"
2709
+ return cfg
2710
+
2711
+ if key == "execution.runtime":
2712
+ runtime_id = str(value or "").strip()
2713
+ if not runtime_id:
2714
+ cfg.execution.backend = "native"
2715
+ cfg.execution.runtime = None
2716
+ return cfg
2717
+ _ensure_selected_runtime_settings(cfg, runtime_id)
2718
+ cfg.execution.runtime = runtime_id
2719
+ cfg.execution.backend = "delegated"
2720
+ return cfg
2721
+
2722
+ if key == "base_url":
2723
+ from .profiles import update_active_profile_defaults, validate_base_url
2724
+
2725
+ normalized = validate_base_url(value, key="base_url", allow_empty=True)
2726
+ if normalized:
2727
+ from .profile_presets import find_preset_for_base_url
2728
+
2729
+ preset = find_preset_for_base_url(normalized)
2730
+ if preset is not None:
2731
+ current_model = str(getattr(cfg, "model", "") or "").strip()
2732
+ default_model = _canonicalize_model_for_preset(current_model, preset)
2733
+ if default_model is None and preset.suggested_models:
2734
+ default_model = str(preset.suggested_models[0] or "").strip()
2735
+ _align_active_profile_to_preset(
2736
+ cfg,
2737
+ preset,
2738
+ base_url=normalized,
2739
+ default_model=default_model,
2740
+ )
2741
+ return cfg
2742
+ cfg.base_url = normalized
2743
+ update_active_profile_defaults(cfg, base_url=normalized)
2744
+ return cfg
2745
+
2746
+ if key == "model":
2747
+ from .profiles import update_active_profile_defaults
2748
+
2749
+ normalized = _canonicalize_model_for_config(cfg, str(value or "").strip())
2750
+ cfg.model = normalized
2751
+ update_active_profile_defaults(
2752
+ cfg,
2753
+ default_model=normalized,
2754
+ allow_subscription_selection=allow_subscription_selection,
2755
+ )
2756
+ return cfg
2757
+
2758
+ if key == "llm_timeout_s":
2759
+ cfg.llm_timeout_s = _coerce_positive_float(value, key=key)
2760
+ return cfg
2761
+
2762
+ if key == "llm_stream_no_progress_timeout_s":
2763
+ cfg.llm_stream_no_progress_timeout_s = _coerce_positive_float(value, key=key)
2764
+ return cfg
2765
+
2766
+ if key == "run_deadline_seconds":
2767
+ cfg.run_deadline_seconds = _coerce_optional_positive_float(value, key=key)
2768
+ # A word form here is a deliberate "no budget", which has to be
2769
+ # distinguishable from an unset value now that unset means "default".
2770
+ cfg.run_deadline_unlimited = cfg.run_deadline_seconds is None
2771
+ return cfg
2772
+
2773
+ if key in {"sampling_temperature", "sampling_top_p", "sampling_seed"}:
2774
+ setattr(cfg, key, _coerce_optional_sampling_value(value, key=key))
2775
+ return cfg
2776
+
2777
+ if key == "llm_enable_thinking":
2778
+ cfg.llm_enable_thinking = _coerce_optional_bool(value, key=key)
2779
+ return cfg
2780
+
2781
+ if key == "llm_reasoning_effort":
2782
+ resolved_effort = _coerce_reasoning_effort(value, key=key)
2783
+ cfg.llm_reasoning_effort = resolved_effort
2784
+ from .profiles import update_active_profile_defaults
2785
+
2786
+ profile_effort = resolved_effort or "auto"
2787
+ update_active_profile_defaults(
2788
+ cfg,
2789
+ reasoning_effort=profile_effort,
2790
+ allow_subscription_selection=allow_subscription_selection,
2791
+ )
2792
+ return cfg
2793
+
2794
+ if key == "provider_concurrency_caps":
2795
+ cfg.provider_concurrency_caps = _parse_provider_concurrency_caps(value, key=key)
2796
+ return cfg
2797
+
2798
+ if key == "provider_retry_max_retries":
2799
+ cfg.provider_retry_max_retries = _coerce_non_negative_int(value, key=key)
2800
+ return cfg
2801
+
2802
+ if key in {
2803
+ "provider_retry_base_delay_seconds",
2804
+ "provider_retry_max_delay_seconds",
2805
+ }:
2806
+ setattr(cfg, key, _coerce_positive_float(value, key=key))
2807
+ return cfg
2808
+
2809
+ if key == "model_metadata_policy":
2810
+ normalized = value.strip().lower()
2811
+ if normalized not in _MODEL_METADATA_POLICIES:
2812
+ raise ConfigError("model_metadata_policy must be one of: strict, warn")
2813
+ cfg.model_metadata_policy = normalized
2814
+ return cfg
2815
+
2816
+ if key == "default_mode":
2817
+ if value not in {"review", "auto", "readonly", "fullaccess"}:
2818
+ raise ConfigError("default_mode must be one of: review, auto, readonly, fullaccess")
2819
+ cfg.default_mode = value
2820
+ return cfg
2821
+
2822
+ if key == "default_persona":
2823
+ v = value.strip().lower()
2824
+ if v not in _PERSONA_NAMES:
2825
+ raise ConfigError("default_persona must be one of: code, architect, ask, debug")
2826
+ cfg.default_persona = v
2827
+ return cfg
2828
+
2829
+ if key == "persona_modes_enabled":
2830
+ v = value.strip().lower()
2831
+ if v in {"1", "true", "yes", "on"}:
2832
+ cfg.persona_modes_enabled = True
2833
+ return cfg
2834
+ if v in {"0", "false", "no", "off"}:
2835
+ cfg.persona_modes_enabled = False
2836
+ return cfg
2837
+ raise ConfigError("persona_modes_enabled must be true/false")
2838
+
2839
+ if key in {"max_steps", "task_max_steps", "subagent_max_steps"}:
2840
+ setattr(cfg, key, _coerce_positive_int(value, key=key))
2841
+ return cfg
2842
+
2843
+ if key == "subagent_timeout_s":
2844
+ cfg.subagent_timeout_s = _coerce_positive_float(value, key=key)
2845
+ return cfg
2846
+
2847
+ if key == "subagent_orchestration.max_background_children":
2848
+ cfg.subagent_orchestration.max_background_children = _coerce_positive_int(
2849
+ value,
2850
+ key=key,
2851
+ )
2852
+ return cfg
2853
+
2854
+ if key == "subagent_orchestration.turn_end_policy":
2855
+ normalized = value.strip().lower()
2856
+ if normalized not in {"wait", "cancel"}:
2857
+ raise ConfigError("subagent_orchestration.turn_end_policy must be one of: wait, cancel")
2858
+ cfg.subagent_orchestration.turn_end_policy = normalized
2859
+ return cfg
2860
+
2861
+ if key == "subagent_orchestration.workspace_isolation_enabled":
2862
+ cfg.subagent_orchestration.workspace_isolation_enabled = _coerce_bool(value, key=key)
2863
+ return cfg
2864
+
2865
+ if key == "subagent_orchestration.parallel_nonwriting_shared":
2866
+ cfg.subagent_orchestration.parallel_nonwriting_shared = _coerce_bool(value, key=key)
2867
+ return cfg
2868
+
2869
+ if key == "subagent_orchestration.helpers_enabled":
2870
+ cfg.subagent_orchestration.helpers_enabled = _coerce_bool(value, key=key)
2871
+ return cfg
2872
+
2873
+ if key == "subagent_orchestration.helper_max_total_per_child":
2874
+ cfg.subagent_orchestration.helper_max_total_per_child = _coerce_non_negative_int(
2875
+ value,
2876
+ key=key,
2877
+ )
2878
+ return cfg
2879
+
2880
+ if key == "subagent_orchestration.helper_timeout_s":
2881
+ cfg.subagent_orchestration.helper_timeout_s = _coerce_positive_float(value, key=key)
2882
+ return cfg
2883
+
2884
+ if key == "subagent_orchestration.helper_max_steps":
2885
+ cfg.subagent_orchestration.helper_max_steps = _coerce_positive_int(value, key=key)
2886
+ return cfg
2887
+
2888
+ if key == "subagent_orchestration.repetition_signal_threshold":
2889
+ threshold = _coerce_positive_int(value, key=key)
2890
+ if threshold < 2:
2891
+ raise ConfigError(
2892
+ "subagent_orchestration.repetition_signal_threshold must be at least 2"
2893
+ )
2894
+ if threshold >= cfg.subagent_orchestration.repetition_backstop_threshold:
2895
+ raise ConfigError(
2896
+ "subagent_orchestration.repetition_signal_threshold must be lower than "
2897
+ "subagent_orchestration.repetition_backstop_threshold"
2898
+ )
2899
+ cfg.subagent_orchestration.repetition_signal_threshold = threshold
2900
+ return cfg
2901
+
2902
+ if key == "subagent_orchestration.repetition_nudge_occurrence_threshold":
2903
+ threshold = _coerce_positive_int(value, key=key)
2904
+ if threshold < 2:
2905
+ raise ConfigError(
2906
+ "subagent_orchestration.repetition_nudge_occurrence_threshold must be at least 2"
2907
+ )
2908
+ if threshold >= cfg.subagent_orchestration.repetition_occurrence_threshold:
2909
+ raise ConfigError(
2910
+ "subagent_orchestration.repetition_nudge_occurrence_threshold must be "
2911
+ "lower than subagent_orchestration.repetition_occurrence_threshold"
2912
+ )
2913
+ cfg.subagent_orchestration.repetition_nudge_occurrence_threshold = threshold
2914
+ return cfg
2915
+
2916
+ if key == "subagent_orchestration.repetition_occurrence_threshold":
2917
+ threshold = _coerce_positive_int(value, key=key)
2918
+ if threshold <= cfg.subagent_orchestration.repetition_nudge_occurrence_threshold:
2919
+ raise ConfigError(
2920
+ "subagent_orchestration.repetition_occurrence_threshold must exceed "
2921
+ "subagent_orchestration.repetition_nudge_occurrence_threshold"
2922
+ )
2923
+ if threshold > cfg.subagent_orchestration.repetition_backstop_threshold:
2924
+ raise ConfigError(
2925
+ "subagent_orchestration.repetition_occurrence_threshold must not exceed "
2926
+ "subagent_orchestration.repetition_backstop_threshold"
2927
+ )
2928
+ cfg.subagent_orchestration.repetition_occurrence_threshold = threshold
2929
+ return cfg
2930
+
2931
+ if key == "subagent_orchestration.repetition_backstop_threshold":
2932
+ threshold = _coerce_positive_int(value, key=key)
2933
+ if threshold <= cfg.subagent_orchestration.repetition_signal_threshold:
2934
+ raise ConfigError(
2935
+ "subagent_orchestration.repetition_backstop_threshold must exceed "
2936
+ "subagent_orchestration.repetition_signal_threshold"
2937
+ )
2938
+ if threshold < cfg.subagent_orchestration.repetition_occurrence_threshold:
2939
+ raise ConfigError(
2940
+ "subagent_orchestration.repetition_backstop_threshold must not be lower "
2941
+ "than subagent_orchestration.repetition_occurrence_threshold"
2942
+ )
2943
+ cfg.subagent_orchestration.repetition_backstop_threshold = threshold
2944
+ return cfg
2945
+
2946
+ if key in {
2947
+ "subagent_orchestration.model_response_activity_after_s",
2948
+ "subagent_orchestration.inactivity_signal_after_s",
2949
+ }:
2950
+ setattr(
2951
+ cfg.subagent_orchestration,
2952
+ key.rsplit(".", 1)[1],
2953
+ _coerce_positive_float(value, key=key),
2954
+ )
2955
+ return cfg
2956
+
2957
+ if key == "subagent_orchestration.inflight_deadline_grace_s":
2958
+ parsed = _coerce_non_negative_float(value, key=key)
2959
+ if not math.isfinite(parsed):
2960
+ raise ConfigError(f"{key} must be a finite number >= 0")
2961
+ cfg.subagent_orchestration.inflight_deadline_grace_s = parsed
2962
+ return cfg
2963
+
2964
+ if key == "cache.prompt_cache_key_enabled":
2965
+ cfg.cache.prompt_cache_key_enabled = _coerce_bool(value, key=key)
2966
+ return cfg
2967
+
2968
+ if key == "cache.keepalive_enabled":
2969
+ cfg.cache.keepalive_enabled = _coerce_bool(value, key=key)
2970
+ return cfg
2971
+
2972
+ if key == "cache.keepalive_idle_threshold_s":
2973
+ cfg.cache.keepalive_idle_threshold_s = _coerce_positive_float(value, key=key)
2974
+ return cfg
2975
+
2976
+ if key == "read_ledger_enabled":
2977
+ cfg.read_ledger_enabled = _coerce_bool(value, key=key)
2978
+ return cfg
2979
+
2980
+ if key == "temperature":
2981
+ _apply_legacy_temperature_override(cfg, _coerce_non_negative_float(value, key=key))
2982
+ return cfg
2983
+
2984
+ if key in _ROLE_TEMPERATURE_FIELDS.values():
2985
+ setattr(cfg, key, _coerce_non_negative_float(value, key=key))
2986
+ return cfg
2987
+
2988
+ if key == "stream":
2989
+ v = value.strip().lower()
2990
+ if v in {"1", "true", "yes", "on"}:
2991
+ cfg.stream = True
2992
+ return cfg
2993
+ if v in {"0", "false", "no", "off"}:
2994
+ cfg.stream = False
2995
+ return cfg
2996
+ raise ConfigError("stream must be true/false")
2997
+
2998
+ if key == "routing_mode":
2999
+ v = value.strip().lower()
3000
+ if v not in {"auto", "code_only"}:
3001
+ raise ConfigError("routing_mode must be one of: auto, code_only")
3002
+ cfg.routing_mode = v
3003
+ return cfg
3004
+
3005
+ if key == "route_arbitration_enabled":
3006
+ v = value.strip().lower()
3007
+ if v in {"1", "true", "yes", "on"}:
3008
+ cfg.route_arbitration_enabled = True
3009
+ return cfg
3010
+ if v in {"0", "false", "no", "off"}:
3011
+ cfg.route_arbitration_enabled = False
3012
+ return cfg
3013
+ raise ConfigError("route_arbitration_enabled must be true/false")
3014
+
3015
+ if key == "semantic_turn_contract_enabled":
3016
+ v = value.strip().lower()
3017
+ if v in {"1", "true", "yes", "on"}:
3018
+ cfg.semantic_turn_contract_enabled = True
3019
+ return cfg
3020
+ if v in {"0", "false", "no", "off"}:
3021
+ cfg.semantic_turn_contract_enabled = False
3022
+ return cfg
3023
+ raise ConfigError("semantic_turn_contract_enabled must be true/false")
3024
+
3025
+ if key == "unified_turn_path_enabled":
3026
+ v = value.strip().lower()
3027
+ if v in {"1", "true", "yes", "on"}:
3028
+ cfg.unified_turn_path_enabled = True
3029
+ return cfg
3030
+ if v in {"0", "false", "no", "off"}:
3031
+ cfg.unified_turn_path_enabled = False
3032
+ return cfg
3033
+ raise ConfigError("unified_turn_path_enabled must be true/false")
3034
+
3035
+ if key == "reply_language":
3036
+ cfg.reply_language = value.strip()
3037
+ return cfg
3038
+
3039
+ if key == "evidence_v2_enabled":
3040
+ v = value.strip().lower()
3041
+ if v in {"1", "true", "yes", "on"}:
3042
+ cfg.evidence_v2_enabled = True
3043
+ return cfg
3044
+ if v in {"0", "false", "no", "off"}:
3045
+ cfg.evidence_v2_enabled = False
3046
+ return cfg
3047
+ raise ConfigError("evidence_v2_enabled must be true/false")
3048
+
3049
+ if key == "regression_baseline_enabled":
3050
+ v = value.strip().lower()
3051
+ if v in {"1", "true", "yes", "on"}:
3052
+ cfg.regression_baseline_enabled = True
3053
+ return cfg
3054
+ if v in {"0", "false", "no", "off"}:
3055
+ cfg.regression_baseline_enabled = False
3056
+ return cfg
3057
+ raise ConfigError("regression_baseline_enabled must be true/false")
3058
+
3059
+ if key == "turn_contract_v2_enabled":
3060
+ v = value.strip().lower()
3061
+ if v in {"1", "true", "yes", "on"}:
3062
+ cfg.turn_contract_v2_enabled = True
3063
+ return cfg
3064
+ if v in {"0", "false", "no", "off"}:
3065
+ cfg.turn_contract_v2_enabled = False
3066
+ return cfg
3067
+ raise ConfigError("turn_contract_v2_enabled must be true/false")
3068
+
3069
+ if key == "reproduction_first_enabled":
3070
+ v = value.strip().lower()
3071
+ if v in {"1", "true", "yes", "on"}:
3072
+ cfg.reproduction_first_enabled = True
3073
+ return cfg
3074
+ if v in {"0", "false", "no", "off"}:
3075
+ cfg.reproduction_first_enabled = False
3076
+ return cfg
3077
+ raise ConfigError("reproduction_first_enabled must be true/false")
3078
+
3079
+ if key == "blast_radius_gate_enabled":
3080
+ v = value.strip().lower()
3081
+ if v in {"1", "true", "yes", "on"}:
3082
+ cfg.blast_radius_gate_enabled = True
3083
+ return cfg
3084
+ if v in {"0", "false", "no", "off"}:
3085
+ cfg.blast_radius_gate_enabled = False
3086
+ return cfg
3087
+ raise ConfigError("blast_radius_gate_enabled must be true/false")
3088
+
3089
+ if key in {"blast_radius_max_scope_files", "blast_radius_over_broad_threshold"}:
3090
+ try:
3091
+ parsed_count = int(value.strip())
3092
+ except ValueError:
3093
+ raise ConfigError(f"{key} must be a positive integer") from None
3094
+ if parsed_count <= 0:
3095
+ raise ConfigError(f"{key} must be a positive integer")
3096
+ setattr(cfg, key, parsed_count)
3097
+ return cfg
3098
+
3099
+ if key == "blast_radius_scope_seconds_cap":
3100
+ try:
3101
+ parsed_seconds = float(value.strip())
3102
+ except ValueError:
3103
+ raise ConfigError("blast_radius_scope_seconds_cap must be a positive number") from None
3104
+ # ``inf``/``nan`` parse fine but would disable the cap silently; the field
3105
+ # declares allow_inf_nan=False and direct assignment does not re-validate.
3106
+ if not math.isfinite(parsed_seconds) or parsed_seconds <= 0:
3107
+ raise ConfigError("blast_radius_scope_seconds_cap must be a positive number")
3108
+ cfg.blast_radius_scope_seconds_cap = parsed_seconds
3109
+ return cfg
3110
+
3111
+ if key == "process_reaping_enabled":
3112
+ v = value.strip().lower()
3113
+ if v in {"1", "true", "yes", "on"}:
3114
+ cfg.process_reaping_enabled = True
3115
+ return cfg
3116
+ if v in {"0", "false", "no", "off"}:
3117
+ cfg.process_reaping_enabled = False
3118
+ return cfg
3119
+ raise ConfigError("process_reaping_enabled must be true/false")
3120
+
3121
+ if key == "workspace_provisioning_enabled":
3122
+ v = value.strip().lower()
3123
+ if v in {"1", "true", "yes", "on"}:
3124
+ cfg.workspace_provisioning_enabled = True
3125
+ return cfg
3126
+ if v in {"0", "false", "no", "off"}:
3127
+ cfg.workspace_provisioning_enabled = False
3128
+ return cfg
3129
+ raise ConfigError("workspace_provisioning_enabled must be true/false")
3130
+
3131
+ if key == "step_budget_policy":
3132
+ normalized = value.strip().lower()
3133
+ if normalized not in {"autonomous", "limited", "adaptive", "fixed"}:
3134
+ raise ConfigError(
3135
+ "step_budget_policy must be one of: autonomous, limited "
3136
+ "(legacy aliases: adaptive, fixed)"
3137
+ )
3138
+ cfg.step_budget_policy = normalize_step_budget_policy(normalized)
3139
+ return cfg
3140
+
3141
+ if key == "subagents_enabled":
3142
+ v = value.strip().lower()
3143
+ if v in {"1", "true", "yes", "on"}:
3144
+ cfg.subagents_enabled = True
3145
+ return cfg
3146
+ if v in {"0", "false", "no", "off"}:
3147
+ cfg.subagents_enabled = False
3148
+ return cfg
3149
+ raise ConfigError("subagents_enabled must be true/false")
3150
+
3151
+ if key == "skills_enabled":
3152
+ v = value.strip().lower()
3153
+ if v in {"1", "true", "yes", "on"}:
3154
+ cfg.skills_enabled = True
3155
+ return cfg
3156
+ if v in {"0", "false", "no", "off"}:
3157
+ cfg.skills_enabled = False
3158
+ return cfg
3159
+ raise ConfigError("skills_enabled must be true/false")
3160
+
3161
+ if key == "skills_auto_invoke":
3162
+ v = value.strip().lower()
3163
+ if v in {"1", "true", "yes", "on"}:
3164
+ cfg.skills_auto_invoke = True
3165
+ return cfg
3166
+ if v in {"0", "false", "no", "off"}:
3167
+ cfg.skills_auto_invoke = False
3168
+ return cfg
3169
+ raise ConfigError("skills_auto_invoke must be true/false")
3170
+
3171
+ if key == "experimental_gemini_interactions_enabled":
3172
+ cfg.experimental_gemini_interactions_enabled = _coerce_bool(value, key=key)
3173
+ return cfg
3174
+
3175
+ if key == "custom_tools_enabled":
3176
+ v = value.strip().lower()
3177
+ if v in {"1", "true", "yes", "on"}:
3178
+ cfg.custom_tools_enabled = True
3179
+ return cfg
3180
+ if v in {"0", "false", "no", "off"}:
3181
+ cfg.custom_tools_enabled = False
3182
+ return cfg
3183
+ raise ConfigError("custom_tools_enabled must be true/false")
3184
+
3185
+ if key == "web_search_mode":
3186
+ cfg.web_search_mode = _normalize_web_search_mode(value)
3187
+ return cfg
3188
+
3189
+ if key == "web_search_policy":
3190
+ cfg.web_search_policy = _normalize_web_search_policy(value)
3191
+ return cfg
3192
+
3193
+ if key == "web_search_enabled":
3194
+ cfg.web_search_mode = _coerce_legacy_web_search_mode(value)
3195
+ return cfg
3196
+
3197
+ if key == "web_search_adapter":
3198
+ cfg.web_search_adapter = _normalize_web_search_adapter(value)
3199
+ return cfg
3200
+
3201
+ if key == "web_search_base_url":
3202
+ cfg.web_search_base_url = value.strip() or None
3203
+ return cfg
3204
+
3205
+ if key == "web_search_model":
3206
+ cfg.web_search_model = value.strip() or None
3207
+ return cfg
3208
+
3209
+ if key == "web_search_timeout_s":
3210
+ cfg.web_search_timeout_s = _coerce_positive_float(value, key=key)
3211
+ return cfg
3212
+
3213
+ if key == "image_generation.enabled":
3214
+ cfg.image_generation.enabled = _coerce_bool(value, key=key)
3215
+ return cfg
3216
+
3217
+ if key == "image_generation.model":
3218
+ normalized = str(value or "").strip()
3219
+ if not normalized:
3220
+ raise ConfigError("image_generation.model cannot be empty")
3221
+ payload = cfg.image_generation.model_dump()
3222
+ payload["model"] = normalized
3223
+ cfg.image_generation = ImageGenerationConfig.model_validate(payload)
3224
+ return cfg
3225
+
3226
+ if key == "image_generation.base_url":
3227
+ from .profiles import validate_base_url
3228
+
3229
+ normalized = str(value or "").strip()
3230
+ payload = cfg.image_generation.model_dump()
3231
+ payload["base_url"] = (
3232
+ validate_base_url(normalized, key=key, allow_empty=True) if normalized else None
3233
+ )
3234
+ try:
3235
+ cfg.image_generation = ImageGenerationConfig.model_validate(payload)
3236
+ except ValidationError as exc:
3237
+ raise ConfigError(str(exc.errors()[0]["msg"])) from exc
3238
+ return cfg
3239
+
3240
+ if key == "image_generation.api_key_env":
3241
+ normalized = str(value or "").strip()
3242
+ if normalized and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", normalized) is None:
3243
+ raise ConfigError(
3244
+ "image_generation.api_key_env must be a valid environment variable name"
3245
+ )
3246
+ payload = cfg.image_generation.model_dump()
3247
+ payload["api_key_env"] = normalized or None
3248
+ cfg.image_generation = ImageGenerationConfig.model_validate(payload)
3249
+ return cfg
3250
+
3251
+ if key == "image_generation.timeout_s":
3252
+ payload = cfg.image_generation.model_dump()
3253
+ payload["timeout_s"] = _coerce_positive_float(value, key=key)
3254
+ try:
3255
+ cfg.image_generation = ImageGenerationConfig.model_validate(payload)
3256
+ except ValidationError as exc:
3257
+ raise ConfigError(str(exc.errors()[0]["msg"])) from exc
3258
+ return cfg
3259
+
3260
+ if key in {
3261
+ "image_generation.max_images_per_call",
3262
+ "image_generation.max_image_bytes",
3263
+ "image_generation.max_pixels",
3264
+ }:
3265
+ field_name = key.rsplit(".", 1)[1]
3266
+ payload = cfg.image_generation.model_dump()
3267
+ payload[field_name] = _coerce_positive_int(value, key=key)
3268
+ try:
3269
+ cfg.image_generation = ImageGenerationConfig.model_validate(payload)
3270
+ except ValidationError as exc:
3271
+ raise ConfigError(str(exc.errors()[0]["msg"])) from exc
3272
+ return cfg
3273
+
3274
+ if key == "update_check_enabled":
3275
+ cfg.update_check_enabled = _coerce_bool(value, key=key)
3276
+ return cfg
3277
+
3278
+ if key == "update_check_interval_hours":
3279
+ cfg.update_check_interval_hours = _coerce_positive_int(value, key=key)
3280
+ return cfg
3281
+
3282
+ if key == "update_check_timeout_s":
3283
+ cfg.update_check_timeout_s = _coerce_positive_float(value, key=key)
3284
+ return cfg
3285
+
3286
+ if key == "update_prompt_enabled":
3287
+ cfg.update_prompt_enabled = _coerce_bool(value, key=key)
3288
+ return cfg
3289
+
3290
+ if key == "feedback_github_enabled":
3291
+ cfg.feedback_github_enabled = _coerce_bool(value, key=key)
3292
+ return cfg
3293
+
3294
+ if key == "feedback_github_repo":
3295
+ cfg.feedback_github_repo = _coerce_github_repo(value, key=key)
3296
+ return cfg
3297
+
3298
+ if key == "feedback_open_browser":
3299
+ cfg.feedback_open_browser = _coerce_bool(value, key=key)
3300
+ return cfg
3301
+
3302
+ if key == "session_log_dir":
3303
+ cfg.session_log_dir = value if value.strip() else None
3304
+ return cfg
3305
+
3306
+ if key == "crash_diagnostic_log_path":
3307
+ cfg.crash_diagnostic_log_path = value.strip() or None
3308
+ return cfg
3309
+
3310
+ if key == "prompt_cache_mode":
3311
+ cfg.prompt_cache_mode = _normalize_prompt_cache_mode(value, key=key)
3312
+ return cfg
3313
+
3314
+ if key == "prompt_cache_key":
3315
+ cfg.prompt_cache_key = value.strip() or None
3316
+ return cfg
3317
+
3318
+ if key == "prompt_cache_retention":
3319
+ cfg.prompt_cache_retention = value.strip() or None
3320
+ return cfg
3321
+
3322
+ if key == "anthropic_prompt_cache_enabled":
3323
+ cfg.anthropic_prompt_cache_enabled = _coerce_bool(value, key=key)
3324
+ return cfg
3325
+
3326
+ if key == "anthropic_prompt_cache_ttl":
3327
+ cfg.anthropic_prompt_cache_ttl = _normalize_anthropic_prompt_cache_ttl(value, key=key)
3328
+ return cfg
3329
+
3330
+ if key == "verify_commands":
3331
+ cfg.verify_commands = _parse_command_list(
3332
+ value,
3333
+ key="verify_commands",
3334
+ allow_empty=False,
3335
+ )
3336
+ return cfg
3337
+
3338
+ if key == "integration_verify_mode":
3339
+ normalized = value.strip().lower()
3340
+ if normalized not in _VERIFY_LIKE_MODES:
3341
+ raise ConfigError("integration_verify_mode must be one of: off, warn, strict")
3342
+ cfg.integration_verify_mode = normalized
3343
+ return cfg
3344
+
3345
+ if key == "integration_verify_commands":
3346
+ cfg.integration_verify_commands = _parse_command_list(
3347
+ value,
3348
+ key="integration_verify_commands",
3349
+ allow_empty=True,
3350
+ )
3351
+ return cfg
3352
+
3353
+ if key == "replanning_mode":
3354
+ normalized = value.strip().lower()
3355
+ if normalized not in {"off", "suggest", "apply"}:
3356
+ raise ConfigError("replanning_mode must be one of: off, suggest, apply")
3357
+ cfg.replanning_mode = normalized
3358
+ return cfg
3359
+
3360
+ if key == "assets.enabled":
3361
+ cfg.assets.enabled = _coerce_bool(value, key=key)
3362
+ return cfg
3363
+
3364
+ if key == "assets.comprehension.role":
3365
+ role = value.strip().lower()
3366
+ if not role:
3367
+ raise ConfigError("assets.comprehension.role must be non-empty")
3368
+ cfg.assets.comprehension.role = role
3369
+ return cfg
3370
+
3371
+ if key == "assets.comprehension.vision_fallback_profile":
3372
+ cfg.assets.comprehension.vision_fallback_profile = value.strip().lower() or None
3373
+ return cfg
3374
+
3375
+ if key == "assets.comprehension.vision_with_ocr_when_available":
3376
+ cfg.assets.comprehension.vision_with_ocr_when_available = _coerce_bool(value, key=key)
3377
+ return cfg
3378
+
3379
+ if key == "assets.comprehension.ocr_enabled":
3380
+ normalized = value.strip().lower()
3381
+ if normalized not in {"auto", "always", "never"}:
3382
+ raise ConfigError(
3383
+ "assets.comprehension.ocr_enabled must be one of: auto, always, never"
3384
+ )
3385
+ cfg.assets.comprehension.ocr_enabled = normalized # type: ignore[assignment]
3386
+ return cfg
3387
+
3388
+ if key == "assets.comprehension.ocr_provider":
3389
+ provider = value.strip().lower()
3390
+ if not provider:
3391
+ raise ConfigError("assets.comprehension.ocr_provider must be non-empty")
3392
+ cfg.assets.comprehension.ocr_provider = provider
3393
+ return cfg
3394
+
3395
+ if key == "assets.comprehension.ocr_timeout_seconds":
3396
+ cfg.assets.comprehension.ocr_timeout_seconds = _coerce_positive_int(value, key=key)
3397
+ return cfg
3398
+
3399
+ if key == "assets.comprehension.image_max_edge_pixels":
3400
+ cfg.assets.comprehension.image_max_edge_pixels = _coerce_positive_int(value, key=key)
3401
+ return cfg
3402
+
3403
+ if key == "assets.comprehension.questioning_mode":
3404
+ normalized = value.strip().lower()
3405
+ if normalized not in {"assertive", "balanced", "assumption_friendly"}:
3406
+ raise ConfigError(
3407
+ "assets.comprehension.questioning_mode must be one of: "
3408
+ "assertive, balanced, assumption_friendly"
3409
+ )
3410
+ cfg.assets.comprehension.questioning_mode = normalized # type: ignore[assignment]
3411
+ return cfg
3412
+
3413
+ if key == "assets.comprehension.schema_version":
3414
+ cfg.assets.comprehension.schema_version = _coerce_positive_int(value, key=key)
3415
+ return cfg
3416
+
3417
+ if key == "assets.planner.inline_images":
3418
+ cfg.assets.planner.inline_images = _coerce_bool(value, key=key)
3419
+ return cfg
3420
+
3421
+ if key == "assets.planner.max_inline_images":
3422
+ cfg.assets.planner.max_inline_images = _coerce_positive_int(value, key=key)
3423
+ return cfg
3424
+
3425
+ if key == "assets.planner.readiness_policy":
3426
+ normalized = value.strip().lower()
3427
+ if normalized not in {"soft", "block", "partial"}:
3428
+ raise ConfigError(
3429
+ "assets.planner.readiness_policy must be one of: soft, block, partial"
3430
+ )
3431
+ cfg.assets.planner.readiness_policy = normalized # type: ignore[assignment]
3432
+ return cfg
3433
+
3434
+ if key == "assets.planner.readiness_timeout_seconds":
3435
+ cfg.assets.planner.readiness_timeout_seconds = _coerce_positive_float(value, key=key)
3436
+ return cfg
3437
+
3438
+ if key == "assets.planner.max_chars_per_asset":
3439
+ cfg.assets.planner.max_chars_per_asset = _coerce_positive_int(value, key=key)
3440
+ return cfg
3441
+
3442
+ if key == "assets.planner.max_primary_per_task":
3443
+ cfg.assets.planner.max_primary_per_task = _coerce_positive_int(value, key=key)
3444
+ return cfg
3445
+
3446
+ if key == "assets.worker.inline_images":
3447
+ cfg.assets.worker.inline_images = _coerce_bool(value, key=key)
3448
+ return cfg
3449
+
3450
+ if key == "assets.worker.max_inline_images":
3451
+ cfg.assets.worker.max_inline_images = _coerce_positive_int(value, key=key)
3452
+ return cfg
3453
+
3454
+ if key == "assets.worker.fail_on_mirror_error":
3455
+ cfg.assets.worker.fail_on_mirror_error = _coerce_bool(value, key=key)
3456
+ return cfg
3457
+
3458
+ if key == "assets.worker.allocator_role":
3459
+ role = value.strip().lower()
3460
+ if not role:
3461
+ raise ConfigError("assets.worker.allocator_role must be non-empty")
3462
+ cfg.assets.worker.allocator_role = role
3463
+ return cfg
3464
+
3465
+ if key == "assets.worker.allocator_timeout_seconds":
3466
+ cfg.assets.worker.allocator_timeout_seconds = _coerce_positive_int(value, key=key)
3467
+ return cfg
3468
+
3469
+ if key == "assets.worker.max_chars_per_asset_block":
3470
+ cfg.assets.worker.max_chars_per_asset_block = _coerce_positive_int(value, key=key)
3471
+ return cfg
3472
+
3473
+ if key == "assets.worker.max_focused_extract_chars":
3474
+ cfg.assets.worker.max_focused_extract_chars = _coerce_positive_int(value, key=key)
3475
+ return cfg
3476
+
3477
+ if key == "assets.worker.schema_version":
3478
+ cfg.assets.worker.schema_version = _coerce_positive_int(value, key=key)
3479
+ return cfg
3480
+
3481
+ if key == "toolbar_items":
3482
+ raw = value.strip()
3483
+ if not raw:
3484
+ raise ConfigError("toolbar_items must be a JSON array of toolbar item names")
3485
+ try:
3486
+ parsed = json.loads(raw)
3487
+ except json.JSONDecodeError as e:
3488
+ raise ConfigError("toolbar_items must be valid JSON array") from e
3489
+ if not isinstance(parsed, list):
3490
+ raise ConfigError("toolbar_items must be a JSON array")
3491
+ items: list[str] = []
3492
+ seen: set[str] = set()
3493
+ valid_items = ", ".join(sorted(_VALID_TOOLBAR_ITEMS))
3494
+ for item in parsed:
3495
+ if not isinstance(item, str):
3496
+ raise ConfigError("toolbar_items must be a JSON array of strings")
3497
+ name = item.strip().lower()
3498
+ if not name:
3499
+ raise ConfigError("toolbar_items cannot contain empty values")
3500
+ if name not in _VALID_TOOLBAR_ITEMS:
3501
+ raise ConfigError(f"Unknown toolbar item: {name}. Valid items: {valid_items}")
3502
+ if name in seen:
3503
+ continue
3504
+ seen.add(name)
3505
+ items.append(name)
3506
+ cfg.toolbar_items = items
3507
+ return cfg
3508
+
3509
+ raise ConfigError(f"Unhandled key: {key}")