alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,4728 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from collections.abc import Callable, Collection
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
from time import perf_counter
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
19
|
+
|
|
20
|
+
from ..approval_scope import (
|
|
21
|
+
exact_command_scope,
|
|
22
|
+
exact_file_set_scope,
|
|
23
|
+
exact_verify_command_set_scope,
|
|
24
|
+
)
|
|
25
|
+
from ..config import (
|
|
26
|
+
AppConfig,
|
|
27
|
+
ConfigError,
|
|
28
|
+
resolve_web_search_policy,
|
|
29
|
+
resolve_web_tools_enabled,
|
|
30
|
+
)
|
|
31
|
+
from ..context.tool_schema_budgeter import (
|
|
32
|
+
CUSTOM_MCP_SCHEMA_FAMILIES,
|
|
33
|
+
DEFAULT_CUSTOM_MCP_DESCRIPTION_MAX_CHARS,
|
|
34
|
+
compact_custom_mcp_tool_parameters,
|
|
35
|
+
)
|
|
36
|
+
from ..crash_diagnostics import CrashDiagnosticLogger
|
|
37
|
+
from ..custom_tools import (
|
|
38
|
+
CustomToolDiscoveryResult,
|
|
39
|
+
CustomToolSessionState,
|
|
40
|
+
CustomToolSpec,
|
|
41
|
+
build_custom_tool_session_state,
|
|
42
|
+
run_custom_tool,
|
|
43
|
+
)
|
|
44
|
+
from ..diff_paths import iter_patch_paths
|
|
45
|
+
from ..dispatch_timing import (
|
|
46
|
+
DISPATCH_OVERHEAD_OPERATION,
|
|
47
|
+
DispatchOverheadAccount,
|
|
48
|
+
run_cancellable_wait,
|
|
49
|
+
)
|
|
50
|
+
from ..durable_service_manager import DurableServiceManager, ProcessOwnership
|
|
51
|
+
from ..edit_discipline import EditDisciplineState
|
|
52
|
+
from ..execution_deadline import (
|
|
53
|
+
DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
|
|
54
|
+
MINIMUM_TOOL_START_SECONDS,
|
|
55
|
+
DeadlineExhausted,
|
|
56
|
+
DeadlineOperation,
|
|
57
|
+
DeadlinePhase,
|
|
58
|
+
ExecutionDeadline,
|
|
59
|
+
deadline_timeout_or_raise,
|
|
60
|
+
)
|
|
61
|
+
from ..extensions.activation import ActivationDecision
|
|
62
|
+
from ..extensions.models import normalize_extension_id
|
|
63
|
+
from ..host_actions import (
|
|
64
|
+
HOST_ACTION_TOOL_NAMES,
|
|
65
|
+
HostActionError,
|
|
66
|
+
HostActionHandler,
|
|
67
|
+
normalize_host_action_arguments,
|
|
68
|
+
normalized_host_action_capabilities,
|
|
69
|
+
)
|
|
70
|
+
from ..ide.managed_browser import (
|
|
71
|
+
BrowserArtifact,
|
|
72
|
+
BrowserError,
|
|
73
|
+
BrowserSessionStatus,
|
|
74
|
+
ManagedBrowserService,
|
|
75
|
+
)
|
|
76
|
+
from ..ide.protocol import redact_secrets
|
|
77
|
+
from ..mcp.manager import ForgeTaskScopedMcpManager, McpManager
|
|
78
|
+
from ..mcp.models import ResolvedMcpConfig, ResolvedMcpServer
|
|
79
|
+
from ..model_registry import ModelRegistry
|
|
80
|
+
from ..personas import is_persona_name, persona_modes_enabled
|
|
81
|
+
from ..pipeline_facts import resolve_pipeline_stage_status
|
|
82
|
+
from ..policy import evaluate_shell_command
|
|
83
|
+
from ..process_reaping import ProcessGroupRegistry
|
|
84
|
+
from ..runtime_kind import RuntimeKind, normalize_runtime_kind
|
|
85
|
+
from ..service_persistence import (
|
|
86
|
+
PersistentServiceRecord,
|
|
87
|
+
PersistentServiceRegistry,
|
|
88
|
+
check_service,
|
|
89
|
+
readiness_spec_for_port,
|
|
90
|
+
resolve_probe_port,
|
|
91
|
+
)
|
|
92
|
+
from ..session_store import SessionStore
|
|
93
|
+
from ..skills import SkillBundle, SkillReadError, read_skill_bundle_file, resolve_skill_by_name
|
|
94
|
+
from ..subagents import (
|
|
95
|
+
EDIT_CAPABLE_SUBAGENT_TOOL_NAMES,
|
|
96
|
+
SubagentDefinition,
|
|
97
|
+
helper_subagent_names,
|
|
98
|
+
routable_subagent_names,
|
|
99
|
+
)
|
|
100
|
+
from ..surface import (
|
|
101
|
+
ApprovalRequest,
|
|
102
|
+
NoopSurface,
|
|
103
|
+
PatchEvent,
|
|
104
|
+
)
|
|
105
|
+
from ..surface.base import Surface
|
|
106
|
+
from ..task_scope import (
|
|
107
|
+
ancestor_directory_scope_patterns,
|
|
108
|
+
is_non_material_untracked_path,
|
|
109
|
+
scope_path_matches_pattern,
|
|
110
|
+
)
|
|
111
|
+
from ..terminal_manager import ProcessOutputSnapshot, TerminalLimitError, TerminalManager
|
|
112
|
+
from ..tools.artifacts import SessionArtifactReadError, session_artifact_read
|
|
113
|
+
from ..tools.availability import mark_available, mark_unavailable, register_tool_availability
|
|
114
|
+
from ..tools.fs import (
|
|
115
|
+
FsError,
|
|
116
|
+
StaleFileError,
|
|
117
|
+
assert_file_precondition,
|
|
118
|
+
capture_file_precondition,
|
|
119
|
+
classify_sensitive_path,
|
|
120
|
+
fs_copy,
|
|
121
|
+
fs_delete,
|
|
122
|
+
fs_list,
|
|
123
|
+
fs_mkdir,
|
|
124
|
+
fs_move,
|
|
125
|
+
fs_read,
|
|
126
|
+
fs_read_lines,
|
|
127
|
+
prepare_fs_edit,
|
|
128
|
+
prepare_fs_write,
|
|
129
|
+
write_prepared_fs_edit,
|
|
130
|
+
write_prepared_fs_write,
|
|
131
|
+
)
|
|
132
|
+
from ..tools.git import git_apply_patch, git_diff, git_history, git_status
|
|
133
|
+
from ..tools.history import history_search
|
|
134
|
+
from ..tools.image_generation import (
|
|
135
|
+
ImageGenerationError,
|
|
136
|
+
generate_images,
|
|
137
|
+
plan_image_output_paths,
|
|
138
|
+
)
|
|
139
|
+
from ..tools.registry import (
|
|
140
|
+
REPORT_BLOCKER_MAX_MESSAGE_CHARS,
|
|
141
|
+
built_in_subagent_tool_names,
|
|
142
|
+
copied_tool_parameters,
|
|
143
|
+
iter_builtin_tool_metadata,
|
|
144
|
+
require_builtin_tool_metadata,
|
|
145
|
+
)
|
|
146
|
+
from ..tools.repo_map import repo_map
|
|
147
|
+
from ..tools.search import search_rg
|
|
148
|
+
from ..tools.shell import shell_run
|
|
149
|
+
from ..tools.symbols import symbol_search
|
|
150
|
+
from ..tools.test_discovery import test_discover
|
|
151
|
+
from ..tools.web import web_fetch
|
|
152
|
+
from ..tools.web_search import WebSearchError, resolve_web_search_runtime_status, web_search
|
|
153
|
+
from ..usage_tracker import UsageSummary
|
|
154
|
+
from ..verification_command_analysis import (
|
|
155
|
+
VerificationCommandEvidentiaryCapability,
|
|
156
|
+
analyze_verification_command,
|
|
157
|
+
)
|
|
158
|
+
from ..verify_gate import (
|
|
159
|
+
ResolvedVerifyCommands,
|
|
160
|
+
VerifyError,
|
|
161
|
+
is_authoritative_verify_command_selection,
|
|
162
|
+
resolve_verify_commands,
|
|
163
|
+
run_task_verification,
|
|
164
|
+
trusted_shell_expression_command_set,
|
|
165
|
+
validation_errors_for_selection,
|
|
166
|
+
verification_command_specs_payload,
|
|
167
|
+
verification_selection_payload,
|
|
168
|
+
verify_run_result_to_payload,
|
|
169
|
+
)
|
|
170
|
+
from ..web_research import (
|
|
171
|
+
build_web_fetch_recovery_search_query,
|
|
172
|
+
canonicalize_web_url_input,
|
|
173
|
+
normalize_web_url,
|
|
174
|
+
)
|
|
175
|
+
from ..workspace_context import resolve_workspace_context
|
|
176
|
+
from . import _patchable
|
|
177
|
+
from .errors import AgentRuntimeError, ApprovalDeclinedError, SessionWorkdirError
|
|
178
|
+
from .mutation_classification import classify_mutation_paths
|
|
179
|
+
from .prompt_context import (
|
|
180
|
+
_MODE_FULLACCESS,
|
|
181
|
+
ALWAYS_PROTECTED_WRITE_PREFIXES,
|
|
182
|
+
_component_plugin_allowed,
|
|
183
|
+
_normalize_rel_match_path,
|
|
184
|
+
_normalize_workspace_relpath,
|
|
185
|
+
_normalized_authoritative_verify_commands,
|
|
186
|
+
_normalized_verify_commands,
|
|
187
|
+
_paths_require_verification,
|
|
188
|
+
_PluginActivationIndex,
|
|
189
|
+
_workspace_relpath_for_path,
|
|
190
|
+
resolve_workdir_relpath_within_workspace,
|
|
191
|
+
)
|
|
192
|
+
from .read_ledger import SessionReadLedger
|
|
193
|
+
from .steering import SteerInbox
|
|
194
|
+
from .subagent_execution import (
|
|
195
|
+
_AUTHORITATIVE_SUBAGENT_FINAL_TEXT_SOURCES as _AUTHORITATIVE_SUBAGENT_FINAL_TEXT_SOURCES,
|
|
196
|
+
)
|
|
197
|
+
from .subagent_execution import (
|
|
198
|
+
_MODE_PERMISSIVENESS_ORDER as _MODE_PERMISSIVENESS_ORDER,
|
|
199
|
+
)
|
|
200
|
+
from .subagent_execution import (
|
|
201
|
+
_MODE_PERMISSIVENESS_RANK as _MODE_PERMISSIVENESS_RANK,
|
|
202
|
+
)
|
|
203
|
+
from .subagent_execution import (
|
|
204
|
+
_ROUTING_MODE_CODE_ONLY as _ROUTING_MODE_CODE_ONLY,
|
|
205
|
+
)
|
|
206
|
+
from .subagent_execution import (
|
|
207
|
+
_SUBAGENT_CANCELLATION_TOKEN_ARG as _SUBAGENT_CANCELLATION_TOKEN_ARG,
|
|
208
|
+
)
|
|
209
|
+
from .subagent_execution import (
|
|
210
|
+
ChildRunRegistry,
|
|
211
|
+
ChildScheduler,
|
|
212
|
+
SubagentLauncher,
|
|
213
|
+
)
|
|
214
|
+
from .subagent_execution import (
|
|
215
|
+
_create_session_for_subagent as _create_session_for_subagent,
|
|
216
|
+
)
|
|
217
|
+
from .subagent_execution import (
|
|
218
|
+
_latest_subagent_message_text as _latest_subagent_message_text,
|
|
219
|
+
)
|
|
220
|
+
from .subagent_execution import (
|
|
221
|
+
_latest_subagent_store_final_text as _latest_subagent_store_final_text,
|
|
222
|
+
)
|
|
223
|
+
from .subagent_execution import (
|
|
224
|
+
_persist_internal_subagent_report as _persist_internal_subagent_report,
|
|
225
|
+
)
|
|
226
|
+
from .subagent_execution import (
|
|
227
|
+
_resolve_subagent_final_text as _resolve_subagent_final_text,
|
|
228
|
+
)
|
|
229
|
+
from .subagent_execution import (
|
|
230
|
+
_subagent_artifact_requirement as _subagent_artifact_requirement,
|
|
231
|
+
)
|
|
232
|
+
from .subagent_execution import (
|
|
233
|
+
_subagent_exact_tool_catalog_message as _subagent_exact_tool_catalog_message,
|
|
234
|
+
)
|
|
235
|
+
from .subagent_execution import (
|
|
236
|
+
_subagent_final_report_problem as _subagent_final_report_problem,
|
|
237
|
+
)
|
|
238
|
+
from .subagent_execution import (
|
|
239
|
+
_subagent_success_event_types as _subagent_success_event_types,
|
|
240
|
+
)
|
|
241
|
+
from .subagent_execution import (
|
|
242
|
+
_subagent_termination_kind as _subagent_termination_kind,
|
|
243
|
+
)
|
|
244
|
+
from .subagent_workspace import SubagentWorkspaceProvider
|
|
245
|
+
from .verification_commands import (
|
|
246
|
+
_expand_simple_verify_command_chain,
|
|
247
|
+
_has_disallowed_shell_control_flow,
|
|
248
|
+
_verify_run_commands_match_effective_contract,
|
|
249
|
+
)
|
|
250
|
+
from .verification_evidence import (
|
|
251
|
+
VerificationEvidence,
|
|
252
|
+
VerificationEvidenceCategory,
|
|
253
|
+
_evidence_v2_enabled,
|
|
254
|
+
classify_verification_evidence,
|
|
255
|
+
command_is_qualifying_execution_evidence,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
_turn_snapshot = importlib.import_module("alysis_code.agent.turn.snapshot")
|
|
259
|
+
_SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX = _turn_snapshot._SHELL_MUTATION_SNAPSHOT_METADATA_PREFIX
|
|
260
|
+
_detect_command_mutation_paths = _turn_snapshot._detect_command_mutation_paths
|
|
261
|
+
_list_git_workspace_snapshot_paths = _turn_snapshot._list_git_workspace_snapshot_paths
|
|
262
|
+
_normalize_snapshot_ignore_paths = _turn_snapshot._normalize_snapshot_ignore_paths
|
|
263
|
+
_path_matches_snapshot_ignore = _turn_snapshot._path_matches_snapshot_ignore
|
|
264
|
+
_run_with_command_mutation_detection = _turn_snapshot._run_with_command_mutation_detection
|
|
265
|
+
_snapshot_workspace_for_command_mutation_detection = (
|
|
266
|
+
_turn_snapshot._snapshot_workspace_for_command_mutation_detection
|
|
267
|
+
)
|
|
268
|
+
_walk_workspace_snapshot_paths = _turn_snapshot._walk_workspace_snapshot_paths
|
|
269
|
+
_workspace_snapshot_signature = _turn_snapshot._workspace_snapshot_signature
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _call_with_optional_kwargs(
|
|
273
|
+
func: Callable[..., Any],
|
|
274
|
+
*,
|
|
275
|
+
required_kwargs: dict[str, Any],
|
|
276
|
+
optional_kwargs: dict[str, Any],
|
|
277
|
+
) -> Any:
|
|
278
|
+
try:
|
|
279
|
+
signature = inspect.signature(func)
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
return func(**required_kwargs, **optional_kwargs)
|
|
282
|
+
accepts_var_kwargs = any(
|
|
283
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
284
|
+
for parameter in signature.parameters.values()
|
|
285
|
+
)
|
|
286
|
+
accepted_kwargs = dict(required_kwargs)
|
|
287
|
+
for key, value in optional_kwargs.items():
|
|
288
|
+
if accepts_var_kwargs or key in signature.parameters:
|
|
289
|
+
accepted_kwargs[key] = value
|
|
290
|
+
return func(**accepted_kwargs)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _command_mutation_metadata(
|
|
294
|
+
*,
|
|
295
|
+
root: Path,
|
|
296
|
+
touched_repo_paths: list[str],
|
|
297
|
+
command_was_verification: bool = False,
|
|
298
|
+
) -> dict[str, Any]:
|
|
299
|
+
classifications = classify_mutation_paths(
|
|
300
|
+
touched_repo_paths,
|
|
301
|
+
root=root,
|
|
302
|
+
command_was_verification=command_was_verification,
|
|
303
|
+
)
|
|
304
|
+
material = [item.path for item in classifications if item.is_material]
|
|
305
|
+
benign = [item.path for item in classifications if not item.is_material]
|
|
306
|
+
out: dict[str, Any] = {
|
|
307
|
+
"mutation_path_classifications": [item.as_payload() for item in classifications],
|
|
308
|
+
}
|
|
309
|
+
if material:
|
|
310
|
+
out["material_touched_repo_paths"] = material
|
|
311
|
+
if benign:
|
|
312
|
+
out["benign_runtime_paths"] = benign
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _verification_relevant_material_paths(paths: list[str]) -> list[str]:
|
|
317
|
+
if not paths or not _paths_require_verification(set(paths)):
|
|
318
|
+
return []
|
|
319
|
+
return list(paths)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _aggregate_tool_evidence_payload(records: list[VerificationEvidence]) -> dict[str, Any]:
|
|
323
|
+
if not records:
|
|
324
|
+
return {
|
|
325
|
+
"verification_evidence_category": VerificationEvidenceCategory.NOT_VERIFICATION.value,
|
|
326
|
+
"verification_evidence_reason": "no_verification_evidence",
|
|
327
|
+
"verification_evidence_allowed": False,
|
|
328
|
+
"verification_evidence_supplemental_only": False,
|
|
329
|
+
}
|
|
330
|
+
priority = {
|
|
331
|
+
VerificationEvidenceCategory.AUTHORITATIVE: 0,
|
|
332
|
+
VerificationEvidenceCategory.REPO_NATIVE: 1,
|
|
333
|
+
VerificationEvidenceCategory.TASK_ACCEPTANCE: 2,
|
|
334
|
+
VerificationEvidenceCategory.NOT_VERIFICATION: 3,
|
|
335
|
+
}
|
|
336
|
+
primary = sorted(records, key=lambda item: priority[item.category])[0]
|
|
337
|
+
allowed = all(
|
|
338
|
+
item.allowed_to_satisfy_contract
|
|
339
|
+
for item in records
|
|
340
|
+
if item.category != VerificationEvidenceCategory.NOT_VERIFICATION
|
|
341
|
+
)
|
|
342
|
+
if any(item.category == VerificationEvidenceCategory.NOT_VERIFICATION for item in records):
|
|
343
|
+
allowed = False
|
|
344
|
+
return {
|
|
345
|
+
"verification_evidence_category": primary.category.value,
|
|
346
|
+
"verification_evidence_reason": (
|
|
347
|
+
primary.reason
|
|
348
|
+
if allowed
|
|
349
|
+
else next(
|
|
350
|
+
(item.reason for item in records if not item.allowed_to_satisfy_contract),
|
|
351
|
+
primary.reason,
|
|
352
|
+
)
|
|
353
|
+
),
|
|
354
|
+
"verification_evidence_allowed": allowed,
|
|
355
|
+
"verification_evidence_supplemental_only": all(item.supplemental_only for item in records),
|
|
356
|
+
"verification_evidence_records": [item.as_payload() for item in records],
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _custom_tool_plugin_id(
|
|
361
|
+
tool: CustomToolSpec,
|
|
362
|
+
index: _PluginActivationIndex,
|
|
363
|
+
) -> str | None:
|
|
364
|
+
parts = PurePosixPath(tool.relative_tool_path).parts
|
|
365
|
+
if len(parts) >= 2 and parts[0] == "plugins":
|
|
366
|
+
return index.slug_to_plugin_id.get(parts[1])
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _filter_custom_tool_session_state_for_plugins(
|
|
371
|
+
*,
|
|
372
|
+
state: CustomToolSessionState,
|
|
373
|
+
activation_decision: ActivationDecision,
|
|
374
|
+
index: _PluginActivationIndex,
|
|
375
|
+
) -> tuple[CustomToolSessionState, Counter[str]]:
|
|
376
|
+
dropped_counts: Counter[str] = Counter()
|
|
377
|
+
keep_cache: dict[str, bool] = {}
|
|
378
|
+
|
|
379
|
+
def keep(tool: CustomToolSpec) -> bool:
|
|
380
|
+
cache_key = os.fspath(tool.source_path)
|
|
381
|
+
if cache_key in keep_cache:
|
|
382
|
+
return keep_cache[cache_key]
|
|
383
|
+
allowed = _component_plugin_allowed(
|
|
384
|
+
_custom_tool_plugin_id(tool, index),
|
|
385
|
+
activation_decision,
|
|
386
|
+
dropped_counts,
|
|
387
|
+
)
|
|
388
|
+
keep_cache[cache_key] = allowed
|
|
389
|
+
return allowed
|
|
390
|
+
|
|
391
|
+
filtered_discovery = CustomToolDiscoveryResult(
|
|
392
|
+
global_tools=tuple(tool for tool in state.discovery.global_tools if keep(tool)),
|
|
393
|
+
project_tools=tuple(tool for tool in state.discovery.project_tools if keep(tool)),
|
|
394
|
+
effective_tools=tuple(tool for tool in state.discovery.effective_tools if keep(tool)),
|
|
395
|
+
shadowed_tools=tuple(tool for tool in state.discovery.shadowed_tools if keep(tool)),
|
|
396
|
+
issues=state.discovery.issues,
|
|
397
|
+
)
|
|
398
|
+
return (
|
|
399
|
+
CustomToolSessionState(
|
|
400
|
+
discovery=filtered_discovery,
|
|
401
|
+
trust_state=state.trust_state,
|
|
402
|
+
catalog_entries=tuple(
|
|
403
|
+
entry for entry in state.catalog_entries if entry.spec is None or keep(entry.spec)
|
|
404
|
+
),
|
|
405
|
+
effective_tools_by_name=filtered_discovery.effective_tools_by_name(),
|
|
406
|
+
exposed_tools_by_name={
|
|
407
|
+
name: tool for name, tool in state.exposed_tools_by_name.items() if keep(tool)
|
|
408
|
+
},
|
|
409
|
+
),
|
|
410
|
+
dropped_counts,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _mcp_server_plugin_id(server: ResolvedMcpServer) -> str | None:
|
|
415
|
+
raw = str(server.id or "")
|
|
416
|
+
if "/" not in raw:
|
|
417
|
+
return None
|
|
418
|
+
return normalize_extension_id(raw.split("/", 1)[0])
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _filter_mcp_config_for_plugins(
|
|
422
|
+
*,
|
|
423
|
+
config: ResolvedMcpConfig,
|
|
424
|
+
activation_decision: ActivationDecision,
|
|
425
|
+
) -> tuple[ResolvedMcpConfig, Counter[str]]:
|
|
426
|
+
dropped_counts: Counter[str] = Counter()
|
|
427
|
+
servers = tuple(
|
|
428
|
+
server
|
|
429
|
+
for server in config.servers
|
|
430
|
+
if _component_plugin_allowed(
|
|
431
|
+
_mcp_server_plugin_id(server),
|
|
432
|
+
activation_decision,
|
|
433
|
+
dropped_counts,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
return replace(config, servers=servers), dropped_counts
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
FULLACCESS_DENYLIST_PATTERNS = [
|
|
440
|
+
r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/\s*$",
|
|
441
|
+
r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/\*",
|
|
442
|
+
r"\bgit\s+push\s+.*--force.*\b(main|master)\b",
|
|
443
|
+
r"\bsudo\b",
|
|
444
|
+
r"\bcurl\s+[^|]*\|\s*sh\b",
|
|
445
|
+
r"\bwget\s+[^|]*\|\s*sh\b",
|
|
446
|
+
r"\bdd\s+if=/dev/",
|
|
447
|
+
r"\bmkfs\.",
|
|
448
|
+
r":\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:",
|
|
449
|
+
r"\bchmod\s+-R\s+777\s+/",
|
|
450
|
+
r">\s*/dev/sd[a-z]",
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _normalize_fullaccess_shell_command(cmd: str) -> str:
|
|
455
|
+
return " ".join(str(cmd).strip().split())
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _fullaccess_denylist_match(cmd: str) -> str | None:
|
|
459
|
+
normalized = _normalize_fullaccess_shell_command(cmd)
|
|
460
|
+
for pattern in FULLACCESS_DENYLIST_PATTERNS:
|
|
461
|
+
if re.search(pattern, normalized, flags=re.IGNORECASE):
|
|
462
|
+
return pattern
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _fullaccess_shell_audit_ts() -> str:
|
|
467
|
+
return datetime.now(UTC).isoformat()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class ToolDispatchGuard(Protocol):
|
|
471
|
+
"""Host-supplied veto hook applied before every tool dispatch."""
|
|
472
|
+
|
|
473
|
+
def check_tool_call(
|
|
474
|
+
self,
|
|
475
|
+
tool_name: str,
|
|
476
|
+
arguments: dict[str, Any],
|
|
477
|
+
*,
|
|
478
|
+
resolve_rel_path: Callable[..., str] | None = None,
|
|
479
|
+
) -> None: ...
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass(frozen=True)
|
|
483
|
+
class ToolDef:
|
|
484
|
+
name: str
|
|
485
|
+
description: str
|
|
486
|
+
parameters: dict[str, Any]
|
|
487
|
+
run: Callable[[dict[str, Any]], dict[str, Any]]
|
|
488
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
489
|
+
|
|
490
|
+
def as_openai_tool(self) -> dict[str, Any]:
|
|
491
|
+
family = _model_schema_family(self.metadata)
|
|
492
|
+
description_max_chars = self.metadata.get("model_description_max_chars")
|
|
493
|
+
if family in CUSTOM_MCP_SCHEMA_FAMILIES:
|
|
494
|
+
description_max_chars = _schema_description_max_chars(description_max_chars)
|
|
495
|
+
description = str(self.metadata.get("model_description") or self.description)
|
|
496
|
+
description = _model_facing_tool_description(
|
|
497
|
+
description,
|
|
498
|
+
max_chars=description_max_chars,
|
|
499
|
+
)
|
|
500
|
+
parameters = self.parameters
|
|
501
|
+
if bool(self.metadata.get("compact_parameters_for_model")):
|
|
502
|
+
if family in CUSTOM_MCP_SCHEMA_FAMILIES:
|
|
503
|
+
parameters = compact_custom_mcp_tool_parameters(self.parameters)
|
|
504
|
+
else:
|
|
505
|
+
parameters = _drop_model_facing_schema_prose(self.parameters)
|
|
506
|
+
return {
|
|
507
|
+
"type": "function",
|
|
508
|
+
"function": {
|
|
509
|
+
"name": self.name,
|
|
510
|
+
"description": description,
|
|
511
|
+
"parameters": parameters,
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
_MODEL_FACING_SCHEMA_PROSE_KEYS = frozenset(
|
|
517
|
+
{
|
|
518
|
+
"$comment",
|
|
519
|
+
"description",
|
|
520
|
+
"example",
|
|
521
|
+
"examples",
|
|
522
|
+
"markdownDescription",
|
|
523
|
+
"title",
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _model_schema_family(metadata: dict[str, Any]) -> str:
|
|
529
|
+
tool_type = str(metadata.get("tool_type") or "").strip().lower()
|
|
530
|
+
if tool_type == "custom_tool":
|
|
531
|
+
return "custom"
|
|
532
|
+
if tool_type in {"mcp", "mcp_tool"}:
|
|
533
|
+
return "mcp"
|
|
534
|
+
return tool_type
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _schema_description_max_chars(value: Any) -> int:
|
|
538
|
+
try:
|
|
539
|
+
configured = int(value)
|
|
540
|
+
except (TypeError, ValueError):
|
|
541
|
+
configured = 0
|
|
542
|
+
if configured <= 0:
|
|
543
|
+
return DEFAULT_CUSTOM_MCP_DESCRIPTION_MAX_CHARS
|
|
544
|
+
return configured
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _model_facing_tool_description(description: str, *, max_chars: Any) -> str:
|
|
548
|
+
text = " ".join(str(description or "").split())
|
|
549
|
+
try:
|
|
550
|
+
limit = int(max_chars)
|
|
551
|
+
except (TypeError, ValueError):
|
|
552
|
+
limit = 0
|
|
553
|
+
if limit <= 0 or len(text) <= limit:
|
|
554
|
+
return text
|
|
555
|
+
return text[: max(0, limit - 3)].rstrip() + "..."
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _drop_model_facing_schema_prose(value: Any) -> Any:
|
|
559
|
+
if isinstance(value, dict):
|
|
560
|
+
reduced: dict[str, Any] = {}
|
|
561
|
+
for key, item in value.items():
|
|
562
|
+
normalized_key = str(key)
|
|
563
|
+
if normalized_key in _MODEL_FACING_SCHEMA_PROSE_KEYS:
|
|
564
|
+
continue
|
|
565
|
+
if normalized_key in {"const", "default", "enum"}:
|
|
566
|
+
reduced[key] = copy.deepcopy(item)
|
|
567
|
+
continue
|
|
568
|
+
reduced[key] = _drop_model_facing_schema_prose(item)
|
|
569
|
+
return reduced
|
|
570
|
+
if isinstance(value, list):
|
|
571
|
+
return [_drop_model_facing_schema_prose(item) for item in value]
|
|
572
|
+
return copy.deepcopy(value)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _drop_schema_descriptions(value: Any) -> Any:
|
|
576
|
+
return _drop_model_facing_schema_prose(value)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
_BUILTIN_MODEL_DESCRIPTIONS: dict[str, str] = {
|
|
580
|
+
"report_blocker": "Report a final unresolvable blocker.",
|
|
581
|
+
"fs_read": "Read a workspace text file.",
|
|
582
|
+
"fs_read_lines": "Read numbered file lines.",
|
|
583
|
+
"fs_edit": "Edit one UTF-8 file.",
|
|
584
|
+
"fs_move": "Move a file.",
|
|
585
|
+
"fs_copy": "Copy a file.",
|
|
586
|
+
"fs_delete": "Delete a file.",
|
|
587
|
+
"fs_write": "Write a UTF-8 text file.",
|
|
588
|
+
"fs_mkdir": "Create a directory.",
|
|
589
|
+
"fs_list": "List workspace files.",
|
|
590
|
+
"web_fetch": "Fetch a supplied/search URL.",
|
|
591
|
+
"web_search": (
|
|
592
|
+
"Search current sources for unstable/requested facts; includes UTC retrieved_at. "
|
|
593
|
+
"Fetch URLs with web_fetch."
|
|
594
|
+
),
|
|
595
|
+
"symbol_search": "Find symbols/snippets.",
|
|
596
|
+
"test_discover": "Suggest tests.",
|
|
597
|
+
"repo_map": "Map code and tests.",
|
|
598
|
+
"search_rg": "Search workspace text.",
|
|
599
|
+
"history_search": "Search prior events.",
|
|
600
|
+
"session_artifact_read": "Read current-session artifact locator.",
|
|
601
|
+
"verify_run": "Run verification.",
|
|
602
|
+
"shell_run": "Run a policy-checked shell command.",
|
|
603
|
+
"shell_background": ("Run a session lifetime command; killed when this session ends."),
|
|
604
|
+
"shell_service_start": (
|
|
605
|
+
"Start a durable service with durable lifetime; keeps running after this session ends."
|
|
606
|
+
),
|
|
607
|
+
"workspace_preview_start": ("Serve files without Docker; semantic access chooses a free port."),
|
|
608
|
+
"shell_service_status": "Check a service that outlives the session.",
|
|
609
|
+
"shell_service_stop": ("Stop a durable service; others keep running after the session ends."),
|
|
610
|
+
"shell_output": "Read process output.",
|
|
611
|
+
"shell_wait": "Wait for output/exit.",
|
|
612
|
+
"shell_kill": "Stop a background process.",
|
|
613
|
+
"shell_list": "List background processes.",
|
|
614
|
+
"session_set_workdir": "Set active_workdir.",
|
|
615
|
+
"switch_mode": "Propose a user-approved persona switch.",
|
|
616
|
+
"subagent_run": "Run child; eligible batches parallelize max4.",
|
|
617
|
+
"subagent_spawn": (
|
|
618
|
+
"Background: shared read-only, isolated writable. Example: run_id=impl; verifier "
|
|
619
|
+
"depends_on=[impl], workspace_from_run=impl."
|
|
620
|
+
),
|
|
621
|
+
"subagent_send": "Message a queued/running child.",
|
|
622
|
+
"subagent_resume": "Resume a terminal child as a linked run.",
|
|
623
|
+
"subagent_status": "List children.",
|
|
624
|
+
"subagent_wait": "Collect children.",
|
|
625
|
+
"subagent_cancel": "Cancel children.",
|
|
626
|
+
"subagent_apply": "Apply patch.",
|
|
627
|
+
"subagent_discard": "Discard worktree.",
|
|
628
|
+
"git_status": "Read Git status.",
|
|
629
|
+
"git_diff": "Run git diff.",
|
|
630
|
+
"git_history": "Read Git history.",
|
|
631
|
+
"git_apply_patch": "Apply a unified Git diff.",
|
|
632
|
+
"browser_start": "Start an approval-gated, IDE-owned browser for public websites.",
|
|
633
|
+
"browser_navigate": "Navigate an owned browser to an approved public HTTP(S) URL.",
|
|
634
|
+
"browser_snapshot": "Read a bounded page snapshot from an owned browser.",
|
|
635
|
+
"browser_screenshot": "Capture a screenshot and return only its opaque artifact id.",
|
|
636
|
+
"browser_artifact_read": "Read a bounded base64 chunk of a browser artifact by opaque id.",
|
|
637
|
+
"browser_diagnostics": "Read bounded, redacted browser console and network events.",
|
|
638
|
+
"browser_click": "Click an approved selector in an owned browser.",
|
|
639
|
+
"browser_type": "Type approved text into a selector without echoing the text.",
|
|
640
|
+
"browser_status": "Read one owned browser session status.",
|
|
641
|
+
"browser_list": "List browser sessions owned by this IDE task.",
|
|
642
|
+
"browser_close": "Close an approved owned browser session.",
|
|
643
|
+
"ide_task_list": "List bounded VS Code workspace tasks exposed by the trusted IDE host.",
|
|
644
|
+
"ide_task_run": "Start one opaque VS Code workspace task through the trusted IDE host.",
|
|
645
|
+
"ide_task_status": "Read bounded status for trusted-host VS Code task executions.",
|
|
646
|
+
"ide_task_terminate": "Terminate one VS Code task execution by opaque execution id.",
|
|
647
|
+
"ide_debug_list": "List bounded VS Code debug configurations exposed by the trusted IDE host.",
|
|
648
|
+
"ide_debug_start": "Start one opaque VS Code debug configuration through the trusted IDE host.",
|
|
649
|
+
"ide_debug_stop": "Stop one VS Code debug session by opaque session id.",
|
|
650
|
+
"ide_debug_status": "Read bounded status for trusted-host VS Code debug sessions.",
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _tool_event_metadata(tool: ToolDef | None) -> dict[str, Any]:
|
|
655
|
+
if tool is None or not tool.metadata:
|
|
656
|
+
return {}
|
|
657
|
+
metadata = copy.deepcopy(tool.metadata)
|
|
658
|
+
event_metadata: dict[str, Any] = {}
|
|
659
|
+
tool_type = str(metadata.get("tool_type") or "").strip()
|
|
660
|
+
if tool_type:
|
|
661
|
+
event_metadata["tool_type"] = tool_type
|
|
662
|
+
custom_tool = metadata.get("custom_tool")
|
|
663
|
+
if isinstance(custom_tool, dict):
|
|
664
|
+
event_metadata["custom_tool"] = {
|
|
665
|
+
key: value for key, value in custom_tool.items() if key != "output_schema"
|
|
666
|
+
}
|
|
667
|
+
return event_metadata
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _custom_tool_capability_summary(spec: Any) -> str:
|
|
671
|
+
capabilities = getattr(spec, "capabilities", None)
|
|
672
|
+
if capabilities is None:
|
|
673
|
+
return "capabilities: unspecified"
|
|
674
|
+
secret_refs = getattr(capabilities, "secret_refs", ())
|
|
675
|
+
secret_summary = ", ".join(secret_refs) if secret_refs else "-"
|
|
676
|
+
network_hosts = getattr(capabilities, "network_hosts", ())
|
|
677
|
+
network_hosts_summary = ", ".join(network_hosts) if network_hosts else "-"
|
|
678
|
+
return (
|
|
679
|
+
"capabilities: "
|
|
680
|
+
f"read_only={bool(getattr(capabilities, 'read_only', False))}, "
|
|
681
|
+
f"destructive={bool(getattr(capabilities, 'destructive', False))}, "
|
|
682
|
+
f"network={getattr(capabilities, 'network_access', 'unspecified')}, "
|
|
683
|
+
f"network_hosts={network_hosts_summary}, "
|
|
684
|
+
f"fs_read={getattr(capabilities, 'filesystem_read_scope', 'unspecified')}, "
|
|
685
|
+
f"fs_write={getattr(capabilities, 'filesystem_write_scope', 'unspecified')}, "
|
|
686
|
+
f"process_spawn={getattr(capabilities, 'process_spawn', 'unspecified')}, "
|
|
687
|
+
f"secrets={secret_summary}"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
_READONLY_MAIN_SESSION_BUILTIN_TOOL_NAMES = frozenset(
|
|
692
|
+
built_in_subagent_tool_names(exposure="readonly")
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
_READONLY_TOP_LEVEL_WEB_TOOL_NAMES = frozenset({"web_fetch", "web_search"})
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# Out-of-band channel for handing the turn's cancellation token to the shell
|
|
700
|
+
# wait path, mirroring _SUBAGENT_CANCELLATION_TOKEN_ARG. The key is an object()
|
|
701
|
+
# rather than a string so it can never collide with a model-supplied argument,
|
|
702
|
+
# is skipped by the ``isinstance(key, str)`` filters that build public args, and
|
|
703
|
+
# never reaches the schema, the transcript, or the provider.
|
|
704
|
+
_SHELL_CANCELLATION_TOKEN_ARG = object()
|
|
705
|
+
|
|
706
|
+
# Tools whose dispatch can block on a running process, and which therefore need
|
|
707
|
+
# the cancellation token so PR2's watchdog can preempt a wait already in flight.
|
|
708
|
+
_SHELL_CANCELLABLE_WAIT_TOOL_NAMES = frozenset({"shell_wait"})
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _built_in_tool_exposed_in_mode(
|
|
712
|
+
*,
|
|
713
|
+
tool_name: str,
|
|
714
|
+
mode: str,
|
|
715
|
+
subagent_depth: int = 0,
|
|
716
|
+
readonly_child_web_tool_names: Collection[str] | None = None,
|
|
717
|
+
) -> bool:
|
|
718
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
719
|
+
if normalized_mode != "readonly":
|
|
720
|
+
return True
|
|
721
|
+
normalized_tool_name = str(tool_name or "").strip()
|
|
722
|
+
if normalized_tool_name in _READONLY_MAIN_SESSION_BUILTIN_TOOL_NAMES:
|
|
723
|
+
return True
|
|
724
|
+
if normalized_tool_name not in _READONLY_TOP_LEVEL_WEB_TOOL_NAMES:
|
|
725
|
+
return False
|
|
726
|
+
# Top-level Plan/readonly sessions can safely use bounded web discovery and
|
|
727
|
+
# fetch tools. At depth one, only a definition-gated research child receives
|
|
728
|
+
# the explicitly allowlisted web tools; helpers at depth two remain narrow.
|
|
729
|
+
if subagent_depth == 0:
|
|
730
|
+
return True
|
|
731
|
+
allowed_child_web_tools = {
|
|
732
|
+
str(name or "").strip() for name in (readonly_child_web_tool_names or ())
|
|
733
|
+
}
|
|
734
|
+
return subagent_depth == 1 and normalized_tool_name in allowed_child_web_tools
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _mcp_tool_exposed_in_mode(*, mode: str, write_scope_restricted: bool = False) -> bool:
|
|
738
|
+
return str(mode or "").strip().lower() != "readonly" and not write_scope_restricted
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _custom_tools_write_scope_restricted(
|
|
742
|
+
*,
|
|
743
|
+
mode: str,
|
|
744
|
+
deny_write_prefixes: list[str] | None,
|
|
745
|
+
allow_write_globs: list[str] | None,
|
|
746
|
+
persona_allow_write_globs: list[str] | None,
|
|
747
|
+
) -> bool:
|
|
748
|
+
if persona_allow_write_globs is not None:
|
|
749
|
+
return True
|
|
750
|
+
if str(mode or "").strip().lower() == _MODE_FULLACCESS:
|
|
751
|
+
return False
|
|
752
|
+
if allow_write_globs is not None:
|
|
753
|
+
return True
|
|
754
|
+
always_protected = {
|
|
755
|
+
_normalize_rel_match_path(prefix).casefold()
|
|
756
|
+
for prefix in ALWAYS_PROTECTED_WRITE_PREFIXES
|
|
757
|
+
if _normalize_rel_match_path(prefix)
|
|
758
|
+
}
|
|
759
|
+
for raw in deny_write_prefixes or []:
|
|
760
|
+
cleaned = _normalize_rel_match_path(str(raw))
|
|
761
|
+
if cleaned and cleaned.casefold() not in always_protected:
|
|
762
|
+
return True
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _unified_diff(old: str, new: str, path: str) -> str:
|
|
767
|
+
import difflib
|
|
768
|
+
|
|
769
|
+
old_lines = old.splitlines(keepends=True)
|
|
770
|
+
new_lines = new.splitlines(keepends=True)
|
|
771
|
+
diff = difflib.unified_diff(
|
|
772
|
+
old_lines,
|
|
773
|
+
new_lines,
|
|
774
|
+
fromfile=f"a/{path}",
|
|
775
|
+
tofile=f"b/{path}",
|
|
776
|
+
)
|
|
777
|
+
return "".join(diff)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def build_tools(
|
|
781
|
+
*,
|
|
782
|
+
root: Path,
|
|
783
|
+
console: Any | None,
|
|
784
|
+
surface: Surface | None = None,
|
|
785
|
+
store: SessionStore,
|
|
786
|
+
mode: str,
|
|
787
|
+
yes: bool,
|
|
788
|
+
cfg: AppConfig | None = None,
|
|
789
|
+
api_key: str | None = None,
|
|
790
|
+
max_steps: int | None = None,
|
|
791
|
+
no_log: bool = False,
|
|
792
|
+
usage_role: str = "main",
|
|
793
|
+
usage_summary: UsageSummary | None = None,
|
|
794
|
+
model_registry: ModelRegistry | None = None,
|
|
795
|
+
deny_write_prefixes: list[str] | None = None,
|
|
796
|
+
allow_write_globs: list[str] | None = None,
|
|
797
|
+
persona_allow_write_globs: list[str] | None = None,
|
|
798
|
+
non_interactive: bool = False,
|
|
799
|
+
shell_runner: Any | None = None,
|
|
800
|
+
process_group_registry: ProcessGroupRegistry | None = None,
|
|
801
|
+
terminal_manager: TerminalManager | None = None,
|
|
802
|
+
durable_service_manager: DurableServiceManager | None = None,
|
|
803
|
+
persistent_service_registry: PersistentServiceRegistry | None = None,
|
|
804
|
+
edit_discipline: EditDisciplineState | None = None,
|
|
805
|
+
verification_enabled: bool = True,
|
|
806
|
+
authoritative_verification_commands: list[str] | None = None,
|
|
807
|
+
effective_verification_commands: list[str] | None = None,
|
|
808
|
+
verify_command_selection: ResolvedVerifyCommands | None = None,
|
|
809
|
+
get_verify_command_selection: Callable[[], ResolvedVerifyCommands | None] | None = None,
|
|
810
|
+
one_shot_execution: bool = False,
|
|
811
|
+
completion_gate_tools_enabled: bool = False,
|
|
812
|
+
skills_enabled: bool = True,
|
|
813
|
+
skill_registry: dict[str, SkillBundle] | None = None,
|
|
814
|
+
subagents_enabled: bool = False,
|
|
815
|
+
helper_subagents_enabled: bool = False,
|
|
816
|
+
subagent_depth: int = 0,
|
|
817
|
+
subagent_registry: dict[str, SubagentDefinition] | None = None,
|
|
818
|
+
session_log_dir_override: Path | None = None,
|
|
819
|
+
step_budget_runtime: Any | None = None,
|
|
820
|
+
emit_web_search_runtime_diagnostics: bool = False,
|
|
821
|
+
runtime_kind: RuntimeKind | str = RuntimeKind.ONE_SHOT,
|
|
822
|
+
persona_switch_state: Any | None = None,
|
|
823
|
+
mcp_manager: McpManager | ForgeTaskScopedMcpManager | None = None,
|
|
824
|
+
custom_tool_session_state: CustomToolSessionState | None = None,
|
|
825
|
+
get_active_workdir_relpath: Callable[[], str] | None = None,
|
|
826
|
+
set_active_workdir_callback: Callable[[str, str], dict[str, Any]] | None = None,
|
|
827
|
+
create_session_factory: Callable[..., Any] | None = None,
|
|
828
|
+
prompt_cache_parent_session_id: str | None = None,
|
|
829
|
+
execution_deadline: ExecutionDeadline | None = None,
|
|
830
|
+
crash_diagnostic_log_path: str | os.PathLike[str] | None = None,
|
|
831
|
+
crash_diagnostics: CrashDiagnosticLogger | None = None,
|
|
832
|
+
tool_dispatch_guard: ToolDispatchGuard | None = None,
|
|
833
|
+
managed_browser_service: ManagedBrowserService | None = None,
|
|
834
|
+
managed_browser_owner_id: str | None = None,
|
|
835
|
+
managed_browser_cancel_check: Callable[[], bool] | None = None,
|
|
836
|
+
host_action_handler: HostActionHandler | None = None,
|
|
837
|
+
host_action_capabilities: Collection[str] | None = None,
|
|
838
|
+
child_scheduler_sink: Callable[[ChildScheduler], None] | None = None,
|
|
839
|
+
parent_steer_inbox: SteerInbox | None = None,
|
|
840
|
+
read_ledger_sink: Callable[[SessionReadLedger], None] | None = None,
|
|
841
|
+
readonly_child_web_tool_names: Collection[str] | None = None,
|
|
842
|
+
child_managed_browser_tool_names: Collection[str] | None = None,
|
|
843
|
+
) -> dict[str, ToolDef]:
|
|
844
|
+
root = root.resolve()
|
|
845
|
+
workspace_context = resolve_workspace_context(root)
|
|
846
|
+
surface = surface or NoopSurface()
|
|
847
|
+
host_managed_approvals = bool(
|
|
848
|
+
getattr(surface, "host_managed_approvals", False)
|
|
849
|
+
or getattr(getattr(surface, "_parent_surface", None), "host_managed_approvals", False)
|
|
850
|
+
)
|
|
851
|
+
resolved_runtime_kind = normalize_runtime_kind(
|
|
852
|
+
runtime_kind, fallback=RuntimeKind.INTERACTIVE_CHAT
|
|
853
|
+
)
|
|
854
|
+
authoritative_verify_commands = _normalized_authoritative_verify_commands(
|
|
855
|
+
authoritative_verification_commands
|
|
856
|
+
)
|
|
857
|
+
static_verify_selection = verify_command_selection
|
|
858
|
+
normalized_effective_verification_commands = _normalized_verify_commands(
|
|
859
|
+
effective_verification_commands
|
|
860
|
+
or (
|
|
861
|
+
list(static_verify_selection.commands)
|
|
862
|
+
if isinstance(static_verify_selection, ResolvedVerifyCommands)
|
|
863
|
+
else []
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
effective_host_actions = normalized_host_action_capabilities(host_action_capabilities)
|
|
867
|
+
read_ledger = SessionReadLedger(
|
|
868
|
+
root=root,
|
|
869
|
+
enabled=bool(getattr(cfg, "read_ledger_enabled", True)),
|
|
870
|
+
)
|
|
871
|
+
if read_ledger_sink is not None:
|
|
872
|
+
read_ledger_sink(read_ledger)
|
|
873
|
+
|
|
874
|
+
def _deadline_payload() -> dict[str, Any]:
|
|
875
|
+
if execution_deadline is None:
|
|
876
|
+
return {
|
|
877
|
+
"failure_category": "deadline",
|
|
878
|
+
"deadline_exhausted": False,
|
|
879
|
+
"remaining_seconds": None,
|
|
880
|
+
"deadline": None,
|
|
881
|
+
}
|
|
882
|
+
remaining = execution_deadline.remaining_seconds()
|
|
883
|
+
return {
|
|
884
|
+
"failure_category": "deadline",
|
|
885
|
+
"deadline_exhausted": execution_deadline.is_exhausted(),
|
|
886
|
+
"remaining_seconds": remaining,
|
|
887
|
+
"deadline": execution_deadline.telemetry_snapshot(),
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
def _deadline_error(
|
|
891
|
+
message: str,
|
|
892
|
+
*,
|
|
893
|
+
prevented_launch: bool = True,
|
|
894
|
+
start_decision: dict[str, Any] | None = None,
|
|
895
|
+
) -> dict[str, Any]:
|
|
896
|
+
payload = {
|
|
897
|
+
"error": message,
|
|
898
|
+
"deadline_prevented_launch": prevented_launch,
|
|
899
|
+
**_deadline_payload(),
|
|
900
|
+
}
|
|
901
|
+
if start_decision is not None:
|
|
902
|
+
payload["deadline_start_decision"] = start_decision
|
|
903
|
+
if crash_diagnostics is not None:
|
|
904
|
+
crash_diagnostics.event(
|
|
905
|
+
"deadline_exhausted",
|
|
906
|
+
{
|
|
907
|
+
"operation": "tool",
|
|
908
|
+
"deadline_exhausted": payload["deadline_exhausted"],
|
|
909
|
+
"remaining_seconds": payload["remaining_seconds"],
|
|
910
|
+
"deadline": payload["deadline"],
|
|
911
|
+
"deadline_start_decision": start_decision,
|
|
912
|
+
},
|
|
913
|
+
durable=True,
|
|
914
|
+
)
|
|
915
|
+
return payload
|
|
916
|
+
|
|
917
|
+
def _deadline_warning_fields(
|
|
918
|
+
message: str,
|
|
919
|
+
*,
|
|
920
|
+
start_decision: dict[str, Any] | None = None,
|
|
921
|
+
) -> dict[str, Any]:
|
|
922
|
+
payload = {
|
|
923
|
+
"deadline_warning": message,
|
|
924
|
+
"deadline_prevented_launch": False,
|
|
925
|
+
**_deadline_payload(),
|
|
926
|
+
}
|
|
927
|
+
if start_decision is not None:
|
|
928
|
+
payload["deadline_start_decision"] = start_decision
|
|
929
|
+
if crash_diagnostics is not None:
|
|
930
|
+
crash_diagnostics.event(
|
|
931
|
+
"deadline_exhausted",
|
|
932
|
+
{
|
|
933
|
+
"operation": "tool",
|
|
934
|
+
"deadline_exhausted": payload["deadline_exhausted"],
|
|
935
|
+
"remaining_seconds": payload["remaining_seconds"],
|
|
936
|
+
"deadline": payload["deadline"],
|
|
937
|
+
"deadline_start_decision": start_decision,
|
|
938
|
+
},
|
|
939
|
+
durable=True,
|
|
940
|
+
)
|
|
941
|
+
return payload
|
|
942
|
+
|
|
943
|
+
def _deadline_start_decision(
|
|
944
|
+
operation: DeadlineOperation,
|
|
945
|
+
*,
|
|
946
|
+
minimum_remaining_seconds: float,
|
|
947
|
+
configured_timeout_seconds: float | None = None,
|
|
948
|
+
allow_during_finalization: bool = False,
|
|
949
|
+
) -> dict[str, Any] | None:
|
|
950
|
+
if execution_deadline is None:
|
|
951
|
+
return None
|
|
952
|
+
return execution_deadline.start_decision(
|
|
953
|
+
operation,
|
|
954
|
+
minimum_remaining_seconds=minimum_remaining_seconds,
|
|
955
|
+
configured_timeout_seconds=configured_timeout_seconds,
|
|
956
|
+
allow_during_finalization=allow_during_finalization,
|
|
957
|
+
).telemetry_snapshot()
|
|
958
|
+
|
|
959
|
+
def _deadline_timeout(
|
|
960
|
+
configured_timeout_seconds: float,
|
|
961
|
+
*,
|
|
962
|
+
operation: str,
|
|
963
|
+
) -> float:
|
|
964
|
+
timeout = deadline_timeout_or_raise(
|
|
965
|
+
execution_deadline,
|
|
966
|
+
configured_timeout_seconds,
|
|
967
|
+
reserve_seconds=DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
|
|
968
|
+
operation=operation,
|
|
969
|
+
)
|
|
970
|
+
return float(configured_timeout_seconds if timeout is None else timeout)
|
|
971
|
+
|
|
972
|
+
def _current_verify_selection() -> ResolvedVerifyCommands | None:
|
|
973
|
+
if callable(get_verify_command_selection):
|
|
974
|
+
try:
|
|
975
|
+
current = get_verify_command_selection()
|
|
976
|
+
except Exception: # noqa: BLE001
|
|
977
|
+
current = None
|
|
978
|
+
if isinstance(current, ResolvedVerifyCommands):
|
|
979
|
+
return current
|
|
980
|
+
if isinstance(static_verify_selection, ResolvedVerifyCommands):
|
|
981
|
+
return static_verify_selection
|
|
982
|
+
if authoritative_verify_commands is not None:
|
|
983
|
+
return ResolvedVerifyCommands(
|
|
984
|
+
commands=tuple(authoritative_verify_commands),
|
|
985
|
+
source="environment.authoritative_verification_commands",
|
|
986
|
+
reason="managed runtime injected authoritative verification commands",
|
|
987
|
+
contract_type="authoritative_override",
|
|
988
|
+
)
|
|
989
|
+
if normalized_effective_verification_commands:
|
|
990
|
+
return ResolvedVerifyCommands(
|
|
991
|
+
commands=tuple(normalized_effective_verification_commands),
|
|
992
|
+
source="session.effective_verification_commands",
|
|
993
|
+
reason="session already resolved an effective verification contract",
|
|
994
|
+
contract_type="selected",
|
|
995
|
+
)
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
command_mutation_tracking_enabled = bool(
|
|
999
|
+
resolved_runtime_kind == RuntimeKind.SUBAGENT
|
|
1000
|
+
or (
|
|
1001
|
+
subagent_depth == 0
|
|
1002
|
+
and (one_shot_execution or resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT)
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
command_mutation_ignored_paths: list[Path] = []
|
|
1006
|
+
if command_mutation_tracking_enabled:
|
|
1007
|
+
command_mutation_ignored_paths = [
|
|
1008
|
+
candidate
|
|
1009
|
+
for candidate in [
|
|
1010
|
+
getattr(store, "path", None),
|
|
1011
|
+
getattr(store, "session_artifact_root", None),
|
|
1012
|
+
]
|
|
1013
|
+
if isinstance(candidate, Path)
|
|
1014
|
+
]
|
|
1015
|
+
history_artifact_persistence_available = bool(
|
|
1016
|
+
getattr(store, "enabled", False) or session_log_dir_override is not None
|
|
1017
|
+
)
|
|
1018
|
+
git_backed_workspace = workspace_context.git_root is not None
|
|
1019
|
+
resolved_skill_registry = dict(skill_registry or {})
|
|
1020
|
+
built_in_tool_names = {spec.name.casefold() for spec in iter_builtin_tool_metadata()}
|
|
1021
|
+
custom_tool_session_state = build_custom_tool_session_state(
|
|
1022
|
+
workspace_root=root,
|
|
1023
|
+
custom_tools_enabled=bool(getattr(cfg, "custom_tools_enabled", True)) if cfg else True,
|
|
1024
|
+
mode=mode,
|
|
1025
|
+
runtime_kind=resolved_runtime_kind,
|
|
1026
|
+
built_in_tool_names=built_in_tool_names,
|
|
1027
|
+
write_scope_restricted=_custom_tools_write_scope_restricted(
|
|
1028
|
+
mode=mode,
|
|
1029
|
+
deny_write_prefixes=deny_write_prefixes,
|
|
1030
|
+
allow_write_globs=allow_write_globs,
|
|
1031
|
+
persona_allow_write_globs=persona_allow_write_globs,
|
|
1032
|
+
),
|
|
1033
|
+
discovery=(
|
|
1034
|
+
custom_tool_session_state.discovery if custom_tool_session_state is not None else None
|
|
1035
|
+
),
|
|
1036
|
+
trust_state=(
|
|
1037
|
+
custom_tool_session_state.trust_state if custom_tool_session_state is not None else None
|
|
1038
|
+
),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
persona_write_scope_active = persona_allow_write_globs is not None
|
|
1042
|
+
# A persona scope remains a real host constraint even if a caller ever
|
|
1043
|
+
# constructs an inconsistent fullaccess+scope session. Normal persona
|
|
1044
|
+
# application also clamps that combination to review.
|
|
1045
|
+
is_full_access_mode = mode == _MODE_FULLACCESS and not persona_write_scope_active
|
|
1046
|
+
deny_prefixes: list[str] = []
|
|
1047
|
+
if not is_full_access_mode:
|
|
1048
|
+
seen_deny_prefixes: set[str] = set()
|
|
1049
|
+
for raw in [
|
|
1050
|
+
*ALWAYS_PROTECTED_WRITE_PREFIXES,
|
|
1051
|
+
*(deny_write_prefixes or []),
|
|
1052
|
+
]:
|
|
1053
|
+
cleaned = _normalize_rel_match_path(str(raw))
|
|
1054
|
+
if cleaned:
|
|
1055
|
+
normalized = cleaned.casefold()
|
|
1056
|
+
if normalized not in seen_deny_prefixes:
|
|
1057
|
+
seen_deny_prefixes.add(normalized)
|
|
1058
|
+
deny_prefixes.append(cleaned)
|
|
1059
|
+
deny_prefixes_cf = [pref.casefold() for pref in deny_prefixes]
|
|
1060
|
+
allow_pattern_groups: list[list[str]] = []
|
|
1061
|
+
allowed_ancestor_dir_groups_cf: list[set[str]] = []
|
|
1062
|
+
if not is_full_access_mode:
|
|
1063
|
+
for raw_group in (allow_write_globs, persona_allow_write_globs):
|
|
1064
|
+
if raw_group is None:
|
|
1065
|
+
continue
|
|
1066
|
+
patterns = [
|
|
1067
|
+
cleaned for raw in raw_group if (cleaned := _normalize_rel_match_path(str(raw)))
|
|
1068
|
+
]
|
|
1069
|
+
allow_pattern_groups.append(patterns)
|
|
1070
|
+
allowed_ancestor_dir_groups_cf.append(
|
|
1071
|
+
{
|
|
1072
|
+
cleaned.casefold()
|
|
1073
|
+
for path in ancestor_directory_scope_patterns(raw_group)
|
|
1074
|
+
if (cleaned := _normalize_rel_match_path(path))
|
|
1075
|
+
}
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
def _is_denied_path(rel_path: str) -> bool:
|
|
1079
|
+
if not deny_prefixes_cf:
|
|
1080
|
+
return False
|
|
1081
|
+
rel_norm = _normalize_rel_match_path(rel_path)
|
|
1082
|
+
rel_cf = rel_norm.casefold()
|
|
1083
|
+
for pref_cf in deny_prefixes_cf:
|
|
1084
|
+
if rel_cf == pref_cf or rel_cf.startswith(pref_cf + "/"):
|
|
1085
|
+
return True
|
|
1086
|
+
return False
|
|
1087
|
+
|
|
1088
|
+
def _path_escape_recovery_payload(
|
|
1089
|
+
*,
|
|
1090
|
+
tool_name: str,
|
|
1091
|
+
attempted_path: str,
|
|
1092
|
+
field_name: str,
|
|
1093
|
+
workspace_root: Path,
|
|
1094
|
+
path_base: str | None = None,
|
|
1095
|
+
) -> dict[str, Any]:
|
|
1096
|
+
base_note = f" with path_base={path_base}" if path_base else ""
|
|
1097
|
+
normalized_tool = str(tool_name or "").strip().lower()
|
|
1098
|
+
write_tools = {
|
|
1099
|
+
"fs_write",
|
|
1100
|
+
"fs_edit",
|
|
1101
|
+
"fs_move",
|
|
1102
|
+
"fs_copy",
|
|
1103
|
+
"fs_delete",
|
|
1104
|
+
"fs_mkdir",
|
|
1105
|
+
}
|
|
1106
|
+
shell_cwd_tools = {"shell_run", "shell_background", "shell_service_start"}
|
|
1107
|
+
|
|
1108
|
+
if normalized_tool in write_tools:
|
|
1109
|
+
guidance = (
|
|
1110
|
+
"Use a workspace-relative path for this filesystem write. If the user "
|
|
1111
|
+
"explicitly requested an absolute path outside the workspace, explain that "
|
|
1112
|
+
"filesystem write tools cannot do that. Use shell_run only when policy and "
|
|
1113
|
+
"any required user approval allow the explicit external write."
|
|
1114
|
+
)
|
|
1115
|
+
suggested_next_actions = [
|
|
1116
|
+
{
|
|
1117
|
+
"action": "use_workspace_relative_path",
|
|
1118
|
+
"description": "Retry with a path inside the workspace.",
|
|
1119
|
+
"requires_user_confirmation": False,
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
"action": "use_shell_run_if_policy_allows",
|
|
1123
|
+
"description": (
|
|
1124
|
+
"Use a specific shell command for an explicitly requested external "
|
|
1125
|
+
"target only when policy and approvals allow it."
|
|
1126
|
+
),
|
|
1127
|
+
"requires_user_confirmation": True,
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
"action": "ask_or_explain_boundary",
|
|
1131
|
+
"description": "Explain the workspace boundary and ask how to proceed.",
|
|
1132
|
+
"requires_user_confirmation": False,
|
|
1133
|
+
},
|
|
1134
|
+
]
|
|
1135
|
+
can_use_other_allowed_tool = (
|
|
1136
|
+
"shell_run may target an absolute path only when policy and any required "
|
|
1137
|
+
"approval allow it"
|
|
1138
|
+
)
|
|
1139
|
+
requires_user_confirmation = True
|
|
1140
|
+
elif normalized_tool in shell_cwd_tools or field_name == "cwd":
|
|
1141
|
+
guidance = (
|
|
1142
|
+
"Use a cwd inside the workspace. If the command needs an external path, keep "
|
|
1143
|
+
"cwd workspace-relative and pass the path explicitly only when shell policy "
|
|
1144
|
+
"and approvals allow that operation."
|
|
1145
|
+
)
|
|
1146
|
+
suggested_next_actions = [
|
|
1147
|
+
{
|
|
1148
|
+
"action": "use_workspace_relative_cwd",
|
|
1149
|
+
"description": "Retry with cwd omitted or set inside the workspace.",
|
|
1150
|
+
"requires_user_confirmation": False,
|
|
1151
|
+
},
|
|
1152
|
+
{
|
|
1153
|
+
"action": "pass_external_path_as_argument_if_policy_allows",
|
|
1154
|
+
"description": (
|
|
1155
|
+
"Keep cwd inside the workspace and pass the external path explicitly "
|
|
1156
|
+
"only when policy and approvals allow it."
|
|
1157
|
+
),
|
|
1158
|
+
"requires_user_confirmation": True,
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
"action": "ask_or_explain_boundary",
|
|
1162
|
+
"description": "Explain the cwd boundary and ask how to proceed.",
|
|
1163
|
+
"requires_user_confirmation": False,
|
|
1164
|
+
},
|
|
1165
|
+
]
|
|
1166
|
+
can_use_other_allowed_tool = "Shell commands must start from a workspace-relative cwd"
|
|
1167
|
+
requires_user_confirmation = True
|
|
1168
|
+
else:
|
|
1169
|
+
guidance = (
|
|
1170
|
+
"Use a workspace-relative path. This tool cannot inspect arbitrary paths "
|
|
1171
|
+
"outside the workspace; ask the user to move the input into the workspace or "
|
|
1172
|
+
"provide its contents."
|
|
1173
|
+
)
|
|
1174
|
+
suggested_next_actions = [
|
|
1175
|
+
{
|
|
1176
|
+
"action": "use_workspace_relative_path",
|
|
1177
|
+
"description": "Retry with a path inside the workspace.",
|
|
1178
|
+
"requires_user_confirmation": False,
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
"action": "ask_user_for_accessible_input",
|
|
1182
|
+
"description": "Ask the user to provide the input inside the workspace.",
|
|
1183
|
+
"requires_user_confirmation": False,
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
"action": "explain_boundary",
|
|
1187
|
+
"description": "Explain that the tool cannot access the external path.",
|
|
1188
|
+
"requires_user_confirmation": False,
|
|
1189
|
+
},
|
|
1190
|
+
]
|
|
1191
|
+
can_use_other_allowed_tool = (
|
|
1192
|
+
"No filesystem read or search tool can access paths outside the workspace"
|
|
1193
|
+
)
|
|
1194
|
+
requires_user_confirmation = False
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
"error": (
|
|
1198
|
+
f"Path escapes root ({field_name}): {attempted_path}. Workspace path arguments "
|
|
1199
|
+
f"are limited to {os.fspath(workspace_root)}{base_note}. Recovery: {guidance}"
|
|
1200
|
+
),
|
|
1201
|
+
"error_code": "path_escapes_workspace",
|
|
1202
|
+
"code": "path_escapes_workspace",
|
|
1203
|
+
"attempted_path": attempted_path,
|
|
1204
|
+
"path_field": field_name,
|
|
1205
|
+
"tool_name": normalized_tool or tool_name,
|
|
1206
|
+
"workspace_root": os.fspath(workspace_root),
|
|
1207
|
+
"rule": "workspace path arguments must resolve under workspace_root",
|
|
1208
|
+
"can_use_other_allowed_tool": can_use_other_allowed_tool,
|
|
1209
|
+
"requires_user_confirmation": requires_user_confirmation,
|
|
1210
|
+
"guidance": guidance,
|
|
1211
|
+
"suggested_next_actions": suggested_next_actions,
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
def _resolve_rel_path(rel_path: str) -> str:
|
|
1215
|
+
root_abs = root.resolve()
|
|
1216
|
+
target = (root_abs / rel_path).resolve()
|
|
1217
|
+
try:
|
|
1218
|
+
normalized = target.relative_to(root_abs)
|
|
1219
|
+
except ValueError as e:
|
|
1220
|
+
payload = _path_escape_recovery_payload(
|
|
1221
|
+
tool_name="filesystem",
|
|
1222
|
+
attempted_path=rel_path,
|
|
1223
|
+
field_name="path",
|
|
1224
|
+
workspace_root=root_abs,
|
|
1225
|
+
)
|
|
1226
|
+
raise AgentRuntimeError(str(payload["error"]), result_payload=payload) from e
|
|
1227
|
+
return os.fspath(normalized)
|
|
1228
|
+
|
|
1229
|
+
def _resolve_rel_write_path(rel_path: str) -> str:
|
|
1230
|
+
return _resolve_rel_path(rel_path)
|
|
1231
|
+
|
|
1232
|
+
def _guard_write_path(rel_path: str) -> None:
|
|
1233
|
+
if is_full_access_mode:
|
|
1234
|
+
return
|
|
1235
|
+
if _is_denied_path(rel_path):
|
|
1236
|
+
raise AgentRuntimeError(f"Blocked write to protected path: {rel_path}")
|
|
1237
|
+
rel_norm = _normalize_rel_match_path(rel_path)
|
|
1238
|
+
rel_cf = rel_norm.casefold()
|
|
1239
|
+
for patterns in allow_pattern_groups:
|
|
1240
|
+
in_scope = any(
|
|
1241
|
+
scope_path_matches_pattern(rel_norm, pattern, root=root) for pattern in patterns
|
|
1242
|
+
)
|
|
1243
|
+
if not in_scope:
|
|
1244
|
+
in_scope = any(
|
|
1245
|
+
rel_cf == _normalize_rel_match_path(pattern).casefold()
|
|
1246
|
+
for pattern in patterns
|
|
1247
|
+
if not any(ch in pattern for ch in ["*", "?", "["])
|
|
1248
|
+
)
|
|
1249
|
+
if not in_scope:
|
|
1250
|
+
raise AgentRuntimeError(f"Blocked write outside allowed scope: {rel_path}")
|
|
1251
|
+
|
|
1252
|
+
def _is_allowed_ancestor_dir_creation(rel_path: str) -> bool:
|
|
1253
|
+
if is_full_access_mode or not allow_pattern_groups:
|
|
1254
|
+
return False
|
|
1255
|
+
rel_norm = _normalize_rel_match_path(rel_path).casefold()
|
|
1256
|
+
return all(rel_norm in ancestors for ancestors in allowed_ancestor_dir_groups_cf)
|
|
1257
|
+
|
|
1258
|
+
def _sensitive_path_findings(paths: list[str]) -> list[dict[str, str]]:
|
|
1259
|
+
findings: list[dict[str, str]] = []
|
|
1260
|
+
for path in paths:
|
|
1261
|
+
classification = classify_sensitive_path(path)
|
|
1262
|
+
if classification.sensitive:
|
|
1263
|
+
findings.append(
|
|
1264
|
+
{
|
|
1265
|
+
"path": path,
|
|
1266
|
+
"category": str(classification.category or "sensitive_file"),
|
|
1267
|
+
}
|
|
1268
|
+
)
|
|
1269
|
+
return findings
|
|
1270
|
+
|
|
1271
|
+
def guard_sensitive_files(kind: str, *, files: list[str]) -> list[dict[str, str]]:
|
|
1272
|
+
"""Require one-time human consent that broad/session policy cannot satisfy."""
|
|
1273
|
+
|
|
1274
|
+
findings = _sensitive_path_findings(files)
|
|
1275
|
+
if not findings:
|
|
1276
|
+
return []
|
|
1277
|
+
if non_interactive and not host_managed_approvals:
|
|
1278
|
+
raise AgentRuntimeError(
|
|
1279
|
+
f"Explicit one-time user approval is required for {kind} on a sensitive file."
|
|
1280
|
+
)
|
|
1281
|
+
categories = sorted({finding["category"] for finding in findings})
|
|
1282
|
+
preview = "\n".join(
|
|
1283
|
+
[
|
|
1284
|
+
f"Sensitive file operation: {kind}",
|
|
1285
|
+
*(f"path: {finding['path']} ({finding['category']})" for finding in findings),
|
|
1286
|
+
"File contents are intentionally omitted from this approval preview.",
|
|
1287
|
+
]
|
|
1288
|
+
)
|
|
1289
|
+
decision = surface.request_approval(
|
|
1290
|
+
ApprovalRequest(
|
|
1291
|
+
kind=kind,
|
|
1292
|
+
reason="sensitive files require an explicit one-time approval",
|
|
1293
|
+
preview=preview,
|
|
1294
|
+
files=[finding["path"] for finding in findings],
|
|
1295
|
+
metadata={
|
|
1296
|
+
"mandatory_explicit_approval": True,
|
|
1297
|
+
"allow_for_session_disabled": True,
|
|
1298
|
+
"sensitive_categories": categories,
|
|
1299
|
+
},
|
|
1300
|
+
# Deliberately no allow_for_session_scope: stored grants must
|
|
1301
|
+
# never authorize current or future sensitive-file access.
|
|
1302
|
+
allow_for_session_scope=None,
|
|
1303
|
+
)
|
|
1304
|
+
)
|
|
1305
|
+
if not decision.allow:
|
|
1306
|
+
raise ApprovalDeclinedError(kind)
|
|
1307
|
+
if decision.allow_for_session:
|
|
1308
|
+
# Auto/YOLO surfaces and cached grants identify themselves through
|
|
1309
|
+
# allow_for_session. Sensitive access only accepts the UI's one-time
|
|
1310
|
+
# allow decision.
|
|
1311
|
+
raise AgentRuntimeError(
|
|
1312
|
+
f"Automatic or session approval cannot authorize {kind} on a sensitive file. "
|
|
1313
|
+
"Switch approvals to ask and approve this operation once."
|
|
1314
|
+
)
|
|
1315
|
+
return findings
|
|
1316
|
+
|
|
1317
|
+
def guard_sensitive_read(kind: str, *, path: str) -> list[dict[str, str]]:
|
|
1318
|
+
findings = _sensitive_path_findings([path])
|
|
1319
|
+
if findings and not (root / path).exists():
|
|
1320
|
+
message = (
|
|
1321
|
+
f"Path does not exist: {path}. This result is terminal; do not retry this path."
|
|
1322
|
+
)
|
|
1323
|
+
raise AgentRuntimeError(
|
|
1324
|
+
message,
|
|
1325
|
+
result_payload={
|
|
1326
|
+
"error": message,
|
|
1327
|
+
"error_code": "fs_path_not_found",
|
|
1328
|
+
"terminal": True,
|
|
1329
|
+
"retryable": False,
|
|
1330
|
+
},
|
|
1331
|
+
)
|
|
1332
|
+
return guard_sensitive_files(kind, files=[path])
|
|
1333
|
+
|
|
1334
|
+
def _mark_sensitive_result(
|
|
1335
|
+
result: dict[str, Any], findings: list[dict[str, str]]
|
|
1336
|
+
) -> dict[str, Any]:
|
|
1337
|
+
if findings:
|
|
1338
|
+
result["_alysis_output_policy"] = {
|
|
1339
|
+
"sensitive": True,
|
|
1340
|
+
"persist": "redact",
|
|
1341
|
+
"display": "redact",
|
|
1342
|
+
"categories": sorted({finding["category"] for finding in findings}),
|
|
1343
|
+
}
|
|
1344
|
+
return result
|
|
1345
|
+
|
|
1346
|
+
def _stale_file_result(error: StaleFileError) -> dict[str, Any]:
|
|
1347
|
+
return {
|
|
1348
|
+
"error": "The file changed after this operation was prepared; no mutation was made.",
|
|
1349
|
+
"error_code": "stale_file",
|
|
1350
|
+
"code": "stale_file",
|
|
1351
|
+
"path": error.path,
|
|
1352
|
+
"recoverable": True,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
def guard_write(kind: str, preview: str, *, files: list[str] | None = None) -> None:
|
|
1356
|
+
if is_full_access_mode:
|
|
1357
|
+
return
|
|
1358
|
+
if mode == "readonly":
|
|
1359
|
+
raise AgentRuntimeError(f"Blocked in readonly mode: {kind}")
|
|
1360
|
+
if mode == "review":
|
|
1361
|
+
if non_interactive and not host_managed_approvals:
|
|
1362
|
+
raise AgentRuntimeError(
|
|
1363
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1364
|
+
)
|
|
1365
|
+
decision = surface.request_approval(
|
|
1366
|
+
ApprovalRequest(
|
|
1367
|
+
kind=kind,
|
|
1368
|
+
reason="review mode requires confirmation for write operations",
|
|
1369
|
+
preview=preview,
|
|
1370
|
+
files=files or [],
|
|
1371
|
+
allow_for_session_scope=exact_file_set_scope(files or [], operation=kind)
|
|
1372
|
+
if files
|
|
1373
|
+
else None,
|
|
1374
|
+
)
|
|
1375
|
+
)
|
|
1376
|
+
if not decision.allow:
|
|
1377
|
+
raise ApprovalDeclinedError(kind)
|
|
1378
|
+
if mode == "auto" and kind == "fs_delete" and not yes:
|
|
1379
|
+
if non_interactive and not host_managed_approvals:
|
|
1380
|
+
raise AgentRuntimeError(
|
|
1381
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1382
|
+
)
|
|
1383
|
+
decision = surface.request_approval(
|
|
1384
|
+
ApprovalRequest(
|
|
1385
|
+
kind=kind,
|
|
1386
|
+
reason="file deletion requires confirmation",
|
|
1387
|
+
preview=preview,
|
|
1388
|
+
files=files or [],
|
|
1389
|
+
allow_for_session_scope=exact_file_set_scope(files or [], operation=kind)
|
|
1390
|
+
if files
|
|
1391
|
+
else None,
|
|
1392
|
+
)
|
|
1393
|
+
)
|
|
1394
|
+
if not decision.allow:
|
|
1395
|
+
raise ApprovalDeclinedError(kind)
|
|
1396
|
+
|
|
1397
|
+
def guard_shell(cmd: str, *, tool_name: str = "shell_run") -> None:
|
|
1398
|
+
if persona_write_scope_active:
|
|
1399
|
+
raise AgentRuntimeError(
|
|
1400
|
+
f"Blocked while persona write scope is active: {tool_name}. "
|
|
1401
|
+
"Use scoped filesystem and inspection tools instead."
|
|
1402
|
+
)
|
|
1403
|
+
if matched_pattern := _fullaccess_denylist_match(cmd):
|
|
1404
|
+
if tool_name != "shell_run" and not is_full_access_mode:
|
|
1405
|
+
raise AgentRuntimeError(f"Blocked command: denylist pattern {matched_pattern}")
|
|
1406
|
+
raise AgentRuntimeError(
|
|
1407
|
+
f"Blocked fullaccess shell command by denylist pattern: {matched_pattern}"
|
|
1408
|
+
)
|
|
1409
|
+
if is_full_access_mode:
|
|
1410
|
+
return
|
|
1411
|
+
if mode == "readonly":
|
|
1412
|
+
raise AgentRuntimeError(f"Blocked in readonly mode: {tool_name}")
|
|
1413
|
+
decision = evaluate_shell_command(cmd)
|
|
1414
|
+
if not decision.allowed:
|
|
1415
|
+
raise AgentRuntimeError(f"Blocked command: {decision.reason}")
|
|
1416
|
+
if mode == "review":
|
|
1417
|
+
if non_interactive and not host_managed_approvals:
|
|
1418
|
+
raise AgentRuntimeError(
|
|
1419
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1420
|
+
)
|
|
1421
|
+
decision = surface.request_approval(
|
|
1422
|
+
ApprovalRequest(
|
|
1423
|
+
kind=tool_name,
|
|
1424
|
+
reason="review mode requires confirmation for shell commands",
|
|
1425
|
+
preview=cmd,
|
|
1426
|
+
command=cmd,
|
|
1427
|
+
allow_for_session_scope=exact_command_scope(cmd, kind=tool_name),
|
|
1428
|
+
)
|
|
1429
|
+
)
|
|
1430
|
+
if not decision.allow:
|
|
1431
|
+
raise ApprovalDeclinedError(tool_name)
|
|
1432
|
+
return
|
|
1433
|
+
# auto mode
|
|
1434
|
+
if decision.needs_confirm and not yes:
|
|
1435
|
+
if non_interactive and not host_managed_approvals:
|
|
1436
|
+
raise AgentRuntimeError(
|
|
1437
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1438
|
+
)
|
|
1439
|
+
choice = surface.request_approval(
|
|
1440
|
+
ApprovalRequest(
|
|
1441
|
+
kind=tool_name,
|
|
1442
|
+
reason=f"sensitive command: {decision.reason}",
|
|
1443
|
+
preview=cmd,
|
|
1444
|
+
command=cmd,
|
|
1445
|
+
allow_for_session_scope=exact_command_scope(cmd, kind=tool_name),
|
|
1446
|
+
)
|
|
1447
|
+
)
|
|
1448
|
+
if not choice.allow:
|
|
1449
|
+
raise ApprovalDeclinedError(tool_name)
|
|
1450
|
+
|
|
1451
|
+
def guard_terminal_op(op_name: str) -> None:
|
|
1452
|
+
if is_full_access_mode:
|
|
1453
|
+
return
|
|
1454
|
+
if mode == "readonly":
|
|
1455
|
+
raise AgentRuntimeError(f"Blocked in readonly mode: {op_name}")
|
|
1456
|
+
|
|
1457
|
+
def guard_verify(commands: list[str]) -> None:
|
|
1458
|
+
if persona_write_scope_active:
|
|
1459
|
+
raise AgentRuntimeError(
|
|
1460
|
+
"Blocked while persona write scope is active: verify_run. "
|
|
1461
|
+
"Verification commands can write paths the persona scope cannot constrain."
|
|
1462
|
+
)
|
|
1463
|
+
if is_full_access_mode:
|
|
1464
|
+
return
|
|
1465
|
+
if mode == "readonly":
|
|
1466
|
+
raise AgentRuntimeError("Blocked in readonly mode: verify_run")
|
|
1467
|
+
|
|
1468
|
+
sensitive_reason: str | None = None
|
|
1469
|
+
for command in commands:
|
|
1470
|
+
decision = evaluate_shell_command(command)
|
|
1471
|
+
if not decision.allowed:
|
|
1472
|
+
raise AgentRuntimeError(f"Blocked command: {decision.reason}")
|
|
1473
|
+
if sensitive_reason is None and decision.needs_confirm:
|
|
1474
|
+
sensitive_reason = decision.reason
|
|
1475
|
+
|
|
1476
|
+
preview = "\n".join(f"$ {command}" for command in commands)
|
|
1477
|
+
command_label = (
|
|
1478
|
+
commands[0] if len(commands) == 1 else f"{len(commands)} verification commands"
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
if mode == "review":
|
|
1482
|
+
if non_interactive and not host_managed_approvals:
|
|
1483
|
+
raise AgentRuntimeError(
|
|
1484
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1485
|
+
)
|
|
1486
|
+
decision = surface.request_approval(
|
|
1487
|
+
ApprovalRequest(
|
|
1488
|
+
kind="verify_run",
|
|
1489
|
+
reason="review mode requires confirmation for verification commands",
|
|
1490
|
+
preview=preview,
|
|
1491
|
+
command=command_label,
|
|
1492
|
+
allow_for_session_scope=exact_verify_command_set_scope(commands),
|
|
1493
|
+
)
|
|
1494
|
+
)
|
|
1495
|
+
if not decision.allow:
|
|
1496
|
+
raise ApprovalDeclinedError("verify_run")
|
|
1497
|
+
return
|
|
1498
|
+
|
|
1499
|
+
if sensitive_reason and not yes:
|
|
1500
|
+
if non_interactive and not host_managed_approvals:
|
|
1501
|
+
raise AgentRuntimeError(
|
|
1502
|
+
"Confirmation required for sensitive command. Re-run with --yes or adjust plan."
|
|
1503
|
+
)
|
|
1504
|
+
choice = surface.request_approval(
|
|
1505
|
+
ApprovalRequest(
|
|
1506
|
+
kind="verify_run",
|
|
1507
|
+
reason=f"sensitive command in verification set: {sensitive_reason}",
|
|
1508
|
+
preview=preview,
|
|
1509
|
+
command=command_label,
|
|
1510
|
+
allow_for_session_scope=exact_verify_command_set_scope(commands),
|
|
1511
|
+
)
|
|
1512
|
+
)
|
|
1513
|
+
if not choice.allow:
|
|
1514
|
+
raise ApprovalDeclinedError("verify_run")
|
|
1515
|
+
|
|
1516
|
+
tools: list[ToolDef] = []
|
|
1517
|
+
|
|
1518
|
+
def _default_active_workdir_relpath() -> str:
|
|
1519
|
+
return (
|
|
1520
|
+
_normalize_workspace_relpath(get_active_workdir_relpath())
|
|
1521
|
+
if callable(get_active_workdir_relpath)
|
|
1522
|
+
else "."
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
def _normalize_tool_path_base(
|
|
1526
|
+
raw_value: Any,
|
|
1527
|
+
*,
|
|
1528
|
+
field_name: str,
|
|
1529
|
+
default: str = "active_workdir",
|
|
1530
|
+
) -> str:
|
|
1531
|
+
if raw_value is None:
|
|
1532
|
+
return default
|
|
1533
|
+
text = str(raw_value).strip().lower()
|
|
1534
|
+
if not text:
|
|
1535
|
+
return default
|
|
1536
|
+
if text in {"active_workdir", "workspace_root"}:
|
|
1537
|
+
return text
|
|
1538
|
+
raise AgentRuntimeError(
|
|
1539
|
+
f"Invalid {field_name}: {raw_value!r}. Expected 'active_workdir' or 'workspace_root'."
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
def _resolve_workspace_relative_path(
|
|
1543
|
+
*,
|
|
1544
|
+
tool_name: str,
|
|
1545
|
+
raw_path: Any,
|
|
1546
|
+
raw_base: Any = None,
|
|
1547
|
+
field_name: str,
|
|
1548
|
+
base_field_name: str,
|
|
1549
|
+
allow_empty: bool = False,
|
|
1550
|
+
) -> str:
|
|
1551
|
+
workspace_root = root.resolve()
|
|
1552
|
+
base_kind = _normalize_tool_path_base(raw_base, field_name=base_field_name)
|
|
1553
|
+
if base_kind == "workspace_root":
|
|
1554
|
+
base_path = workspace_root
|
|
1555
|
+
else:
|
|
1556
|
+
base_path = resolve_workdir_relpath_within_workspace(
|
|
1557
|
+
workspace_root=workspace_root,
|
|
1558
|
+
relpath=_default_active_workdir_relpath(),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
text = "" if raw_path is None else str(raw_path).strip()
|
|
1562
|
+
if not text:
|
|
1563
|
+
if allow_empty:
|
|
1564
|
+
return _workspace_relpath_for_path(workspace_root=workspace_root, path=base_path)
|
|
1565
|
+
raise AgentRuntimeError(f"Missing required argument: {field_name}")
|
|
1566
|
+
|
|
1567
|
+
requested = Path(text)
|
|
1568
|
+
candidate = (
|
|
1569
|
+
requested.resolve() if requested.is_absolute() else (base_path / requested).resolve()
|
|
1570
|
+
)
|
|
1571
|
+
try:
|
|
1572
|
+
candidate.relative_to(workspace_root)
|
|
1573
|
+
except ValueError as e:
|
|
1574
|
+
payload = _path_escape_recovery_payload(
|
|
1575
|
+
tool_name=tool_name,
|
|
1576
|
+
attempted_path=text,
|
|
1577
|
+
field_name=field_name,
|
|
1578
|
+
workspace_root=workspace_root,
|
|
1579
|
+
path_base=base_kind,
|
|
1580
|
+
)
|
|
1581
|
+
raise AgentRuntimeError(str(payload["error"]), result_payload=payload) from e
|
|
1582
|
+
rel_path = _workspace_relpath_for_path(workspace_root=workspace_root, path=candidate)
|
|
1583
|
+
if rel_path == "README" and not (workspace_root / "README").exists():
|
|
1584
|
+
if (workspace_root / "README.md").exists():
|
|
1585
|
+
return "README.md"
|
|
1586
|
+
if rel_path == "README.md" and not (workspace_root / "README.md").exists():
|
|
1587
|
+
if (workspace_root / "README").exists():
|
|
1588
|
+
return "README"
|
|
1589
|
+
return rel_path
|
|
1590
|
+
|
|
1591
|
+
def _make_tool_def(
|
|
1592
|
+
name: str,
|
|
1593
|
+
*,
|
|
1594
|
+
run: Callable[[dict[str, Any]], dict[str, Any]],
|
|
1595
|
+
parameters: dict[str, Any] | None = None,
|
|
1596
|
+
) -> ToolDef:
|
|
1597
|
+
metadata = require_builtin_tool_metadata(name)
|
|
1598
|
+
return ToolDef(
|
|
1599
|
+
name=metadata.name,
|
|
1600
|
+
description=metadata.description,
|
|
1601
|
+
parameters=parameters if parameters is not None else copied_tool_parameters(name),
|
|
1602
|
+
run=run,
|
|
1603
|
+
metadata={
|
|
1604
|
+
"tool_type": "builtin",
|
|
1605
|
+
"compact_parameters_for_model": True,
|
|
1606
|
+
"model_description": _BUILTIN_MODEL_DESCRIPTIONS.get(
|
|
1607
|
+
metadata.name, metadata.description
|
|
1608
|
+
),
|
|
1609
|
+
},
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
def _custom_tool_requires_approval(spec: Any) -> bool:
|
|
1613
|
+
if mode == "review":
|
|
1614
|
+
return True
|
|
1615
|
+
return False
|
|
1616
|
+
|
|
1617
|
+
def _run_custom_tool(spec: Any, args: dict[str, Any]) -> dict[str, Any]:
|
|
1618
|
+
if mode == "readonly":
|
|
1619
|
+
raise AgentRuntimeError(f"Blocked in readonly mode: custom tool '{spec.name}'")
|
|
1620
|
+
args_preview = json.dumps(args, ensure_ascii=True, indent=2, sort_keys=True)
|
|
1621
|
+
preview = (
|
|
1622
|
+
f"Run custom tool\n"
|
|
1623
|
+
f"name: {spec.name}\n"
|
|
1624
|
+
f"scope: {spec.source_scope}\n"
|
|
1625
|
+
f"path: {spec.source_path}\n"
|
|
1626
|
+
f"{_custom_tool_capability_summary(spec)}\n"
|
|
1627
|
+
f"args:\n{args_preview}"
|
|
1628
|
+
)
|
|
1629
|
+
if _custom_tool_requires_approval(spec):
|
|
1630
|
+
if non_interactive and not host_managed_approvals:
|
|
1631
|
+
raise AgentRuntimeError(
|
|
1632
|
+
"Confirmation required for custom tool execution. Re-run with --yes or adjust plan."
|
|
1633
|
+
)
|
|
1634
|
+
decision = surface.request_approval(
|
|
1635
|
+
ApprovalRequest(
|
|
1636
|
+
kind=f"custom_tool_run:{spec.name}",
|
|
1637
|
+
reason="review mode requires confirmation for custom tools",
|
|
1638
|
+
preview=preview,
|
|
1639
|
+
files=[spec.relative_tool_path],
|
|
1640
|
+
command=spec.name,
|
|
1641
|
+
metadata={"custom_tool": spec.metadata(include_output_schema=True)},
|
|
1642
|
+
)
|
|
1643
|
+
)
|
|
1644
|
+
if not decision.allow:
|
|
1645
|
+
raise ApprovalDeclinedError(
|
|
1646
|
+
f"custom tool '{spec.name}'",
|
|
1647
|
+
message=f"User declined: custom tool '{spec.name}'",
|
|
1648
|
+
)
|
|
1649
|
+
artifact_dir: Path | None = None
|
|
1650
|
+
artifact_reference_prefix: str | None = None
|
|
1651
|
+
if store.artifact_persistence_enabled:
|
|
1652
|
+
artifact_dir = store.runtime_artifact_path("tool_logs")
|
|
1653
|
+
artifact_reference_prefix = store.session_artifact_layout.artifact_locator("tool_logs")
|
|
1654
|
+
return run_custom_tool(
|
|
1655
|
+
spec=spec,
|
|
1656
|
+
args=args,
|
|
1657
|
+
workspace_root=root,
|
|
1658
|
+
session_id=store.session_id,
|
|
1659
|
+
artifact_dir=artifact_dir,
|
|
1660
|
+
artifact_reference_prefix=artifact_reference_prefix,
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
def _append_builtin_tool(
|
|
1664
|
+
name: str,
|
|
1665
|
+
*,
|
|
1666
|
+
run: Callable[[dict[str, Any]], dict[str, Any]],
|
|
1667
|
+
parameters: dict[str, Any] | None = None,
|
|
1668
|
+
) -> None:
|
|
1669
|
+
if not _built_in_tool_exposed_in_mode(
|
|
1670
|
+
tool_name=name,
|
|
1671
|
+
mode=mode,
|
|
1672
|
+
subagent_depth=subagent_depth,
|
|
1673
|
+
readonly_child_web_tool_names=readonly_child_web_tool_names,
|
|
1674
|
+
):
|
|
1675
|
+
return
|
|
1676
|
+
tools.append(_make_tool_def(name, run=run, parameters=parameters))
|
|
1677
|
+
|
|
1678
|
+
def _fs_read(args: dict[str, Any]) -> dict[str, Any]:
|
|
1679
|
+
path = _resolve_workspace_relative_path(
|
|
1680
|
+
tool_name="fs_read",
|
|
1681
|
+
raw_path=args.get("path"),
|
|
1682
|
+
raw_base=args.get("path_base"),
|
|
1683
|
+
field_name="path",
|
|
1684
|
+
base_field_name="path_base",
|
|
1685
|
+
)
|
|
1686
|
+
sensitive = guard_sensitive_read("fs_read", path=path)
|
|
1687
|
+
content_hash_before = read_ledger.content_hash(path)
|
|
1688
|
+
result = _patchable("fs_read", fs_read)(
|
|
1689
|
+
root=root,
|
|
1690
|
+
path=path,
|
|
1691
|
+
max_bytes=int(args.get("max_bytes") or 20000),
|
|
1692
|
+
allow_derived=bool(args.get("allow_derived", False)),
|
|
1693
|
+
)
|
|
1694
|
+
result = read_ledger.filter_result(
|
|
1695
|
+
path=path,
|
|
1696
|
+
result=result,
|
|
1697
|
+
content_hash_before=content_hash_before,
|
|
1698
|
+
force=args.get("force") is True,
|
|
1699
|
+
)
|
|
1700
|
+
return _mark_sensitive_result(result, sensitive)
|
|
1701
|
+
|
|
1702
|
+
_append_builtin_tool("fs_read", run=_fs_read)
|
|
1703
|
+
|
|
1704
|
+
# report_blocker is a top-level completion-gate control signal, not a
|
|
1705
|
+
# repository action. Keep the runtime-kind check here as a fail-closed
|
|
1706
|
+
# boundary even though the session also computes gate eligibility.
|
|
1707
|
+
if (
|
|
1708
|
+
completion_gate_tools_enabled
|
|
1709
|
+
and subagent_depth == 0
|
|
1710
|
+
and resolved_runtime_kind
|
|
1711
|
+
in {
|
|
1712
|
+
RuntimeKind.INTERACTIVE_CHAT,
|
|
1713
|
+
RuntimeKind.ONE_SHOT,
|
|
1714
|
+
RuntimeKind.FORGE_EXEC,
|
|
1715
|
+
}
|
|
1716
|
+
):
|
|
1717
|
+
|
|
1718
|
+
def _report_blocker(args: dict[str, Any]) -> dict[str, Any]:
|
|
1719
|
+
raw_message = args.get("message")
|
|
1720
|
+
if not isinstance(raw_message, str) or not raw_message.strip():
|
|
1721
|
+
return {
|
|
1722
|
+
"error": "message must be a non-empty string",
|
|
1723
|
+
"error_code": "invalid_blocker_message",
|
|
1724
|
+
"reported": False,
|
|
1725
|
+
}
|
|
1726
|
+
message = raw_message.strip()
|
|
1727
|
+
if len(message) > REPORT_BLOCKER_MAX_MESSAGE_CHARS:
|
|
1728
|
+
return {
|
|
1729
|
+
"error": (
|
|
1730
|
+
"message exceeds the transport limit of "
|
|
1731
|
+
f"{REPORT_BLOCKER_MAX_MESSAGE_CHARS} characters"
|
|
1732
|
+
),
|
|
1733
|
+
"error_code": "blocker_message_too_long",
|
|
1734
|
+
"reported": False,
|
|
1735
|
+
}
|
|
1736
|
+
return {"ok": True, "reported": True, "message": message}
|
|
1737
|
+
|
|
1738
|
+
_append_builtin_tool("report_blocker", run=_report_blocker)
|
|
1739
|
+
|
|
1740
|
+
# switch_mode: model-proposed persona switch, user-approved, applied by the
|
|
1741
|
+
# chat loop at turn end (the tool surface is never swapped mid-turn). Only
|
|
1742
|
+
# the top-level interactive chat runtime provides persona_switch_state, so
|
|
1743
|
+
# one_shot/forge/swarm/subagent/conflict runtimes never see this tool and
|
|
1744
|
+
# automation can never switch personas silently.
|
|
1745
|
+
if (
|
|
1746
|
+
persona_switch_state is not None
|
|
1747
|
+
and resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT
|
|
1748
|
+
and not non_interactive
|
|
1749
|
+
and subagent_depth == 0
|
|
1750
|
+
and persona_modes_enabled(cfg)
|
|
1751
|
+
):
|
|
1752
|
+
|
|
1753
|
+
def _switch_mode(args: dict[str, Any]) -> dict[str, Any]:
|
|
1754
|
+
persona_raw = str(args.get("persona") or "").strip().lower()
|
|
1755
|
+
reason = " ".join(str(args.get("reason") or "").split())[:300]
|
|
1756
|
+
if not is_persona_name(persona_raw):
|
|
1757
|
+
return {
|
|
1758
|
+
"ok": False,
|
|
1759
|
+
"applied": False,
|
|
1760
|
+
"error": "unknown persona; valid: code, architect, ask, debug",
|
|
1761
|
+
}
|
|
1762
|
+
if persona_switch_state.last_declined == persona_raw:
|
|
1763
|
+
return {
|
|
1764
|
+
"ok": True,
|
|
1765
|
+
"applied": False,
|
|
1766
|
+
"declined": True,
|
|
1767
|
+
"note": (
|
|
1768
|
+
"The user already declined switching to this persona in "
|
|
1769
|
+
"this session; continue in the current persona without "
|
|
1770
|
+
"asking again."
|
|
1771
|
+
),
|
|
1772
|
+
}
|
|
1773
|
+
decision = surface.request_approval(
|
|
1774
|
+
ApprovalRequest(
|
|
1775
|
+
kind="persona_switch",
|
|
1776
|
+
reason=f"model proposes a persona switch: {reason or 'no reason given'}",
|
|
1777
|
+
preview=f"Switch persona to {persona_raw} for the rest of the session?",
|
|
1778
|
+
metadata={"persona": persona_raw},
|
|
1779
|
+
)
|
|
1780
|
+
)
|
|
1781
|
+
if not decision.allow:
|
|
1782
|
+
persona_switch_state.last_declined = persona_raw
|
|
1783
|
+
return {
|
|
1784
|
+
"ok": True,
|
|
1785
|
+
"applied": False,
|
|
1786
|
+
"declined": True,
|
|
1787
|
+
"note": "User declined; continue in the current persona.",
|
|
1788
|
+
}
|
|
1789
|
+
persona_switch_state.last_declined = None
|
|
1790
|
+
persona_switch_state.pending = (persona_raw, reason)
|
|
1791
|
+
return {
|
|
1792
|
+
"ok": True,
|
|
1793
|
+
"applied": False,
|
|
1794
|
+
"scheduled": True,
|
|
1795
|
+
"persona": persona_raw,
|
|
1796
|
+
"note": "Approved. The persona switch applies when this turn ends.",
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
_append_builtin_tool("switch_mode", run=_switch_mode)
|
|
1800
|
+
|
|
1801
|
+
def _fs_read_lines(args: dict[str, Any]) -> dict[str, Any]:
|
|
1802
|
+
path = _resolve_workspace_relative_path(
|
|
1803
|
+
tool_name="fs_read_lines",
|
|
1804
|
+
raw_path=args.get("path"),
|
|
1805
|
+
raw_base=args.get("path_base"),
|
|
1806
|
+
field_name="path",
|
|
1807
|
+
base_field_name="path_base",
|
|
1808
|
+
)
|
|
1809
|
+
sensitive = guard_sensitive_read("fs_read_lines", path=path)
|
|
1810
|
+
content_hash_before = read_ledger.content_hash(path)
|
|
1811
|
+
result = _patchable("fs_read_lines", fs_read_lines)(
|
|
1812
|
+
root=root,
|
|
1813
|
+
path=path,
|
|
1814
|
+
start_line=int(args["start_line"]) if args.get("start_line") is not None else 0,
|
|
1815
|
+
end_line=(int(args["end_line"]) if args.get("end_line") is not None else None),
|
|
1816
|
+
max_lines=(int(args["max_lines"]) if args.get("max_lines") is not None else 200),
|
|
1817
|
+
include_line_numbers=bool(args.get("include_line_numbers", True)),
|
|
1818
|
+
max_bytes=(int(args["max_bytes"]) if args.get("max_bytes") is not None else 48_000),
|
|
1819
|
+
)
|
|
1820
|
+
result = read_ledger.filter_result(
|
|
1821
|
+
path=path,
|
|
1822
|
+
result=result,
|
|
1823
|
+
content_hash_before=content_hash_before,
|
|
1824
|
+
force=args.get("force") is True,
|
|
1825
|
+
)
|
|
1826
|
+
return _mark_sensitive_result(result, sensitive)
|
|
1827
|
+
|
|
1828
|
+
_append_builtin_tool("fs_read_lines", run=_fs_read_lines)
|
|
1829
|
+
|
|
1830
|
+
def _fs_edit(args: dict[str, Any]) -> dict[str, Any]:
|
|
1831
|
+
path = _resolve_workspace_relative_path(
|
|
1832
|
+
tool_name="fs_edit",
|
|
1833
|
+
raw_path=args.get("path"),
|
|
1834
|
+
raw_base=args.get("path_base"),
|
|
1835
|
+
field_name="path",
|
|
1836
|
+
base_field_name="path_base",
|
|
1837
|
+
)
|
|
1838
|
+
_guard_write_path(path)
|
|
1839
|
+
raw_edits = args.get("edits")
|
|
1840
|
+
if not isinstance(raw_edits, list):
|
|
1841
|
+
raise FsError("edits must be a non-empty array of edit objects")
|
|
1842
|
+
sensitive_findings = _sensitive_path_findings([path])
|
|
1843
|
+
stamped_precondition = (
|
|
1844
|
+
capture_file_precondition(root=root, path=path) if sensitive_findings else None
|
|
1845
|
+
)
|
|
1846
|
+
sensitive = guard_sensitive_files("fs_edit", files=[path])
|
|
1847
|
+
if stamped_precondition is not None:
|
|
1848
|
+
try:
|
|
1849
|
+
assert_file_precondition(root=root, precondition=stamped_precondition)
|
|
1850
|
+
except StaleFileError as error:
|
|
1851
|
+
return _stale_file_result(error)
|
|
1852
|
+
try:
|
|
1853
|
+
prepared = prepare_fs_edit(root=root, path=path, edits=raw_edits)
|
|
1854
|
+
except FsError:
|
|
1855
|
+
if sensitive_findings:
|
|
1856
|
+
try:
|
|
1857
|
+
assert_file_precondition(root=root, precondition=stamped_precondition)
|
|
1858
|
+
except StaleFileError as error:
|
|
1859
|
+
return _stale_file_result(error)
|
|
1860
|
+
raise AgentRuntimeError(
|
|
1861
|
+
"Sensitive file edit could not be prepared; content details were redacted."
|
|
1862
|
+
) from None
|
|
1863
|
+
raise
|
|
1864
|
+
if stamped_precondition is not None:
|
|
1865
|
+
if prepared.precondition != stamped_precondition:
|
|
1866
|
+
return _stale_file_result(StaleFileError(path))
|
|
1867
|
+
prepared = replace(prepared, precondition=stamped_precondition)
|
|
1868
|
+
if sensitive:
|
|
1869
|
+
store.append(
|
|
1870
|
+
"sensitive_change_preview",
|
|
1871
|
+
{"path": path, "operation": "fs_edit", "content_redacted": True},
|
|
1872
|
+
)
|
|
1873
|
+
else:
|
|
1874
|
+
diff = _unified_diff(prepared.original_content, prepared.updated_content, path)
|
|
1875
|
+
store.append("diff_preview", {"path": path, "diff": diff[:20000]})
|
|
1876
|
+
surface.on_patch_generated(
|
|
1877
|
+
PatchEvent(
|
|
1878
|
+
files=[path],
|
|
1879
|
+
diff=diff,
|
|
1880
|
+
summary=f"1 file changed via fs_edit ({path})",
|
|
1881
|
+
)
|
|
1882
|
+
)
|
|
1883
|
+
guard_write("fs_edit", diff[:20000] or f"(no diff) {path}", files=[path])
|
|
1884
|
+
try:
|
|
1885
|
+
result = write_prepared_fs_edit(prepared, root=root)
|
|
1886
|
+
except StaleFileError as error:
|
|
1887
|
+
return _stale_file_result(error)
|
|
1888
|
+
read_ledger.invalidate(path)
|
|
1889
|
+
return _mark_sensitive_result(result, sensitive)
|
|
1890
|
+
|
|
1891
|
+
_append_builtin_tool("fs_edit", run=_fs_edit)
|
|
1892
|
+
|
|
1893
|
+
def _fs_move(args: dict[str, Any]) -> dict[str, Any]:
|
|
1894
|
+
source_path = _resolve_workspace_relative_path(
|
|
1895
|
+
tool_name="fs_move",
|
|
1896
|
+
raw_path=args.get("source_path"),
|
|
1897
|
+
raw_base=args.get("source_path_base"),
|
|
1898
|
+
field_name="source_path",
|
|
1899
|
+
base_field_name="source_path_base",
|
|
1900
|
+
)
|
|
1901
|
+
destination_path = _resolve_workspace_relative_path(
|
|
1902
|
+
tool_name="fs_move",
|
|
1903
|
+
raw_path=args.get("destination_path"),
|
|
1904
|
+
raw_base=args.get("destination_path_base"),
|
|
1905
|
+
field_name="destination_path",
|
|
1906
|
+
base_field_name="destination_path_base",
|
|
1907
|
+
)
|
|
1908
|
+
_guard_write_path(source_path)
|
|
1909
|
+
_guard_write_path(destination_path)
|
|
1910
|
+
overwrite = bool(args.get("overwrite", False))
|
|
1911
|
+
source_precondition = capture_file_precondition(root=root, path=source_path)
|
|
1912
|
+
destination_precondition = capture_file_precondition(root=root, path=destination_path)
|
|
1913
|
+
sensitive = guard_sensitive_files("fs_move", files=[source_path, destination_path])
|
|
1914
|
+
preview = (
|
|
1915
|
+
"Move file\n"
|
|
1916
|
+
f"source: {source_path}\n"
|
|
1917
|
+
f"destination: {destination_path}\n"
|
|
1918
|
+
f"overwrite: {str(overwrite).lower()}"
|
|
1919
|
+
)
|
|
1920
|
+
if not sensitive:
|
|
1921
|
+
guard_write("fs_move", preview, files=[source_path, destination_path])
|
|
1922
|
+
try:
|
|
1923
|
+
result = fs_move(
|
|
1924
|
+
root=root,
|
|
1925
|
+
source_path=source_path,
|
|
1926
|
+
destination_path=destination_path,
|
|
1927
|
+
overwrite=overwrite,
|
|
1928
|
+
source_precondition=source_precondition,
|
|
1929
|
+
destination_precondition=destination_precondition,
|
|
1930
|
+
)
|
|
1931
|
+
except StaleFileError as error:
|
|
1932
|
+
return _stale_file_result(error)
|
|
1933
|
+
read_ledger.invalidate(source_path, destination_path)
|
|
1934
|
+
return _mark_sensitive_result(result, sensitive)
|
|
1935
|
+
|
|
1936
|
+
_append_builtin_tool("fs_move", run=_fs_move)
|
|
1937
|
+
|
|
1938
|
+
def _fs_copy(args: dict[str, Any]) -> dict[str, Any]:
|
|
1939
|
+
source_path = _resolve_workspace_relative_path(
|
|
1940
|
+
tool_name="fs_copy",
|
|
1941
|
+
raw_path=args.get("source_path"),
|
|
1942
|
+
raw_base=args.get("source_path_base"),
|
|
1943
|
+
field_name="source_path",
|
|
1944
|
+
base_field_name="source_path_base",
|
|
1945
|
+
)
|
|
1946
|
+
destination_path = _resolve_workspace_relative_path(
|
|
1947
|
+
tool_name="fs_copy",
|
|
1948
|
+
raw_path=args.get("destination_path"),
|
|
1949
|
+
raw_base=args.get("destination_path_base"),
|
|
1950
|
+
field_name="destination_path",
|
|
1951
|
+
base_field_name="destination_path_base",
|
|
1952
|
+
)
|
|
1953
|
+
_guard_write_path(destination_path)
|
|
1954
|
+
overwrite = bool(args.get("overwrite", False))
|
|
1955
|
+
source_precondition = capture_file_precondition(root=root, path=source_path)
|
|
1956
|
+
destination_precondition = capture_file_precondition(root=root, path=destination_path)
|
|
1957
|
+
sensitive = guard_sensitive_files("fs_copy", files=[source_path, destination_path])
|
|
1958
|
+
preview = (
|
|
1959
|
+
"Copy file\n"
|
|
1960
|
+
f"source: {source_path}\n"
|
|
1961
|
+
f"destination: {destination_path}\n"
|
|
1962
|
+
f"overwrite: {str(overwrite).lower()}"
|
|
1963
|
+
)
|
|
1964
|
+
if not sensitive:
|
|
1965
|
+
guard_write("fs_copy", preview, files=[source_path, destination_path])
|
|
1966
|
+
try:
|
|
1967
|
+
result = fs_copy(
|
|
1968
|
+
root=root,
|
|
1969
|
+
source_path=source_path,
|
|
1970
|
+
destination_path=destination_path,
|
|
1971
|
+
overwrite=overwrite,
|
|
1972
|
+
source_precondition=source_precondition,
|
|
1973
|
+
destination_precondition=destination_precondition,
|
|
1974
|
+
)
|
|
1975
|
+
except StaleFileError as error:
|
|
1976
|
+
return _stale_file_result(error)
|
|
1977
|
+
read_ledger.invalidate(destination_path)
|
|
1978
|
+
return _mark_sensitive_result(result, sensitive)
|
|
1979
|
+
|
|
1980
|
+
_append_builtin_tool("fs_copy", run=_fs_copy)
|
|
1981
|
+
|
|
1982
|
+
def _fs_delete(args: dict[str, Any]) -> dict[str, Any]:
|
|
1983
|
+
path = _resolve_workspace_relative_path(
|
|
1984
|
+
tool_name="fs_delete",
|
|
1985
|
+
raw_path=args.get("path"),
|
|
1986
|
+
raw_base=args.get("path_base"),
|
|
1987
|
+
field_name="path",
|
|
1988
|
+
base_field_name="path_base",
|
|
1989
|
+
)
|
|
1990
|
+
try:
|
|
1991
|
+
_guard_write_path(path)
|
|
1992
|
+
except AgentRuntimeError as exc:
|
|
1993
|
+
if (
|
|
1994
|
+
persona_write_scope_active
|
|
1995
|
+
or "outside allowed scope" not in str(exc)
|
|
1996
|
+
or not is_non_material_untracked_path(path)
|
|
1997
|
+
):
|
|
1998
|
+
raise
|
|
1999
|
+
precondition = capture_file_precondition(root=root, path=path)
|
|
2000
|
+
sensitive = guard_sensitive_files("fs_delete", files=[path])
|
|
2001
|
+
preview = f"Delete file\npath: {path}"
|
|
2002
|
+
if not sensitive:
|
|
2003
|
+
guard_write("fs_delete", preview, files=[path])
|
|
2004
|
+
try:
|
|
2005
|
+
result = fs_delete(root=root, path=path, precondition=precondition)
|
|
2006
|
+
except StaleFileError as error:
|
|
2007
|
+
return _stale_file_result(error)
|
|
2008
|
+
read_ledger.invalidate(path)
|
|
2009
|
+
return _mark_sensitive_result(result, sensitive)
|
|
2010
|
+
|
|
2011
|
+
_append_builtin_tool("fs_delete", run=_fs_delete)
|
|
2012
|
+
|
|
2013
|
+
def _fs_write(args: dict[str, Any]) -> dict[str, Any]:
|
|
2014
|
+
path = _resolve_workspace_relative_path(
|
|
2015
|
+
tool_name="fs_write",
|
|
2016
|
+
raw_path=args.get("path"),
|
|
2017
|
+
raw_base=args.get("path_base"),
|
|
2018
|
+
field_name="path",
|
|
2019
|
+
base_field_name="path_base",
|
|
2020
|
+
)
|
|
2021
|
+
_guard_write_path(path)
|
|
2022
|
+
content = str(args.get("content", ""))
|
|
2023
|
+
prepared = prepare_fs_write(root=root, path=path, content=content)
|
|
2024
|
+
sensitive = guard_sensitive_files("fs_write", files=[path])
|
|
2025
|
+
rewrite_warning: str | None = None
|
|
2026
|
+
if sensitive:
|
|
2027
|
+
store.append(
|
|
2028
|
+
"sensitive_change_preview",
|
|
2029
|
+
{"path": path, "operation": "fs_write", "content_redacted": True},
|
|
2030
|
+
)
|
|
2031
|
+
else:
|
|
2032
|
+
if prepared.precondition.exists:
|
|
2033
|
+
old = _patchable("fs_read", fs_read)(root=root, path=path, max_bytes=2_000_000)[
|
|
2034
|
+
"content"
|
|
2035
|
+
]
|
|
2036
|
+
else:
|
|
2037
|
+
old = ""
|
|
2038
|
+
if edit_discipline is not None and old:
|
|
2039
|
+
# Free: `old` is already in hand for the diff preview below, so
|
|
2040
|
+
# scoring the overwrite costs no I/O and never scans the
|
|
2041
|
+
# workspace. Advisory only -- the write proceeds either way, and
|
|
2042
|
+
# a guard that raised would be a guard that broke writes.
|
|
2043
|
+
try:
|
|
2044
|
+
rewrite_warning = edit_discipline.warn_for_write(
|
|
2045
|
+
path=path, original=old, updated=content
|
|
2046
|
+
)
|
|
2047
|
+
except Exception: # noqa: BLE001 - advice must never fail a write
|
|
2048
|
+
rewrite_warning = None
|
|
2049
|
+
diff = _unified_diff(old, content, path)
|
|
2050
|
+
store.append("diff_preview", {"path": path, "diff": diff[:20000]})
|
|
2051
|
+
surface.on_patch_generated(
|
|
2052
|
+
PatchEvent(
|
|
2053
|
+
files=[path],
|
|
2054
|
+
diff=diff,
|
|
2055
|
+
summary=f"1 file changed via fs_write ({path})",
|
|
2056
|
+
)
|
|
2057
|
+
)
|
|
2058
|
+
guard_write("fs_write", diff[:20000] or f"(no diff) {path}", files=[path])
|
|
2059
|
+
try:
|
|
2060
|
+
result = write_prepared_fs_write(prepared, root=root)
|
|
2061
|
+
except StaleFileError as error:
|
|
2062
|
+
return _stale_file_result(error)
|
|
2063
|
+
read_ledger.invalidate(path)
|
|
2064
|
+
if rewrite_warning is not None:
|
|
2065
|
+
# Attached only after the write succeeded, so the model never reads
|
|
2066
|
+
# advice about an overwrite that did not happen.
|
|
2067
|
+
result["warning"] = rewrite_warning
|
|
2068
|
+
return _mark_sensitive_result(result, sensitive)
|
|
2069
|
+
|
|
2070
|
+
_append_builtin_tool("fs_write", run=_fs_write)
|
|
2071
|
+
|
|
2072
|
+
def _fs_mkdir(args: dict[str, Any]) -> dict[str, Any]:
|
|
2073
|
+
path = _resolve_workspace_relative_path(
|
|
2074
|
+
tool_name="fs_mkdir",
|
|
2075
|
+
raw_path=args.get("path"),
|
|
2076
|
+
raw_base=args.get("path_base"),
|
|
2077
|
+
field_name="path",
|
|
2078
|
+
base_field_name="path_base",
|
|
2079
|
+
)
|
|
2080
|
+
if _is_allowed_ancestor_dir_creation(path):
|
|
2081
|
+
if _is_denied_path(path):
|
|
2082
|
+
raise AgentRuntimeError(f"Blocked write to protected path: {path}")
|
|
2083
|
+
else:
|
|
2084
|
+
_guard_write_path(path)
|
|
2085
|
+
parents = bool(args.get("parents", True))
|
|
2086
|
+
exist_ok = bool(args.get("exist_ok", True))
|
|
2087
|
+
preview = (
|
|
2088
|
+
"Create directory\n"
|
|
2089
|
+
f"path: {path}\n"
|
|
2090
|
+
f"parents: {str(parents).lower()}\n"
|
|
2091
|
+
f"exist_ok: {str(exist_ok).lower()}"
|
|
2092
|
+
)
|
|
2093
|
+
guard_write("fs_mkdir", preview, files=[path])
|
|
2094
|
+
return fs_mkdir(
|
|
2095
|
+
root=root,
|
|
2096
|
+
path=path,
|
|
2097
|
+
parents=parents,
|
|
2098
|
+
exist_ok=exist_ok,
|
|
2099
|
+
)
|
|
2100
|
+
|
|
2101
|
+
_append_builtin_tool("fs_mkdir", run=_fs_mkdir)
|
|
2102
|
+
|
|
2103
|
+
if cfg is not None and cfg.image_generation.enabled:
|
|
2104
|
+
|
|
2105
|
+
def _image_generate(args: dict[str, Any]) -> dict[str, Any]:
|
|
2106
|
+
try:
|
|
2107
|
+
count = int(args.get("count", 1))
|
|
2108
|
+
planned = plan_image_output_paths(
|
|
2109
|
+
root=root,
|
|
2110
|
+
output_path=str(args.get("output_path") or ""),
|
|
2111
|
+
count=count,
|
|
2112
|
+
)
|
|
2113
|
+
except (ImageGenerationError, TypeError, ValueError) as exc:
|
|
2114
|
+
raise AgentRuntimeError(f"Invalid image generation request: {exc}") from exc
|
|
2115
|
+
relative_paths = [relative for _path, relative in planned]
|
|
2116
|
+
for relative_path in relative_paths:
|
|
2117
|
+
_guard_write_path(relative_path)
|
|
2118
|
+
preview = (
|
|
2119
|
+
"Generate image asset(s)\n"
|
|
2120
|
+
f"model: {cfg.image_generation.model}\n"
|
|
2121
|
+
f"paths: {', '.join(relative_paths)}\n"
|
|
2122
|
+
f"count: {count}\n"
|
|
2123
|
+
f"size: {str(args.get('size') or 'auto')}\n"
|
|
2124
|
+
f"quality: {str(args.get('quality') or 'auto')}\n"
|
|
2125
|
+
f"background: {str(args.get('background') or 'auto')}"
|
|
2126
|
+
)
|
|
2127
|
+
guard_write("image_generate", preview, files=relative_paths)
|
|
2128
|
+
try:
|
|
2129
|
+
result = generate_images(
|
|
2130
|
+
root=root,
|
|
2131
|
+
cfg=cfg,
|
|
2132
|
+
fallback_api_key=api_key,
|
|
2133
|
+
prompt=str(args.get("prompt") or ""),
|
|
2134
|
+
output_path=str(args.get("output_path") or ""),
|
|
2135
|
+
count=count,
|
|
2136
|
+
size=str(args.get("size") or "auto"),
|
|
2137
|
+
quality=str(args.get("quality") or "auto"),
|
|
2138
|
+
background=str(args.get("background") or "auto"),
|
|
2139
|
+
timeout_s=_deadline_timeout(
|
|
2140
|
+
cfg.image_generation.timeout_s,
|
|
2141
|
+
operation="image_generate",
|
|
2142
|
+
),
|
|
2143
|
+
)
|
|
2144
|
+
except (ImageGenerationError, DeadlineExhausted) as exc:
|
|
2145
|
+
raise AgentRuntimeError(f"Image generation failed: {exc}") from exc
|
|
2146
|
+
store.append("image_generated", dict(result))
|
|
2147
|
+
return result
|
|
2148
|
+
|
|
2149
|
+
_append_builtin_tool("image_generate", run=_image_generate)
|
|
2150
|
+
|
|
2151
|
+
_append_builtin_tool(
|
|
2152
|
+
"fs_list",
|
|
2153
|
+
run=lambda args: _patchable("fs_list", fs_list)(
|
|
2154
|
+
root=root,
|
|
2155
|
+
root_path=_resolve_workspace_relative_path(
|
|
2156
|
+
tool_name="fs_list",
|
|
2157
|
+
raw_path=args.get("root_path"),
|
|
2158
|
+
raw_base=args.get("path_base"),
|
|
2159
|
+
field_name="root_path",
|
|
2160
|
+
base_field_name="path_base",
|
|
2161
|
+
allow_empty=True,
|
|
2162
|
+
),
|
|
2163
|
+
globs=args.get("globs"),
|
|
2164
|
+
ignore=args.get("ignore"),
|
|
2165
|
+
),
|
|
2166
|
+
)
|
|
2167
|
+
|
|
2168
|
+
def _managed_browser_cancelled() -> bool:
|
|
2169
|
+
if managed_browser_cancel_check is None:
|
|
2170
|
+
return False
|
|
2171
|
+
try:
|
|
2172
|
+
return bool(managed_browser_cancel_check())
|
|
2173
|
+
except Exception: # noqa: BLE001 - a broken host token fails closed
|
|
2174
|
+
return True
|
|
2175
|
+
|
|
2176
|
+
def _browser_public_url(raw_url: Any) -> str | None:
|
|
2177
|
+
value = str(raw_url or "").strip()
|
|
2178
|
+
if not value:
|
|
2179
|
+
return None
|
|
2180
|
+
try:
|
|
2181
|
+
split = urlsplit(value)
|
|
2182
|
+
scheme = split.scheme.lower()
|
|
2183
|
+
hostname = split.hostname
|
|
2184
|
+
if scheme not in {"http", "https"} or not hostname:
|
|
2185
|
+
return None
|
|
2186
|
+
host = f"[{hostname}]" if ":" in hostname else hostname
|
|
2187
|
+
if split.port is not None:
|
|
2188
|
+
host = f"{host}:{split.port}"
|
|
2189
|
+
public_url = urlunsplit((scheme, host, split.path or "/", "", ""))
|
|
2190
|
+
except (TypeError, ValueError):
|
|
2191
|
+
return None
|
|
2192
|
+
return str(redact_secrets(public_url))
|
|
2193
|
+
|
|
2194
|
+
def _browser_status_payload(status: BrowserSessionStatus | Any) -> dict[str, Any]:
|
|
2195
|
+
if isinstance(status, dict):
|
|
2196
|
+
source = status
|
|
2197
|
+
else:
|
|
2198
|
+
source = {
|
|
2199
|
+
key: getattr(status, key, None)
|
|
2200
|
+
for key in (
|
|
2201
|
+
"session_id",
|
|
2202
|
+
"product",
|
|
2203
|
+
"state",
|
|
2204
|
+
"created_at",
|
|
2205
|
+
"active_url",
|
|
2206
|
+
"artifact_count",
|
|
2207
|
+
)
|
|
2208
|
+
}
|
|
2209
|
+
return {
|
|
2210
|
+
"session_id": str(source.get("session_id") or ""),
|
|
2211
|
+
"product": str(source.get("product") or ""),
|
|
2212
|
+
"state": str(source.get("state") or ""),
|
|
2213
|
+
"created_at": source.get("created_at"),
|
|
2214
|
+
"active_url": _browser_public_url(source.get("active_url")),
|
|
2215
|
+
"artifact_count": int(source.get("artifact_count") or 0),
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
def _browser_action_preview(kind: str, args: dict[str, Any]) -> str:
|
|
2219
|
+
session_id = str(args.get("session_id") or "").strip()
|
|
2220
|
+
lines = [f"Managed browser action: {kind}"]
|
|
2221
|
+
if session_id:
|
|
2222
|
+
lines.append(f"session: {session_id[:80]}")
|
|
2223
|
+
if kind == "browser_navigate":
|
|
2224
|
+
target = _browser_public_url(args.get("url"))
|
|
2225
|
+
lines.append(f"public target: {target or '(invalid URL)'}")
|
|
2226
|
+
lines.append("query parameters and fragments are omitted from this preview")
|
|
2227
|
+
elif kind in {"browser_click", "browser_type"}:
|
|
2228
|
+
selector = str(args.get("selector") or "")
|
|
2229
|
+
lines.append(f"selector: {selector[:300]}")
|
|
2230
|
+
if kind == "browser_type":
|
|
2231
|
+
lines.append(f"input characters: {len(str(args.get('text') or ''))}")
|
|
2232
|
+
lines.append("input text is intentionally omitted")
|
|
2233
|
+
elif kind == "browser_start":
|
|
2234
|
+
target = _browser_public_url(args.get("url"))
|
|
2235
|
+
if target:
|
|
2236
|
+
lines.append(f"initial target: {target}")
|
|
2237
|
+
lines.append("query parameters and fragments are omitted from this preview")
|
|
2238
|
+
lines.append("network policy: public or session-owned preview destinations")
|
|
2239
|
+
return "\n".join(lines)
|
|
2240
|
+
|
|
2241
|
+
def _guard_managed_browser_action(kind: str, args: dict[str, Any]) -> None:
|
|
2242
|
+
if not host_managed_approvals:
|
|
2243
|
+
raise AgentRuntimeError(
|
|
2244
|
+
"Managed browser state changes require an IDE host-managed approval."
|
|
2245
|
+
)
|
|
2246
|
+
decision = surface.request_approval(
|
|
2247
|
+
ApprovalRequest(
|
|
2248
|
+
kind=kind,
|
|
2249
|
+
reason="managed browser actions can launch processes or change remote page state",
|
|
2250
|
+
preview=_browser_action_preview(kind, args),
|
|
2251
|
+
command=kind,
|
|
2252
|
+
metadata={
|
|
2253
|
+
"managed_browser": True,
|
|
2254
|
+
"mandatory_explicit_approval": True,
|
|
2255
|
+
"allow_for_session_disabled": True,
|
|
2256
|
+
"public_or_owned_preview_destinations": True,
|
|
2257
|
+
},
|
|
2258
|
+
allow_for_session_scope=None,
|
|
2259
|
+
)
|
|
2260
|
+
)
|
|
2261
|
+
if not decision.allow:
|
|
2262
|
+
raise ApprovalDeclinedError(kind)
|
|
2263
|
+
if decision.allow_for_session:
|
|
2264
|
+
raise AgentRuntimeError(
|
|
2265
|
+
"Managed browser state changes require a one-time host approval for each action."
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
def _run_managed_browser(operation: Callable[[], Any]) -> Any:
|
|
2269
|
+
try:
|
|
2270
|
+
return operation()
|
|
2271
|
+
except BrowserError as exc:
|
|
2272
|
+
raise AgentRuntimeError(str(exc)) from exc
|
|
2273
|
+
except Exception as exc: # noqa: BLE001 - do not expose provider or page secrets
|
|
2274
|
+
raise AgentRuntimeError("Managed browser operation failed safely.") from exc
|
|
2275
|
+
|
|
2276
|
+
allowed_child_browser_tools = {
|
|
2277
|
+
str(name or "").strip() for name in (child_managed_browser_tool_names or ())
|
|
2278
|
+
}
|
|
2279
|
+
browser_tools_enabled = managed_browser_service is not None and (
|
|
2280
|
+
subagent_depth == 0 or (subagent_depth == 1 and bool(allowed_child_browser_tools))
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
def _session_owned_preview_urls() -> tuple[str, ...]:
|
|
2284
|
+
if durable_service_manager is None:
|
|
2285
|
+
return ()
|
|
2286
|
+
try:
|
|
2287
|
+
active_services = durable_service_manager.list_active()
|
|
2288
|
+
except Exception:
|
|
2289
|
+
return ()
|
|
2290
|
+
urls: list[str] = []
|
|
2291
|
+
for service in active_services:
|
|
2292
|
+
if not isinstance(service, dict):
|
|
2293
|
+
continue
|
|
2294
|
+
candidates = [service.get("preview_url")]
|
|
2295
|
+
raw_urls = service.get("preview_urls")
|
|
2296
|
+
if isinstance(raw_urls, list):
|
|
2297
|
+
candidates.extend(raw_urls)
|
|
2298
|
+
for raw in candidates:
|
|
2299
|
+
value = str(raw or "").strip()
|
|
2300
|
+
if value and value not in urls:
|
|
2301
|
+
urls.append(value)
|
|
2302
|
+
if len(urls) >= 64:
|
|
2303
|
+
return tuple(urls)
|
|
2304
|
+
return tuple(urls)
|
|
2305
|
+
|
|
2306
|
+
if browser_tools_enabled:
|
|
2307
|
+
browser_owner_id = str(managed_browser_owner_id or store.session_id).strip()
|
|
2308
|
+
|
|
2309
|
+
def _browser_is_direct_only(status: BrowserSessionStatus | Any) -> bool:
|
|
2310
|
+
if isinstance(status, dict):
|
|
2311
|
+
return bool(status.get("allow_local_destinations"))
|
|
2312
|
+
return bool(getattr(status, "allow_local_destinations", False))
|
|
2313
|
+
|
|
2314
|
+
def _browser_require_agent_visible(session_id: str) -> BrowserSessionStatus | Any:
|
|
2315
|
+
"""Fence direct localhost sessions away from model-controlled tools.
|
|
2316
|
+
|
|
2317
|
+
``allow_local_destinations`` is immutable for a managed-browser
|
|
2318
|
+
session, so checking the owner-scoped status before every action is
|
|
2319
|
+
a stable actor boundary rather than a best-effort URL check.
|
|
2320
|
+
"""
|
|
2321
|
+
|
|
2322
|
+
status = _run_managed_browser(
|
|
2323
|
+
lambda: managed_browser_service.status(browser_owner_id, session_id)
|
|
2324
|
+
)
|
|
2325
|
+
if _browser_is_direct_only(status):
|
|
2326
|
+
raise AgentRuntimeError(
|
|
2327
|
+
"This browser session is reserved for direct IDE localhost testing."
|
|
2328
|
+
)
|
|
2329
|
+
return status
|
|
2330
|
+
|
|
2331
|
+
def _browser_start(args: dict[str, Any]) -> dict[str, Any]:
|
|
2332
|
+
_guard_managed_browser_action("browser_start", args)
|
|
2333
|
+
status = _run_managed_browser(
|
|
2334
|
+
lambda: managed_browser_service.start(
|
|
2335
|
+
browser_owner_id,
|
|
2336
|
+
allow_local_destinations=False,
|
|
2337
|
+
allowed_preview_urls_provider=_session_owned_preview_urls,
|
|
2338
|
+
cancel=_managed_browser_cancelled,
|
|
2339
|
+
)
|
|
2340
|
+
)
|
|
2341
|
+
payload = _browser_status_payload(status)
|
|
2342
|
+
target = str(args.get("url") or "").strip()
|
|
2343
|
+
if not target:
|
|
2344
|
+
return payload
|
|
2345
|
+
session_id = str(payload.get("session_id") or "")
|
|
2346
|
+
try:
|
|
2347
|
+
navigated = _run_managed_browser(
|
|
2348
|
+
lambda: managed_browser_service.navigate(
|
|
2349
|
+
browser_owner_id,
|
|
2350
|
+
session_id,
|
|
2351
|
+
target,
|
|
2352
|
+
timeout=args.get("timeout"),
|
|
2353
|
+
cancel=_managed_browser_cancelled,
|
|
2354
|
+
)
|
|
2355
|
+
)
|
|
2356
|
+
except Exception:
|
|
2357
|
+
try:
|
|
2358
|
+
managed_browser_service.close(
|
|
2359
|
+
browser_owner_id,
|
|
2360
|
+
session_id,
|
|
2361
|
+
delete_artifacts=True,
|
|
2362
|
+
)
|
|
2363
|
+
except Exception:
|
|
2364
|
+
pass
|
|
2365
|
+
raise
|
|
2366
|
+
navigated_payload = dict(navigated) if isinstance(navigated, dict) else {}
|
|
2367
|
+
payload["active_url"] = _browser_public_url(navigated_payload.get("url") or target)
|
|
2368
|
+
return payload
|
|
2369
|
+
|
|
2370
|
+
def _browser_navigate(args: dict[str, Any]) -> dict[str, Any]:
|
|
2371
|
+
session_id = str(args.get("session_id") or "")
|
|
2372
|
+
_browser_require_agent_visible(session_id)
|
|
2373
|
+
_guard_managed_browser_action("browser_navigate", args)
|
|
2374
|
+
result = _run_managed_browser(
|
|
2375
|
+
lambda: managed_browser_service.navigate(
|
|
2376
|
+
browser_owner_id,
|
|
2377
|
+
session_id,
|
|
2378
|
+
str(args.get("url") or ""),
|
|
2379
|
+
timeout=args.get("timeout"),
|
|
2380
|
+
cancel=_managed_browser_cancelled,
|
|
2381
|
+
)
|
|
2382
|
+
)
|
|
2383
|
+
payload = dict(result) if isinstance(result, dict) else {}
|
|
2384
|
+
payload["url"] = _browser_public_url(payload.get("url") or args.get("url"))
|
|
2385
|
+
return payload
|
|
2386
|
+
|
|
2387
|
+
def _browser_snapshot(args: dict[str, Any]) -> dict[str, Any]:
|
|
2388
|
+
session_id = str(args.get("session_id") or "")
|
|
2389
|
+
_browser_require_agent_visible(session_id)
|
|
2390
|
+
result = _run_managed_browser(
|
|
2391
|
+
lambda: managed_browser_service.snapshot(
|
|
2392
|
+
browser_owner_id,
|
|
2393
|
+
session_id,
|
|
2394
|
+
kind=str(args.get("kind") or "semantic"),
|
|
2395
|
+
timeout=args.get("timeout"),
|
|
2396
|
+
cancel=_managed_browser_cancelled,
|
|
2397
|
+
)
|
|
2398
|
+
)
|
|
2399
|
+
return dict(result) if isinstance(result, dict) else {"data": result}
|
|
2400
|
+
|
|
2401
|
+
def _browser_screenshot(args: dict[str, Any]) -> dict[str, Any]:
|
|
2402
|
+
session_id = str(args.get("session_id") or "")
|
|
2403
|
+
_browser_require_agent_visible(session_id)
|
|
2404
|
+
artifact = _run_managed_browser(
|
|
2405
|
+
lambda: managed_browser_service.screenshot(
|
|
2406
|
+
browser_owner_id,
|
|
2407
|
+
session_id,
|
|
2408
|
+
full_page=bool(args.get("full_page", False)),
|
|
2409
|
+
timeout=args.get("timeout"),
|
|
2410
|
+
cancel=_managed_browser_cancelled,
|
|
2411
|
+
)
|
|
2412
|
+
)
|
|
2413
|
+
if not isinstance(artifact, BrowserArtifact) and not hasattr(artifact, "artifact_id"):
|
|
2414
|
+
raise AgentRuntimeError("Managed browser returned an invalid artifact.")
|
|
2415
|
+
return {
|
|
2416
|
+
"artifact_id": str(artifact.artifact_id),
|
|
2417
|
+
"media_type": str(artifact.media_type),
|
|
2418
|
+
"size_bytes": int(artifact.size_bytes),
|
|
2419
|
+
"sha256": str(artifact.sha256),
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
def _browser_artifact_read(args: dict[str, Any]) -> dict[str, Any]:
|
|
2423
|
+
session_id = str(args.get("session_id") or "")
|
|
2424
|
+
_browser_require_agent_visible(session_id)
|
|
2425
|
+
result = _run_managed_browser(
|
|
2426
|
+
lambda: managed_browser_service.read_artifact(
|
|
2427
|
+
browser_owner_id,
|
|
2428
|
+
session_id,
|
|
2429
|
+
str(args.get("artifact_id") or ""),
|
|
2430
|
+
offset=args.get("offset", 0),
|
|
2431
|
+
max_bytes=args.get("max_bytes", 256 * 1024),
|
|
2432
|
+
)
|
|
2433
|
+
)
|
|
2434
|
+
return dict(result) if isinstance(result, dict) else {"data": result}
|
|
2435
|
+
|
|
2436
|
+
def _browser_diagnostics(args: dict[str, Any]) -> dict[str, Any]:
|
|
2437
|
+
session_id = str(args.get("session_id") or "")
|
|
2438
|
+
_browser_require_agent_visible(session_id)
|
|
2439
|
+
result = _run_managed_browser(
|
|
2440
|
+
lambda: managed_browser_service.diagnostics(
|
|
2441
|
+
browser_owner_id,
|
|
2442
|
+
session_id,
|
|
2443
|
+
max_events=args.get("max_events"),
|
|
2444
|
+
timeout=args.get("timeout"),
|
|
2445
|
+
cancel=_managed_browser_cancelled,
|
|
2446
|
+
)
|
|
2447
|
+
)
|
|
2448
|
+
return dict(result) if isinstance(result, dict) else {"data": result}
|
|
2449
|
+
|
|
2450
|
+
def _browser_click(args: dict[str, Any]) -> dict[str, Any]:
|
|
2451
|
+
session_id = str(args.get("session_id") or "")
|
|
2452
|
+
_browser_require_agent_visible(session_id)
|
|
2453
|
+
_guard_managed_browser_action("browser_click", args)
|
|
2454
|
+
result = _run_managed_browser(
|
|
2455
|
+
lambda: managed_browser_service.click(
|
|
2456
|
+
browser_owner_id,
|
|
2457
|
+
session_id,
|
|
2458
|
+
str(args.get("selector") or ""),
|
|
2459
|
+
timeout=args.get("timeout"),
|
|
2460
|
+
cancel=_managed_browser_cancelled,
|
|
2461
|
+
)
|
|
2462
|
+
)
|
|
2463
|
+
return dict(result) if isinstance(result, dict) else {"clicked": bool(result)}
|
|
2464
|
+
|
|
2465
|
+
def _browser_type(args: dict[str, Any]) -> dict[str, Any]:
|
|
2466
|
+
session_id = str(args.get("session_id") or "")
|
|
2467
|
+
_browser_require_agent_visible(session_id)
|
|
2468
|
+
_guard_managed_browser_action("browser_type", args)
|
|
2469
|
+
result = _run_managed_browser(
|
|
2470
|
+
lambda: managed_browser_service.type_text(
|
|
2471
|
+
browser_owner_id,
|
|
2472
|
+
session_id,
|
|
2473
|
+
str(args.get("selector") or ""),
|
|
2474
|
+
str(args.get("text") or ""),
|
|
2475
|
+
replace=bool(args.get("replace", True)),
|
|
2476
|
+
timeout=args.get("timeout"),
|
|
2477
|
+
cancel=_managed_browser_cancelled,
|
|
2478
|
+
)
|
|
2479
|
+
)
|
|
2480
|
+
payload = dict(result) if isinstance(result, dict) else {"typed": bool(result)}
|
|
2481
|
+
payload.pop("text", None)
|
|
2482
|
+
return payload
|
|
2483
|
+
|
|
2484
|
+
def _browser_status(args: dict[str, Any]) -> dict[str, Any]:
|
|
2485
|
+
status = _browser_require_agent_visible(str(args.get("session_id") or ""))
|
|
2486
|
+
return _browser_status_payload(status)
|
|
2487
|
+
|
|
2488
|
+
def _browser_list(_args: dict[str, Any]) -> dict[str, Any]:
|
|
2489
|
+
statuses = _run_managed_browser(lambda: managed_browser_service.list(browser_owner_id))
|
|
2490
|
+
items = [
|
|
2491
|
+
_browser_status_payload(status)
|
|
2492
|
+
for status in tuple(statuses)
|
|
2493
|
+
if not _browser_is_direct_only(status)
|
|
2494
|
+
]
|
|
2495
|
+
return {"sessions": items, "count": len(items)}
|
|
2496
|
+
|
|
2497
|
+
def _browser_close(args: dict[str, Any]) -> dict[str, Any]:
|
|
2498
|
+
session_id = str(args.get("session_id") or "")
|
|
2499
|
+
_browser_require_agent_visible(session_id)
|
|
2500
|
+
_guard_managed_browser_action("browser_close", args)
|
|
2501
|
+
closed = _run_managed_browser(
|
|
2502
|
+
lambda: managed_browser_service.close(
|
|
2503
|
+
browser_owner_id,
|
|
2504
|
+
session_id,
|
|
2505
|
+
# Browser screenshots are ephemeral owner-scoped artifacts.
|
|
2506
|
+
# The service has no safe retained-session read surface, so
|
|
2507
|
+
# close must not leave unreachable private files behind.
|
|
2508
|
+
delete_artifacts=True,
|
|
2509
|
+
)
|
|
2510
|
+
)
|
|
2511
|
+
return {"session_id": session_id, "closed": bool(closed)}
|
|
2512
|
+
|
|
2513
|
+
def _append_browser_tool(
|
|
2514
|
+
name: str,
|
|
2515
|
+
*,
|
|
2516
|
+
run: Callable[[dict[str, Any]], dict[str, Any]],
|
|
2517
|
+
) -> None:
|
|
2518
|
+
if subagent_depth == 1 and name not in allowed_child_browser_tools:
|
|
2519
|
+
return
|
|
2520
|
+
_append_builtin_tool(name, run=run)
|
|
2521
|
+
|
|
2522
|
+
_append_browser_tool("browser_start", run=_browser_start)
|
|
2523
|
+
_append_browser_tool("browser_navigate", run=_browser_navigate)
|
|
2524
|
+
_append_browser_tool("browser_snapshot", run=_browser_snapshot)
|
|
2525
|
+
_append_browser_tool("browser_screenshot", run=_browser_screenshot)
|
|
2526
|
+
_append_browser_tool("browser_artifact_read", run=_browser_artifact_read)
|
|
2527
|
+
_append_browser_tool("browser_diagnostics", run=_browser_diagnostics)
|
|
2528
|
+
_append_browser_tool("browser_click", run=_browser_click)
|
|
2529
|
+
_append_browser_tool("browser_type", run=_browser_type)
|
|
2530
|
+
_append_browser_tool("browser_status", run=_browser_status)
|
|
2531
|
+
_append_browser_tool("browser_list", run=_browser_list)
|
|
2532
|
+
_append_browser_tool("browser_close", run=_browser_close)
|
|
2533
|
+
|
|
2534
|
+
# Master web-tools switch: when off (config field or ALYSIS_WEB_TOOLS env),
|
|
2535
|
+
# neither web_fetch nor web_search is registered at all — the model never sees
|
|
2536
|
+
# them in its tool list. Required for benchmark/offline integrity.
|
|
2537
|
+
web_tools_enabled = resolve_web_tools_enabled(cfg)
|
|
2538
|
+
|
|
2539
|
+
web_search_exposed_in_mode = (
|
|
2540
|
+
web_tools_enabled
|
|
2541
|
+
and _built_in_tool_exposed_in_mode(
|
|
2542
|
+
tool_name="web_search",
|
|
2543
|
+
mode=mode,
|
|
2544
|
+
subagent_depth=subagent_depth,
|
|
2545
|
+
readonly_child_web_tool_names=readonly_child_web_tool_names,
|
|
2546
|
+
)
|
|
2547
|
+
and resolve_web_search_policy(cfg) != "off"
|
|
2548
|
+
)
|
|
2549
|
+
web_search_status = (
|
|
2550
|
+
resolve_web_search_runtime_status(cfg=cfg, api_key=api_key)
|
|
2551
|
+
if web_search_exposed_in_mode
|
|
2552
|
+
else None
|
|
2553
|
+
)
|
|
2554
|
+
|
|
2555
|
+
def _web_fetch_recovery_is_public_candidate(raw_url: str) -> bool:
|
|
2556
|
+
normalized = normalize_web_url(raw_url)
|
|
2557
|
+
if normalized is None:
|
|
2558
|
+
return False
|
|
2559
|
+
try:
|
|
2560
|
+
split = urlsplit(normalized)
|
|
2561
|
+
except ValueError:
|
|
2562
|
+
return False
|
|
2563
|
+
host = (split.hostname or "").rstrip(".").lower()
|
|
2564
|
+
if not host or host == "localhost" or host.endswith(".localhost"):
|
|
2565
|
+
return False
|
|
2566
|
+
if split.username is not None or split.password is not None:
|
|
2567
|
+
return False
|
|
2568
|
+
try:
|
|
2569
|
+
ip = ipaddress.ip_address(host)
|
|
2570
|
+
except ValueError:
|
|
2571
|
+
return True
|
|
2572
|
+
return not (
|
|
2573
|
+
ip.is_loopback
|
|
2574
|
+
or ip.is_private
|
|
2575
|
+
or ip.is_link_local
|
|
2576
|
+
or ip.is_multicast
|
|
2577
|
+
or ip.is_unspecified
|
|
2578
|
+
or ip.is_reserved
|
|
2579
|
+
)
|
|
2580
|
+
|
|
2581
|
+
def _web_fetch_source_matches_requested(*, source_url: str, requested_url: str) -> bool:
|
|
2582
|
+
source = normalize_web_url(source_url)
|
|
2583
|
+
requested = normalize_web_url(requested_url)
|
|
2584
|
+
if source is None or requested is None:
|
|
2585
|
+
return False
|
|
2586
|
+
if source == requested:
|
|
2587
|
+
return True
|
|
2588
|
+
source_split = urlsplit(source)
|
|
2589
|
+
requested_split = urlsplit(requested)
|
|
2590
|
+
if (
|
|
2591
|
+
source_split.scheme,
|
|
2592
|
+
source_split.netloc,
|
|
2593
|
+
source_split.query,
|
|
2594
|
+
) != (
|
|
2595
|
+
requested_split.scheme,
|
|
2596
|
+
requested_split.netloc,
|
|
2597
|
+
requested_split.query,
|
|
2598
|
+
):
|
|
2599
|
+
return False
|
|
2600
|
+
return source_split.path.rstrip("/") == requested_split.path.rstrip("/")
|
|
2601
|
+
|
|
2602
|
+
def _web_fetch_recovery_display_url(raw_url: str) -> str:
|
|
2603
|
+
normalized = normalize_web_url(raw_url)
|
|
2604
|
+
if normalized is not None:
|
|
2605
|
+
return normalized
|
|
2606
|
+
canonical = canonicalize_web_url_input(raw_url)
|
|
2607
|
+
if canonical is not None:
|
|
2608
|
+
return canonical
|
|
2609
|
+
try:
|
|
2610
|
+
split = urlsplit(str(raw_url or "").strip())
|
|
2611
|
+
port = split.port
|
|
2612
|
+
except ValueError:
|
|
2613
|
+
return "[invalid URL omitted]"
|
|
2614
|
+
scheme = str(split.scheme or "").lower()
|
|
2615
|
+
host = (split.hostname or "").rstrip(".").lower()
|
|
2616
|
+
if scheme not in {"http", "https"} or not host:
|
|
2617
|
+
return "[unsupported URL omitted]"
|
|
2618
|
+
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
|
|
2619
|
+
port = None
|
|
2620
|
+
netloc = host if port is None else f"{host}:{port}"
|
|
2621
|
+
path = split.path or "/"
|
|
2622
|
+
return urlunsplit((scheme, netloc, path, split.query, ""))
|
|
2623
|
+
|
|
2624
|
+
def _web_fetch_recovery_payload(
|
|
2625
|
+
*,
|
|
2626
|
+
requested_url: str,
|
|
2627
|
+
raw_requested_url: str,
|
|
2628
|
+
finalization_suppressed: bool,
|
|
2629
|
+
automatic_attempted: bool = False,
|
|
2630
|
+
search_error: str = "",
|
|
2631
|
+
) -> dict[str, Any]:
|
|
2632
|
+
display_url = _web_fetch_recovery_display_url(requested_url)
|
|
2633
|
+
query = build_web_fetch_recovery_search_query(display_url)
|
|
2634
|
+
payload: dict[str, Any] = {
|
|
2635
|
+
"error": (
|
|
2636
|
+
"web_fetch only allows a URL explicitly provided by the user or one returned "
|
|
2637
|
+
"by web_search earlier in this session."
|
|
2638
|
+
),
|
|
2639
|
+
"error_code": "web_fetch_provenance_required",
|
|
2640
|
+
"url": display_url,
|
|
2641
|
+
"allowed_provenance": [
|
|
2642
|
+
"user_provided",
|
|
2643
|
+
"returned_by_web_search",
|
|
2644
|
+
"fetched_page_link",
|
|
2645
|
+
"trusted_local_file",
|
|
2646
|
+
"trusted_tool_output",
|
|
2647
|
+
"canonical_redirect",
|
|
2648
|
+
"search_mediated_recovery",
|
|
2649
|
+
"same_origin_derived_search_result",
|
|
2650
|
+
],
|
|
2651
|
+
"provenance_recovery": {
|
|
2652
|
+
"suggested_search_query": query,
|
|
2653
|
+
"web_search_available": bool(
|
|
2654
|
+
web_search_status is not None and web_search_status.registration_ready
|
|
2655
|
+
),
|
|
2656
|
+
"automatic_recovery_attempted": automatic_attempted,
|
|
2657
|
+
"finalization_suppressed": finalization_suppressed,
|
|
2658
|
+
"search_error": search_error,
|
|
2659
|
+
},
|
|
2660
|
+
}
|
|
2661
|
+
canonical_raw = normalize_web_url(raw_requested_url)
|
|
2662
|
+
if canonical_raw is not None and canonical_raw != display_url:
|
|
2663
|
+
payload["raw_input_url"] = raw_requested_url
|
|
2664
|
+
return payload
|
|
2665
|
+
|
|
2666
|
+
def _maybe_establish_web_fetch_provenance_via_search(
|
|
2667
|
+
*,
|
|
2668
|
+
requested_url: str,
|
|
2669
|
+
raw_requested_url: str,
|
|
2670
|
+
) -> tuple[str | None, str | None, dict[str, Any] | None]:
|
|
2671
|
+
finalization_suppressed = (
|
|
2672
|
+
execution_deadline is not None
|
|
2673
|
+
and execution_deadline.phase() == DeadlinePhase.FINALIZATION_WINDOW
|
|
2674
|
+
)
|
|
2675
|
+
base_payload = _web_fetch_recovery_payload(
|
|
2676
|
+
requested_url=requested_url,
|
|
2677
|
+
raw_requested_url=raw_requested_url,
|
|
2678
|
+
finalization_suppressed=finalization_suppressed,
|
|
2679
|
+
)
|
|
2680
|
+
if finalization_suppressed:
|
|
2681
|
+
return None, None, base_payload
|
|
2682
|
+
if web_search_status is None or not web_search_status.registration_ready:
|
|
2683
|
+
return None, None, base_payload
|
|
2684
|
+
if not _web_fetch_recovery_is_public_candidate(requested_url):
|
|
2685
|
+
return None, None, base_payload
|
|
2686
|
+
query = str(base_payload["provenance_recovery"]["suggested_search_query"] or "").strip()
|
|
2687
|
+
if not query:
|
|
2688
|
+
return None, None, base_payload
|
|
2689
|
+
try:
|
|
2690
|
+
host = (
|
|
2691
|
+
urlsplit(normalize_web_url(requested_url) or requested_url).hostname or ""
|
|
2692
|
+
).lower()
|
|
2693
|
+
search_result = web_search(
|
|
2694
|
+
query=query,
|
|
2695
|
+
cfg=cfg,
|
|
2696
|
+
api_key=api_key,
|
|
2697
|
+
allowed_domains=[host] if host else None,
|
|
2698
|
+
max_sources=5,
|
|
2699
|
+
external_web_access=True,
|
|
2700
|
+
session_id=str(getattr(store, "session_id", "") or "") or None,
|
|
2701
|
+
)
|
|
2702
|
+
except WebSearchError as exc:
|
|
2703
|
+
return (
|
|
2704
|
+
None,
|
|
2705
|
+
None,
|
|
2706
|
+
_web_fetch_recovery_payload(
|
|
2707
|
+
requested_url=requested_url,
|
|
2708
|
+
raw_requested_url=raw_requested_url,
|
|
2709
|
+
finalization_suppressed=finalization_suppressed,
|
|
2710
|
+
automatic_attempted=True,
|
|
2711
|
+
search_error=str(exc),
|
|
2712
|
+
),
|
|
2713
|
+
)
|
|
2714
|
+
matching_source_url = ""
|
|
2715
|
+
for source in list(search_result.get("sources") or []):
|
|
2716
|
+
if not isinstance(source, dict):
|
|
2717
|
+
continue
|
|
2718
|
+
source_url = str(source.get("url") or "").strip()
|
|
2719
|
+
if _web_fetch_source_matches_requested(
|
|
2720
|
+
source_url=source_url,
|
|
2721
|
+
requested_url=requested_url,
|
|
2722
|
+
):
|
|
2723
|
+
matching_source_url = source_url
|
|
2724
|
+
break
|
|
2725
|
+
if not matching_source_url:
|
|
2726
|
+
payload = _web_fetch_recovery_payload(
|
|
2727
|
+
requested_url=requested_url,
|
|
2728
|
+
raw_requested_url=raw_requested_url,
|
|
2729
|
+
finalization_suppressed=finalization_suppressed,
|
|
2730
|
+
automatic_attempted=True,
|
|
2731
|
+
)
|
|
2732
|
+
payload["provenance_recovery"]["search_result_source_count"] = len(
|
|
2733
|
+
list(search_result.get("sources") or [])
|
|
2734
|
+
)
|
|
2735
|
+
return None, None, payload
|
|
2736
|
+
_changed, normalized = store.establish_search_mediated_web_fetch_url(
|
|
2737
|
+
raw_url=requested_url,
|
|
2738
|
+
query=query,
|
|
2739
|
+
source_url=matching_source_url,
|
|
2740
|
+
)
|
|
2741
|
+
store.append(
|
|
2742
|
+
"web_fetch_provenance_recovery",
|
|
2743
|
+
{
|
|
2744
|
+
"url": requested_url,
|
|
2745
|
+
"normalized_url": normalized,
|
|
2746
|
+
"query": query,
|
|
2747
|
+
"source_url": matching_source_url,
|
|
2748
|
+
"provenance_classification": "search_mediated_recovery",
|
|
2749
|
+
},
|
|
2750
|
+
)
|
|
2751
|
+
return store.resolve_web_fetch_url(requested_url)[0], normalized, None
|
|
2752
|
+
|
|
2753
|
+
def _web_fetch_tool(args: dict[str, Any]) -> dict[str, Any]:
|
|
2754
|
+
raw_requested_url = str(args.get("url", "")).strip()
|
|
2755
|
+
provenance_classification, resolved_requested_url = store.resolve_web_fetch_url(
|
|
2756
|
+
raw_requested_url
|
|
2757
|
+
)
|
|
2758
|
+
requested_url = resolved_requested_url or raw_requested_url
|
|
2759
|
+
recovered_via_search = False
|
|
2760
|
+
if provenance_classification is None:
|
|
2761
|
+
(
|
|
2762
|
+
provenance_classification,
|
|
2763
|
+
recovered_url,
|
|
2764
|
+
recovery_result,
|
|
2765
|
+
) = _maybe_establish_web_fetch_provenance_via_search(
|
|
2766
|
+
requested_url=requested_url,
|
|
2767
|
+
raw_requested_url=raw_requested_url,
|
|
2768
|
+
)
|
|
2769
|
+
if provenance_classification is None:
|
|
2770
|
+
rejection = recovery_result or _web_fetch_recovery_payload(
|
|
2771
|
+
requested_url=requested_url,
|
|
2772
|
+
raw_requested_url=raw_requested_url,
|
|
2773
|
+
finalization_suppressed=False,
|
|
2774
|
+
)
|
|
2775
|
+
# Make the rejection self-correcting: tell the model exactly which
|
|
2776
|
+
# URLs it MAY fetch (prior trusted session evidence) so it retries
|
|
2777
|
+
# against a real source instead of a guessed/restated one — without
|
|
2778
|
+
# widening what is authorized.
|
|
2779
|
+
fetchable_urls = store.fetchable_web_fetch_urls()
|
|
2780
|
+
if fetchable_urls:
|
|
2781
|
+
rejection["fetchable_urls"] = fetchable_urls
|
|
2782
|
+
rejection["guidance"] = (
|
|
2783
|
+
"Do not guess or restate URLs from memory. Retry web_fetch with one of "
|
|
2784
|
+
"fetchable_urls (these came from prior trusted session evidence), or run "
|
|
2785
|
+
"web_search again to find the page."
|
|
2786
|
+
)
|
|
2787
|
+
else:
|
|
2788
|
+
rejection["guidance"] = (
|
|
2789
|
+
"No URLs are fetchable yet. Run web_search first, or ask the user for "
|
|
2790
|
+
"the exact URL. Do not guess URLs."
|
|
2791
|
+
)
|
|
2792
|
+
return rejection
|
|
2793
|
+
recovered_via_search = True
|
|
2794
|
+
if recovered_url:
|
|
2795
|
+
requested_url = recovered_url
|
|
2796
|
+
result = _patchable("web_fetch", web_fetch)(
|
|
2797
|
+
url=requested_url,
|
|
2798
|
+
max_chars=(args["max_chars"] if "max_chars" in args else 20000),
|
|
2799
|
+
)
|
|
2800
|
+
if recovered_via_search:
|
|
2801
|
+
result["provenance_classification"] = provenance_classification
|
|
2802
|
+
return result
|
|
2803
|
+
|
|
2804
|
+
if web_tools_enabled:
|
|
2805
|
+
_append_builtin_tool("web_fetch", run=_web_fetch_tool)
|
|
2806
|
+
|
|
2807
|
+
if web_search_exposed_in_mode and web_search_status is not None:
|
|
2808
|
+
if (
|
|
2809
|
+
emit_web_search_runtime_diagnostics
|
|
2810
|
+
and web_search_status.mode == "auto"
|
|
2811
|
+
and not web_search_status.registration_ready
|
|
2812
|
+
):
|
|
2813
|
+
store.append("web_search_runtime_unavailable", web_search_status.to_payload())
|
|
2814
|
+
|
|
2815
|
+
if web_search_status.registration_ready:
|
|
2816
|
+
_append_builtin_tool(
|
|
2817
|
+
"web_search",
|
|
2818
|
+
run=lambda args: web_search(
|
|
2819
|
+
query=str(args.get("query", "")),
|
|
2820
|
+
cfg=cfg,
|
|
2821
|
+
api_key=api_key,
|
|
2822
|
+
allowed_domains=args.get("allowed_domains"),
|
|
2823
|
+
max_sources=(args["max_sources"] if "max_sources" in args else 8),
|
|
2824
|
+
external_web_access=(
|
|
2825
|
+
args["external_web_access"] if "external_web_access" in args else True
|
|
2826
|
+
),
|
|
2827
|
+
session_id=str(getattr(store, "session_id", "") or "") or None,
|
|
2828
|
+
),
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
_append_builtin_tool(
|
|
2832
|
+
"symbol_search",
|
|
2833
|
+
run=lambda args: _patchable("symbol_search", symbol_search)(
|
|
2834
|
+
root=root,
|
|
2835
|
+
query=str(args.get("query", "")),
|
|
2836
|
+
kind=str(args["kind"]) if args.get("kind") is not None else None,
|
|
2837
|
+
root_path=_resolve_workspace_relative_path(
|
|
2838
|
+
tool_name="symbol_search",
|
|
2839
|
+
raw_path=args.get("root_path"),
|
|
2840
|
+
raw_base=args.get("path_base"),
|
|
2841
|
+
field_name="root_path",
|
|
2842
|
+
base_field_name="path_base",
|
|
2843
|
+
allow_empty=True,
|
|
2844
|
+
),
|
|
2845
|
+
globs=args.get("globs"),
|
|
2846
|
+
max_results=(int(args["max_results"]) if args.get("max_results") is not None else 100),
|
|
2847
|
+
exact=bool(args.get("exact", False)),
|
|
2848
|
+
include_details=bool(args.get("include_details", False)),
|
|
2849
|
+
include_snippet=bool(args.get("include_snippet", False)),
|
|
2850
|
+
include_references=bool(args.get("include_references", False)),
|
|
2851
|
+
),
|
|
2852
|
+
)
|
|
2853
|
+
|
|
2854
|
+
_append_builtin_tool(
|
|
2855
|
+
"test_discover",
|
|
2856
|
+
run=lambda args: _patchable("test_discover", test_discover)(
|
|
2857
|
+
root=root,
|
|
2858
|
+
paths=args.get("paths"),
|
|
2859
|
+
symbols=args.get("symbols"),
|
|
2860
|
+
changed_only=bool(args.get("changed_only", False)),
|
|
2861
|
+
include_commands=bool(args.get("include_commands", True)),
|
|
2862
|
+
max_results=(int(args["max_results"]) if args.get("max_results") is not None else 20),
|
|
2863
|
+
failure_summary=(
|
|
2864
|
+
args.get("failure_summary")
|
|
2865
|
+
if isinstance(args.get("failure_summary"), dict)
|
|
2866
|
+
else None
|
|
2867
|
+
),
|
|
2868
|
+
),
|
|
2869
|
+
)
|
|
2870
|
+
|
|
2871
|
+
_append_builtin_tool(
|
|
2872
|
+
"repo_map",
|
|
2873
|
+
run=lambda args: _patchable("repo_map", repo_map)(
|
|
2874
|
+
root=root,
|
|
2875
|
+
paths=args.get("paths"),
|
|
2876
|
+
symbols=args.get("symbols"),
|
|
2877
|
+
include_tests=bool(args.get("include_tests", True)),
|
|
2878
|
+
include_imports=bool(args.get("include_imports", True)),
|
|
2879
|
+
include_references=bool(args.get("include_references", False)),
|
|
2880
|
+
depth=(int(args["depth"]) if args.get("depth") is not None else 2),
|
|
2881
|
+
max_items=(int(args["max_items"]) if args.get("max_items") is not None else 80),
|
|
2882
|
+
),
|
|
2883
|
+
)
|
|
2884
|
+
|
|
2885
|
+
_append_builtin_tool(
|
|
2886
|
+
"search_rg",
|
|
2887
|
+
run=lambda args: _patchable("search_rg", search_rg)(
|
|
2888
|
+
root=root,
|
|
2889
|
+
pattern=str(args.get("pattern", "")),
|
|
2890
|
+
root_path=_resolve_workspace_relative_path(
|
|
2891
|
+
tool_name="search_rg",
|
|
2892
|
+
raw_path=args.get("root_path"),
|
|
2893
|
+
raw_base=args.get("path_base"),
|
|
2894
|
+
field_name="root_path",
|
|
2895
|
+
base_field_name="path_base",
|
|
2896
|
+
allow_empty=True,
|
|
2897
|
+
),
|
|
2898
|
+
globs=args.get("globs"),
|
|
2899
|
+
before_context=(
|
|
2900
|
+
int(args["before_context"]) if args.get("before_context") is not None else 0
|
|
2901
|
+
),
|
|
2902
|
+
after_context=(
|
|
2903
|
+
int(args["after_context"]) if args.get("after_context") is not None else 0
|
|
2904
|
+
),
|
|
2905
|
+
literal=bool(args.get("literal", False)),
|
|
2906
|
+
case_sensitive=bool(args.get("case_sensitive", True)),
|
|
2907
|
+
include_hidden=bool(args.get("include_hidden", False)),
|
|
2908
|
+
max_results=(int(args["max_results"]) if args.get("max_results") is not None else 200),
|
|
2909
|
+
),
|
|
2910
|
+
)
|
|
2911
|
+
|
|
2912
|
+
if history_artifact_persistence_available:
|
|
2913
|
+
|
|
2914
|
+
def _session_artifact_read(args: dict[str, Any]) -> dict[str, Any]:
|
|
2915
|
+
locator = str(args.get("locator", ""))
|
|
2916
|
+
try:
|
|
2917
|
+
return session_artifact_read(
|
|
2918
|
+
artifact_layout=store.session_artifact_layout,
|
|
2919
|
+
locator=locator,
|
|
2920
|
+
max_bytes=args.get("max_bytes"),
|
|
2921
|
+
offset=args.get("offset"),
|
|
2922
|
+
)
|
|
2923
|
+
except SessionArtifactReadError as exc:
|
|
2924
|
+
payload = getattr(exc, "result_payload", None)
|
|
2925
|
+
if (
|
|
2926
|
+
isinstance(payload, dict)
|
|
2927
|
+
and payload.get("error_code") == "session_artifact_session_mismatch"
|
|
2928
|
+
):
|
|
2929
|
+
store.append(
|
|
2930
|
+
"session_artifact_read_session_mismatch",
|
|
2931
|
+
{
|
|
2932
|
+
"locator": locator,
|
|
2933
|
+
"runtime_kind": resolved_runtime_kind.value,
|
|
2934
|
+
"terminal": True,
|
|
2935
|
+
},
|
|
2936
|
+
)
|
|
2937
|
+
raise
|
|
2938
|
+
|
|
2939
|
+
_append_builtin_tool(
|
|
2940
|
+
"session_artifact_read",
|
|
2941
|
+
run=_session_artifact_read,
|
|
2942
|
+
)
|
|
2943
|
+
_append_builtin_tool(
|
|
2944
|
+
"history_search",
|
|
2945
|
+
run=lambda args: history_search(
|
|
2946
|
+
root=root,
|
|
2947
|
+
session_id=store.session_id,
|
|
2948
|
+
session_artifact_root=store.session_artifact_root,
|
|
2949
|
+
pattern=str(args.get("pattern", "")),
|
|
2950
|
+
max_results=int(args.get("max_results") or 50),
|
|
2951
|
+
max_file_bytes=int(args.get("max_file_bytes") or 200000),
|
|
2952
|
+
include_history=bool(args.get("include_history", True)),
|
|
2953
|
+
include_tool_outputs=bool(args.get("include_tool_outputs", True)),
|
|
2954
|
+
include_memory=bool(args.get("include_memory", True)),
|
|
2955
|
+
),
|
|
2956
|
+
)
|
|
2957
|
+
|
|
2958
|
+
if skills_enabled and resolved_skill_registry:
|
|
2959
|
+
|
|
2960
|
+
def _skill_read(args: dict[str, Any]) -> dict[str, Any]:
|
|
2961
|
+
raw_name = str(args.get("name", "")).strip()
|
|
2962
|
+
if not raw_name:
|
|
2963
|
+
return {"error": "Missing required argument: name"}
|
|
2964
|
+
skill = resolve_skill_by_name(resolved_skill_registry, raw_name)
|
|
2965
|
+
if skill is None:
|
|
2966
|
+
return {
|
|
2967
|
+
"error": f"Unknown skill: {raw_name}",
|
|
2968
|
+
"available_skills": sorted(
|
|
2969
|
+
skill.name for skill in resolved_skill_registry.values()
|
|
2970
|
+
),
|
|
2971
|
+
}
|
|
2972
|
+
try:
|
|
2973
|
+
return read_skill_bundle_file(
|
|
2974
|
+
skill,
|
|
2975
|
+
path=(str(args["path"]) if args.get("path") is not None else None),
|
|
2976
|
+
)
|
|
2977
|
+
except SkillReadError as exc:
|
|
2978
|
+
return {
|
|
2979
|
+
"error": str(exc),
|
|
2980
|
+
"name": skill.name,
|
|
2981
|
+
"source_path": skill.source_path.as_posix(),
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
_append_builtin_tool("skill_read", run=_skill_read)
|
|
2985
|
+
|
|
2986
|
+
verify_artifact_counter = 0
|
|
2987
|
+
|
|
2988
|
+
def _next_verify_artifact_path() -> Path:
|
|
2989
|
+
nonlocal verify_artifact_counter
|
|
2990
|
+
verify_artifact_counter += 1
|
|
2991
|
+
return store.runtime_artifact_path(
|
|
2992
|
+
"verify",
|
|
2993
|
+
f"step{verify_artifact_counter:03d}_verify_run.txt",
|
|
2994
|
+
)
|
|
2995
|
+
|
|
2996
|
+
def _workspace_services_before_verification() -> list[dict[str, Any]]:
|
|
2997
|
+
"""Alysis Code-managed processes already alive in this workspace.
|
|
2998
|
+
|
|
2999
|
+
Reported, never acted on. A dev server holding a port or writing into
|
|
3000
|
+
the same build directory is a common source of confusing verification
|
|
3001
|
+
output, and the host cannot tell a genuine conflict from a deliberate
|
|
3002
|
+
setup. The agent decides whether it matters and stops anything through
|
|
3003
|
+
the normal approval path.
|
|
3004
|
+
"""
|
|
3005
|
+
|
|
3006
|
+
services: list[dict[str, Any]] = []
|
|
3007
|
+
if terminal_manager is not None:
|
|
3008
|
+
try:
|
|
3009
|
+
for summary in terminal_manager.list():
|
|
3010
|
+
if summary.status != "running":
|
|
3011
|
+
continue
|
|
3012
|
+
services.append(
|
|
3013
|
+
{
|
|
3014
|
+
"kind": "background_process",
|
|
3015
|
+
"process_id": summary.process_id,
|
|
3016
|
+
"command": summary.cmd,
|
|
3017
|
+
"cwd": str(summary.cwd),
|
|
3018
|
+
"runtime_s": round(float(summary.runtime_s), 1),
|
|
3019
|
+
}
|
|
3020
|
+
)
|
|
3021
|
+
except Exception: # noqa: BLE001 - reporting must never fail verification
|
|
3022
|
+
pass
|
|
3023
|
+
if durable_service_manager is not None:
|
|
3024
|
+
try:
|
|
3025
|
+
for entry in durable_service_manager.list_active():
|
|
3026
|
+
services.append(
|
|
3027
|
+
{
|
|
3028
|
+
"kind": "durable_service",
|
|
3029
|
+
"service_id": str(entry.get("service_id") or ""),
|
|
3030
|
+
"command": str(entry.get("command") or ""),
|
|
3031
|
+
"url": str(entry.get("url") or ""),
|
|
3032
|
+
}
|
|
3033
|
+
)
|
|
3034
|
+
except Exception: # noqa: BLE001 - reporting must never fail verification
|
|
3035
|
+
pass
|
|
3036
|
+
return services
|
|
3037
|
+
|
|
3038
|
+
def _verify_run(args: dict[str, Any]) -> dict[str, Any]:
|
|
3039
|
+
deadline_decision = _deadline_start_decision(
|
|
3040
|
+
DeadlineOperation.VERIFICATION,
|
|
3041
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
3042
|
+
allow_during_finalization=True,
|
|
3043
|
+
)
|
|
3044
|
+
if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
|
|
3045
|
+
return _deadline_error(
|
|
3046
|
+
"verify_run skipped because the run deadline is exhausted or too close.",
|
|
3047
|
+
start_decision=deadline_decision,
|
|
3048
|
+
)
|
|
3049
|
+
try:
|
|
3050
|
+
verify_timeout_s = _deadline_timeout(900, operation="verify_run")
|
|
3051
|
+
except DeadlineExhausted:
|
|
3052
|
+
return _deadline_error(
|
|
3053
|
+
"verify_run skipped because the run deadline is exhausted or too close."
|
|
3054
|
+
)
|
|
3055
|
+
effective_cfg = cfg or AppConfig(model="")
|
|
3056
|
+
raw_commands = args.get("commands")
|
|
3057
|
+
verify_cmd: list[str] | None = None
|
|
3058
|
+
current_selection = _current_verify_selection()
|
|
3059
|
+
current_effective_verification_commands = _normalized_verify_commands(
|
|
3060
|
+
list(current_selection.commands) if current_selection is not None else []
|
|
3061
|
+
)
|
|
3062
|
+
unavailable_verification_contract = bool(
|
|
3063
|
+
current_selection is not None
|
|
3064
|
+
and str(current_selection.contract_type or "").strip() == "unavailable"
|
|
3065
|
+
and not current_effective_verification_commands
|
|
3066
|
+
)
|
|
3067
|
+
ignore_explicit_commands_for_unavailable_contract = (
|
|
3068
|
+
unavailable_verification_contract
|
|
3069
|
+
and authoritative_verify_commands is None
|
|
3070
|
+
and (
|
|
3071
|
+
(not one_shot_execution and resolved_runtime_kind == RuntimeKind.INTERACTIVE_CHAT)
|
|
3072
|
+
or not _normalized_verify_commands(
|
|
3073
|
+
getattr(effective_cfg, "verify_commands", []) or []
|
|
3074
|
+
)
|
|
3075
|
+
)
|
|
3076
|
+
)
|
|
3077
|
+
trusted_shell_commands = trusted_shell_expression_command_set(current_selection)
|
|
3078
|
+
|
|
3079
|
+
def _validate_explicit_verify_candidate(command: str) -> None:
|
|
3080
|
+
normalized_exact = " ".join(str(command or "").strip().split())
|
|
3081
|
+
trusted = normalized_exact in trusted_shell_commands
|
|
3082
|
+
analysis = analyze_verification_command(
|
|
3083
|
+
command,
|
|
3084
|
+
trusted=trusted,
|
|
3085
|
+
workspace_root=root,
|
|
3086
|
+
)
|
|
3087
|
+
if analysis.rejection_reason:
|
|
3088
|
+
raise VerifyError("verification command is invalid: " + analysis.rejection_reason)
|
|
3089
|
+
if _has_disallowed_shell_control_flow(command) and not trusted:
|
|
3090
|
+
raise VerifyError("verification command is invalid: disallowed_shell_control_flow")
|
|
3091
|
+
|
|
3092
|
+
selection_metadata = verification_selection_payload(
|
|
3093
|
+
current_selection
|
|
3094
|
+
if current_selection is not None
|
|
3095
|
+
else ResolvedVerifyCommands(
|
|
3096
|
+
commands=tuple(current_effective_verification_commands),
|
|
3097
|
+
source="session.effective_verification_commands",
|
|
3098
|
+
reason="session already resolved an effective verification contract",
|
|
3099
|
+
contract_type="selected",
|
|
3100
|
+
),
|
|
3101
|
+
authoritative=(
|
|
3102
|
+
is_authoritative_verify_command_selection(current_selection)
|
|
3103
|
+
if current_selection is not None
|
|
3104
|
+
else bool(authoritative_verify_commands is not None)
|
|
3105
|
+
),
|
|
3106
|
+
)
|
|
3107
|
+
selection_metadata.update(verification_command_specs_payload(current_selection))
|
|
3108
|
+
if raw_commands is not None:
|
|
3109
|
+
if not isinstance(raw_commands, list):
|
|
3110
|
+
raise VerifyError("commands must be an array of command strings.")
|
|
3111
|
+
verify_cmd = []
|
|
3112
|
+
for item in raw_commands:
|
|
3113
|
+
text = str(item).strip()
|
|
3114
|
+
if not text:
|
|
3115
|
+
raise VerifyError("commands cannot contain empty values.")
|
|
3116
|
+
expanded_commands = _expand_simple_verify_command_chain(
|
|
3117
|
+
text,
|
|
3118
|
+
workspace_root=root,
|
|
3119
|
+
)
|
|
3120
|
+
if not ignore_explicit_commands_for_unavailable_contract:
|
|
3121
|
+
if len(expanded_commands) == 1 and expanded_commands[0] == text:
|
|
3122
|
+
_validate_explicit_verify_candidate(text)
|
|
3123
|
+
else:
|
|
3124
|
+
for command in expanded_commands:
|
|
3125
|
+
_validate_explicit_verify_candidate(command)
|
|
3126
|
+
verify_cmd.extend(expanded_commands)
|
|
3127
|
+
if not verify_cmd:
|
|
3128
|
+
raise VerifyError("commands cannot be empty.")
|
|
3129
|
+
|
|
3130
|
+
ignored_model_verification_commands: list[str] = []
|
|
3131
|
+
if authoritative_verify_commands is not None:
|
|
3132
|
+
if verify_cmd is not None:
|
|
3133
|
+
requested_commands = _normalized_verify_commands(verify_cmd)
|
|
3134
|
+
if requested_commands != authoritative_verify_commands:
|
|
3135
|
+
raise VerifyError(
|
|
3136
|
+
"Managed verification commands are locked to the authoritative Forge command set."
|
|
3137
|
+
)
|
|
3138
|
+
commands = list(authoritative_verify_commands)
|
|
3139
|
+
elif verify_cmd is not None and current_effective_verification_commands:
|
|
3140
|
+
requested_commands = _normalized_verify_commands(verify_cmd)
|
|
3141
|
+
incompatible_commands = _verify_run_commands_match_effective_contract(
|
|
3142
|
+
requested_commands=requested_commands,
|
|
3143
|
+
effective_verification_commands=current_effective_verification_commands,
|
|
3144
|
+
)
|
|
3145
|
+
if incompatible_commands:
|
|
3146
|
+
raise VerifyError(
|
|
3147
|
+
"verify_run commands must stay within the session's effective verification contract."
|
|
3148
|
+
)
|
|
3149
|
+
commands = requested_commands
|
|
3150
|
+
elif verify_cmd is not None and unavailable_verification_contract:
|
|
3151
|
+
requested_commands = _normalized_verify_commands(verify_cmd)
|
|
3152
|
+
commands = []
|
|
3153
|
+
for command in requested_commands:
|
|
3154
|
+
analysis = analyze_verification_command(command, trusted=False, workspace_root=root)
|
|
3155
|
+
if (
|
|
3156
|
+
analysis.evidentiary_capability
|
|
3157
|
+
== VerificationCommandEvidentiaryCapability.ASSERTIVE
|
|
3158
|
+
and not analysis.rejection_reason
|
|
3159
|
+
):
|
|
3160
|
+
commands.append(command)
|
|
3161
|
+
else:
|
|
3162
|
+
ignored_model_verification_commands.append(command)
|
|
3163
|
+
elif verify_cmd is not None and current_selection is not None:
|
|
3164
|
+
commands = _normalized_verify_commands(verify_cmd)
|
|
3165
|
+
elif verify_cmd is None and current_selection is not None:
|
|
3166
|
+
commands = list(current_effective_verification_commands)
|
|
3167
|
+
else:
|
|
3168
|
+
commands = resolve_verify_commands(
|
|
3169
|
+
cfg=effective_cfg,
|
|
3170
|
+
verify_cmd=verify_cmd,
|
|
3171
|
+
)
|
|
3172
|
+
validation_errors = validation_errors_for_selection(current_selection)
|
|
3173
|
+
if validation_errors and (
|
|
3174
|
+
authoritative_verify_commands is not None
|
|
3175
|
+
or (
|
|
3176
|
+
current_selection is not None
|
|
3177
|
+
and is_authoritative_verify_command_selection(current_selection)
|
|
3178
|
+
)
|
|
3179
|
+
):
|
|
3180
|
+
raise VerifyError(
|
|
3181
|
+
"authoritative verification command is invalid: " + "; ".join(validation_errors[:3])
|
|
3182
|
+
)
|
|
3183
|
+
for command in commands:
|
|
3184
|
+
normalized_exact = " ".join(str(command or "").strip().split())
|
|
3185
|
+
analysis = analyze_verification_command(
|
|
3186
|
+
command,
|
|
3187
|
+
trusted=normalized_exact in trusted_shell_commands,
|
|
3188
|
+
workspace_root=root,
|
|
3189
|
+
)
|
|
3190
|
+
if analysis.rejection_reason:
|
|
3191
|
+
raise VerifyError("verification command is invalid: " + analysis.rejection_reason)
|
|
3192
|
+
if (
|
|
3193
|
+
_has_disallowed_shell_control_flow(command)
|
|
3194
|
+
and normalized_exact not in trusted_shell_commands
|
|
3195
|
+
):
|
|
3196
|
+
raise VerifyError(
|
|
3197
|
+
"verification commands must be single commands without shell control flow or chaining."
|
|
3198
|
+
)
|
|
3199
|
+
guard_verify(commands)
|
|
3200
|
+
workspace_services = _workspace_services_before_verification()
|
|
3201
|
+
artifact_path = _next_verify_artifact_path()
|
|
3202
|
+
result, touched_repo_paths = _run_with_command_mutation_detection(
|
|
3203
|
+
root=root,
|
|
3204
|
+
enabled=command_mutation_tracking_enabled,
|
|
3205
|
+
ignored_paths=command_mutation_ignored_paths,
|
|
3206
|
+
operation=lambda: _call_with_optional_kwargs(
|
|
3207
|
+
_patchable("run_task_verification", run_task_verification),
|
|
3208
|
+
required_kwargs={
|
|
3209
|
+
"root": root,
|
|
3210
|
+
"commands": commands,
|
|
3211
|
+
"artifact_path": artifact_path,
|
|
3212
|
+
"cfg": effective_cfg,
|
|
3213
|
+
},
|
|
3214
|
+
optional_kwargs={
|
|
3215
|
+
"timeout_s": verify_timeout_s,
|
|
3216
|
+
"process_group_registry": process_group_registry,
|
|
3217
|
+
},
|
|
3218
|
+
),
|
|
3219
|
+
)
|
|
3220
|
+
payload = verify_run_result_to_payload(root=root, result=result)
|
|
3221
|
+
if workspace_services:
|
|
3222
|
+
payload = dict(payload)
|
|
3223
|
+
payload["workspace_services"] = workspace_services
|
|
3224
|
+
if ignored_model_verification_commands:
|
|
3225
|
+
payload["ignored_model_verification_commands"] = ignored_model_verification_commands
|
|
3226
|
+
payload["verification_skip_reason"] = "verification_contract_unavailable"
|
|
3227
|
+
material_touched_repo_paths: list[str] = []
|
|
3228
|
+
if touched_repo_paths:
|
|
3229
|
+
payload = dict(payload)
|
|
3230
|
+
payload["touched_repo_paths"] = touched_repo_paths
|
|
3231
|
+
mutation_metadata = _command_mutation_metadata(
|
|
3232
|
+
root=root,
|
|
3233
|
+
touched_repo_paths=touched_repo_paths,
|
|
3234
|
+
command_was_verification=True,
|
|
3235
|
+
)
|
|
3236
|
+
payload.update(mutation_metadata)
|
|
3237
|
+
material_touched_repo_paths = list(
|
|
3238
|
+
mutation_metadata.get("material_touched_repo_paths") or []
|
|
3239
|
+
)
|
|
3240
|
+
verification_relevant_material_touched_paths = _verification_relevant_material_paths(
|
|
3241
|
+
material_touched_repo_paths
|
|
3242
|
+
)
|
|
3243
|
+
evidence_records: list[VerificationEvidence] = []
|
|
3244
|
+
command_results = payload.get("command_results")
|
|
3245
|
+
if isinstance(command_results, list):
|
|
3246
|
+
for item in command_results:
|
|
3247
|
+
if not isinstance(item, dict):
|
|
3248
|
+
continue
|
|
3249
|
+
command = str(item.get("command") or item.get("effective_command") or "")
|
|
3250
|
+
if not command:
|
|
3251
|
+
continue
|
|
3252
|
+
exit_code_raw = item.get("exit_code")
|
|
3253
|
+
evidence_records.append(
|
|
3254
|
+
classify_verification_evidence(
|
|
3255
|
+
command,
|
|
3256
|
+
known_verification_commands=current_effective_verification_commands,
|
|
3257
|
+
authoritative=bool(selection_metadata.get("verification_authoritative")),
|
|
3258
|
+
material_touched_paths=verification_relevant_material_touched_paths,
|
|
3259
|
+
exit_code=(exit_code_raw if isinstance(exit_code_raw, int) else None),
|
|
3260
|
+
output=str(item.get("output_preview") or ""),
|
|
3261
|
+
real_execution=(
|
|
3262
|
+
item.get("real_execution")
|
|
3263
|
+
if isinstance(item.get("real_execution"), bool)
|
|
3264
|
+
or item.get("real_execution") is None
|
|
3265
|
+
else None
|
|
3266
|
+
),
|
|
3267
|
+
root=root,
|
|
3268
|
+
)
|
|
3269
|
+
)
|
|
3270
|
+
payload.update(_aggregate_tool_evidence_payload(evidence_records))
|
|
3271
|
+
payload.update(selection_metadata)
|
|
3272
|
+
stored_artifact_path = (
|
|
3273
|
+
os.fspath(result.artifact_path.resolve())
|
|
3274
|
+
if result.artifact_path.exists()
|
|
3275
|
+
else os.fspath(result.artifact_path)
|
|
3276
|
+
)
|
|
3277
|
+
store.append(
|
|
3278
|
+
"verify_run",
|
|
3279
|
+
{
|
|
3280
|
+
"commands": commands,
|
|
3281
|
+
"all_passed": result.all_passed,
|
|
3282
|
+
"summary": result.summary,
|
|
3283
|
+
"fallback_used": payload.get("fallback_used"),
|
|
3284
|
+
"fallback_count": payload.get("fallback_count"),
|
|
3285
|
+
"fallback_details": payload.get("fallback_details"),
|
|
3286
|
+
"artifact_path": stored_artifact_path,
|
|
3287
|
+
"model_artifact_path": payload.get("artifact_path"),
|
|
3288
|
+
"artifact_saved": payload.get("artifact_saved"),
|
|
3289
|
+
"artifact_readable_via_fs": payload.get("artifact_readable_via_fs"),
|
|
3290
|
+
"artifact_location": payload.get("artifact_location"),
|
|
3291
|
+
"verification_evidence_category": payload.get("verification_evidence_category"),
|
|
3292
|
+
"verification_evidence_reason": payload.get("verification_evidence_reason"),
|
|
3293
|
+
"verification_evidence_allowed": payload.get("verification_evidence_allowed"),
|
|
3294
|
+
"verification_evidence_supplemental_only": payload.get(
|
|
3295
|
+
"verification_evidence_supplemental_only"
|
|
3296
|
+
),
|
|
3297
|
+
"ignored_model_verification_commands": payload.get(
|
|
3298
|
+
"ignored_model_verification_commands", []
|
|
3299
|
+
),
|
|
3300
|
+
"verification_skip_reason": payload.get("verification_skip_reason"),
|
|
3301
|
+
"material_touched_repo_paths": payload.get("material_touched_repo_paths", []),
|
|
3302
|
+
"benign_runtime_paths": payload.get("benign_runtime_paths", []),
|
|
3303
|
+
**selection_metadata,
|
|
3304
|
+
},
|
|
3305
|
+
)
|
|
3306
|
+
return payload
|
|
3307
|
+
|
|
3308
|
+
if verification_enabled:
|
|
3309
|
+
_append_builtin_tool("verify_run", run=_verify_run)
|
|
3310
|
+
|
|
3311
|
+
def _shell(args: dict[str, Any]) -> dict[str, Any]:
|
|
3312
|
+
dispatch_started = perf_counter()
|
|
3313
|
+
deadline_decision = _deadline_start_decision(
|
|
3314
|
+
DeadlineOperation.SHELL_TOOL,
|
|
3315
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
3316
|
+
allow_during_finalization=True,
|
|
3317
|
+
)
|
|
3318
|
+
if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
|
|
3319
|
+
return _deadline_error(
|
|
3320
|
+
"shell_run skipped because the run deadline is exhausted or too close.",
|
|
3321
|
+
start_decision=deadline_decision,
|
|
3322
|
+
)
|
|
3323
|
+
try:
|
|
3324
|
+
shell_timeout_s = _deadline_timeout(60, operation="shell_run")
|
|
3325
|
+
except DeadlineExhausted:
|
|
3326
|
+
return _deadline_error(
|
|
3327
|
+
"shell_run skipped because the run deadline is exhausted or too close."
|
|
3328
|
+
)
|
|
3329
|
+
cmd = str(args.get("cmd", ""))
|
|
3330
|
+
effective_cwd = _resolve_workspace_relative_path(
|
|
3331
|
+
tool_name="shell_run",
|
|
3332
|
+
raw_path=args.get("cwd"),
|
|
3333
|
+
raw_base=args.get("cwd_base"),
|
|
3334
|
+
field_name="cwd",
|
|
3335
|
+
base_field_name="cwd_base",
|
|
3336
|
+
allow_empty=True,
|
|
3337
|
+
)
|
|
3338
|
+
guard_shell(cmd)
|
|
3339
|
+
store.append("cmd", {"cmd": cmd, "cwd": effective_cwd})
|
|
3340
|
+
started = perf_counter()
|
|
3341
|
+
result: dict[str, Any] | None = None
|
|
3342
|
+
command_seconds = 0.0
|
|
3343
|
+
|
|
3344
|
+
def _run_shell_command() -> Any:
|
|
3345
|
+
# Timed on its own so that the workspace walks mutation detection
|
|
3346
|
+
# performs on either side of it are attributable to the dispatch
|
|
3347
|
+
# path rather than to the command the model asked for.
|
|
3348
|
+
nonlocal command_seconds
|
|
3349
|
+
command_started = perf_counter()
|
|
3350
|
+
try:
|
|
3351
|
+
return _call_with_optional_kwargs(
|
|
3352
|
+
_patchable("shell_run", shell_run),
|
|
3353
|
+
required_kwargs={
|
|
3354
|
+
"root": root,
|
|
3355
|
+
"cmd": cmd,
|
|
3356
|
+
"cwd": effective_cwd,
|
|
3357
|
+
"runner": shell_runner,
|
|
3358
|
+
},
|
|
3359
|
+
optional_kwargs={
|
|
3360
|
+
"timeout_s": shell_timeout_s,
|
|
3361
|
+
"capture_pipeline_status": _evidence_v2_enabled(cfg),
|
|
3362
|
+
},
|
|
3363
|
+
)
|
|
3364
|
+
finally:
|
|
3365
|
+
command_seconds += perf_counter() - command_started
|
|
3366
|
+
|
|
3367
|
+
try:
|
|
3368
|
+
result, touched_repo_paths = _run_with_command_mutation_detection(
|
|
3369
|
+
root=root,
|
|
3370
|
+
enabled=command_mutation_tracking_enabled,
|
|
3371
|
+
ignored_paths=command_mutation_ignored_paths,
|
|
3372
|
+
operation=_run_shell_command,
|
|
3373
|
+
)
|
|
3374
|
+
finally:
|
|
3375
|
+
if is_full_access_mode:
|
|
3376
|
+
duration_ms = int((perf_counter() - started) * 1000)
|
|
3377
|
+
store.append(
|
|
3378
|
+
"fullaccess_shell",
|
|
3379
|
+
{
|
|
3380
|
+
"event": "fullaccess_shell",
|
|
3381
|
+
"ts": _fullaccess_shell_audit_ts(),
|
|
3382
|
+
"command": cmd,
|
|
3383
|
+
"cwd": str((result or {}).get("cwd") or effective_cwd or root),
|
|
3384
|
+
"exit_code": int((result or {}).get("exit_code", -1)),
|
|
3385
|
+
"pipeline_stage_status": (result or {}).get("pipeline_stage_status"),
|
|
3386
|
+
"duration_ms": duration_ms,
|
|
3387
|
+
"mode": "fullaccess",
|
|
3388
|
+
},
|
|
3389
|
+
)
|
|
3390
|
+
if touched_repo_paths:
|
|
3391
|
+
result = dict(result)
|
|
3392
|
+
result["touched_repo_paths"] = touched_repo_paths
|
|
3393
|
+
result.update(
|
|
3394
|
+
_command_mutation_metadata(
|
|
3395
|
+
root=root,
|
|
3396
|
+
touched_repo_paths=touched_repo_paths,
|
|
3397
|
+
command_was_verification=False,
|
|
3398
|
+
)
|
|
3399
|
+
)
|
|
3400
|
+
current_selection = _current_verify_selection()
|
|
3401
|
+
current_effective_verification_commands = _normalized_verify_commands(
|
|
3402
|
+
list(current_selection.commands) if current_selection is not None else []
|
|
3403
|
+
)
|
|
3404
|
+
shell_exit_code = result.get("exit_code") if isinstance(result, dict) else None
|
|
3405
|
+
evidence_v2 = _evidence_v2_enabled(cfg)
|
|
3406
|
+
shell_effective_cmd = str(result.get("effective_cmd") or result.get("cmd") or cmd)
|
|
3407
|
+
|
|
3408
|
+
def _reexec_first_stage_exit(stage: str) -> int | None:
|
|
3409
|
+
# Bounded ground-truth fallback: when PIPESTATUS could not be observed
|
|
3410
|
+
# (e.g. bash was unavailable), re-run only a recognized test/execution
|
|
3411
|
+
# first stage unpiped, once, to learn its true exit code. Never re-run
|
|
3412
|
+
# an arbitrary side-effecting first stage.
|
|
3413
|
+
nonlocal command_seconds
|
|
3414
|
+
if not command_is_qualifying_execution_evidence(stage):
|
|
3415
|
+
return None
|
|
3416
|
+
rerun_started = perf_counter()
|
|
3417
|
+
try:
|
|
3418
|
+
rerun = _call_with_optional_kwargs(
|
|
3419
|
+
_patchable("shell_run", shell_run),
|
|
3420
|
+
required_kwargs={
|
|
3421
|
+
"root": root,
|
|
3422
|
+
"cmd": stage,
|
|
3423
|
+
"cwd": effective_cwd,
|
|
3424
|
+
"runner": shell_runner,
|
|
3425
|
+
},
|
|
3426
|
+
optional_kwargs={"timeout_s": shell_timeout_s},
|
|
3427
|
+
)
|
|
3428
|
+
except Exception: # noqa: BLE001
|
|
3429
|
+
return None
|
|
3430
|
+
finally:
|
|
3431
|
+
# Still command time, not dispatch time, even when it fails.
|
|
3432
|
+
command_seconds += perf_counter() - rerun_started
|
|
3433
|
+
code = rerun.get("exit_code") if isinstance(rerun, dict) else None
|
|
3434
|
+
return code if isinstance(code, int) else None
|
|
3435
|
+
|
|
3436
|
+
stage_status = result.get("pipeline_stage_status") if isinstance(result, dict) else None
|
|
3437
|
+
if evidence_v2 and stage_status is None:
|
|
3438
|
+
resolved_status = resolve_pipeline_stage_status(
|
|
3439
|
+
shell_effective_cmd,
|
|
3440
|
+
None,
|
|
3441
|
+
reexec=_reexec_first_stage_exit,
|
|
3442
|
+
)
|
|
3443
|
+
if resolved_status is not None:
|
|
3444
|
+
stage_status = resolved_status
|
|
3445
|
+
result["pipeline_stage_status"] = resolved_status
|
|
3446
|
+
result["pipeline_stage_status_source"] = "reexec"
|
|
3447
|
+
shell_evidence = classify_verification_evidence(
|
|
3448
|
+
shell_effective_cmd,
|
|
3449
|
+
known_verification_commands=current_effective_verification_commands,
|
|
3450
|
+
authoritative=(
|
|
3451
|
+
is_authoritative_verify_command_selection(current_selection)
|
|
3452
|
+
if current_selection is not None
|
|
3453
|
+
else bool(authoritative_verify_commands is not None)
|
|
3454
|
+
),
|
|
3455
|
+
material_touched_paths=_verification_relevant_material_paths(
|
|
3456
|
+
result.get("material_touched_repo_paths", [])
|
|
3457
|
+
if isinstance(result.get("material_touched_repo_paths"), list)
|
|
3458
|
+
else [],
|
|
3459
|
+
),
|
|
3460
|
+
exit_code=(shell_exit_code if isinstance(shell_exit_code, int) else None),
|
|
3461
|
+
output="\n".join(
|
|
3462
|
+
[
|
|
3463
|
+
str(result.get("stdout") or "").strip(),
|
|
3464
|
+
str(result.get("stderr") or "").strip(),
|
|
3465
|
+
]
|
|
3466
|
+
).strip(),
|
|
3467
|
+
root=root,
|
|
3468
|
+
stage_status=stage_status if isinstance(stage_status, list) else None,
|
|
3469
|
+
evidence_v2=evidence_v2,
|
|
3470
|
+
)
|
|
3471
|
+
result["verification_evidence_category"] = shell_evidence.category.value
|
|
3472
|
+
result["verification_evidence_reason"] = shell_evidence.reason
|
|
3473
|
+
result["verification_evidence_allowed"] = shell_evidence.allowed_to_satisfy_contract
|
|
3474
|
+
result["verification_evidence_supplemental_only"] = shell_evidence.supplemental_only
|
|
3475
|
+
result["evidence_verdict"] = shell_evidence.evidence_verdict
|
|
3476
|
+
result["dispatch_overhead_seconds"] = round(
|
|
3477
|
+
_observe_dispatch_overhead(
|
|
3478
|
+
dispatch_started=dispatch_started,
|
|
3479
|
+
command_seconds=command_seconds,
|
|
3480
|
+
),
|
|
3481
|
+
6,
|
|
3482
|
+
)
|
|
3483
|
+
return result
|
|
3484
|
+
|
|
3485
|
+
_append_builtin_tool("shell_run", run=_shell)
|
|
3486
|
+
|
|
3487
|
+
def _require_terminal_manager() -> TerminalManager:
|
|
3488
|
+
if terminal_manager is None:
|
|
3489
|
+
raise AgentRuntimeError("Background shell tools are unavailable in this session.")
|
|
3490
|
+
return terminal_manager
|
|
3491
|
+
|
|
3492
|
+
def _require_durable_service_manager() -> DurableServiceManager:
|
|
3493
|
+
if durable_service_manager is None:
|
|
3494
|
+
raise AgentRuntimeError("Durable service tools are unavailable in this session.")
|
|
3495
|
+
return durable_service_manager
|
|
3496
|
+
|
|
3497
|
+
def _service_event_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
3498
|
+
readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
|
|
3499
|
+
return {
|
|
3500
|
+
"service_id": payload.get("service_id"),
|
|
3501
|
+
"ownership": payload.get("ownership") or ProcessOwnership.DURABLE_SERVICE.value,
|
|
3502
|
+
"status": payload.get("status"),
|
|
3503
|
+
"alive": bool(payload.get("alive")),
|
|
3504
|
+
"backend": payload.get("backend"),
|
|
3505
|
+
"readiness": {
|
|
3506
|
+
"type": readiness.get("type"),
|
|
3507
|
+
"status": readiness.get("status"),
|
|
3508
|
+
"host": readiness.get("host"),
|
|
3509
|
+
"port": readiness.get("port"),
|
|
3510
|
+
"path": readiness.get("path"),
|
|
3511
|
+
},
|
|
3512
|
+
"failure_category": payload.get("failure_category"),
|
|
3513
|
+
"log_paths": payload.get("log_paths"),
|
|
3514
|
+
"preview_url": payload.get("preview_url"),
|
|
3515
|
+
"startup_error": payload.get("startup_error"),
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
def _guard_service_readiness_spec(raw_readiness: Any) -> dict[str, Any] | None:
|
|
3519
|
+
if raw_readiness is None:
|
|
3520
|
+
return None
|
|
3521
|
+
if not isinstance(raw_readiness, dict):
|
|
3522
|
+
raise AgentRuntimeError("readiness must be an object when provided")
|
|
3523
|
+
readiness = dict(raw_readiness)
|
|
3524
|
+
if str(readiness.get("type") or "").strip().lower() == "command":
|
|
3525
|
+
command = str(readiness.get("command") or "").strip()
|
|
3526
|
+
if not command:
|
|
3527
|
+
raise AgentRuntimeError("readiness.command is required for command readiness")
|
|
3528
|
+
guard_shell(command, tool_name="shell_service_start")
|
|
3529
|
+
return readiness
|
|
3530
|
+
|
|
3531
|
+
def _format_bg_snapshot(
|
|
3532
|
+
*,
|
|
3533
|
+
process_id: str,
|
|
3534
|
+
snapshot: ProcessOutputSnapshot,
|
|
3535
|
+
max_bytes: int | None = None,
|
|
3536
|
+
) -> dict[str, Any]:
|
|
3537
|
+
lines: list[dict[str, Any]] = []
|
|
3538
|
+
output_truncated_by_max_bytes = False
|
|
3539
|
+
remaining_bytes = max_bytes if max_bytes is not None else None
|
|
3540
|
+
for line in snapshot.lines:
|
|
3541
|
+
text = line.text
|
|
3542
|
+
if remaining_bytes is not None:
|
|
3543
|
+
encoded = text.encode("utf-8", errors="replace")
|
|
3544
|
+
if remaining_bytes <= 0:
|
|
3545
|
+
output_truncated_by_max_bytes = True
|
|
3546
|
+
break
|
|
3547
|
+
if len(encoded) > remaining_bytes:
|
|
3548
|
+
text = encoded[:remaining_bytes].decode("utf-8", errors="replace")
|
|
3549
|
+
output_truncated_by_max_bytes = True
|
|
3550
|
+
remaining_bytes = 0
|
|
3551
|
+
else:
|
|
3552
|
+
remaining_bytes -= len(encoded)
|
|
3553
|
+
lines.append({"seq": line.seq, "stream": line.stream, "text": text})
|
|
3554
|
+
payload = {
|
|
3555
|
+
"process_id": process_id,
|
|
3556
|
+
"lifetime": "session",
|
|
3557
|
+
"status": snapshot.status,
|
|
3558
|
+
"exit_code": snapshot.exit_code,
|
|
3559
|
+
"failure_reason": snapshot.failure_reason,
|
|
3560
|
+
"lines": lines,
|
|
3561
|
+
"next_seq": snapshot.next_seq,
|
|
3562
|
+
"dropped_lines": snapshot.dropped_lines,
|
|
3563
|
+
"runtime_s": round(snapshot.runtime_s, 3),
|
|
3564
|
+
"total_bytes": snapshot.total_bytes,
|
|
3565
|
+
}
|
|
3566
|
+
if output_truncated_by_max_bytes:
|
|
3567
|
+
payload["output_truncated_by_max_bytes"] = True
|
|
3568
|
+
payload["max_bytes"] = max_bytes
|
|
3569
|
+
return payload
|
|
3570
|
+
|
|
3571
|
+
def _format_bg_summaries(manager: TerminalManager) -> list[dict[str, Any]]:
|
|
3572
|
+
return [
|
|
3573
|
+
{
|
|
3574
|
+
"process_id": summary.process_id,
|
|
3575
|
+
"cmd": summary.cmd,
|
|
3576
|
+
"cwd": str(summary.cwd),
|
|
3577
|
+
"status": summary.status,
|
|
3578
|
+
"exit_code": summary.exit_code,
|
|
3579
|
+
"runtime_s": round(summary.runtime_s, 3),
|
|
3580
|
+
"started_at_wall": summary.started_at_wall,
|
|
3581
|
+
}
|
|
3582
|
+
for summary in manager.list()
|
|
3583
|
+
]
|
|
3584
|
+
|
|
3585
|
+
def _unknown_bg_process_payload(
|
|
3586
|
+
*,
|
|
3587
|
+
manager: TerminalManager,
|
|
3588
|
+
process_id: str,
|
|
3589
|
+
operation: str,
|
|
3590
|
+
since: int | None = None,
|
|
3591
|
+
) -> dict[str, Any]:
|
|
3592
|
+
known_processes = _format_bg_summaries(manager)
|
|
3593
|
+
payload: dict[str, Any] = {
|
|
3594
|
+
"status": "unknown_process_id",
|
|
3595
|
+
"unknown_process_id": True,
|
|
3596
|
+
"process_id": process_id,
|
|
3597
|
+
"requested_process_id": process_id,
|
|
3598
|
+
"operation": operation,
|
|
3599
|
+
"exit_code": None,
|
|
3600
|
+
"failure_reason": "No background process with that process_id is tracked in this session.",
|
|
3601
|
+
"lines": [],
|
|
3602
|
+
"next_seq": since if since is not None else 0,
|
|
3603
|
+
"dropped_lines": 0,
|
|
3604
|
+
"runtime_s": 0.0,
|
|
3605
|
+
"total_bytes": 0,
|
|
3606
|
+
"known_processes": known_processes,
|
|
3607
|
+
"known_process_ids": [process["process_id"] for process in known_processes],
|
|
3608
|
+
"recovery": {
|
|
3609
|
+
"recommended_tool": "shell_list",
|
|
3610
|
+
"suggested_arguments": {},
|
|
3611
|
+
"reason": (
|
|
3612
|
+
"The supplied process_id is not tracked. Use shell_list or the process_id "
|
|
3613
|
+
"returned by shell_background; do not use a tool_call_id as process_id."
|
|
3614
|
+
),
|
|
3615
|
+
},
|
|
3616
|
+
}
|
|
3617
|
+
if since is not None:
|
|
3618
|
+
payload["since"] = since
|
|
3619
|
+
store.append(
|
|
3620
|
+
"bg_unknown_process",
|
|
3621
|
+
{
|
|
3622
|
+
"operation": operation,
|
|
3623
|
+
"process_id": process_id,
|
|
3624
|
+
"known_process_count": len(known_processes),
|
|
3625
|
+
},
|
|
3626
|
+
)
|
|
3627
|
+
return payload
|
|
3628
|
+
|
|
3629
|
+
shell_empty_poll_counts: dict[tuple[str, int, int, str], int] = {}
|
|
3630
|
+
|
|
3631
|
+
def _coerce_shell_since(raw_since: Any) -> int:
|
|
3632
|
+
try:
|
|
3633
|
+
since = int(raw_since) if raw_since is not None else 0
|
|
3634
|
+
except (TypeError, ValueError) as exc:
|
|
3635
|
+
raise AgentRuntimeError(f"Invalid since value: {raw_since!r}") from exc
|
|
3636
|
+
if since < 0:
|
|
3637
|
+
raise AgentRuntimeError("since must be non-negative")
|
|
3638
|
+
return since
|
|
3639
|
+
|
|
3640
|
+
def _coerce_shell_wait_seconds(raw_wait: Any) -> float:
|
|
3641
|
+
try:
|
|
3642
|
+
wait_seconds = float(raw_wait) if raw_wait is not None else 5.0
|
|
3643
|
+
except (TypeError, ValueError) as exc:
|
|
3644
|
+
raise AgentRuntimeError(f"Invalid wait_seconds value: {raw_wait!r}") from exc
|
|
3645
|
+
if wait_seconds < 0:
|
|
3646
|
+
raise AgentRuntimeError("wait_seconds must be non-negative")
|
|
3647
|
+
return min(wait_seconds, 60.0)
|
|
3648
|
+
|
|
3649
|
+
def _coerce_shell_max_bytes(raw_max_bytes: Any) -> int | None:
|
|
3650
|
+
if raw_max_bytes is None:
|
|
3651
|
+
return None
|
|
3652
|
+
try:
|
|
3653
|
+
max_bytes = int(raw_max_bytes)
|
|
3654
|
+
except (TypeError, ValueError) as exc:
|
|
3655
|
+
raise AgentRuntimeError(f"Invalid max_bytes value: {raw_max_bytes!r}") from exc
|
|
3656
|
+
if max_bytes <= 0:
|
|
3657
|
+
raise AgentRuntimeError("max_bytes must be positive")
|
|
3658
|
+
return max_bytes
|
|
3659
|
+
|
|
3660
|
+
def _coerce_shell_wait_until(raw_until: Any) -> str:
|
|
3661
|
+
until = str(raw_until or "either").strip().lower()
|
|
3662
|
+
if until not in {"output_available", "process_exited", "either"}:
|
|
3663
|
+
raise AgentRuntimeError(
|
|
3664
|
+
"until must be one of output_available, process_exited, or either"
|
|
3665
|
+
)
|
|
3666
|
+
return until
|
|
3667
|
+
|
|
3668
|
+
def _clamp_shell_wait_seconds(wait_seconds: float) -> tuple[float, dict[str, Any] | None]:
|
|
3669
|
+
if execution_deadline is None:
|
|
3670
|
+
return wait_seconds, None
|
|
3671
|
+
decision = execution_deadline.start_decision(
|
|
3672
|
+
DeadlineOperation.SHELL_TOOL,
|
|
3673
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
3674
|
+
configured_timeout_seconds=wait_seconds,
|
|
3675
|
+
allow_during_finalization=True,
|
|
3676
|
+
).telemetry_snapshot()
|
|
3677
|
+
if not bool(decision.get("allowed")):
|
|
3678
|
+
return 0.0, decision
|
|
3679
|
+
clamped = execution_deadline.clamp_timeout(
|
|
3680
|
+
wait_seconds,
|
|
3681
|
+
reserve_seconds=DEFAULT_DEADLINE_CLEANUP_RESERVE_SECONDS,
|
|
3682
|
+
)
|
|
3683
|
+
if clamped is None:
|
|
3684
|
+
return 0.0, decision
|
|
3685
|
+
if execution_deadline.phase() == DeadlinePhase.FINALIZATION_WINDOW:
|
|
3686
|
+
clamped = min(float(clamped), 1.0)
|
|
3687
|
+
return float(clamped), decision
|
|
3688
|
+
|
|
3689
|
+
def _observe_dispatch_overhead(
|
|
3690
|
+
*,
|
|
3691
|
+
dispatch_started: float,
|
|
3692
|
+
command_seconds: float,
|
|
3693
|
+
) -> float:
|
|
3694
|
+
"""Record time spent in dispatch machinery, excluding the command itself.
|
|
3695
|
+
|
|
3696
|
+
``shell_tool`` and ``tool_dispatch`` both time the whole dispatch, so a
|
|
3697
|
+
build that legitimately takes twenty minutes is indistinguishable from a
|
|
3698
|
+
dispatch path that has become expensive. Subtracting the command's own
|
|
3699
|
+
runtime leaves the number that actually describes this code, reported as
|
|
3700
|
+
its own ``duration_observations`` category.
|
|
3701
|
+
"""
|
|
3702
|
+
account = DispatchOverheadAccount.from_totals(
|
|
3703
|
+
perf_counter() - dispatch_started,
|
|
3704
|
+
command_seconds,
|
|
3705
|
+
)
|
|
3706
|
+
if execution_deadline is not None:
|
|
3707
|
+
execution_deadline.observe_duration(
|
|
3708
|
+
DISPATCH_OVERHEAD_OPERATION,
|
|
3709
|
+
account.overhead_seconds,
|
|
3710
|
+
)
|
|
3711
|
+
return account.overhead_seconds
|
|
3712
|
+
|
|
3713
|
+
def _cancellation_probe(token: Any | None) -> Callable[[], bool] | None:
|
|
3714
|
+
"""Read-only view of a cancellation token, or ``None`` when absent."""
|
|
3715
|
+
if token is None:
|
|
3716
|
+
return None
|
|
3717
|
+
return lambda: bool(getattr(token, "is_cancelled", False))
|
|
3718
|
+
|
|
3719
|
+
def _wait_for_output_cancellably(
|
|
3720
|
+
manager: TerminalManager,
|
|
3721
|
+
*,
|
|
3722
|
+
process_id: str,
|
|
3723
|
+
since: int,
|
|
3724
|
+
timeout_s: float,
|
|
3725
|
+
until: str,
|
|
3726
|
+
cancellation_token: Any | None,
|
|
3727
|
+
) -> tuple[ProcessOutputSnapshot, bool, bool]:
|
|
3728
|
+
"""Wait for background output without outliving the run budget.
|
|
3729
|
+
|
|
3730
|
+
The manager's wait is already completion-driven -- it returns the moment
|
|
3731
|
+
the process speaks or exits -- but it blocks on a condition variable that
|
|
3732
|
+
knows nothing about the budget. A ``shell_wait`` armed just before the
|
|
3733
|
+
deadline therefore ran to its full 60s while the run was already over,
|
|
3734
|
+
which is PR2's documented "a running tool cannot be preempted" gap in the
|
|
3735
|
+
one place the telemetry says it costs whole runs.
|
|
3736
|
+
|
|
3737
|
+
Driving the same wait in slices and re-reading the token between them
|
|
3738
|
+
bounds cancellation latency to a single slice. Because each slice still
|
|
3739
|
+
returns early on completion, nothing about the non-cancelled path gets
|
|
3740
|
+
slower, and no busy-polling is introduced. When no token is supplied
|
|
3741
|
+
there is nothing to observe, so the wait is taken in one step exactly as
|
|
3742
|
+
before.
|
|
3743
|
+
|
|
3744
|
+
Returns ``(snapshot, timed_out, cancelled)``.
|
|
3745
|
+
"""
|
|
3746
|
+
latest_snapshot: ProcessOutputSnapshot | None = None
|
|
3747
|
+
|
|
3748
|
+
def _wait_once(step: float) -> bool:
|
|
3749
|
+
nonlocal latest_snapshot
|
|
3750
|
+
snapshot, wait_timed_out = manager.wait_for_output(
|
|
3751
|
+
process_id,
|
|
3752
|
+
since=since,
|
|
3753
|
+
timeout_s=step,
|
|
3754
|
+
until=until, # type: ignore[arg-type]
|
|
3755
|
+
)
|
|
3756
|
+
latest_snapshot = snapshot
|
|
3757
|
+
return not wait_timed_out
|
|
3758
|
+
|
|
3759
|
+
is_cancelled = _cancellation_probe(cancellation_token)
|
|
3760
|
+
result = run_cancellable_wait(
|
|
3761
|
+
wait_once=_wait_once,
|
|
3762
|
+
total_seconds=timeout_s,
|
|
3763
|
+
is_cancelled=is_cancelled,
|
|
3764
|
+
slice_seconds=None if is_cancelled is not None else 0.0,
|
|
3765
|
+
)
|
|
3766
|
+
snapshot = (
|
|
3767
|
+
latest_snapshot
|
|
3768
|
+
if latest_snapshot is not None
|
|
3769
|
+
# Cancelled before the first block: report the current state rather
|
|
3770
|
+
# than an empty one, so nothing already emitted is dropped.
|
|
3771
|
+
else manager.read(process_id, since=since)
|
|
3772
|
+
)
|
|
3773
|
+
return snapshot, not result.completed, result.cancelled
|
|
3774
|
+
|
|
3775
|
+
def _maybe_add_empty_poll_guidance(
|
|
3776
|
+
*,
|
|
3777
|
+
payload: dict[str, Any],
|
|
3778
|
+
process_id: str,
|
|
3779
|
+
since: int,
|
|
3780
|
+
snapshot: ProcessOutputSnapshot,
|
|
3781
|
+
) -> None:
|
|
3782
|
+
if snapshot.lines or snapshot.status != "running":
|
|
3783
|
+
shell_empty_poll_counts.pop(
|
|
3784
|
+
(process_id, since, snapshot.next_seq, snapshot.status), None
|
|
3785
|
+
)
|
|
3786
|
+
return
|
|
3787
|
+
key = (process_id, since, snapshot.next_seq, snapshot.status)
|
|
3788
|
+
count = shell_empty_poll_counts.get(key, 0) + 1
|
|
3789
|
+
shell_empty_poll_counts[key] = count
|
|
3790
|
+
payload["empty_poll_count"] = count
|
|
3791
|
+
if count >= 2:
|
|
3792
|
+
payload["wait_guidance"] = {
|
|
3793
|
+
"recommended_tool": "shell_wait",
|
|
3794
|
+
"reason": "No new output or process status change was observed for repeated immediate polls.",
|
|
3795
|
+
"process_id": process_id,
|
|
3796
|
+
"since": since,
|
|
3797
|
+
"suggested_arguments": {
|
|
3798
|
+
"process_id": process_id,
|
|
3799
|
+
"since": since,
|
|
3800
|
+
"until": "either",
|
|
3801
|
+
"wait_seconds": 5,
|
|
3802
|
+
},
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
def _persist_background_start(
|
|
3806
|
+
*,
|
|
3807
|
+
cmd: str,
|
|
3808
|
+
cwd_path: Path,
|
|
3809
|
+
effective_cwd_relpath: str,
|
|
3810
|
+
probe_port: int | None,
|
|
3811
|
+
deadline_warning: dict[str, Any] | None,
|
|
3812
|
+
) -> dict[str, Any]:
|
|
3813
|
+
"""Route a persist=true background start to the durable-service manager.
|
|
3814
|
+
|
|
3815
|
+
Persistence is deliberately not reimplemented here. The durable manager
|
|
3816
|
+
already spawns into its own session, redirects stdio to files under the
|
|
3817
|
+
session's service directory, and is excluded from every reaping path --
|
|
3818
|
+
so routing keeps one implementation and one lifecycle rather than a
|
|
3819
|
+
second, subtly different one.
|
|
3820
|
+
"""
|
|
3821
|
+
|
|
3822
|
+
manager = _require_durable_service_manager()
|
|
3823
|
+
readiness = readiness_spec_for_port(probe_port) if probe_port is not None else None
|
|
3824
|
+
try:
|
|
3825
|
+
started = manager.start(cmd=cmd, cwd=cwd_path, readiness=readiness)
|
|
3826
|
+
except ValueError as exc:
|
|
3827
|
+
raise AgentRuntimeError(f"Invalid durable service request: {exc}") from exc
|
|
3828
|
+
except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
3829
|
+
raise AgentRuntimeError(f"Failed to start durable service: {exc}") from exc
|
|
3830
|
+
payload = dict(started.payload)
|
|
3831
|
+
payload["lifetime"] = "durable"
|
|
3832
|
+
payload["persist"] = True
|
|
3833
|
+
record = PersistentServiceRecord(
|
|
3834
|
+
service_id=started.service_id,
|
|
3835
|
+
command=cmd,
|
|
3836
|
+
pid=int(payload.get("pid") or 0),
|
|
3837
|
+
probe_port=probe_port,
|
|
3838
|
+
)
|
|
3839
|
+
# A start that silently produced nothing is the failure this PR exists
|
|
3840
|
+
# for, so say now whether the process is up and the port is answering.
|
|
3841
|
+
payload.update(check_service(record).as_payload())
|
|
3842
|
+
if persistent_service_registry is not None:
|
|
3843
|
+
persistent_service_registry.register(record)
|
|
3844
|
+
store.append(
|
|
3845
|
+
"service_start",
|
|
3846
|
+
{
|
|
3847
|
+
**_service_event_payload(payload),
|
|
3848
|
+
"cwd": effective_cwd_relpath,
|
|
3849
|
+
"persist": True,
|
|
3850
|
+
"probe_port": probe_port,
|
|
3851
|
+
},
|
|
3852
|
+
)
|
|
3853
|
+
if deadline_warning is not None:
|
|
3854
|
+
payload.update(deadline_warning)
|
|
3855
|
+
return payload
|
|
3856
|
+
|
|
3857
|
+
def _shell_background(args: dict[str, Any]) -> dict[str, Any]:
|
|
3858
|
+
deadline_decision = _deadline_start_decision(
|
|
3859
|
+
DeadlineOperation.SHELL_BACKGROUND,
|
|
3860
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
3861
|
+
)
|
|
3862
|
+
deadline_warning = None
|
|
3863
|
+
if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
|
|
3864
|
+
deadline_warning = _deadline_warning_fields(
|
|
3865
|
+
"Deadline policy would normally block background work; start proceeded because "
|
|
3866
|
+
"this is advisory and not safety.",
|
|
3867
|
+
start_decision=deadline_decision,
|
|
3868
|
+
)
|
|
3869
|
+
manager = _require_terminal_manager()
|
|
3870
|
+
cmd = str(args.get("cmd", ""))
|
|
3871
|
+
effective_cwd_relpath = _resolve_workspace_relative_path(
|
|
3872
|
+
tool_name="shell_background",
|
|
3873
|
+
raw_path=args.get("cwd"),
|
|
3874
|
+
raw_base=args.get("cwd_base"),
|
|
3875
|
+
field_name="cwd",
|
|
3876
|
+
base_field_name="cwd_base",
|
|
3877
|
+
allow_empty=True,
|
|
3878
|
+
)
|
|
3879
|
+
guard_shell(cmd, tool_name="shell_background")
|
|
3880
|
+
cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
|
|
3881
|
+
try:
|
|
3882
|
+
probe_port = resolve_probe_port(requested=args.get("probe_port"), cmd=cmd)
|
|
3883
|
+
except ValueError as exc:
|
|
3884
|
+
raise AgentRuntimeError(str(exc)) from exc
|
|
3885
|
+
if bool(args.get("persist")):
|
|
3886
|
+
return _persist_background_start(
|
|
3887
|
+
cmd=cmd,
|
|
3888
|
+
cwd_path=cwd_path,
|
|
3889
|
+
effective_cwd_relpath=effective_cwd_relpath,
|
|
3890
|
+
probe_port=probe_port,
|
|
3891
|
+
deadline_warning=deadline_warning,
|
|
3892
|
+
)
|
|
3893
|
+
store.append("bg_start", {"cmd": cmd, "cwd": effective_cwd_relpath})
|
|
3894
|
+
started = perf_counter()
|
|
3895
|
+
snapshot: ProcessOutputSnapshot | None = None
|
|
3896
|
+
try:
|
|
3897
|
+
try:
|
|
3898
|
+
process_id = manager.start(
|
|
3899
|
+
cmd=cmd,
|
|
3900
|
+
cwd=cwd_path,
|
|
3901
|
+
root=root,
|
|
3902
|
+
)
|
|
3903
|
+
except TerminalLimitError as exc:
|
|
3904
|
+
raise AgentRuntimeError(str(exc)) from exc
|
|
3905
|
+
except ValueError as exc:
|
|
3906
|
+
raise AgentRuntimeError(f"Invalid background process request: {exc}") from exc
|
|
3907
|
+
except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
3908
|
+
raise AgentRuntimeError(f"Failed to start background process: {exc}") from exc
|
|
3909
|
+
snapshot = manager.read(process_id)
|
|
3910
|
+
payload = _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
|
|
3911
|
+
if deadline_warning is not None:
|
|
3912
|
+
payload.update(deadline_warning)
|
|
3913
|
+
return payload
|
|
3914
|
+
finally:
|
|
3915
|
+
if is_full_access_mode:
|
|
3916
|
+
duration_ms = int((perf_counter() - started) * 1000)
|
|
3917
|
+
exit_code = snapshot.exit_code if snapshot is not None else None
|
|
3918
|
+
store.append(
|
|
3919
|
+
"fullaccess_shell",
|
|
3920
|
+
{
|
|
3921
|
+
"event": "fullaccess_shell",
|
|
3922
|
+
"ts": _fullaccess_shell_audit_ts(),
|
|
3923
|
+
"command": cmd,
|
|
3924
|
+
"cwd": str(cwd_path),
|
|
3925
|
+
"exit_code": int(exit_code if exit_code is not None else -1),
|
|
3926
|
+
"duration_ms": duration_ms,
|
|
3927
|
+
"mode": "fullaccess",
|
|
3928
|
+
},
|
|
3929
|
+
)
|
|
3930
|
+
|
|
3931
|
+
def _shell_output(args: dict[str, Any]) -> dict[str, Any]:
|
|
3932
|
+
manager = _require_terminal_manager()
|
|
3933
|
+
guard_terminal_op("shell_output")
|
|
3934
|
+
process_id = str(args.get("process_id", "")).strip()
|
|
3935
|
+
if not process_id:
|
|
3936
|
+
raise AgentRuntimeError("Missing required argument: process_id")
|
|
3937
|
+
since = _coerce_shell_since(args.get("since"))
|
|
3938
|
+
try:
|
|
3939
|
+
snapshot = manager.read(process_id, since=since)
|
|
3940
|
+
except KeyError:
|
|
3941
|
+
return _unknown_bg_process_payload(
|
|
3942
|
+
manager=manager,
|
|
3943
|
+
process_id=process_id,
|
|
3944
|
+
operation="shell_output",
|
|
3945
|
+
since=since,
|
|
3946
|
+
)
|
|
3947
|
+
payload = _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
|
|
3948
|
+
_maybe_add_empty_poll_guidance(
|
|
3949
|
+
payload=payload,
|
|
3950
|
+
process_id=process_id,
|
|
3951
|
+
since=since,
|
|
3952
|
+
snapshot=snapshot,
|
|
3953
|
+
)
|
|
3954
|
+
return payload
|
|
3955
|
+
|
|
3956
|
+
def _shell_wait(args: dict[str, Any]) -> dict[str, Any]:
|
|
3957
|
+
dispatch_started = perf_counter()
|
|
3958
|
+
manager = _require_terminal_manager()
|
|
3959
|
+
guard_terminal_op("shell_wait")
|
|
3960
|
+
process_id = str(args.get("process_id", "")).strip()
|
|
3961
|
+
if not process_id:
|
|
3962
|
+
raise AgentRuntimeError("Missing required argument: process_id")
|
|
3963
|
+
since = _coerce_shell_since(args.get("since"))
|
|
3964
|
+
wait_seconds = _coerce_shell_wait_seconds(args.get("wait_seconds"))
|
|
3965
|
+
until = _coerce_shell_wait_until(args.get("until"))
|
|
3966
|
+
max_bytes = _coerce_shell_max_bytes(args.get("max_bytes"))
|
|
3967
|
+
clamped_wait_seconds, deadline_decision = _clamp_shell_wait_seconds(wait_seconds)
|
|
3968
|
+
started = perf_counter()
|
|
3969
|
+
wait_cancelled = False
|
|
3970
|
+
try:
|
|
3971
|
+
snapshot, timed_out, wait_cancelled = _wait_for_output_cancellably(
|
|
3972
|
+
manager,
|
|
3973
|
+
process_id=process_id,
|
|
3974
|
+
since=since,
|
|
3975
|
+
timeout_s=clamped_wait_seconds,
|
|
3976
|
+
until=until,
|
|
3977
|
+
cancellation_token=args.get(_SHELL_CANCELLATION_TOKEN_ARG),
|
|
3978
|
+
)
|
|
3979
|
+
except KeyError:
|
|
3980
|
+
payload = _unknown_bg_process_payload(
|
|
3981
|
+
manager=manager,
|
|
3982
|
+
process_id=process_id,
|
|
3983
|
+
operation="shell_wait",
|
|
3984
|
+
since=since,
|
|
3985
|
+
)
|
|
3986
|
+
payload.update(
|
|
3987
|
+
{
|
|
3988
|
+
"waited": False,
|
|
3989
|
+
"timed_out": False,
|
|
3990
|
+
"wait_seconds_requested": wait_seconds,
|
|
3991
|
+
"wait_seconds_effective": 0.0,
|
|
3992
|
+
"until": until,
|
|
3993
|
+
"elapsed_ms": int((perf_counter() - started) * 1000),
|
|
3994
|
+
}
|
|
3995
|
+
)
|
|
3996
|
+
if deadline_decision is not None:
|
|
3997
|
+
payload["deadline_start_decision"] = deadline_decision
|
|
3998
|
+
payload["deadline_clamped"] = clamped_wait_seconds < wait_seconds
|
|
3999
|
+
_observe_dispatch_overhead(
|
|
4000
|
+
dispatch_started=dispatch_started,
|
|
4001
|
+
command_seconds=perf_counter() - started,
|
|
4002
|
+
)
|
|
4003
|
+
return payload
|
|
4004
|
+
waited_seconds = perf_counter() - started
|
|
4005
|
+
elapsed_ms = int(waited_seconds * 1000)
|
|
4006
|
+
payload = _format_bg_snapshot(
|
|
4007
|
+
process_id=process_id,
|
|
4008
|
+
snapshot=snapshot,
|
|
4009
|
+
max_bytes=max_bytes,
|
|
4010
|
+
)
|
|
4011
|
+
payload.update(
|
|
4012
|
+
{
|
|
4013
|
+
"waited": True,
|
|
4014
|
+
"timed_out": timed_out,
|
|
4015
|
+
"wait_seconds_requested": wait_seconds,
|
|
4016
|
+
"wait_seconds_effective": clamped_wait_seconds,
|
|
4017
|
+
"until": until,
|
|
4018
|
+
"elapsed_ms": elapsed_ms,
|
|
4019
|
+
}
|
|
4020
|
+
)
|
|
4021
|
+
if wait_cancelled:
|
|
4022
|
+
# The watchdog fired mid-wait. Reported rather than raised: the step
|
|
4023
|
+
# loop's existing cancellation checkpoint stops the run on its next
|
|
4024
|
+
# iteration, and returning normally keeps the output collected so
|
|
4025
|
+
# far instead of discarding it with an exception.
|
|
4026
|
+
payload["wait_interrupted_by_budget"] = True
|
|
4027
|
+
if deadline_decision is not None:
|
|
4028
|
+
payload["deadline_start_decision"] = deadline_decision
|
|
4029
|
+
payload["deadline_clamped"] = clamped_wait_seconds < wait_seconds
|
|
4030
|
+
payload["dispatch_overhead_seconds"] = round(
|
|
4031
|
+
_observe_dispatch_overhead(
|
|
4032
|
+
dispatch_started=dispatch_started,
|
|
4033
|
+
command_seconds=waited_seconds,
|
|
4034
|
+
),
|
|
4035
|
+
6,
|
|
4036
|
+
)
|
|
4037
|
+
return payload
|
|
4038
|
+
|
|
4039
|
+
def _shell_kill(args: dict[str, Any]) -> dict[str, Any]:
|
|
4040
|
+
manager = _require_terminal_manager()
|
|
4041
|
+
guard_terminal_op("shell_kill")
|
|
4042
|
+
process_id = str(args.get("process_id", "")).strip()
|
|
4043
|
+
if not process_id:
|
|
4044
|
+
raise AgentRuntimeError("Missing required argument: process_id")
|
|
4045
|
+
try:
|
|
4046
|
+
snapshot = manager.kill(process_id)
|
|
4047
|
+
except KeyError as exc:
|
|
4048
|
+
raise AgentRuntimeError(f"Unknown background process_id: {process_id}") from exc
|
|
4049
|
+
store.append(
|
|
4050
|
+
"bg_kill",
|
|
4051
|
+
{
|
|
4052
|
+
"process_id": process_id,
|
|
4053
|
+
"status": snapshot.status,
|
|
4054
|
+
"exit_code": snapshot.exit_code,
|
|
4055
|
+
},
|
|
4056
|
+
)
|
|
4057
|
+
return _format_bg_snapshot(process_id=process_id, snapshot=snapshot)
|
|
4058
|
+
|
|
4059
|
+
def _shell_list(_args: dict[str, Any]) -> dict[str, Any]:
|
|
4060
|
+
manager = _require_terminal_manager()
|
|
4061
|
+
guard_terminal_op("shell_list")
|
|
4062
|
+
return {"processes": _format_bg_summaries(manager)}
|
|
4063
|
+
|
|
4064
|
+
def _shell_service_start(args: dict[str, Any]) -> dict[str, Any]:
|
|
4065
|
+
deadline_decision = _deadline_start_decision(
|
|
4066
|
+
DeadlineOperation.SHELL_BACKGROUND,
|
|
4067
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
4068
|
+
)
|
|
4069
|
+
deadline_warning = None
|
|
4070
|
+
if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
|
|
4071
|
+
deadline_warning = _deadline_warning_fields(
|
|
4072
|
+
"Deadline policy would normally block service work; start proceeded because "
|
|
4073
|
+
"this is advisory and not safety.",
|
|
4074
|
+
start_decision=deadline_decision,
|
|
4075
|
+
)
|
|
4076
|
+
manager = _require_durable_service_manager()
|
|
4077
|
+
cmd = str(args.get("cmd", ""))
|
|
4078
|
+
guard_shell(cmd, tool_name="shell_service_start")
|
|
4079
|
+
readiness = _guard_service_readiness_spec(args.get("readiness"))
|
|
4080
|
+
effective_cwd_relpath = _resolve_workspace_relative_path(
|
|
4081
|
+
tool_name="shell_service_start",
|
|
4082
|
+
raw_path=args.get("cwd"),
|
|
4083
|
+
raw_base=args.get("cwd_base"),
|
|
4084
|
+
field_name="cwd",
|
|
4085
|
+
base_field_name="cwd_base",
|
|
4086
|
+
allow_empty=True,
|
|
4087
|
+
)
|
|
4088
|
+
cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
|
|
4089
|
+
try:
|
|
4090
|
+
started = manager.start(cmd=cmd, cwd=cwd_path, readiness=readiness)
|
|
4091
|
+
except ValueError as exc:
|
|
4092
|
+
raise AgentRuntimeError(f"Invalid durable service request: {exc}") from exc
|
|
4093
|
+
except (ConfigError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
4094
|
+
raise AgentRuntimeError(f"Failed to start durable service: {exc}") from exc
|
|
4095
|
+
payload = dict(started.payload)
|
|
4096
|
+
payload["lifetime"] = "durable"
|
|
4097
|
+
if deadline_warning is not None:
|
|
4098
|
+
payload.update(deadline_warning)
|
|
4099
|
+
store.append(
|
|
4100
|
+
"service_start",
|
|
4101
|
+
{
|
|
4102
|
+
**_service_event_payload(payload),
|
|
4103
|
+
"cwd": effective_cwd_relpath,
|
|
4104
|
+
},
|
|
4105
|
+
)
|
|
4106
|
+
return payload
|
|
4107
|
+
|
|
4108
|
+
def _workspace_preview_start(args: dict[str, Any]) -> dict[str, Any]:
|
|
4109
|
+
deadline_decision = _deadline_start_decision(
|
|
4110
|
+
DeadlineOperation.SHELL_BACKGROUND,
|
|
4111
|
+
minimum_remaining_seconds=MINIMUM_TOOL_START_SECONDS,
|
|
4112
|
+
)
|
|
4113
|
+
deadline_warning = None
|
|
4114
|
+
if deadline_decision is not None and not bool(deadline_decision.get("allowed")):
|
|
4115
|
+
deadline_warning = _deadline_warning_fields(
|
|
4116
|
+
"Deadline policy would normally block preview work; start proceeded because "
|
|
4117
|
+
"this is advisory and not safety.",
|
|
4118
|
+
start_decision=deadline_decision,
|
|
4119
|
+
)
|
|
4120
|
+
manager = _require_durable_service_manager()
|
|
4121
|
+
guard_terminal_op("workspace_preview_start")
|
|
4122
|
+
requested_access = str(args.get("access") or "auto").strip().lower()
|
|
4123
|
+
try:
|
|
4124
|
+
effective_access = manager.resolve_preview_access(requested_access)
|
|
4125
|
+
except ValueError as exc:
|
|
4126
|
+
raise AgentRuntimeError(f"Invalid workspace preview request: {exc}") from exc
|
|
4127
|
+
if effective_access == "lan" and not yes and not is_full_access_mode:
|
|
4128
|
+
if non_interactive and not host_managed_approvals:
|
|
4129
|
+
raise AgentRuntimeError(
|
|
4130
|
+
"LAN preview exposure requires interactive approval. Use local access or "
|
|
4131
|
+
"re-run in an approval-capable session."
|
|
4132
|
+
)
|
|
4133
|
+
decision = surface.request_approval(
|
|
4134
|
+
ApprovalRequest(
|
|
4135
|
+
kind="workspace_preview_lan",
|
|
4136
|
+
reason=(
|
|
4137
|
+
"LAN preview access exposes an authenticated workspace server to other "
|
|
4138
|
+
"devices on the current network"
|
|
4139
|
+
),
|
|
4140
|
+
preview="Start a temporary authenticated LAN workspace preview",
|
|
4141
|
+
)
|
|
4142
|
+
)
|
|
4143
|
+
if not decision.allow:
|
|
4144
|
+
raise ApprovalDeclinedError("workspace_preview_lan")
|
|
4145
|
+
raw_port = args.get("port")
|
|
4146
|
+
if raw_port is None or raw_port == "":
|
|
4147
|
+
port = None
|
|
4148
|
+
else:
|
|
4149
|
+
if isinstance(raw_port, bool):
|
|
4150
|
+
raise AgentRuntimeError("Preview port must be an integer")
|
|
4151
|
+
try:
|
|
4152
|
+
port = int(raw_port)
|
|
4153
|
+
except (TypeError, ValueError) as exc:
|
|
4154
|
+
raise AgentRuntimeError("Preview port must be an integer") from exc
|
|
4155
|
+
effective_cwd_relpath = _resolve_workspace_relative_path(
|
|
4156
|
+
tool_name="workspace_preview_start",
|
|
4157
|
+
raw_path=args.get("cwd"),
|
|
4158
|
+
raw_base=args.get("cwd_base"),
|
|
4159
|
+
field_name="cwd",
|
|
4160
|
+
base_field_name="cwd_base",
|
|
4161
|
+
allow_empty=True,
|
|
4162
|
+
)
|
|
4163
|
+
cwd_path = root if not effective_cwd_relpath else (root / effective_cwd_relpath).resolve()
|
|
4164
|
+
try:
|
|
4165
|
+
started = manager.start_preview(
|
|
4166
|
+
cwd=cwd_path,
|
|
4167
|
+
access=requested_access,
|
|
4168
|
+
port=port,
|
|
4169
|
+
)
|
|
4170
|
+
except ValueError as exc:
|
|
4171
|
+
raise AgentRuntimeError(f"Invalid workspace preview request: {exc}") from exc
|
|
4172
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
4173
|
+
raise AgentRuntimeError(f"Failed to start workspace preview: {exc}") from exc
|
|
4174
|
+
payload = dict(started.payload)
|
|
4175
|
+
payload["lifetime"] = "durable"
|
|
4176
|
+
if deadline_warning is not None:
|
|
4177
|
+
payload.update(deadline_warning)
|
|
4178
|
+
store.append(
|
|
4179
|
+
"service_start",
|
|
4180
|
+
{
|
|
4181
|
+
**_service_event_payload(payload),
|
|
4182
|
+
"cwd": effective_cwd_relpath,
|
|
4183
|
+
"service_kind": "workspace_preview",
|
|
4184
|
+
},
|
|
4185
|
+
)
|
|
4186
|
+
return payload
|
|
4187
|
+
|
|
4188
|
+
def _shell_service_status(args: dict[str, Any]) -> dict[str, Any]:
|
|
4189
|
+
manager = _require_durable_service_manager()
|
|
4190
|
+
guard_terminal_op("shell_service_status")
|
|
4191
|
+
service_id = str(args.get("service_id", "")).strip()
|
|
4192
|
+
if not service_id:
|
|
4193
|
+
raise AgentRuntimeError("Missing required argument: service_id")
|
|
4194
|
+
try:
|
|
4195
|
+
payload = manager.status(service_id)
|
|
4196
|
+
except ValueError as exc:
|
|
4197
|
+
raise AgentRuntimeError(f"Invalid durable service_id: {exc}") from exc
|
|
4198
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
4199
|
+
raise AgentRuntimeError(f"Failed to inspect durable service: {exc}") from exc
|
|
4200
|
+
store.append("service_status", _service_event_payload(payload))
|
|
4201
|
+
return payload
|
|
4202
|
+
|
|
4203
|
+
def _shell_service_stop(args: dict[str, Any]) -> dict[str, Any]:
|
|
4204
|
+
manager = _require_durable_service_manager()
|
|
4205
|
+
guard_terminal_op("shell_service_stop")
|
|
4206
|
+
service_id = str(args.get("service_id", "")).strip()
|
|
4207
|
+
if not service_id:
|
|
4208
|
+
raise AgentRuntimeError("Missing required argument: service_id")
|
|
4209
|
+
try:
|
|
4210
|
+
payload = manager.stop(service_id)
|
|
4211
|
+
except ValueError as exc:
|
|
4212
|
+
raise AgentRuntimeError(f"Invalid durable service_id: {exc}") from exc
|
|
4213
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
4214
|
+
raise AgentRuntimeError(f"Failed to stop durable service: {exc}") from exc
|
|
4215
|
+
store.append("service_stop", _service_event_payload(payload))
|
|
4216
|
+
return payload
|
|
4217
|
+
|
|
4218
|
+
_append_builtin_tool("shell_background", run=_shell_background)
|
|
4219
|
+
_append_builtin_tool("shell_output", run=_shell_output)
|
|
4220
|
+
_append_builtin_tool("shell_wait", run=_shell_wait)
|
|
4221
|
+
_append_builtin_tool("shell_kill", run=_shell_kill)
|
|
4222
|
+
_append_builtin_tool("shell_list", run=_shell_list)
|
|
4223
|
+
_append_builtin_tool("shell_service_start", run=_shell_service_start)
|
|
4224
|
+
_append_builtin_tool("workspace_preview_start", run=_workspace_preview_start)
|
|
4225
|
+
_append_builtin_tool("shell_service_status", run=_shell_service_status)
|
|
4226
|
+
_append_builtin_tool("shell_service_stop", run=_shell_service_stop)
|
|
4227
|
+
|
|
4228
|
+
def _session_set_workdir(args: dict[str, Any]) -> dict[str, Any]:
|
|
4229
|
+
raw_path = str(args.get("path", "")).strip()
|
|
4230
|
+
if not raw_path:
|
|
4231
|
+
raise SessionWorkdirError("Missing required argument: path")
|
|
4232
|
+
if not callable(set_active_workdir_callback):
|
|
4233
|
+
raise SessionWorkdirError("session_set_workdir is unavailable in this session.")
|
|
4234
|
+
return set_active_workdir_callback(raw_path, "tool")
|
|
4235
|
+
|
|
4236
|
+
_append_builtin_tool("session_set_workdir", run=_session_set_workdir)
|
|
4237
|
+
|
|
4238
|
+
def _host_action(tool_name: str, action: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
4239
|
+
if host_action_handler is None or action not in effective_host_actions:
|
|
4240
|
+
raise AgentRuntimeError(
|
|
4241
|
+
f"{tool_name} is unavailable because the IDE host did not advertise this capability."
|
|
4242
|
+
)
|
|
4243
|
+
try:
|
|
4244
|
+
normalized = normalize_host_action_arguments(action, args)
|
|
4245
|
+
if action in {"tasks.run", "debug.start"}:
|
|
4246
|
+
if persona_write_scope_active:
|
|
4247
|
+
raise AgentRuntimeError(
|
|
4248
|
+
f"Blocked while persona write scope is active: {tool_name}. "
|
|
4249
|
+
"Opaque IDE executions cannot be constrained to persona paths."
|
|
4250
|
+
)
|
|
4251
|
+
if mode == "readonly":
|
|
4252
|
+
raise AgentRuntimeError(f"Blocked in readonly mode: {tool_name}")
|
|
4253
|
+
elif not host_managed_approvals:
|
|
4254
|
+
raise AgentRuntimeError(
|
|
4255
|
+
f"Explicit one-time IDE approval is required for {tool_name}."
|
|
4256
|
+
)
|
|
4257
|
+
else:
|
|
4258
|
+
identifier_field = "task_id" if action == "tasks.run" else "configuration_id"
|
|
4259
|
+
opaque_id = normalized[identifier_field]
|
|
4260
|
+
decision = surface.request_approval(
|
|
4261
|
+
ApprovalRequest(
|
|
4262
|
+
kind=tool_name,
|
|
4263
|
+
reason=(
|
|
4264
|
+
"opaque IDE task and debug configurations may execute arbitrary "
|
|
4265
|
+
"workspace commands"
|
|
4266
|
+
),
|
|
4267
|
+
preview=(
|
|
4268
|
+
f"IDE host execution: {action}\n"
|
|
4269
|
+
f"{identifier_field}: {opaque_id}\n"
|
|
4270
|
+
f"workspace: {root}"
|
|
4271
|
+
),
|
|
4272
|
+
metadata={
|
|
4273
|
+
"mandatory_explicit_approval": True,
|
|
4274
|
+
"allow_for_session_disabled": True,
|
|
4275
|
+
"host_action": action,
|
|
4276
|
+
"opaque_id": opaque_id,
|
|
4277
|
+
"workspace_root": str(root),
|
|
4278
|
+
},
|
|
4279
|
+
# Opaque task/configuration ids can resolve to changed commands later.
|
|
4280
|
+
# A cached or broad session grant must never authorize execution.
|
|
4281
|
+
allow_for_session_scope=None,
|
|
4282
|
+
)
|
|
4283
|
+
)
|
|
4284
|
+
if not decision.allow:
|
|
4285
|
+
raise ApprovalDeclinedError(tool_name)
|
|
4286
|
+
if decision.allow_for_session:
|
|
4287
|
+
raise AgentRuntimeError(
|
|
4288
|
+
f"Session approval cannot authorize opaque IDE execution: {tool_name}."
|
|
4289
|
+
)
|
|
4290
|
+
return host_action_handler(action, normalized)
|
|
4291
|
+
except HostActionError as exc:
|
|
4292
|
+
raise AgentRuntimeError(exc.message, result_payload=exc.to_result_payload()) from exc
|
|
4293
|
+
|
|
4294
|
+
host_tool_actions = {tool_name: action for action, tool_name in HOST_ACTION_TOOL_NAMES.items()}
|
|
4295
|
+
for tool_name, action in host_tool_actions.items():
|
|
4296
|
+
if action not in effective_host_actions or host_action_handler is None:
|
|
4297
|
+
continue
|
|
4298
|
+
|
|
4299
|
+
def _run_host_action(
|
|
4300
|
+
args: dict[str, Any],
|
|
4301
|
+
*,
|
|
4302
|
+
_tool_name: str = tool_name,
|
|
4303
|
+
_action: str = action,
|
|
4304
|
+
) -> dict[str, Any]:
|
|
4305
|
+
if _action in {"tasks.terminate", "debug.stop"}:
|
|
4306
|
+
guard_terminal_op(_tool_name)
|
|
4307
|
+
return _host_action(_tool_name, _action, args)
|
|
4308
|
+
|
|
4309
|
+
_append_builtin_tool(tool_name, run=_run_host_action)
|
|
4310
|
+
|
|
4311
|
+
_append_builtin_tool(
|
|
4312
|
+
"git_status",
|
|
4313
|
+
run=lambda _args: git_status(root=root),
|
|
4314
|
+
)
|
|
4315
|
+
|
|
4316
|
+
_append_builtin_tool(
|
|
4317
|
+
"git_diff",
|
|
4318
|
+
run=lambda _args: git_diff(root=root),
|
|
4319
|
+
)
|
|
4320
|
+
|
|
4321
|
+
if git_backed_workspace:
|
|
4322
|
+
_append_builtin_tool(
|
|
4323
|
+
"git_history",
|
|
4324
|
+
run=lambda args: git_history(
|
|
4325
|
+
root=root,
|
|
4326
|
+
mode=str(args.get("mode", "")),
|
|
4327
|
+
path=str(args["path"]) if args.get("path") is not None else None,
|
|
4328
|
+
limit=int(args["limit"]) if args.get("limit") is not None else 10,
|
|
4329
|
+
ref=str(args["ref"]) if args.get("ref") is not None else None,
|
|
4330
|
+
grep=str(args["grep"]) if args.get("grep") is not None else None,
|
|
4331
|
+
author=str(args["author"]) if args.get("author") is not None else None,
|
|
4332
|
+
commit=str(args["commit"]) if args.get("commit") is not None else None,
|
|
4333
|
+
start_line=(
|
|
4334
|
+
int(args["start_line"]) if args.get("start_line") is not None else None
|
|
4335
|
+
),
|
|
4336
|
+
end_line=(int(args["end_line"]) if args.get("end_line") is not None else None),
|
|
4337
|
+
),
|
|
4338
|
+
)
|
|
4339
|
+
|
|
4340
|
+
def _git_apply(args: dict[str, Any]) -> dict[str, Any]:
|
|
4341
|
+
patch = str(args.get("patch", ""))
|
|
4342
|
+
patch_paths = sorted(set(iter_patch_paths(patch)))
|
|
4343
|
+
for p in patch_paths:
|
|
4344
|
+
_guard_write_path(p)
|
|
4345
|
+
preconditions = [capture_file_precondition(root=root, path=p) for p in patch_paths]
|
|
4346
|
+
sensitive = guard_sensitive_files("git_apply_patch", files=patch_paths)
|
|
4347
|
+
preview = patch[:20000]
|
|
4348
|
+
if sensitive:
|
|
4349
|
+
store.append(
|
|
4350
|
+
"sensitive_change_preview",
|
|
4351
|
+
{
|
|
4352
|
+
"paths": patch_paths,
|
|
4353
|
+
"operation": "git_apply_patch",
|
|
4354
|
+
"content_redacted": True,
|
|
4355
|
+
},
|
|
4356
|
+
)
|
|
4357
|
+
else:
|
|
4358
|
+
store.append("diff_preview", {"patch": preview})
|
|
4359
|
+
surface.on_patch_generated(
|
|
4360
|
+
PatchEvent(
|
|
4361
|
+
files=patch_paths,
|
|
4362
|
+
diff=patch,
|
|
4363
|
+
summary=f"{len(patch_paths)} file(s) changed via git_apply_patch",
|
|
4364
|
+
)
|
|
4365
|
+
)
|
|
4366
|
+
guard_write("git_apply_patch", preview or "(empty patch)", files=patch_paths)
|
|
4367
|
+
try:
|
|
4368
|
+
for precondition in preconditions:
|
|
4369
|
+
assert_file_precondition(root=root, precondition=precondition)
|
|
4370
|
+
result = git_apply_patch(root=root, patch=patch)
|
|
4371
|
+
except StaleFileError as error:
|
|
4372
|
+
return _stale_file_result(error)
|
|
4373
|
+
return _mark_sensitive_result(result, sensitive)
|
|
4374
|
+
|
|
4375
|
+
_append_builtin_tool("git_apply_patch", run=_git_apply)
|
|
4376
|
+
|
|
4377
|
+
if subagents_enabled and subagent_depth == 0:
|
|
4378
|
+
callable_subagent_names = routable_subagent_names(
|
|
4379
|
+
registry=subagent_registry,
|
|
4380
|
+
cfg=cfg,
|
|
4381
|
+
available_tool_names={tool.name for tool in tools},
|
|
4382
|
+
)
|
|
4383
|
+
subagent_parameters = copied_tool_parameters("subagent_run")
|
|
4384
|
+
properties = subagent_parameters.get("properties")
|
|
4385
|
+
if not isinstance(properties, dict):
|
|
4386
|
+
raise AgentRuntimeError("subagent_run parameters must define properties")
|
|
4387
|
+
subagent_name_schema = properties.get("name")
|
|
4388
|
+
if not isinstance(subagent_name_schema, dict):
|
|
4389
|
+
raise AgentRuntimeError("subagent_run parameters must define a name property")
|
|
4390
|
+
if callable_subagent_names:
|
|
4391
|
+
subagent_name_schema["enum"] = callable_subagent_names
|
|
4392
|
+
|
|
4393
|
+
child_run_registry = ChildRunRegistry()
|
|
4394
|
+
workspace_provider = (
|
|
4395
|
+
SubagentWorkspaceProvider(root=root, store=store)
|
|
4396
|
+
if (
|
|
4397
|
+
(cfg is None or cfg.subagent_orchestration.workspace_isolation_enabled)
|
|
4398
|
+
and isinstance(store, SessionStore)
|
|
4399
|
+
)
|
|
4400
|
+
else None
|
|
4401
|
+
)
|
|
4402
|
+
subagent_launcher = SubagentLauncher(
|
|
4403
|
+
root=root,
|
|
4404
|
+
surface=surface,
|
|
4405
|
+
store=store,
|
|
4406
|
+
mode=mode,
|
|
4407
|
+
yes=yes,
|
|
4408
|
+
cfg=cfg,
|
|
4409
|
+
api_key=api_key,
|
|
4410
|
+
max_steps=max_steps,
|
|
4411
|
+
no_log=no_log,
|
|
4412
|
+
usage_role=usage_role,
|
|
4413
|
+
usage_summary=usage_summary,
|
|
4414
|
+
deny_write_prefixes=deny_write_prefixes,
|
|
4415
|
+
allow_write_globs=allow_write_globs,
|
|
4416
|
+
persona_allow_write_globs=persona_allow_write_globs,
|
|
4417
|
+
non_interactive=non_interactive,
|
|
4418
|
+
verification_enabled=verification_enabled,
|
|
4419
|
+
authoritative_verification_commands=authoritative_verify_commands,
|
|
4420
|
+
subagents_enabled=subagents_enabled,
|
|
4421
|
+
subagent_depth=subagent_depth,
|
|
4422
|
+
subagent_registry=subagent_registry,
|
|
4423
|
+
session_log_dir_override=session_log_dir_override,
|
|
4424
|
+
step_budget_runtime=step_budget_runtime,
|
|
4425
|
+
get_active_workdir_relpath=get_active_workdir_relpath,
|
|
4426
|
+
create_session_factory=create_session_factory,
|
|
4427
|
+
prompt_cache_parent_session_id=prompt_cache_parent_session_id,
|
|
4428
|
+
execution_deadline=execution_deadline,
|
|
4429
|
+
crash_diagnostic_log_path=crash_diagnostic_log_path,
|
|
4430
|
+
crash_diagnostics=crash_diagnostics,
|
|
4431
|
+
tools=tools,
|
|
4432
|
+
command_mutation_metadata=_command_mutation_metadata,
|
|
4433
|
+
workspace_provider=workspace_provider,
|
|
4434
|
+
child_run_registry=child_run_registry,
|
|
4435
|
+
helpers_enabled_for_children=(
|
|
4436
|
+
resolved_runtime_kind
|
|
4437
|
+
not in {
|
|
4438
|
+
RuntimeKind.FORGE_EXEC,
|
|
4439
|
+
RuntimeKind.SWARM_WORKER,
|
|
4440
|
+
}
|
|
4441
|
+
),
|
|
4442
|
+
managed_browser_service=managed_browser_service,
|
|
4443
|
+
managed_browser_owner_id=managed_browser_owner_id,
|
|
4444
|
+
managed_browser_cancel_check=managed_browser_cancel_check,
|
|
4445
|
+
)
|
|
4446
|
+
child_scheduler = ChildScheduler(
|
|
4447
|
+
launcher=subagent_launcher,
|
|
4448
|
+
max_background_children=(
|
|
4449
|
+
cfg.subagent_orchestration.max_background_children if cfg is not None else 3
|
|
4450
|
+
),
|
|
4451
|
+
parent_steer_inbox=parent_steer_inbox,
|
|
4452
|
+
)
|
|
4453
|
+
if child_scheduler_sink is not None:
|
|
4454
|
+
child_scheduler_sink(child_scheduler)
|
|
4455
|
+
_append_builtin_tool(
|
|
4456
|
+
"subagent_run",
|
|
4457
|
+
parameters=subagent_parameters,
|
|
4458
|
+
run=subagent_launcher.run,
|
|
4459
|
+
)
|
|
4460
|
+
background_tools_enabled = resolved_runtime_kind not in {
|
|
4461
|
+
RuntimeKind.FORGE_EXEC,
|
|
4462
|
+
RuntimeKind.SWARM_WORKER,
|
|
4463
|
+
}
|
|
4464
|
+
if background_tools_enabled:
|
|
4465
|
+
spawn_parameters = copied_tool_parameters("subagent_spawn")
|
|
4466
|
+
spawn_properties = spawn_parameters.get("properties")
|
|
4467
|
+
if not isinstance(spawn_properties, dict):
|
|
4468
|
+
raise AgentRuntimeError("subagent_spawn parameters must define properties")
|
|
4469
|
+
spawn_name_schema = spawn_properties.get("name")
|
|
4470
|
+
if not isinstance(spawn_name_schema, dict):
|
|
4471
|
+
raise AgentRuntimeError("subagent_spawn parameters must define a name property")
|
|
4472
|
+
if callable_subagent_names:
|
|
4473
|
+
spawn_name_schema["enum"] = callable_subagent_names
|
|
4474
|
+
|
|
4475
|
+
def _subagent_spawn(args: dict[str, Any]) -> dict[str, Any]:
|
|
4476
|
+
parent_token = args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG)
|
|
4477
|
+
public_args = {key: value for key, value in args.items() if isinstance(key, str)}
|
|
4478
|
+
return child_scheduler.spawn(
|
|
4479
|
+
public_args,
|
|
4480
|
+
parent_cancellation_token=parent_token,
|
|
4481
|
+
)
|
|
4482
|
+
|
|
4483
|
+
def _subagent_status(args: dict[str, Any]) -> dict[str, Any]:
|
|
4484
|
+
raw_run_id = str(args.get("run_id") or "").strip()
|
|
4485
|
+
return child_scheduler.status(run_id=raw_run_id or None)
|
|
4486
|
+
|
|
4487
|
+
def _subagent_send(args: dict[str, Any]) -> dict[str, Any]:
|
|
4488
|
+
return child_scheduler.send(
|
|
4489
|
+
run_id=str(args.get("run_id") or "").strip(),
|
|
4490
|
+
message=str(args.get("message") or ""),
|
|
4491
|
+
)
|
|
4492
|
+
|
|
4493
|
+
def _subagent_resume(args: dict[str, Any]) -> dict[str, Any]:
|
|
4494
|
+
return child_scheduler.resume(
|
|
4495
|
+
args,
|
|
4496
|
+
parent_cancellation_token=args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG),
|
|
4497
|
+
)
|
|
4498
|
+
|
|
4499
|
+
def _subagent_wait(args: dict[str, Any]) -> dict[str, Any]:
|
|
4500
|
+
raw_run_id = str(args.get("run_id") or "all").strip() or "all"
|
|
4501
|
+
raw_timeout = args.get("timeout_s")
|
|
4502
|
+
timeout_s = float(raw_timeout) if raw_timeout is not None else None
|
|
4503
|
+
return child_scheduler.collect(
|
|
4504
|
+
run_id=raw_run_id,
|
|
4505
|
+
timeout_s=timeout_s,
|
|
4506
|
+
cancellation_token=args.get(_SUBAGENT_CANCELLATION_TOKEN_ARG),
|
|
4507
|
+
)
|
|
4508
|
+
|
|
4509
|
+
def _subagent_cancel(args: dict[str, Any]) -> dict[str, Any]:
|
|
4510
|
+
raw_run_id = str(args.get("run_id") or "all").strip() or "all"
|
|
4511
|
+
return child_scheduler.cancel(run_id=raw_run_id, wait_for_running=False)
|
|
4512
|
+
|
|
4513
|
+
_append_builtin_tool(
|
|
4514
|
+
"subagent_spawn",
|
|
4515
|
+
parameters=spawn_parameters,
|
|
4516
|
+
run=_subagent_spawn,
|
|
4517
|
+
)
|
|
4518
|
+
_append_builtin_tool("subagent_send", run=_subagent_send)
|
|
4519
|
+
_append_builtin_tool("subagent_resume", run=_subagent_resume)
|
|
4520
|
+
_append_builtin_tool("subagent_status", run=_subagent_status)
|
|
4521
|
+
_append_builtin_tool("subagent_wait", run=_subagent_wait)
|
|
4522
|
+
_append_builtin_tool("subagent_cancel", run=_subagent_cancel)
|
|
4523
|
+
|
|
4524
|
+
workspace_actions_enabled = bool(
|
|
4525
|
+
workspace_provider is not None
|
|
4526
|
+
and (cfg is None or cfg.subagent_orchestration.workspace_isolation_enabled)
|
|
4527
|
+
)
|
|
4528
|
+
if workspace_actions_enabled:
|
|
4529
|
+
|
|
4530
|
+
def _subagent_apply(args: dict[str, Any]) -> dict[str, Any]:
|
|
4531
|
+
run_id = str(args.get("run_id") or "").strip()
|
|
4532
|
+
workspace_record = workspace_provider.get(run_id)
|
|
4533
|
+
if workspace_record is not None and workspace_record.no_changes:
|
|
4534
|
+
return workspace_provider.apply(run_id)
|
|
4535
|
+
preflight = child_scheduler.candidate_apply_preflight(
|
|
4536
|
+
run_id=run_id,
|
|
4537
|
+
acknowledge_incomplete=args.get("acknowledge_incomplete") is True,
|
|
4538
|
+
)
|
|
4539
|
+
if not bool(preflight.get("allowed")):
|
|
4540
|
+
return {key: value for key, value in preflight.items() if key != "allowed"}
|
|
4541
|
+
result = workspace_provider.apply(run_id)
|
|
4542
|
+
if preflight.get("incomplete_acknowledged"):
|
|
4543
|
+
result.update(
|
|
4544
|
+
{key: value for key, value in preflight.items() if key != "allowed"}
|
|
4545
|
+
)
|
|
4546
|
+
return result
|
|
4547
|
+
|
|
4548
|
+
def _subagent_discard(args: dict[str, Any]) -> dict[str, Any]:
|
|
4549
|
+
return workspace_provider.release(
|
|
4550
|
+
str(args.get("run_id") or "").strip(),
|
|
4551
|
+
action="discarded",
|
|
4552
|
+
)
|
|
4553
|
+
|
|
4554
|
+
_append_builtin_tool("subagent_apply", run=_subagent_apply)
|
|
4555
|
+
_append_builtin_tool("subagent_discard", run=_subagent_discard)
|
|
4556
|
+
|
|
4557
|
+
elif (
|
|
4558
|
+
helper_subagents_enabled
|
|
4559
|
+
and subagent_depth == 1
|
|
4560
|
+
and (cfg is None or cfg.subagent_orchestration.helpers_enabled)
|
|
4561
|
+
):
|
|
4562
|
+
available_names = {tool.name for tool in tools}
|
|
4563
|
+
helper_names = helper_subagent_names(
|
|
4564
|
+
registry=subagent_registry,
|
|
4565
|
+
cfg=cfg,
|
|
4566
|
+
available_tool_names=available_names,
|
|
4567
|
+
)
|
|
4568
|
+
if helper_names and EDIT_CAPABLE_SUBAGENT_TOOL_NAMES.intersection(available_names):
|
|
4569
|
+
helper_parameters = copied_tool_parameters("subagent_run")
|
|
4570
|
+
helper_properties = helper_parameters.get("properties")
|
|
4571
|
+
if not isinstance(helper_properties, dict):
|
|
4572
|
+
raise AgentRuntimeError("subagent_run parameters must define properties")
|
|
4573
|
+
helper_parameters["properties"] = {
|
|
4574
|
+
name: schema
|
|
4575
|
+
for name, schema in helper_properties.items()
|
|
4576
|
+
if name in {"name", "task", "max_steps"}
|
|
4577
|
+
}
|
|
4578
|
+
helper_name_schema = helper_parameters["properties"].get("name")
|
|
4579
|
+
if not isinstance(helper_name_schema, dict):
|
|
4580
|
+
raise AgentRuntimeError("subagent_run parameters must define a name property")
|
|
4581
|
+
helper_name_schema["enum"] = helper_names
|
|
4582
|
+
helper_launcher = SubagentLauncher(
|
|
4583
|
+
root=root,
|
|
4584
|
+
surface=surface,
|
|
4585
|
+
store=store,
|
|
4586
|
+
mode=mode,
|
|
4587
|
+
yes=yes,
|
|
4588
|
+
cfg=cfg,
|
|
4589
|
+
api_key=api_key,
|
|
4590
|
+
max_steps=max_steps,
|
|
4591
|
+
no_log=no_log,
|
|
4592
|
+
usage_role=usage_role,
|
|
4593
|
+
usage_summary=usage_summary,
|
|
4594
|
+
deny_write_prefixes=deny_write_prefixes,
|
|
4595
|
+
allow_write_globs=allow_write_globs,
|
|
4596
|
+
persona_allow_write_globs=persona_allow_write_globs,
|
|
4597
|
+
non_interactive=non_interactive,
|
|
4598
|
+
verification_enabled=verification_enabled,
|
|
4599
|
+
authoritative_verification_commands=authoritative_verify_commands,
|
|
4600
|
+
subagents_enabled=True,
|
|
4601
|
+
subagent_depth=subagent_depth,
|
|
4602
|
+
subagent_registry=subagent_registry,
|
|
4603
|
+
session_log_dir_override=session_log_dir_override,
|
|
4604
|
+
step_budget_runtime=step_budget_runtime,
|
|
4605
|
+
get_active_workdir_relpath=get_active_workdir_relpath,
|
|
4606
|
+
create_session_factory=create_session_factory,
|
|
4607
|
+
prompt_cache_parent_session_id=prompt_cache_parent_session_id,
|
|
4608
|
+
execution_deadline=execution_deadline,
|
|
4609
|
+
crash_diagnostic_log_path=crash_diagnostic_log_path,
|
|
4610
|
+
crash_diagnostics=crash_diagnostics,
|
|
4611
|
+
tools=tools,
|
|
4612
|
+
command_mutation_metadata=_command_mutation_metadata,
|
|
4613
|
+
helper_only=True,
|
|
4614
|
+
helper_allowed_names=tuple(helper_names),
|
|
4615
|
+
)
|
|
4616
|
+
helper_description = (
|
|
4617
|
+
"Run one bounded non-editing helper in this workspace and return its "
|
|
4618
|
+
"advisory report. Helpers cannot delegate further."
|
|
4619
|
+
)
|
|
4620
|
+
tools.append(
|
|
4621
|
+
ToolDef(
|
|
4622
|
+
name="subagent_run",
|
|
4623
|
+
description=helper_description,
|
|
4624
|
+
parameters=helper_parameters,
|
|
4625
|
+
run=helper_launcher.run,
|
|
4626
|
+
metadata={
|
|
4627
|
+
"tool_type": "builtin",
|
|
4628
|
+
"compact_parameters_for_model": True,
|
|
4629
|
+
"model_description": helper_description,
|
|
4630
|
+
},
|
|
4631
|
+
)
|
|
4632
|
+
)
|
|
4633
|
+
|
|
4634
|
+
for custom_tool_spec in sorted(
|
|
4635
|
+
custom_tool_session_state.exposed_tools_by_name.values(),
|
|
4636
|
+
key=lambda spec: spec.name.casefold(),
|
|
4637
|
+
):
|
|
4638
|
+
tools.append(
|
|
4639
|
+
ToolDef(
|
|
4640
|
+
name=custom_tool_spec.name,
|
|
4641
|
+
description=custom_tool_spec.description,
|
|
4642
|
+
parameters=copy.deepcopy(custom_tool_spec.input_schema),
|
|
4643
|
+
run=lambda args, spec=custom_tool_spec: _run_custom_tool(spec, args),
|
|
4644
|
+
metadata={
|
|
4645
|
+
"tool_type": "custom_tool",
|
|
4646
|
+
"compact_parameters_for_model": True,
|
|
4647
|
+
"model_description_max_chars": 1200,
|
|
4648
|
+
"custom_tool": custom_tool_spec.metadata(include_output_schema=True),
|
|
4649
|
+
},
|
|
4650
|
+
)
|
|
4651
|
+
)
|
|
4652
|
+
|
|
4653
|
+
if mcp_manager is not None and _mcp_tool_exposed_in_mode(
|
|
4654
|
+
mode=mode,
|
|
4655
|
+
write_scope_restricted=persona_write_scope_active,
|
|
4656
|
+
):
|
|
4657
|
+
for binding in mcp_manager.tool_bindings:
|
|
4658
|
+
bound_binding = binding.bind_session_mode(mode)
|
|
4659
|
+
tools.append(
|
|
4660
|
+
ToolDef(
|
|
4661
|
+
name=bound_binding.tool_alias,
|
|
4662
|
+
description=bound_binding.description,
|
|
4663
|
+
parameters=bound_binding.parameters,
|
|
4664
|
+
run=bound_binding.run,
|
|
4665
|
+
metadata={
|
|
4666
|
+
"tool_type": "mcp",
|
|
4667
|
+
"compact_parameters_for_model": True,
|
|
4668
|
+
"model_description_max_chars": 1000,
|
|
4669
|
+
},
|
|
4670
|
+
)
|
|
4671
|
+
)
|
|
4672
|
+
active_tools = {t.name: t for t in tools}
|
|
4673
|
+
if tool_dispatch_guard is not None:
|
|
4674
|
+
|
|
4675
|
+
def _resolve_guard_rel_path(
|
|
4676
|
+
*,
|
|
4677
|
+
raw_path: Any,
|
|
4678
|
+
raw_base: Any = None,
|
|
4679
|
+
field_name: str,
|
|
4680
|
+
base_field_name: str,
|
|
4681
|
+
) -> str:
|
|
4682
|
+
return _resolve_workspace_relative_path(
|
|
4683
|
+
tool_name="tool_dispatch_guard",
|
|
4684
|
+
raw_path=raw_path,
|
|
4685
|
+
raw_base=raw_base,
|
|
4686
|
+
field_name=field_name,
|
|
4687
|
+
base_field_name=base_field_name,
|
|
4688
|
+
)
|
|
4689
|
+
|
|
4690
|
+
def _wrap_with_dispatch_guard(tool: ToolDef) -> ToolDef:
|
|
4691
|
+
original_run = tool.run
|
|
4692
|
+
|
|
4693
|
+
def guarded_run(
|
|
4694
|
+
arguments: dict[str, Any],
|
|
4695
|
+
*,
|
|
4696
|
+
_original_run: Callable[[dict[str, Any]], dict[str, Any]] = original_run,
|
|
4697
|
+
_tool_name: str = tool.name,
|
|
4698
|
+
) -> dict[str, Any]:
|
|
4699
|
+
tool_dispatch_guard.check_tool_call(
|
|
4700
|
+
_tool_name,
|
|
4701
|
+
arguments if isinstance(arguments, dict) else {},
|
|
4702
|
+
resolve_rel_path=_resolve_guard_rel_path,
|
|
4703
|
+
)
|
|
4704
|
+
return _original_run(arguments)
|
|
4705
|
+
|
|
4706
|
+
return replace(tool, run=guarded_run)
|
|
4707
|
+
|
|
4708
|
+
active_tools = {
|
|
4709
|
+
name: _wrap_with_dispatch_guard(tool) for name, tool in active_tools.items()
|
|
4710
|
+
}
|
|
4711
|
+
for metadata in iter_builtin_tool_metadata():
|
|
4712
|
+
register_tool_availability(metadata.name, optional=metadata.optional)
|
|
4713
|
+
if metadata.name in active_tools:
|
|
4714
|
+
mark_available(metadata.name)
|
|
4715
|
+
elif metadata.optional:
|
|
4716
|
+
if metadata.name == "image_generate" and (
|
|
4717
|
+
cfg is None or not cfg.image_generation.enabled
|
|
4718
|
+
):
|
|
4719
|
+
reason = "image_generation.enabled is false"
|
|
4720
|
+
elif metadata.name == "image_generate":
|
|
4721
|
+
reason = f"image generation is not exposed in mode={mode}"
|
|
4722
|
+
else:
|
|
4723
|
+
reason = metadata.optional_unavailable_reason or (
|
|
4724
|
+
"not registered in active tool registry "
|
|
4725
|
+
f"for mode={mode} runtime_kind={resolved_runtime_kind.value}"
|
|
4726
|
+
)
|
|
4727
|
+
mark_unavailable(metadata.name, reason)
|
|
4728
|
+
return active_tools
|