alysis-code 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1065 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import statistics
5
+ import time
6
+ from collections.abc import Callable, Iterator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass, field
9
+ from enum import StrEnum
10
+ from typing import Any
11
+
12
+ DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS = 1.0
13
+
14
+ # Ceiling for a single LLM attempt when a run deadline is active. One hung read
15
+ # must not consume the whole remaining budget: bounding the attempt turns a
16
+ # stuck provider into an ordinary retryable timeout while the deadline still
17
+ # owns the overall budget. Generous on purpose - legitimate long generations
18
+ # (reasoning models) routinely run several minutes.
19
+ DEFAULT_LLM_ATTEMPT_TIMEOUT_CEILING_SECONDS = 900.0
20
+ _LLM_ATTEMPT_TIMEOUT_ENV = "ALYSIS_LLM_ATTEMPT_TIMEOUT_S"
21
+ _ATTEMPT_CEILING_OFF_WORDS = frozenset({"off", "unlimited", "none", "never", "0", "disabled"})
22
+ MINIMUM_OPERATION_TIMEOUT_SECONDS = 0.05
23
+ MINIMUM_LLM_START_SECONDS = 0.25
24
+ MINIMUM_TOOL_START_SECONDS = 0.05
25
+ MINIMUM_FORCED_SUMMARY_SECONDS = 2.0
26
+ MINIMUM_SUBAGENT_START_SECONDS = 2.0
27
+ DEFAULT_FINALIZATION_MINIMUM_SECONDS = 1.0
28
+ DEFAULT_FINALIZATION_MAX_SECONDS = 120.0
29
+ DEFAULT_FINALIZATION_MAX_FRACTION = 0.25
30
+ DEADLINE_ESTIMATE_WINDOW_SIZE = 5
31
+
32
+ # Wall-clock budget applied to non-interactive runs that did not configure one.
33
+ # Generous on purpose: it exists to bound a run that has stopped converging, not
34
+ # to cut short a long job a user deliberately started.
35
+ DEFAULT_RUN_DEADLINE_SECONDS = 3600.0
36
+
37
+ # Fractions of the configured budget at which the run degrades. Both are
38
+ # elapsed-time fractions, so they are monotonic: once a stage is reached the run
39
+ # never returns to a less degraded one.
40
+ DEFAULT_CONVERGENCE_ELAPSED_FRACTION = 0.75
41
+ DEFAULT_WRAP_UP_ELAPSED_FRACTION = 0.90
42
+
43
+ _MISSING = object()
44
+
45
+
46
+ class DeadlinePhase(StrEnum):
47
+ NORMAL = "normal"
48
+ CONVERGENCE = "convergence"
49
+ WRAP_UP = "wrap_up"
50
+ FINALIZATION_WINDOW = "finalization_window"
51
+ EXHAUSTED = "exhausted"
52
+
53
+
54
+ class DeadlineSource(StrEnum):
55
+ EXPLICIT_CLI = "explicit_cli"
56
+ ENVIRONMENT = "environment"
57
+ CONFIG = "config"
58
+ RUNTIME_DEFAULT = "runtime_default"
59
+ INHERITED_PARENT = "inherited_parent"
60
+ SUBAGENT_FALLBACK = "subagent_fallback"
61
+ ABSENT = "absent"
62
+ UNKNOWN = "unknown"
63
+
64
+
65
+ class DeadlineOperation(StrEnum):
66
+ MAIN_LLM = "main_llm"
67
+ MAIN_LLM_RETRY = "main_llm_retry"
68
+ COMPACTION_LLM = "compaction_llm"
69
+ ADAPTIVE_RETRY_LLM = "adaptive_retry_llm"
70
+ SUBAGENT = "subagent"
71
+ VERIFICATION = "verification"
72
+ SHELL_TOOL = "shell_tool"
73
+ SHELL_BACKGROUND = "shell_background"
74
+ EXPLORATION_TOOL = "exploration_tool"
75
+ MUTATION_TOOL = "mutation_tool"
76
+ PROVIDER_RETRY_SLEEP = "provider_retry_sleep"
77
+ LOCAL_FINAL_SUMMARY = "local_final_summary"
78
+ TOOL_DISPATCH = "tool_dispatch"
79
+
80
+
81
+ _FINALIZATION_BLOCKED_OPERATIONS = frozenset(
82
+ {
83
+ DeadlineOperation.MAIN_LLM_RETRY.value,
84
+ DeadlineOperation.COMPACTION_LLM.value,
85
+ DeadlineOperation.ADAPTIVE_RETRY_LLM.value,
86
+ DeadlineOperation.SUBAGENT.value,
87
+ DeadlineOperation.SHELL_BACKGROUND.value,
88
+ DeadlineOperation.EXPLORATION_TOOL.value,
89
+ DeadlineOperation.PROVIDER_RETRY_SLEEP.value,
90
+ }
91
+ )
92
+
93
+ # Convergence closes the operations that open a *new* line of work. Reads and
94
+ # edits stay available: the run still has to drive what it already started to a
95
+ # verifiable state, and it cannot do that with its hands tied.
96
+ _CONVERGENCE_BLOCKED_OPERATIONS = frozenset(
97
+ {
98
+ DeadlineOperation.SUBAGENT.value,
99
+ DeadlineOperation.SHELL_BACKGROUND.value,
100
+ }
101
+ )
102
+
103
+ # Wrap-up closes editing and exploration. Verification, foreground shell
104
+ # commands, and the main model call stay open, because they are exactly what
105
+ # "run final verification and write the summary" needs.
106
+ _WRAP_UP_BLOCKED_OPERATIONS = frozenset(
107
+ {
108
+ *_CONVERGENCE_BLOCKED_OPERATIONS,
109
+ DeadlineOperation.EXPLORATION_TOOL.value,
110
+ DeadlineOperation.MUTATION_TOOL.value,
111
+ }
112
+ )
113
+
114
+ _DEGRADATION_BLOCKED_OPERATIONS: dict[str, frozenset[str]] = {
115
+ DeadlinePhase.CONVERGENCE.value: _CONVERGENCE_BLOCKED_OPERATIONS,
116
+ DeadlinePhase.WRAP_UP.value: _WRAP_UP_BLOCKED_OPERATIONS,
117
+ }
118
+
119
+
120
+ class DeadlineExhausted(RuntimeError):
121
+ """Internal control-flow marker for run deadline exhaustion."""
122
+
123
+
124
+ def validate_deadline_seconds(value: Any, *, key: str = "run_deadline_seconds") -> float:
125
+ try:
126
+ parsed = float(value)
127
+ except (TypeError, ValueError) as exc:
128
+ raise ValueError(f"{key} must be a finite number > 0") from exc
129
+ if parsed <= 0 or not math.isfinite(parsed):
130
+ raise ValueError(f"{key} must be a finite number > 0")
131
+ return parsed
132
+
133
+
134
+ def _clamp_fraction(value: Any, fallback: float) -> float:
135
+ try:
136
+ parsed = float(value)
137
+ except (TypeError, ValueError):
138
+ return fallback
139
+ if not math.isfinite(parsed) or parsed <= 0.0 or parsed > 1.0:
140
+ return fallback
141
+ return parsed
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class DeadlineDegradationPolicy:
146
+ """When a run stops opening new work, and when it stops editing.
147
+
148
+ Both thresholds are fractions of the configured budget. ``enabled`` is the
149
+ kill-switch: with it off the deadline behaves exactly as it did before
150
+ phased degradation existed.
151
+ """
152
+
153
+ enabled: bool = True
154
+ convergence_fraction: float = DEFAULT_CONVERGENCE_ELAPSED_FRACTION
155
+ wrap_up_fraction: float = DEFAULT_WRAP_UP_ELAPSED_FRACTION
156
+
157
+ def normalized(self) -> DeadlineDegradationPolicy:
158
+ wrap_up = _clamp_fraction(self.wrap_up_fraction, DEFAULT_WRAP_UP_ELAPSED_FRACTION)
159
+ convergence = _clamp_fraction(
160
+ self.convergence_fraction,
161
+ DEFAULT_CONVERGENCE_ELAPSED_FRACTION,
162
+ )
163
+ # Convergence can never come after wrap-up: a misconfigured pair
164
+ # collapses to a single threshold rather than inverting the ladder.
165
+ convergence = min(convergence, wrap_up)
166
+ return DeadlineDegradationPolicy(
167
+ enabled=bool(self.enabled),
168
+ convergence_fraction=convergence,
169
+ wrap_up_fraction=wrap_up,
170
+ )
171
+
172
+ def as_payload(self) -> dict[str, Any]:
173
+ normalized = self.normalized()
174
+ return {
175
+ "enabled": normalized.enabled,
176
+ "convergence_fraction": normalized.convergence_fraction,
177
+ "wrap_up_fraction": normalized.wrap_up_fraction,
178
+ }
179
+
180
+
181
+ def run_deadline_degradation_enabled(cfg: Any | None) -> bool:
182
+ """Kill-switch for phased budget degradation.
183
+
184
+ ``ALYSIS_RUN_BUDGET_DEGRADATION`` (off/0/false/no/disabled) wins over the
185
+ config value; default is on. When off, the wall-clock budget still applies
186
+ but the run runs flat out until the existing finalization window.
187
+ """
188
+ from .branding import env_get
189
+
190
+ env_value = env_get("ALYSIS_RUN_BUDGET_DEGRADATION")
191
+ if env_value is not None:
192
+ normalized = str(env_value).strip().lower()
193
+ if normalized in {"off", "0", "false", "no", "disabled"}:
194
+ return False
195
+ if normalized in {"on", "1", "true", "yes", "enabled"}:
196
+ return True
197
+ return bool(getattr(cfg, "run_deadline_degradation_enabled", True))
198
+
199
+
200
+ def resolve_deadline_degradation_policy(cfg: Any | None) -> DeadlineDegradationPolicy:
201
+ """Build the degradation policy from config, falling back on bad values."""
202
+ return DeadlineDegradationPolicy(
203
+ enabled=run_deadline_degradation_enabled(cfg),
204
+ convergence_fraction=_clamp_fraction(
205
+ getattr(cfg, "run_deadline_convergence_fraction", None),
206
+ DEFAULT_CONVERGENCE_ELAPSED_FRACTION,
207
+ ),
208
+ wrap_up_fraction=_clamp_fraction(
209
+ getattr(cfg, "run_deadline_wrap_up_fraction", None),
210
+ DEFAULT_WRAP_UP_ELAPSED_FRACTION,
211
+ ),
212
+ ).normalized()
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class DeadlineFinalizationPolicy:
217
+ minimum_reserve_seconds: float = DEFAULT_FINALIZATION_MINIMUM_SECONDS
218
+ cleanup_reserve_seconds: float = DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS
219
+ max_reserve_seconds: float = DEFAULT_FINALIZATION_MAX_SECONDS
220
+ max_reserve_fraction: float = DEFAULT_FINALIZATION_MAX_FRACTION
221
+ llm_latency_multiplier: float = 1.5
222
+ verification_latency_multiplier: float = 1.25
223
+ tool_latency_multiplier: float = 1.1
224
+ local_cleanup_seconds: float = DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS
225
+
226
+ def normalized(self) -> DeadlineFinalizationPolicy:
227
+ return DeadlineFinalizationPolicy(
228
+ minimum_reserve_seconds=max(0.0, float(self.minimum_reserve_seconds)),
229
+ cleanup_reserve_seconds=max(0.0, float(self.cleanup_reserve_seconds)),
230
+ max_reserve_seconds=max(0.0, float(self.max_reserve_seconds)),
231
+ max_reserve_fraction=max(0.0, min(1.0, float(self.max_reserve_fraction))),
232
+ llm_latency_multiplier=max(0.0, float(self.llm_latency_multiplier)),
233
+ verification_latency_multiplier=max(0.0, float(self.verification_latency_multiplier)),
234
+ tool_latency_multiplier=max(0.0, float(self.tool_latency_multiplier)),
235
+ local_cleanup_seconds=max(0.0, float(self.local_cleanup_seconds)),
236
+ )
237
+
238
+
239
+ @dataclass
240
+ class DeadlineDurationObservation:
241
+ count: int = 0
242
+ total_seconds: float = 0.0
243
+ max_seconds: float = 0.0
244
+ recent_seconds: list[float] = field(default_factory=list)
245
+
246
+ def record(self, duration_seconds: float) -> None:
247
+ duration = max(0.0, float(duration_seconds))
248
+ self.count += 1
249
+ self.total_seconds += duration
250
+ self.max_seconds = max(self.max_seconds, duration)
251
+ self.recent_seconds.append(duration)
252
+ del self.recent_seconds[:-DEADLINE_ESTIMATE_WINDOW_SIZE]
253
+
254
+ @property
255
+ def average_seconds(self) -> float:
256
+ if self.count <= 0:
257
+ return 0.0
258
+ return self.total_seconds / float(self.count)
259
+
260
+ def estimate_seconds(self) -> float:
261
+ if not self.recent_seconds:
262
+ return 0.0
263
+ return float(statistics.median(self.recent_seconds))
264
+
265
+ def telemetry_snapshot(self) -> dict[str, Any]:
266
+ return {
267
+ "count": self.count,
268
+ "average_seconds": round(self.average_seconds, 6),
269
+ "max_seconds": round(self.max_seconds, 6),
270
+ "estimate_strategy": "median_recent",
271
+ "estimate_window_size": DEADLINE_ESTIMATE_WINDOW_SIZE,
272
+ "estimate_window_seconds": [round(duration, 6) for duration in self.recent_seconds],
273
+ "estimated_seconds": round(self.estimate_seconds(), 6),
274
+ }
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class DeadlineStartDecision:
279
+ operation: str
280
+ allowed: bool
281
+ phase: DeadlinePhase
282
+ reason: str
283
+ remaining_seconds: float | None
284
+ normal_work_remaining_seconds: float | None
285
+ finalization_reserve_seconds: float
286
+ minimum_required_seconds: float
287
+ estimated_duration_seconds: float | None
288
+
289
+ def telemetry_snapshot(self) -> dict[str, Any]:
290
+ return {
291
+ "operation": self.operation,
292
+ "allowed": self.allowed,
293
+ "phase": self.phase.value,
294
+ "reason": self.reason,
295
+ "remaining_seconds": (
296
+ None if self.remaining_seconds is None else round(self.remaining_seconds, 6)
297
+ ),
298
+ "normal_work_remaining_seconds": (
299
+ None
300
+ if self.normal_work_remaining_seconds is None
301
+ else round(self.normal_work_remaining_seconds, 6)
302
+ ),
303
+ "finalization_reserve_seconds": round(self.finalization_reserve_seconds, 6),
304
+ "minimum_required_seconds": round(max(0.0, self.minimum_required_seconds), 6),
305
+ "estimated_duration_seconds": (
306
+ None
307
+ if self.estimated_duration_seconds is None
308
+ else round(max(0.0, self.estimated_duration_seconds), 6)
309
+ ),
310
+ }
311
+
312
+
313
+ @dataclass
314
+ class ExecutionDeadline:
315
+ started_at_monotonic: float
316
+ deadline_monotonic: float | None
317
+ configured_duration_seconds: float | None = None
318
+ source: DeadlineSource | str = DeadlineSource.UNKNOWN
319
+ finalization_policy: DeadlineFinalizationPolicy = field(
320
+ default_factory=DeadlineFinalizationPolicy,
321
+ repr=False,
322
+ compare=False,
323
+ )
324
+ degradation_policy: DeadlineDegradationPolicy = field(
325
+ default_factory=DeadlineDegradationPolicy,
326
+ repr=False,
327
+ compare=False,
328
+ )
329
+ _clock: Callable[[], float] = field(default=time.monotonic, repr=False, compare=False)
330
+ _duration_observations: dict[str, DeadlineDurationObservation] = field(
331
+ default_factory=dict,
332
+ repr=False,
333
+ compare=False,
334
+ )
335
+ _finalization_reason: str | None = field(default=None, repr=False, compare=False)
336
+ _finalization_entered_at_monotonic: float | None = field(
337
+ default=None,
338
+ repr=False,
339
+ compare=False,
340
+ )
341
+ _finalization_directive_sent: bool = field(default=False, repr=False, compare=False)
342
+ _finalization_llm_started: bool = field(default=False, repr=False, compare=False)
343
+ _degradation_stages_entered: dict[str, float] = field(
344
+ default_factory=dict,
345
+ repr=False,
346
+ compare=False,
347
+ )
348
+
349
+ @classmethod
350
+ def from_duration(
351
+ cls,
352
+ duration_seconds: float | None,
353
+ *,
354
+ clock: Callable[[], float] = time.monotonic,
355
+ source: DeadlineSource | str = DeadlineSource.UNKNOWN,
356
+ finalization_policy: DeadlineFinalizationPolicy | None = None,
357
+ degradation_policy: DeadlineDegradationPolicy | None = None,
358
+ ) -> ExecutionDeadline:
359
+ started = float(clock())
360
+ if duration_seconds is None:
361
+ return cls(
362
+ started_at_monotonic=started,
363
+ deadline_monotonic=None,
364
+ configured_duration_seconds=None,
365
+ source=source,
366
+ finalization_policy=finalization_policy or DeadlineFinalizationPolicy(),
367
+ degradation_policy=degradation_policy or DeadlineDegradationPolicy(),
368
+ _clock=clock,
369
+ )
370
+ duration = validate_deadline_seconds(duration_seconds)
371
+ return cls(
372
+ started_at_monotonic=started,
373
+ deadline_monotonic=started + duration,
374
+ configured_duration_seconds=duration,
375
+ source=source,
376
+ finalization_policy=finalization_policy or DeadlineFinalizationPolicy(),
377
+ degradation_policy=degradation_policy or DeadlineDegradationPolicy(),
378
+ _clock=clock,
379
+ )
380
+
381
+ @classmethod
382
+ def from_absolute(
383
+ cls,
384
+ *,
385
+ started_at_monotonic: float,
386
+ deadline_monotonic: float | None,
387
+ configured_duration_seconds: float | None = None,
388
+ clock: Callable[[], float] = time.monotonic,
389
+ source: DeadlineSource | str = DeadlineSource.INHERITED_PARENT,
390
+ finalization_policy: DeadlineFinalizationPolicy | None = None,
391
+ degradation_policy: DeadlineDegradationPolicy | None = None,
392
+ ) -> ExecutionDeadline:
393
+ if deadline_monotonic is not None and not math.isfinite(float(deadline_monotonic)):
394
+ raise ValueError("deadline_monotonic must be finite when provided")
395
+ if configured_duration_seconds is not None:
396
+ validate_deadline_seconds(configured_duration_seconds)
397
+ return cls(
398
+ started_at_monotonic=float(started_at_monotonic),
399
+ deadline_monotonic=(
400
+ float(deadline_monotonic) if deadline_monotonic is not None else None
401
+ ),
402
+ configured_duration_seconds=configured_duration_seconds,
403
+ source=source,
404
+ finalization_policy=finalization_policy or DeadlineFinalizationPolicy(),
405
+ degradation_policy=degradation_policy or DeadlineDegradationPolicy(),
406
+ _clock=clock,
407
+ )
408
+
409
+ @property
410
+ def enabled(self) -> bool:
411
+ return self.deadline_monotonic is not None
412
+
413
+ def elapsed_seconds(self) -> float:
414
+ return max(0.0, float(self._clock()) - self.started_at_monotonic)
415
+
416
+ def remaining_seconds(self) -> float | None:
417
+ if self.deadline_monotonic is None:
418
+ return None
419
+ return max(0.0, self.deadline_monotonic - float(self._clock()))
420
+
421
+ def is_exhausted(self) -> bool:
422
+ remaining = self.remaining_seconds()
423
+ return remaining is not None and remaining <= 0.0
424
+
425
+ def is_exhausted_after(self, grace_seconds: float = 0.0) -> bool:
426
+ """Return whether the hard deadline plus a bounded grace has elapsed."""
427
+ grace = float(grace_seconds)
428
+ if grace < 0.0 or not math.isfinite(grace):
429
+ raise ValueError("grace_seconds must be a finite number >= 0")
430
+ if self.deadline_monotonic is None:
431
+ return False
432
+ return float(self._clock()) >= self.deadline_monotonic + grace
433
+
434
+ def can_start(self, minimum_remaining_seconds: float = 0.0) -> bool:
435
+ remaining = self.remaining_seconds()
436
+ if remaining is None:
437
+ return True
438
+ return remaining >= max(0.0, float(minimum_remaining_seconds))
439
+
440
+ def clamp_timeout(
441
+ self,
442
+ configured_timeout_seconds: float | None,
443
+ *,
444
+ reserve_seconds: float = DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
445
+ minimum_timeout_seconds: float = MINIMUM_OPERATION_TIMEOUT_SECONDS,
446
+ ) -> float | None:
447
+ remaining = self.remaining_seconds()
448
+ if remaining is None:
449
+ return configured_timeout_seconds
450
+ safe_remaining = remaining - max(0.0, float(reserve_seconds))
451
+ if safe_remaining < max(0.0, float(minimum_timeout_seconds)):
452
+ return None
453
+ if configured_timeout_seconds is None:
454
+ return safe_remaining
455
+ configured = validate_deadline_seconds(
456
+ configured_timeout_seconds,
457
+ key="configured_timeout_seconds",
458
+ )
459
+ return max(minimum_timeout_seconds, min(configured, safe_remaining))
460
+
461
+ def observe_duration(self, operation: str | DeadlineOperation, duration_seconds: float) -> None:
462
+ key = _normalize_operation(operation)
463
+ observation = self._duration_observations.setdefault(
464
+ key,
465
+ DeadlineDurationObservation(),
466
+ )
467
+ observation.record(duration_seconds)
468
+
469
+ def estimated_duration_seconds(
470
+ self,
471
+ operation: str | DeadlineOperation,
472
+ *,
473
+ configured_timeout_seconds: float | None = None,
474
+ default_seconds: float | None = None,
475
+ ) -> float | None:
476
+ key = _normalize_operation(operation)
477
+ observed = self._duration_observations.get(key)
478
+ candidates: list[float] = []
479
+ if observed is not None and observed.count > 0:
480
+ candidates.append(observed.estimate_seconds())
481
+ if default_seconds is not None:
482
+ candidates.append(max(0.0, float(default_seconds)))
483
+ if configured_timeout_seconds is not None:
484
+ configured = validate_deadline_seconds(
485
+ configured_timeout_seconds,
486
+ key="configured_timeout_seconds",
487
+ )
488
+ clamped = self.clamp_timeout(configured)
489
+ candidates.append(configured if clamped is None else clamped)
490
+ if not candidates:
491
+ return None
492
+ return max(candidates)
493
+
494
+ def robust_llm_estimate_snapshot(self) -> dict[str, Any]:
495
+ """Summarize recent per-call LLM latency for nested-work admission."""
496
+ observations = {
497
+ name: observation.telemetry_snapshot()
498
+ for name, observation in sorted(self._duration_observations.items())
499
+ if "llm" in name and observation.count > 0
500
+ }
501
+ estimates = [float(snapshot["estimated_seconds"]) for snapshot in observations.values()]
502
+ return {
503
+ "estimate_strategy": "max_of_recent_operation_medians",
504
+ "estimated_seconds": max(estimates, default=0.0),
505
+ "operations": observations,
506
+ }
507
+
508
+ def finalization_reserve_seconds(
509
+ self,
510
+ policy: DeadlineFinalizationPolicy | None = None,
511
+ ) -> float:
512
+ if not self.enabled:
513
+ return 0.0
514
+ effective = (policy or self.finalization_policy).normalized()
515
+ observed_llm = max(
516
+ (
517
+ observation.estimate_seconds()
518
+ for name, observation in self._duration_observations.items()
519
+ if "llm" in name and observation.count > 0
520
+ ),
521
+ default=0.0,
522
+ )
523
+ observed_verification = max(
524
+ (
525
+ observation.estimate_seconds()
526
+ for name, observation in self._duration_observations.items()
527
+ if ("verification" in name or "verify" in name) and observation.count > 0
528
+ ),
529
+ default=0.0,
530
+ )
531
+ observed_tool = max(
532
+ (
533
+ observation.estimate_seconds()
534
+ for name, observation in self._duration_observations.items()
535
+ if "llm" not in name
536
+ and "verification" not in name
537
+ and "verify" not in name
538
+ and observation.count > 0
539
+ ),
540
+ default=0.0,
541
+ )
542
+ raw_reserve = max(
543
+ effective.minimum_reserve_seconds,
544
+ effective.cleanup_reserve_seconds,
545
+ effective.local_cleanup_seconds,
546
+ observed_llm * effective.llm_latency_multiplier,
547
+ observed_verification * effective.verification_latency_multiplier,
548
+ observed_tool * effective.tool_latency_multiplier,
549
+ )
550
+ if self.configured_duration_seconds is None:
551
+ relative_cap = effective.max_reserve_seconds
552
+ else:
553
+ relative_cap = max(
554
+ MINIMUM_OPERATION_TIMEOUT_SECONDS,
555
+ self.configured_duration_seconds * effective.max_reserve_fraction,
556
+ )
557
+ cap = max(
558
+ MINIMUM_OPERATION_TIMEOUT_SECONDS,
559
+ min(effective.max_reserve_seconds, relative_cap),
560
+ )
561
+ return max(0.0, min(raw_reserve, cap))
562
+
563
+ def normal_work_remaining_seconds(self) -> float | None:
564
+ remaining = self.remaining_seconds()
565
+ if remaining is None:
566
+ return None
567
+ return max(0.0, remaining - self.finalization_reserve_seconds())
568
+
569
+ def budget_span_seconds(self) -> float | None:
570
+ """The budget the degradation fractions are measured against.
571
+
572
+ A deadline built from absolute bounds may carry no configured duration
573
+ (an inherited parent ceiling), so its own span is the fallback.
574
+ """
575
+ if self.deadline_monotonic is None:
576
+ return None
577
+ configured = self.configured_duration_seconds
578
+ if configured is not None and configured > 0:
579
+ return float(configured)
580
+ span = self.deadline_monotonic - self.started_at_monotonic
581
+ return span if span > 0 else None
582
+
583
+ def elapsed_fraction(self) -> float | None:
584
+ span = self.budget_span_seconds()
585
+ if span is None:
586
+ return None
587
+ return max(0.0, self.elapsed_seconds() / span)
588
+
589
+ def degradation_stage(self) -> DeadlinePhase:
590
+ """How far the run has degraded, from elapsed budget alone.
591
+
592
+ This is deliberately independent of the finalization reserve: the
593
+ reserve tracks observed latency and can open at any point past 75%,
594
+ while these stages are fixed fractions of the budget. Keeping them
595
+ separate is what makes the ladder monotonic.
596
+ """
597
+ policy = self.degradation_policy.normalized()
598
+ if not policy.enabled:
599
+ return DeadlinePhase.NORMAL
600
+ fraction = self.elapsed_fraction()
601
+ if fraction is None:
602
+ return DeadlinePhase.NORMAL
603
+ if fraction >= policy.wrap_up_fraction:
604
+ return DeadlinePhase.WRAP_UP
605
+ if fraction >= policy.convergence_fraction:
606
+ return DeadlinePhase.CONVERGENCE
607
+ return DeadlinePhase.NORMAL
608
+
609
+ def degradation_blocked_operations(self) -> frozenset[str]:
610
+ """Operations closed by the current degradation stage, cumulatively."""
611
+ return _DEGRADATION_BLOCKED_OPERATIONS.get(
612
+ self.degradation_stage().value,
613
+ frozenset(),
614
+ )
615
+
616
+ def maybe_enter_degradation_stage(self) -> DeadlinePhase | None:
617
+ """Report a degradation stage the first time it is reached.
618
+
619
+ Returns ``None`` on every later call for the same stage, so a caller can
620
+ log one transition per stage without tracking that itself. A run that
621
+ jumps a stage (one long tool call spanning both thresholds) reports only
622
+ the stage it actually landed in; the restrictions are cumulative either
623
+ way.
624
+ """
625
+ stage = self.degradation_stage()
626
+ if stage is DeadlinePhase.NORMAL:
627
+ return None
628
+ if stage.value in self._degradation_stages_entered:
629
+ return None
630
+ self._degradation_stages_entered[stage.value] = float(self._clock())
631
+ return stage
632
+
633
+ @property
634
+ def degradation_stages_entered(self) -> tuple[str, ...]:
635
+ return tuple(
636
+ stage
637
+ for stage, _entered_at in sorted(
638
+ self._degradation_stages_entered.items(),
639
+ key=lambda item: item[1],
640
+ )
641
+ )
642
+
643
+ def phase(self) -> DeadlinePhase:
644
+ if self.is_exhausted():
645
+ return DeadlinePhase.EXHAUSTED
646
+ remaining = self.remaining_seconds()
647
+ if remaining is None:
648
+ return DeadlinePhase.NORMAL
649
+ if remaining <= self.finalization_reserve_seconds():
650
+ return DeadlinePhase.FINALIZATION_WINDOW
651
+ return self.degradation_stage()
652
+
653
+ def maybe_enter_finalization(self, reason: str = "reserve_reached") -> bool:
654
+ phase = self.phase()
655
+ if phase != DeadlinePhase.FINALIZATION_WINDOW:
656
+ return False
657
+ if self._finalization_reason is None:
658
+ self._finalization_reason = str(reason or "reserve_reached")
659
+ self._finalization_entered_at_monotonic = float(self._clock())
660
+ return True
661
+ return False
662
+
663
+ @property
664
+ def finalization_reason(self) -> str | None:
665
+ if self.phase() == DeadlinePhase.FINALIZATION_WINDOW:
666
+ return self._finalization_reason or "reserve_reached"
667
+ return self._finalization_reason
668
+
669
+ @property
670
+ def finalization_directive_sent(self) -> bool:
671
+ return self._finalization_directive_sent
672
+
673
+ def mark_finalization_directive_sent(self) -> None:
674
+ self._finalization_directive_sent = True
675
+
676
+ @property
677
+ def finalization_llm_started(self) -> bool:
678
+ return self._finalization_llm_started
679
+
680
+ def mark_finalization_llm_started(self) -> None:
681
+ self._finalization_llm_started = True
682
+
683
+ def start_decision(
684
+ self,
685
+ operation: str | DeadlineOperation,
686
+ *,
687
+ minimum_remaining_seconds: float = 0.0,
688
+ estimated_duration_seconds: float | None = None,
689
+ configured_timeout_seconds: float | None = None,
690
+ allow_during_finalization: bool = False,
691
+ ) -> DeadlineStartDecision:
692
+ operation_name = _normalize_operation(operation)
693
+ remaining = self.remaining_seconds()
694
+ reserve = self.finalization_reserve_seconds()
695
+ normal_remaining = self.normal_work_remaining_seconds()
696
+ phase = self.phase()
697
+ minimum = max(0.0, float(minimum_remaining_seconds))
698
+ estimate = estimated_duration_seconds
699
+ if estimate is None:
700
+ estimate = self.estimated_duration_seconds(
701
+ operation_name,
702
+ configured_timeout_seconds=configured_timeout_seconds,
703
+ default_seconds=minimum,
704
+ )
705
+ if remaining is None:
706
+ return DeadlineStartDecision(
707
+ operation=operation_name,
708
+ allowed=True,
709
+ phase=phase,
710
+ reason="deadline_unconfigured",
711
+ remaining_seconds=None,
712
+ normal_work_remaining_seconds=None,
713
+ finalization_reserve_seconds=reserve,
714
+ minimum_required_seconds=minimum,
715
+ estimated_duration_seconds=estimate,
716
+ )
717
+ if phase == DeadlinePhase.EXHAUSTED:
718
+ return DeadlineStartDecision(
719
+ operation=operation_name,
720
+ allowed=False,
721
+ phase=phase,
722
+ reason="deadline_exhausted",
723
+ remaining_seconds=remaining,
724
+ normal_work_remaining_seconds=normal_remaining,
725
+ finalization_reserve_seconds=reserve,
726
+ minimum_required_seconds=minimum,
727
+ estimated_duration_seconds=estimate,
728
+ )
729
+ if remaining < minimum:
730
+ return DeadlineStartDecision(
731
+ operation=operation_name,
732
+ allowed=False,
733
+ phase=phase,
734
+ reason="insufficient_hard_remaining",
735
+ remaining_seconds=remaining,
736
+ normal_work_remaining_seconds=normal_remaining,
737
+ finalization_reserve_seconds=reserve,
738
+ minimum_required_seconds=minimum,
739
+ estimated_duration_seconds=estimate,
740
+ )
741
+ if estimate is not None and estimate > remaining:
742
+ return DeadlineStartDecision(
743
+ operation=operation_name,
744
+ allowed=False,
745
+ phase=phase,
746
+ reason="insufficient_hard_remaining",
747
+ remaining_seconds=remaining,
748
+ normal_work_remaining_seconds=normal_remaining,
749
+ finalization_reserve_seconds=reserve,
750
+ minimum_required_seconds=minimum,
751
+ estimated_duration_seconds=estimate,
752
+ )
753
+ degradation_blocked = self.degradation_blocked_operations()
754
+ if phase == DeadlinePhase.FINALIZATION_WINDOW:
755
+ blocked = operation_name in _FINALIZATION_BLOCKED_OPERATIONS
756
+ if blocked and not allow_during_finalization:
757
+ return DeadlineStartDecision(
758
+ operation=operation_name,
759
+ allowed=False,
760
+ phase=phase,
761
+ reason="finalization_disallows_operation",
762
+ remaining_seconds=remaining,
763
+ normal_work_remaining_seconds=normal_remaining,
764
+ finalization_reserve_seconds=reserve,
765
+ minimum_required_seconds=minimum,
766
+ estimated_duration_seconds=estimate,
767
+ )
768
+ if operation_name in degradation_blocked:
769
+ # The finalization carve-out lets an operation finish the run,
770
+ # but it cannot reopen something an earlier stage already
771
+ # closed: the reserve window can begin before wrap-up, so
772
+ # without this an edit blocked at 90% would be legal again at
773
+ # 98%.
774
+ return DeadlineStartDecision(
775
+ operation=operation_name,
776
+ allowed=False,
777
+ phase=phase,
778
+ reason="budget_degradation_disallows_operation",
779
+ remaining_seconds=remaining,
780
+ normal_work_remaining_seconds=normal_remaining,
781
+ finalization_reserve_seconds=reserve,
782
+ minimum_required_seconds=minimum,
783
+ estimated_duration_seconds=estimate,
784
+ )
785
+ return DeadlineStartDecision(
786
+ operation=operation_name,
787
+ allowed=True,
788
+ phase=phase,
789
+ reason="finalization_allowed",
790
+ remaining_seconds=remaining,
791
+ normal_work_remaining_seconds=normal_remaining,
792
+ finalization_reserve_seconds=reserve,
793
+ minimum_required_seconds=minimum,
794
+ estimated_duration_seconds=estimate,
795
+ )
796
+ if operation_name in degradation_blocked:
797
+ return DeadlineStartDecision(
798
+ operation=operation_name,
799
+ allowed=False,
800
+ phase=phase,
801
+ reason="budget_degradation_disallows_operation",
802
+ remaining_seconds=remaining,
803
+ normal_work_remaining_seconds=normal_remaining,
804
+ finalization_reserve_seconds=reserve,
805
+ minimum_required_seconds=minimum,
806
+ estimated_duration_seconds=estimate,
807
+ )
808
+ if estimate is not None and normal_remaining is not None and estimate > normal_remaining:
809
+ return DeadlineStartDecision(
810
+ operation=operation_name,
811
+ allowed=False,
812
+ phase=phase,
813
+ reason="insufficient_normal_work_remaining",
814
+ remaining_seconds=remaining,
815
+ normal_work_remaining_seconds=normal_remaining,
816
+ finalization_reserve_seconds=reserve,
817
+ minimum_required_seconds=minimum,
818
+ estimated_duration_seconds=estimate,
819
+ )
820
+ return DeadlineStartDecision(
821
+ operation=operation_name,
822
+ allowed=True,
823
+ phase=phase,
824
+ reason="normal_work_allowed",
825
+ remaining_seconds=remaining,
826
+ normal_work_remaining_seconds=normal_remaining,
827
+ finalization_reserve_seconds=reserve,
828
+ minimum_required_seconds=minimum,
829
+ estimated_duration_seconds=estimate,
830
+ )
831
+
832
+ def telemetry_snapshot(self) -> dict[str, Any]:
833
+ remaining = self.remaining_seconds()
834
+ phase = self.phase()
835
+ normal_work_remaining = self.normal_work_remaining_seconds()
836
+ elapsed_fraction = self.elapsed_fraction()
837
+ return {
838
+ "enabled": self.enabled,
839
+ "configured_seconds": self.configured_duration_seconds,
840
+ "source": _normalize_source(self.source),
841
+ "deadline_monotonic": self.deadline_monotonic,
842
+ "elapsed_seconds": round(self.elapsed_seconds(), 6),
843
+ "elapsed_fraction": (None if elapsed_fraction is None else round(elapsed_fraction, 6)),
844
+ "degradation_stage": self.degradation_stage().value,
845
+ "degradation_stages_entered": list(self.degradation_stages_entered),
846
+ "degradation_policy": self.degradation_policy.as_payload(),
847
+ "degradation_blocked_operations": sorted(self.degradation_blocked_operations()),
848
+ "remaining_seconds": None if remaining is None else round(remaining, 6),
849
+ "normal_work_remaining_seconds": (
850
+ None if normal_work_remaining is None else round(normal_work_remaining, 6)
851
+ ),
852
+ "finalization_reserve_seconds": round(self.finalization_reserve_seconds(), 6),
853
+ "phase": phase.value,
854
+ "finalization_reason": self.finalization_reason,
855
+ "finalization_directive_sent": self.finalization_directive_sent,
856
+ "finalization_llm_started": self.finalization_llm_started,
857
+ "exhausted": self.is_exhausted(),
858
+ "duration_observations": {
859
+ key: observation.telemetry_snapshot()
860
+ for key, observation in sorted(self._duration_observations.items())
861
+ },
862
+ }
863
+
864
+
865
+ def derive_subagent_deadline(
866
+ parent_deadline: ExecutionDeadline | None,
867
+ fallback_seconds: float,
868
+ ) -> ExecutionDeadline:
869
+ """Return the earlier of an active parent ceiling and a finite child fallback."""
870
+
871
+ fallback = validate_deadline_seconds(fallback_seconds, key="subagent_timeout_s")
872
+ if parent_deadline is None:
873
+ return ExecutionDeadline.from_duration(
874
+ fallback,
875
+ source=DeadlineSource.SUBAGENT_FALLBACK,
876
+ )
877
+
878
+ clock = parent_deadline._clock
879
+ now = float(clock())
880
+ fallback_deadline_monotonic = now + fallback
881
+ if parent_deadline.enabled and parent_deadline.phase() in {
882
+ DeadlinePhase.FINALIZATION_WINDOW,
883
+ DeadlinePhase.EXHAUSTED,
884
+ }:
885
+ return parent_deadline
886
+ if (
887
+ parent_deadline.deadline_monotonic is not None
888
+ and parent_deadline.deadline_monotonic <= fallback_deadline_monotonic
889
+ ):
890
+ return parent_deadline
891
+
892
+ return ExecutionDeadline.from_absolute(
893
+ started_at_monotonic=now,
894
+ deadline_monotonic=fallback_deadline_monotonic,
895
+ configured_duration_seconds=fallback,
896
+ clock=clock,
897
+ source=DeadlineSource.SUBAGENT_FALLBACK,
898
+ finalization_policy=parent_deadline.finalization_policy,
899
+ degradation_policy=parent_deadline.degradation_policy,
900
+ )
901
+
902
+
903
+ def _normalize_operation(operation: str | DeadlineOperation) -> str:
904
+ raw = getattr(operation, "value", operation)
905
+ return str(raw or "operation").strip().lower() or "operation"
906
+
907
+
908
+ def _normalize_source(source: DeadlineSource | str) -> str:
909
+ raw = getattr(source, "value", source)
910
+ cleaned = str(raw or "").strip().lower()
911
+ return cleaned or DeadlineSource.UNKNOWN.value
912
+
913
+
914
+ def resolve_llm_attempt_timeout_ceiling_seconds() -> float | None:
915
+ """Per-attempt LLM timeout ceiling; ``None`` disables the ceiling.
916
+
917
+ ``ALYSIS_LLM_ATTEMPT_TIMEOUT_S`` overrides the default. Off-words
918
+ (off/unlimited/none/never/0/disabled) disable the ceiling entirely,
919
+ restoring the pre-ceiling behavior where only the run deadline bounds an
920
+ attempt.
921
+ """
922
+ from .branding import env_get
923
+
924
+ raw = env_get(_LLM_ATTEMPT_TIMEOUT_ENV)
925
+ if raw is None:
926
+ return DEFAULT_LLM_ATTEMPT_TIMEOUT_CEILING_SECONDS
927
+ cleaned = str(raw).strip().lower()
928
+ if not cleaned:
929
+ return DEFAULT_LLM_ATTEMPT_TIMEOUT_CEILING_SECONDS
930
+ if cleaned in _ATTEMPT_CEILING_OFF_WORDS:
931
+ return None
932
+ try:
933
+ value = float(cleaned)
934
+ except ValueError:
935
+ return DEFAULT_LLM_ATTEMPT_TIMEOUT_CEILING_SECONDS
936
+ if value <= 0 or not math.isfinite(value):
937
+ return None
938
+ return value
939
+
940
+
941
+ def deadline_timeout_or_raise(
942
+ deadline: ExecutionDeadline | None,
943
+ configured_timeout_seconds: float | None,
944
+ *,
945
+ reserve_seconds: float = DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
946
+ minimum_timeout_seconds: float = MINIMUM_OPERATION_TIMEOUT_SECONDS,
947
+ operation: str = "operation",
948
+ ) -> float | None:
949
+ if deadline is None:
950
+ return configured_timeout_seconds
951
+ timeout = deadline.clamp_timeout(
952
+ configured_timeout_seconds,
953
+ reserve_seconds=reserve_seconds,
954
+ minimum_timeout_seconds=minimum_timeout_seconds,
955
+ )
956
+ if timeout is None:
957
+ raise DeadlineExhausted(f"run deadline exhausted before {operation}")
958
+ return timeout
959
+
960
+
961
+ @contextmanager
962
+ def temporarily_clamp_client_timeout(
963
+ client: Any,
964
+ deadline: ExecutionDeadline | None,
965
+ *,
966
+ reserve_seconds: float = DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
967
+ minimum_timeout_seconds: float = MINIMUM_OPERATION_TIMEOUT_SECONDS,
968
+ operation: str = "llm_call",
969
+ ) -> Iterator[None]:
970
+ if deadline is None or not hasattr(client, "timeout_s"):
971
+ yield
972
+ return
973
+ original = client.timeout_s
974
+ original_retry_deadline = getattr(client, "_provider_retry_deadline_allows", _MISSING)
975
+ original_stream_deadline = getattr(client, "_stream_deadline_exhausted", _MISSING)
976
+ attempt_ceiling = resolve_llm_attempt_timeout_ceiling_seconds()
977
+ configured = float(original) if original is not None else None
978
+ if attempt_ceiling is not None:
979
+ configured = attempt_ceiling if configured is None else min(configured, attempt_ceiling)
980
+ # Outside the finalization window, a single attempt must also leave the
981
+ # finalization reserve untouched, so a slow provider times out with enough
982
+ # budget left for the wrap-up call. Inside the window (or when the
983
+ # reserve-aware bound would be unusably small) fall back to the plain
984
+ # cleanup-reserve clamp, which is the pre-existing behavior.
985
+ timeout: float | None = None
986
+ if deadline.phase() not in (DeadlinePhase.FINALIZATION_WINDOW, DeadlinePhase.EXHAUSTED):
987
+ timeout = deadline.clamp_timeout(
988
+ configured,
989
+ reserve_seconds=(
990
+ max(0.0, float(reserve_seconds)) + deadline.finalization_reserve_seconds()
991
+ ),
992
+ minimum_timeout_seconds=minimum_timeout_seconds,
993
+ )
994
+ if timeout is None:
995
+ timeout = deadline_timeout_or_raise(
996
+ deadline,
997
+ configured,
998
+ reserve_seconds=reserve_seconds,
999
+ minimum_timeout_seconds=minimum_timeout_seconds,
1000
+ operation=operation,
1001
+ )
1002
+
1003
+ def _provider_retry_deadline_allows(wait_seconds: float) -> bool:
1004
+ retry_window_seconds = max(0.0, float(wait_seconds)) + max(
1005
+ 0.0,
1006
+ float(minimum_timeout_seconds),
1007
+ )
1008
+ in_finalization = deadline.phase() == DeadlinePhase.FINALIZATION_WINDOW
1009
+ decision = deadline.start_decision(
1010
+ DeadlineOperation.PROVIDER_RETRY_SLEEP,
1011
+ minimum_remaining_seconds=retry_window_seconds,
1012
+ estimated_duration_seconds=retry_window_seconds,
1013
+ allow_during_finalization=in_finalization,
1014
+ )
1015
+ if not decision.allowed:
1016
+ return False
1017
+ # The client timeout was computed when the call started; by retry time
1018
+ # far less budget may remain. A retry is only allowed when the sleep
1019
+ # plus a minimally useful attempt still fits, and the next attempt is
1020
+ # shrunk to the remaining budget so it cannot outlive the deadline
1021
+ # (each provider attempt re-reads client.timeout_s). Outside the
1022
+ # finalization window the budget also excludes the finalization
1023
+ # reserve, mirroring the entry-time clamp: the reserve belongs to the
1024
+ # wrap-up call, not to provider retries.
1025
+ remaining = deadline.remaining_seconds()
1026
+ if remaining is not None:
1027
+ reserve_guard = max(0.0, float(reserve_seconds))
1028
+ if not in_finalization:
1029
+ reserve_guard += deadline.finalization_reserve_seconds()
1030
+ budget_after_sleep = remaining - max(0.0, float(wait_seconds)) - reserve_guard
1031
+ floor = max(float(minimum_timeout_seconds), MINIMUM_OPERATION_TIMEOUT_SECONDS)
1032
+ if budget_after_sleep < floor:
1033
+ return False
1034
+ current_timeout = getattr(client, "timeout_s", None)
1035
+ if current_timeout is None or float(current_timeout) > budget_after_sleep:
1036
+ client.timeout_s = budget_after_sleep
1037
+ return True
1038
+
1039
+ client.timeout_s = timeout
1040
+ client._provider_retry_deadline_allows = _provider_retry_deadline_allows
1041
+
1042
+ def _stream_deadline_exhausted() -> bool:
1043
+ return deadline.is_exhausted_after(
1044
+ float(getattr(client, "inflight_deadline_grace_s", 10.0))
1045
+ )
1046
+
1047
+ client._stream_deadline_exhausted = _stream_deadline_exhausted
1048
+ try:
1049
+ yield
1050
+ finally:
1051
+ client.timeout_s = original
1052
+ if original_retry_deadline is _MISSING:
1053
+ try:
1054
+ delattr(client, "_provider_retry_deadline_allows")
1055
+ except AttributeError:
1056
+ pass
1057
+ else:
1058
+ client._provider_retry_deadline_allows = original_retry_deadline
1059
+ if original_stream_deadline is _MISSING:
1060
+ try:
1061
+ delattr(client, "_stream_deadline_exhausted")
1062
+ except AttributeError:
1063
+ pass
1064
+ else:
1065
+ client._stream_deadline_exhausted = original_stream_deadline