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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- 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
|