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