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,659 @@
|
|
|
1
|
+
"""Edit discipline: wholesale-rewrite detection, thrash detection, scratch files.
|
|
2
|
+
|
|
3
|
+
Three warn-only guardrails against three observed failure modes. None of them
|
|
4
|
+
blocks or rewrites a model action; each one states a fact the model could not
|
|
5
|
+
otherwise see and then gets out of the way.
|
|
6
|
+
|
|
7
|
+
*Wholesale rewrites.* A full-file write that reproduces a file's meaning but not
|
|
8
|
+
its bytes passes every semantic test and fails every byte-comparison one. The
|
|
9
|
+
observed case re-serialized HTML it was only meant to filter -- indentation
|
|
10
|
+
stripped, attributes reordered, ``<br>`` become ``<br/>``, ``©`` become
|
|
11
|
+
``(c)`` -- so a checker asserting that untouched files were untouched failed on
|
|
12
|
+
files the task never asked it to change. The same disease shows up on patch
|
|
13
|
+
benchmarks as pure regressions: the target test passes, unrelated ones break.
|
|
14
|
+
Two signals catch it cheaply: what fraction of the original lines did not
|
|
15
|
+
survive, and whether the surviving difference is *only* serialization.
|
|
16
|
+
|
|
17
|
+
*Thrashing.* A run that keeps re-attempting one variation of one idea -- 45
|
|
18
|
+
material actions, 52 scratch files, no answer -- has stopped learning from its
|
|
19
|
+
own results. Counting near-identical failures per action *family* (a path with
|
|
20
|
+
its numeric tail removed, so ``analyze_final.py`` / ``analyze_final2.py`` /
|
|
21
|
+
``analyze_final3.py`` are one thing) turns that into a number worth reporting
|
|
22
|
+
back once.
|
|
23
|
+
|
|
24
|
+
*Scratch files.* The same run left its whole working set on disk. Naming them at
|
|
25
|
+
finalization costs nothing and is the difference between a tree-state verifier
|
|
26
|
+
passing and failing. This module only *identifies* them; deleting is a
|
|
27
|
+
deliberate non-goal here.
|
|
28
|
+
|
|
29
|
+
Stdlib only, no package imports: the agent pulls this in from the tool layer and
|
|
30
|
+
the turn controller, and the tests load it straight from this file path in a
|
|
31
|
+
bare interpreter.
|
|
32
|
+
|
|
33
|
+
Cost note: nothing here walks the workspace. The rewrite guard is handed the
|
|
34
|
+
pre-write content that the write tool has already read for its own diff preview,
|
|
35
|
+
so the guard adds no I/O at all; every other input is something the caller
|
|
36
|
+
already had in hand.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import difflib
|
|
42
|
+
import re
|
|
43
|
+
import threading
|
|
44
|
+
from collections.abc import Iterable, Sequence
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Line-change fraction
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def changed_line_fraction(old: str, new: str) -> float:
|
|
53
|
+
"""Fraction of lines that a rewrite did not carry over unchanged.
|
|
54
|
+
|
|
55
|
+
The denominator is the longer of the two line counts, so both directions of
|
|
56
|
+
size change are penalised: deleting half a file and doubling it are each a
|
|
57
|
+
large fraction. The numerator is everything ``difflib`` could not place in a
|
|
58
|
+
common block, which means a line that merely *moved* still counts as
|
|
59
|
+
changed. That is the intent -- a reordering serializer is exactly the thing
|
|
60
|
+
this signal exists to notice.
|
|
61
|
+
|
|
62
|
+
Returns 0.0 for two empty inputs, and 1.0 when one side is empty and the
|
|
63
|
+
other is not.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
old_lines = old.splitlines()
|
|
67
|
+
new_lines = new.splitlines()
|
|
68
|
+
denominator = max(len(old_lines), len(new_lines))
|
|
69
|
+
if denominator == 0:
|
|
70
|
+
return 0.0
|
|
71
|
+
matcher = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
|
|
72
|
+
matched = sum(block.size for block in matcher.get_matching_blocks())
|
|
73
|
+
return (denominator - matched) / denominator
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def changed_line_percent(old: str, new: str) -> int:
|
|
77
|
+
"""``changed_line_fraction`` as a whole percent, for display."""
|
|
78
|
+
|
|
79
|
+
return int(round(changed_line_fraction(old, new) * 100))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Serialization-only detection
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_WHITESPACE_RUN = re.compile(r"\s+")
|
|
87
|
+
|
|
88
|
+
# A tag with a name and optional attributes. Deliberately not an HTML parser:
|
|
89
|
+
# this only has to recognise the shapes a re-serializer produces, and anything
|
|
90
|
+
# it fails to recognise is left byte-for-byte alone (a miss costs a warning that
|
|
91
|
+
# is not emitted, never a wrong claim about semantics).
|
|
92
|
+
_TAG = re.compile(r"<(/?)([A-Za-z][-A-Za-z0-9:]*)((?:\s+[^<>]*?)?)\s*(/?)>")
|
|
93
|
+
_ATTR = re.compile(
|
|
94
|
+
r"""([-A-Za-z_:][-A-Za-z0-9_:.]*) # name
|
|
95
|
+
(?:\s*=\s* # optional value
|
|
96
|
+
(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+))
|
|
97
|
+
)?""",
|
|
98
|
+
re.VERBOSE,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Only entities that cannot be confused with markup. Normalising the structural
|
|
102
|
+
# five (& < > " ') would let a document containing a literal
|
|
103
|
+
# escaped tag compare equal to one containing a real tag, which is a genuine
|
|
104
|
+
# semantic difference -- so they are deliberately left alone.
|
|
105
|
+
_NON_STRUCTURAL_ENTITIES: dict[str, str] = {
|
|
106
|
+
"©": "©",
|
|
107
|
+
"®": "®",
|
|
108
|
+
"™": "™",
|
|
109
|
+
" ": " ",
|
|
110
|
+
"—": "—",
|
|
111
|
+
"–": "–",
|
|
112
|
+
"…": "…",
|
|
113
|
+
"«": "«",
|
|
114
|
+
"»": "»",
|
|
115
|
+
"°": "°",
|
|
116
|
+
}
|
|
117
|
+
_NUMERIC_ENTITY = re.compile(r"&#(?:[xX]([0-9A-Fa-f]+)|([0-9]+));")
|
|
118
|
+
|
|
119
|
+
# Above this, the markup pass is skipped and only whitespace normalisation
|
|
120
|
+
# applies. A re-serializer that touched a file this large is already going to
|
|
121
|
+
# trip the line-fraction signal, so the cheaper check loses nothing.
|
|
122
|
+
MAX_MARKUP_NORMALIZE_CHARS = 2_000_000
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_entities(text: str) -> str:
|
|
126
|
+
for entity, replacement in _NON_STRUCTURAL_ENTITIES.items():
|
|
127
|
+
if entity in text:
|
|
128
|
+
text = text.replace(entity, replacement)
|
|
129
|
+
|
|
130
|
+
def _numeric(match: re.Match[str]) -> str:
|
|
131
|
+
raw_hex, raw_dec = match.group(1), match.group(2)
|
|
132
|
+
try:
|
|
133
|
+
code = int(raw_hex, 16) if raw_hex else int(raw_dec or "", 10)
|
|
134
|
+
except ValueError: # pragma: no cover - regex guarantees digits
|
|
135
|
+
return match.group(0)
|
|
136
|
+
# Below 160 lives the structural range (& < ' ...), left as-is
|
|
137
|
+
# for the same reason as the named structural entities.
|
|
138
|
+
if code < 160 or code > 0x10FFFF:
|
|
139
|
+
return match.group(0)
|
|
140
|
+
return chr(code)
|
|
141
|
+
|
|
142
|
+
return _NUMERIC_ENTITY.sub(_numeric, text)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _normalize_tag(match: re.Match[str]) -> str:
|
|
146
|
+
closing, name, raw_attrs, self_closing = match.groups()
|
|
147
|
+
attrs: list[str] = []
|
|
148
|
+
for attr in _ATTR.finditer(raw_attrs or ""):
|
|
149
|
+
attr_name = attr.group(1).lower()
|
|
150
|
+
value = attr.group(2)
|
|
151
|
+
if value is None:
|
|
152
|
+
value = attr.group(3)
|
|
153
|
+
if value is None:
|
|
154
|
+
value = attr.group(4)
|
|
155
|
+
if value is None:
|
|
156
|
+
attrs.append(attr_name)
|
|
157
|
+
else:
|
|
158
|
+
attrs.append(f'{attr_name}="{value}"')
|
|
159
|
+
# Sorted, so `<a href=".." class="..">` and `<a class=".." href="..">` agree.
|
|
160
|
+
# Order is not semantic in HTML/XML attributes; a serializer routinely
|
|
161
|
+
# changes it and nothing downstream should care.
|
|
162
|
+
rendered = " ".join(sorted(attrs))
|
|
163
|
+
body = f"{closing}{name.lower()}"
|
|
164
|
+
if rendered:
|
|
165
|
+
body = f"{body} {rendered}"
|
|
166
|
+
# `<br>` and `<br/>` are the same element; the trailing slash is dropped so
|
|
167
|
+
# the two spellings compare equal.
|
|
168
|
+
del self_closing
|
|
169
|
+
return f"<{body}>"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def normalize_for_serialization(text: str) -> str:
|
|
173
|
+
"""Strip the differences a re-serializer is allowed to introduce.
|
|
174
|
+
|
|
175
|
+
Two layers. The first always runs: collapse every whitespace run to one
|
|
176
|
+
space, strip each line, and drop blank lines -- so re-indentation and
|
|
177
|
+
re-wrapping vanish. The second runs only when the text looks like markup:
|
|
178
|
+
lowercase tag and attribute names, sort attributes, drop the trailing slash
|
|
179
|
+
on self-closing tags, and decode non-structural character entities.
|
|
180
|
+
|
|
181
|
+
Known limits, all in the safe direction (a missed normalisation suppresses a
|
|
182
|
+
warning; it never invents one):
|
|
183
|
+
|
|
184
|
+
* Not a parser. Tags inside comments, CDATA, ``<script>`` or ``<style>``
|
|
185
|
+
bodies are normalised like any other tag, and malformed markup is left
|
|
186
|
+
alone rather than repaired.
|
|
187
|
+
* Whitespace collapsing is content-blind, so it also flattens the interior
|
|
188
|
+
of ``<pre>`` and of triple-quoted strings in source files. For the
|
|
189
|
+
*serialization-only* question that is the desired reading -- but it means
|
|
190
|
+
a change that only altered whitespace inside a ``<pre>`` block is reported
|
|
191
|
+
as serialization-only even though it is visible.
|
|
192
|
+
* Attribute *values* are compared verbatim: no URL, case, or numeric
|
|
193
|
+
normalisation.
|
|
194
|
+
* The structural entities are left encoded on purpose (see
|
|
195
|
+
``_NON_STRUCTURAL_ENTITIES``).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
body = text
|
|
199
|
+
if "<" in body and len(body) <= MAX_MARKUP_NORMALIZE_CHARS and _TAG.search(body):
|
|
200
|
+
body = _TAG.sub(_normalize_tag, body)
|
|
201
|
+
body = _normalize_entities(body)
|
|
202
|
+
lines = []
|
|
203
|
+
for line in body.splitlines():
|
|
204
|
+
collapsed = _WHITESPACE_RUN.sub(" ", line).strip()
|
|
205
|
+
if collapsed:
|
|
206
|
+
lines.append(collapsed)
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def is_serialization_only(old: str, new: str) -> bool:
|
|
211
|
+
"""True when the bytes changed but nothing survives normalisation.
|
|
212
|
+
|
|
213
|
+
This is the filter-js signature exactly: a file whose meaning is identical
|
|
214
|
+
and whose bytes are not.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
if old == new:
|
|
218
|
+
return False
|
|
219
|
+
return normalize_for_serialization(old) == normalize_for_serialization(new)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Wholesale-rewrite assessment
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
# The single model-visible string this guard introduces. Kept as one constant so
|
|
227
|
+
# it is greppable and cannot drift.
|
|
228
|
+
REWRITE_WARNING_TEMPLATE = (
|
|
229
|
+
"warning: full-file rewrite of {path}: {pct}% of lines changed. "
|
|
230
|
+
"If the task requires preserving formatting of untouched regions, "
|
|
231
|
+
"prefer a targeted edit."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Strictly greater-than: a write that changes exactly 80% of the lines does not
|
|
235
|
+
# warn.
|
|
236
|
+
REWRITE_LINE_FRACTION_THRESHOLD = 0.80
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class RewriteAssessment:
|
|
241
|
+
"""What a full-file write did to a file that already existed."""
|
|
242
|
+
|
|
243
|
+
path: str
|
|
244
|
+
changed_fraction: float
|
|
245
|
+
changed_percent: int
|
|
246
|
+
serialization_only: bool
|
|
247
|
+
should_warn: bool
|
|
248
|
+
|
|
249
|
+
def warning_text(self) -> str | None:
|
|
250
|
+
if not self.should_warn:
|
|
251
|
+
return None
|
|
252
|
+
return REWRITE_WARNING_TEMPLATE.format(path=self.path, pct=self.changed_percent)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def assess_full_file_rewrite(*, path: str, original: str, updated: str) -> RewriteAssessment:
|
|
256
|
+
"""Score one overwrite of an existing file.
|
|
257
|
+
|
|
258
|
+
Warns on either signal. A large line fraction says the write replaced the
|
|
259
|
+
file rather than edited it; serialization-only says the write changed bytes
|
|
260
|
+
it had no reason to change. The second matters even when the fraction is
|
|
261
|
+
small, because a checker comparing bytes does not care how few lines moved.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
fraction = changed_line_fraction(original, updated)
|
|
265
|
+
serialization_only = is_serialization_only(original, updated)
|
|
266
|
+
should_warn = original != updated and (
|
|
267
|
+
fraction > REWRITE_LINE_FRACTION_THRESHOLD or serialization_only
|
|
268
|
+
)
|
|
269
|
+
return RewriteAssessment(
|
|
270
|
+
path=str(path),
|
|
271
|
+
changed_fraction=fraction,
|
|
272
|
+
changed_percent=int(round(fraction * 100)),
|
|
273
|
+
serialization_only=serialization_only,
|
|
274
|
+
should_warn=should_warn,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# How many already-warned paths to remember. Past this the oldest entry is
|
|
279
|
+
# dropped, which can at worst let one path warn a second time in a very long
|
|
280
|
+
# run -- the bound on memory is worth more than the bound on repeats.
|
|
281
|
+
MAX_TRACKED_REWRITE_PATHS = 512
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class RewriteGuard:
|
|
285
|
+
"""Per-run, at-most-once-per-file rewrite warnings.
|
|
286
|
+
|
|
287
|
+
Holds no file content. The caller passes the pre-write bytes it already
|
|
288
|
+
read, so nothing here is cached between calls except the set of paths that
|
|
289
|
+
have already been warned about.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def __init__(self, *, max_paths: int = MAX_TRACKED_REWRITE_PATHS) -> None:
|
|
293
|
+
self.max_paths = max(1, int(max_paths))
|
|
294
|
+
self._warned: dict[str, None] = {}
|
|
295
|
+
self._lock = threading.Lock()
|
|
296
|
+
|
|
297
|
+
def warn_for_write(self, *, path: str, original: str, updated: str) -> str | None:
|
|
298
|
+
"""The warning for this write, or None.
|
|
299
|
+
|
|
300
|
+
Returns None for a first-time create (no original), for an unchanged
|
|
301
|
+
write, for a change too small to be interesting, and for every write to
|
|
302
|
+
a path already warned about in this run.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
key = str(path)
|
|
306
|
+
if not original:
|
|
307
|
+
# Nothing existed to preserve; a create cannot be a rewrite.
|
|
308
|
+
return None
|
|
309
|
+
with self._lock:
|
|
310
|
+
if key in self._warned:
|
|
311
|
+
return None
|
|
312
|
+
assessment = assess_full_file_rewrite(path=key, original=original, updated=updated)
|
|
313
|
+
text = assessment.warning_text()
|
|
314
|
+
if text is None:
|
|
315
|
+
return None
|
|
316
|
+
with self._lock:
|
|
317
|
+
if key in self._warned:
|
|
318
|
+
return None
|
|
319
|
+
self._warned[key] = None
|
|
320
|
+
while len(self._warned) > self.max_paths:
|
|
321
|
+
self._warned.pop(next(iter(self._warned)), None)
|
|
322
|
+
return text
|
|
323
|
+
|
|
324
|
+
def warned_paths(self) -> tuple[str, ...]:
|
|
325
|
+
with self._lock:
|
|
326
|
+
return tuple(self._warned)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Action families
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
# A stem ending in digits, where something non-numeric precedes them. Requiring
|
|
334
|
+
# a preceding non-digit keeps `2024` intact instead of collapsing it to nothing.
|
|
335
|
+
_NUMERIC_TAIL = re.compile(r"^(?P<base>.*[^0-9])(?P<digits>[0-9]+)$")
|
|
336
|
+
_PATHISH = re.compile(r"[/\\.]")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _strip_numeric_tail(stem: str) -> str:
|
|
340
|
+
match = _NUMERIC_TAIL.match(stem)
|
|
341
|
+
if match is None:
|
|
342
|
+
return stem
|
|
343
|
+
base = match.group("base")
|
|
344
|
+
# `foo_2` and `foo-2` are variations of `foo`, not of `foo_`.
|
|
345
|
+
return base.rstrip("_-") or base
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def path_family(path: str) -> str:
|
|
349
|
+
"""A path with its extension and numeric tail removed.
|
|
350
|
+
|
|
351
|
+
``analyze_final.py``, ``analyze_final2.py`` and ``analyze_final3.py`` all
|
|
352
|
+
reduce to ``analyze_final``: three names for one idea the run keeps
|
|
353
|
+
re-attempting. Directory structure is kept, so the same basename in two
|
|
354
|
+
different directories stays two families.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
text = str(path or "").strip().replace("\\", "/")
|
|
358
|
+
if not text:
|
|
359
|
+
return ""
|
|
360
|
+
text = text.rstrip("/")
|
|
361
|
+
head, _, tail = text.rpartition("/")
|
|
362
|
+
stem, dot, _ext = tail.rpartition(".")
|
|
363
|
+
if not dot:
|
|
364
|
+
stem = tail
|
|
365
|
+
stem = _strip_numeric_tail(stem)
|
|
366
|
+
return f"{head}/{stem}" if head else stem
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def command_family(command: str) -> str:
|
|
370
|
+
"""A command line with whitespace collapsed and path arguments reduced.
|
|
371
|
+
|
|
372
|
+
``python analyze_final2.py --check`` and ``python analyze_final3.py --check``
|
|
373
|
+
are the same attempt, so the tokens that look like paths go through
|
|
374
|
+
``path_family`` and everything else is kept verbatim.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
tokens = str(command or "").split()
|
|
378
|
+
if not tokens:
|
|
379
|
+
return ""
|
|
380
|
+
reduced = [path_family(token) if _PATHISH.search(token) else token for token in tokens]
|
|
381
|
+
return " ".join(token for token in reduced if token)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def action_family(tool: str, target: str) -> str:
|
|
385
|
+
"""The family key for one mutation attempt.
|
|
386
|
+
|
|
387
|
+
Keyed by tool as well as target so a file being written and the same file
|
|
388
|
+
being run are not conflated.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
name = str(tool or "").strip() or "action"
|
|
392
|
+
text = str(target or "").strip()
|
|
393
|
+
if not text:
|
|
394
|
+
return name
|
|
395
|
+
reduced = command_family(text) if " " in text else path_family(text)
|
|
396
|
+
return f"{name}:{reduced}" if reduced else name
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# Repetition counter
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
# The single model-visible string this guard introduces.
|
|
404
|
+
THRASH_NOTICE_TEMPLATE = (
|
|
405
|
+
"Progress check: {n} similar attempts on {family} without a passing result. "
|
|
406
|
+
"Synthesize what you know into a final answer or a concrete blocker report now."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
THRASH_REPETITION_THRESHOLD = 8
|
|
410
|
+
MAX_THRASH_NOTICES_PER_RUN = 2
|
|
411
|
+
# Families past this are dropped oldest-first. A run cycling through hundreds of
|
|
412
|
+
# distinct targets is not thrashing on any one of them, so the eviction cannot
|
|
413
|
+
# hide the pattern this counts.
|
|
414
|
+
MAX_TRACKED_FAMILIES = 256
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class ThrashCounter:
|
|
418
|
+
"""Per-run count of failed, near-identical mutation attempts.
|
|
419
|
+
|
|
420
|
+
Success is the reset: a family that produces a passing result starts over,
|
|
421
|
+
because the notice claims ``without a passing result`` and must be true when
|
|
422
|
+
it fires. Fires at most once per family and at most
|
|
423
|
+
``MAX_THRASH_NOTICES_PER_RUN`` times per run -- past that the model has been
|
|
424
|
+
told, and repeating it is the same failure the guard is warning about.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
*,
|
|
430
|
+
threshold: int = THRASH_REPETITION_THRESHOLD,
|
|
431
|
+
max_notices: int = MAX_THRASH_NOTICES_PER_RUN,
|
|
432
|
+
max_families: int = MAX_TRACKED_FAMILIES,
|
|
433
|
+
) -> None:
|
|
434
|
+
self.threshold = max(1, int(threshold))
|
|
435
|
+
self.max_notices = max(0, int(max_notices))
|
|
436
|
+
self.max_families = max(1, int(max_families))
|
|
437
|
+
self._failures: dict[str, int] = {}
|
|
438
|
+
self._notified: set[str] = set()
|
|
439
|
+
self._notices_sent = 0
|
|
440
|
+
self._lock = threading.Lock()
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def notices_sent(self) -> int:
|
|
444
|
+
with self._lock:
|
|
445
|
+
return self._notices_sent
|
|
446
|
+
|
|
447
|
+
def failure_count(self, family: str) -> int:
|
|
448
|
+
with self._lock:
|
|
449
|
+
return self._failures.get(str(family), 0)
|
|
450
|
+
|
|
451
|
+
def record(self, *, tool: str, target: str, failed: bool) -> str | None:
|
|
452
|
+
"""Record one mutation attempt; return a notice when it should fire."""
|
|
453
|
+
|
|
454
|
+
family = action_family(tool, target)
|
|
455
|
+
if not family:
|
|
456
|
+
return None
|
|
457
|
+
with self._lock:
|
|
458
|
+
if not failed:
|
|
459
|
+
self._failures.pop(family, None)
|
|
460
|
+
return None
|
|
461
|
+
count = self._failures.pop(family, 0) + 1
|
|
462
|
+
# Re-inserted at the end, so the family just touched is never the
|
|
463
|
+
# eviction candidate and the dict cannot grow past its cap.
|
|
464
|
+
self._failures[family] = count
|
|
465
|
+
while len(self._failures) > self.max_families:
|
|
466
|
+
self._failures.pop(next(iter(self._failures)), None)
|
|
467
|
+
if count < self.threshold:
|
|
468
|
+
return None
|
|
469
|
+
if family in self._notified or self._notices_sent >= self.max_notices:
|
|
470
|
+
return None
|
|
471
|
+
self._notified.add(family)
|
|
472
|
+
self._notices_sent += 1
|
|
473
|
+
return THRASH_NOTICE_TEMPLATE.format(n=count, family=family)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
# Scratch files
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
# The one line added to the local runtime finalization summary. That summary is
|
|
481
|
+
# not model-visible (it is a runtime artifact printed and logged locally), so
|
|
482
|
+
# this is not a model-facing string -- it is kept here beside the others so the
|
|
483
|
+
# whole vocabulary of this PR lives in one file.
|
|
484
|
+
SCRATCH_FILES_SUMMARY_LINE = "Scratch files left in tree: {list}"
|
|
485
|
+
|
|
486
|
+
# `analyze_`, `analysis3_`, `scratch-`, `tmp_` ... at a word boundary. The
|
|
487
|
+
# optional digits are what let `analysis3_output.txt` match the same rule as
|
|
488
|
+
# `analyze_final.py`.
|
|
489
|
+
_SCRATCH_NAME = re.compile(
|
|
490
|
+
r"(?:^|[_\-.])(?:analyze|analysis|scratch|tmp|temp)[0-9]*[_\-]",
|
|
491
|
+
re.IGNORECASE,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
MAX_TRACKED_CREATED_PATHS = 2048
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _split_numbered(name: str) -> tuple[str, str] | None:
|
|
498
|
+
"""``('analyze_final', '.py')`` for ``analyze_final2.py``, else None."""
|
|
499
|
+
|
|
500
|
+
stem, dot, ext = name.rpartition(".")
|
|
501
|
+
if not dot:
|
|
502
|
+
stem, ext = name, ""
|
|
503
|
+
match = _NUMERIC_TAIL.match(stem)
|
|
504
|
+
if match is None:
|
|
505
|
+
return None
|
|
506
|
+
base = match.group("base")
|
|
507
|
+
if not base or base.rstrip("_-") == "":
|
|
508
|
+
return None
|
|
509
|
+
suffix = f".{ext}" if ext else ""
|
|
510
|
+
return base, suffix
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def looks_like_scratch_name(name: str) -> bool:
|
|
514
|
+
"""True for names that announce themselves as working files."""
|
|
515
|
+
|
|
516
|
+
return bool(_SCRATCH_NAME.search(str(name or "")))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def find_scratch_files(
|
|
520
|
+
created_paths: Iterable[str],
|
|
521
|
+
*,
|
|
522
|
+
existing_paths: Iterable[str] = (),
|
|
523
|
+
referenced_paths: Iterable[str] = (),
|
|
524
|
+
) -> tuple[str, ...]:
|
|
525
|
+
"""Files created this run that look like leftovers, in creation order.
|
|
526
|
+
|
|
527
|
+
Two rules. A path is scratch when its name carries a numeric tail and a
|
|
528
|
+
sibling without that tail exists (``analyze_final2.py`` beside
|
|
529
|
+
``analyze_final.py`` -- the second attempt at one thing), or when its name
|
|
530
|
+
matches a scratch word.
|
|
531
|
+
|
|
532
|
+
``existing_paths`` widens the sibling search to files the run did not
|
|
533
|
+
create; ``referenced_paths`` is a caller-supplied allowlist of paths that
|
|
534
|
+
are part of the deliverable and must never be reported. Reference analysis
|
|
535
|
+
itself is out of scope: deciding whether a file is referenced means reading
|
|
536
|
+
the workspace, and these guards are not allowed to scan it.
|
|
537
|
+
|
|
538
|
+
No deletion, here or anywhere: this reports, and the run decides.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
created = [str(path).replace("\\", "/") for path in created_paths if str(path or "").strip()]
|
|
542
|
+
keep = {str(path).replace("\\", "/") for path in referenced_paths}
|
|
543
|
+
universe = set(created)
|
|
544
|
+
universe.update(str(path).replace("\\", "/") for path in existing_paths)
|
|
545
|
+
|
|
546
|
+
found: list[str] = []
|
|
547
|
+
seen: set[str] = set()
|
|
548
|
+
for path in created:
|
|
549
|
+
if path in keep or path in seen:
|
|
550
|
+
continue
|
|
551
|
+
head, _, name = path.rpartition("/")
|
|
552
|
+
scratch = looks_like_scratch_name(name)
|
|
553
|
+
if not scratch:
|
|
554
|
+
split = _split_numbered(name)
|
|
555
|
+
if split is not None:
|
|
556
|
+
base, suffix = split
|
|
557
|
+
for sibling_stem in (base, base.rstrip("_-")):
|
|
558
|
+
sibling = f"{sibling_stem}{suffix}"
|
|
559
|
+
candidate = f"{head}/{sibling}" if head else sibling
|
|
560
|
+
if candidate != path and candidate in universe:
|
|
561
|
+
scratch = True
|
|
562
|
+
break
|
|
563
|
+
if scratch:
|
|
564
|
+
seen.add(path)
|
|
565
|
+
found.append(path)
|
|
566
|
+
return tuple(found)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def scratch_summary_line(paths: Sequence[str], *, limit: int = 10) -> str | None:
|
|
570
|
+
"""The one-line finalization-summary addition, or None when the tree is clean."""
|
|
571
|
+
|
|
572
|
+
if not paths:
|
|
573
|
+
return None
|
|
574
|
+
shown = ", ".join(paths[:limit])
|
|
575
|
+
if len(paths) > limit:
|
|
576
|
+
shown += f", ... (+{len(paths) - limit} more)"
|
|
577
|
+
return SCRATCH_FILES_SUMMARY_LINE.format(list=shown)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Per-run state
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class EditDisciplineState:
|
|
586
|
+
"""Every per-run edit-discipline counter, in one bounded, thread-safe object.
|
|
587
|
+
|
|
588
|
+
One object because the tool layer and the turn controller each need part of
|
|
589
|
+
it and neither should have to know about the other's. Bounded because a long
|
|
590
|
+
run must not grow it without limit: at most
|
|
591
|
+
``MAX_TRACKED_REWRITE_PATHS`` warned paths, ``MAX_TRACKED_FAMILIES``
|
|
592
|
+
families and ``MAX_TRACKED_CREATED_PATHS`` created paths, each evicting
|
|
593
|
+
oldest-first. Thread-safe because tool calls can be dispatched
|
|
594
|
+
concurrently.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def __init__(self, *, max_created_paths: int = MAX_TRACKED_CREATED_PATHS) -> None:
|
|
598
|
+
self.rewrite_guard = RewriteGuard()
|
|
599
|
+
self.thrash = ThrashCounter()
|
|
600
|
+
self.max_created_paths = max(1, int(max_created_paths))
|
|
601
|
+
self._created: dict[str, None] = {}
|
|
602
|
+
self._pending_notices: list[str] = []
|
|
603
|
+
self._lock = threading.Lock()
|
|
604
|
+
|
|
605
|
+
# -- rewrite guard ----------------------------------------------------
|
|
606
|
+
|
|
607
|
+
def warn_for_write(self, *, path: str, original: str, updated: str) -> str | None:
|
|
608
|
+
return self.rewrite_guard.warn_for_write(path=path, original=original, updated=updated)
|
|
609
|
+
|
|
610
|
+
# -- thrash guard -----------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def record_attempt(self, *, tool: str, target: str, failed: bool) -> str | None:
|
|
613
|
+
"""Record an attempt and queue any notice it triggers.
|
|
614
|
+
|
|
615
|
+
The queue exists because the two halves run at different moments: an
|
|
616
|
+
attempt is observed while tool results come back, and a notice can only
|
|
617
|
+
be delivered when the next request's system messages are assembled.
|
|
618
|
+
Queueing per run rather than per turn means a notice earned on the last
|
|
619
|
+
tool call of a turn is still delivered on the next one instead of being
|
|
620
|
+
silently dropped -- and dropped notices would be doubly wrong here,
|
|
621
|
+
since the counter has already spent one of its two per-run slots.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
notice = self.thrash.record(tool=tool, target=target, failed=failed)
|
|
625
|
+
if notice is not None:
|
|
626
|
+
with self._lock:
|
|
627
|
+
self._pending_notices.append(notice)
|
|
628
|
+
return notice
|
|
629
|
+
|
|
630
|
+
def take_notice(self) -> str | None:
|
|
631
|
+
"""Pop the next queued notice, or None. Delivering is the caller's job."""
|
|
632
|
+
|
|
633
|
+
with self._lock:
|
|
634
|
+
if not self._pending_notices:
|
|
635
|
+
return None
|
|
636
|
+
return self._pending_notices.pop(0)
|
|
637
|
+
|
|
638
|
+
# -- scratch files ----------------------------------------------------
|
|
639
|
+
|
|
640
|
+
def record_created(self, path: str) -> None:
|
|
641
|
+
key = str(path or "").strip().replace("\\", "/")
|
|
642
|
+
if not key:
|
|
643
|
+
return
|
|
644
|
+
with self._lock:
|
|
645
|
+
if key in self._created:
|
|
646
|
+
return
|
|
647
|
+
self._created[key] = None
|
|
648
|
+
while len(self._created) > self.max_created_paths:
|
|
649
|
+
self._created.pop(next(iter(self._created)), None)
|
|
650
|
+
|
|
651
|
+
def created_paths(self) -> tuple[str, ...]:
|
|
652
|
+
with self._lock:
|
|
653
|
+
return tuple(self._created)
|
|
654
|
+
|
|
655
|
+
def scratch_files(self, *, referenced_paths: Iterable[str] = ()) -> tuple[str, ...]:
|
|
656
|
+
return find_scratch_files(self.created_paths(), referenced_paths=referenced_paths)
|
|
657
|
+
|
|
658
|
+
def scratch_summary_line(self) -> str | None:
|
|
659
|
+
return scratch_summary_line(self.scratch_files())
|