code-muse 0.0.1__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.
- code_muse/__init__.py +26 -0
- code_muse/__main__.py +10 -0
- code_muse/agents/__init__.py +31 -0
- code_muse/agents/_builder.py +214 -0
- code_muse/agents/_compaction.py +506 -0
- code_muse/agents/_diagnostics.py +171 -0
- code_muse/agents/_history.py +382 -0
- code_muse/agents/_key_listeners.py +148 -0
- code_muse/agents/_non_streaming_render.py +148 -0
- code_muse/agents/_runtime.py +596 -0
- code_muse/agents/agent_creator_agent.py +603 -0
- code_muse/agents/agent_helios.py +47 -0
- code_muse/agents/agent_manager.py +740 -0
- code_muse/agents/agent_muse.py +78 -0
- code_muse/agents/agent_planning.py +44 -0
- code_muse/agents/agent_qa_melpomene.py +207 -0
- code_muse/agents/base_agent.py +194 -0
- code_muse/agents/event_stream_handler.py +361 -0
- code_muse/agents/json_agent.py +201 -0
- code_muse/agents/prompt_v3.py +521 -0
- code_muse/agents/subagent_stream_handler.py +273 -0
- code_muse/callbacks.py +941 -0
- code_muse/chatgpt_codex_client.py +333 -0
- code_muse/claude_cache_client.py +853 -0
- code_muse/cli_runner/__init__.py +319 -0
- code_muse/cli_runner/args.py +63 -0
- code_muse/cli_runner/loop.py +510 -0
- code_muse/cli_runner/resume.py +72 -0
- code_muse/cli_runner/runner.py +161 -0
- code_muse/command_line/__init__.py +1 -0
- code_muse/command_line/add_model_menu.py +1331 -0
- code_muse/command_line/agent_menu.py +674 -0
- code_muse/command_line/attachments.py +397 -0
- code_muse/command_line/autosave_menu.py +709 -0
- code_muse/command_line/clipboard.py +528 -0
- code_muse/command_line/colors_menu.py +530 -0
- code_muse/command_line/command_handler.py +262 -0
- code_muse/command_line/command_registry.py +150 -0
- code_muse/command_line/config_commands.py +711 -0
- code_muse/command_line/core_commands.py +740 -0
- code_muse/command_line/diff_menu.py +865 -0
- code_muse/command_line/file_path_completion.py +73 -0
- code_muse/command_line/load_context_completion.py +57 -0
- code_muse/command_line/model_picker_completion.py +512 -0
- code_muse/command_line/model_settings_menu.py +983 -0
- code_muse/command_line/onboarding_slides.py +162 -0
- code_muse/command_line/onboarding_wizard.py +337 -0
- code_muse/command_line/pagination.py +41 -0
- code_muse/command_line/pin_command_completion.py +329 -0
- code_muse/command_line/prompt_toolkit_completion.py +886 -0
- code_muse/command_line/session_commands.py +304 -0
- code_muse/command_line/shell_passthrough.py +145 -0
- code_muse/command_line/skills_completion.py +158 -0
- code_muse/command_line/types.py +18 -0
- code_muse/command_line/uc_menu.py +908 -0
- code_muse/command_line/utils.py +105 -0
- code_muse/command_line/wiggum_state.py +77 -0
- code_muse/config.py +1138 -0
- code_muse/config_agent.py +168 -0
- code_muse/config_appearance.py +241 -0
- code_muse/config_model.py +357 -0
- code_muse/config_security.py +73 -0
- code_muse/error_logging.py +132 -0
- code_muse/evals/__init__.py +35 -0
- code_muse/evals/eval_helpers.py +81 -0
- code_muse/evals/eval_runner.py +299 -0
- code_muse/evals/sample_evals/__init__.py +1 -0
- code_muse/evals/sample_evals/eval_frugal_reads.py +59 -0
- code_muse/evals/sample_evals/eval_memory_planning.py +31 -0
- code_muse/evals/sample_evals/eval_shell_efficiency.py +39 -0
- code_muse/evals/sample_evals/eval_tool_masking.py +33 -0
- code_muse/fs_scan_cache/__init__.py +31 -0
- code_muse/fs_scan_cache/invalidation_hooks.py +89 -0
- code_muse/fs_scan_cache/scan_cache_core.cpython-314-darwin.so +0 -0
- code_muse/fs_scan_cache/scan_cache_core.pyx +203 -0
- code_muse/fs_scan_cache/tool_integration.py +309 -0
- code_muse/fs_scan_cache/ttl_policy.py +44 -0
- code_muse/gemini_code_assist.py +383 -0
- code_muse/gemini_model.py +838 -0
- code_muse/hook_engine/README.md +105 -0
- code_muse/hook_engine/__init__.py +21 -0
- code_muse/hook_engine/aliases.py +153 -0
- code_muse/hook_engine/engine.py +221 -0
- code_muse/hook_engine/executor.py +347 -0
- code_muse/hook_engine/matcher.py +154 -0
- code_muse/hook_engine/models.py +245 -0
- code_muse/hook_engine/registry.py +114 -0
- code_muse/hook_engine/trust.py +268 -0
- code_muse/hook_engine/validator.py +144 -0
- code_muse/http_utils.py +360 -0
- code_muse/keymap.py +128 -0
- code_muse/list_filtering.py +26 -0
- code_muse/main.py +10 -0
- code_muse/messaging/__init__.py +259 -0
- code_muse/messaging/bus.py +621 -0
- code_muse/messaging/commands.py +166 -0
- code_muse/messaging/markdown_patches.py +57 -0
- code_muse/messaging/message_queue.py +397 -0
- code_muse/messaging/messages.py +591 -0
- code_muse/messaging/queue_console.py +269 -0
- code_muse/messaging/renderers.py +308 -0
- code_muse/messaging/rich_renderer.py +1158 -0
- code_muse/messaging/shimmer.py +154 -0
- code_muse/messaging/spinner/__init__.py +87 -0
- code_muse/messaging/spinner/console_spinner.py +250 -0
- code_muse/messaging/spinner/spinner_base.py +82 -0
- code_muse/messaging/subagent_console.py +458 -0
- code_muse/model_factory.py +1203 -0
- code_muse/model_switching.py +59 -0
- code_muse/model_utils.py +156 -0
- code_muse/models.json +66 -0
- code_muse/models_cache/__init__.py +26 -0
- code_muse/models_cache/blocking_lru_cache.py +98 -0
- code_muse/models_cache/cache_writer.py +86 -0
- code_muse/models_cache/sha256_hash.cpython-314-darwin.so +0 -0
- code_muse/models_cache/sha256_hash.pyx +34 -0
- code_muse/models_cache/startup_integration.py +75 -0
- code_muse/models_dev_api.json +1 -0
- code_muse/models_dev_parser.py +590 -0
- code_muse/motion.py +126 -0
- code_muse/plugins/__init__.py +471 -0
- code_muse/plugins/agent_skills/__init__.py +32 -0
- code_muse/plugins/agent_skills/config.py +176 -0
- code_muse/plugins/agent_skills/discovery.py +309 -0
- code_muse/plugins/agent_skills/downloader.py +389 -0
- code_muse/plugins/agent_skills/installer.py +19 -0
- code_muse/plugins/agent_skills/metadata.py +293 -0
- code_muse/plugins/agent_skills/prompt_builder.py +66 -0
- code_muse/plugins/agent_skills/register_callbacks.py +298 -0
- code_muse/plugins/agent_skills/remote_catalog.py +320 -0
- code_muse/plugins/agent_skills/skill_catalog.py +254 -0
- code_muse/plugins/agent_skills/skills_install_menu.py +690 -0
- code_muse/plugins/agent_skills/skills_menu.py +791 -0
- code_muse/plugins/autonomous_memory/__init__.py +39 -0
- code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-darwin.so +0 -0
- code_muse/plugins/autonomous_memory/bm25_scorer.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/autonomous_memory/bm25_scorer.pyx +291 -0
- code_muse/plugins/autonomous_memory/consolidation.py +82 -0
- code_muse/plugins/autonomous_memory/extraction.py +382 -0
- code_muse/plugins/autonomous_memory/lease_lock.py +105 -0
- code_muse/plugins/autonomous_memory/memory_injection.py +59 -0
- code_muse/plugins/autonomous_memory/register_callbacks.py +268 -0
- code_muse/plugins/autonomous_memory/secret_scanner.py +62 -0
- code_muse/plugins/autonomous_memory/session_scanner.py +163 -0
- code_muse/plugins/aws_bedrock/__init__.py +14 -0
- code_muse/plugins/aws_bedrock/config.py +99 -0
- code_muse/plugins/aws_bedrock/register_callbacks.py +241 -0
- code_muse/plugins/aws_bedrock/utils.py +153 -0
- code_muse/plugins/azure_foundry/README.md +238 -0
- code_muse/plugins/azure_foundry/__init__.py +15 -0
- code_muse/plugins/azure_foundry/config.py +125 -0
- code_muse/plugins/azure_foundry/discovery.py +187 -0
- code_muse/plugins/azure_foundry/register_callbacks.py +495 -0
- code_muse/plugins/azure_foundry/token.py +180 -0
- code_muse/plugins/azure_foundry/utils.py +345 -0
- code_muse/plugins/build_filter/__init__.py +1 -0
- code_muse/plugins/build_filter/register_callbacks.py +201 -0
- code_muse/plugins/build_filter/strategies/__init__.py +1 -0
- code_muse/plugins/build_filter/strategies/build.py +397 -0
- code_muse/plugins/chatgpt_oauth/__init__.py +6 -0
- code_muse/plugins/chatgpt_oauth/config.py +52 -0
- code_muse/plugins/chatgpt_oauth/oauth_flow.py +338 -0
- code_muse/plugins/chatgpt_oauth/register_callbacks.py +172 -0
- code_muse/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_muse/plugins/chatgpt_oauth/utils.py +538 -0
- code_muse/plugins/checkpointing/__init__.py +29 -0
- code_muse/plugins/checkpointing/checkpoint_hook.py +51 -0
- code_muse/plugins/checkpointing/conversation_snapshots.py +117 -0
- code_muse/plugins/checkpointing/register_callbacks.py +51 -0
- code_muse/plugins/checkpointing/restore_command.py +263 -0
- code_muse/plugins/checkpointing/rewind_shortcut.py +88 -0
- code_muse/plugins/checkpointing/shadow_git.py +90 -0
- code_muse/plugins/claude_code_hooks/__init__.py +1 -0
- code_muse/plugins/claude_code_hooks/config.py +188 -0
- code_muse/plugins/claude_code_hooks/register_callbacks.py +208 -0
- code_muse/plugins/claude_code_oauth/README.md +167 -0
- code_muse/plugins/claude_code_oauth/SETUP.md +93 -0
- code_muse/plugins/claude_code_oauth/__init__.py +25 -0
- code_muse/plugins/claude_code_oauth/config.py +52 -0
- code_muse/plugins/claude_code_oauth/fast_mode.py +124 -0
- code_muse/plugins/claude_code_oauth/prompt_handler.py +63 -0
- code_muse/plugins/claude_code_oauth/register_callbacks.py +547 -0
- code_muse/plugins/claude_code_oauth/test_fast_mode.py +165 -0
- code_muse/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_muse/plugins/claude_code_oauth/token_refresh_heartbeat.py +237 -0
- code_muse/plugins/claude_code_oauth/utils.py +664 -0
- code_muse/plugins/copilot_auth/__init__.py +11 -0
- code_muse/plugins/copilot_auth/config.py +91 -0
- code_muse/plugins/copilot_auth/reasoning_client.py +409 -0
- code_muse/plugins/copilot_auth/register_callbacks.py +461 -0
- code_muse/plugins/copilot_auth/utils.py +584 -0
- code_muse/plugins/custom_commands/__init__.py +14 -0
- code_muse/plugins/custom_commands/args_injection.py +82 -0
- code_muse/plugins/custom_commands/command_discovery.py +89 -0
- code_muse/plugins/custom_commands/command_toml_schema.py +71 -0
- code_muse/plugins/custom_commands/register_callbacks.py +176 -0
- code_muse/plugins/customizable_commands/__init__.py +0 -0
- code_muse/plugins/customizable_commands/register_callbacks.py +136 -0
- code_muse/plugins/destructive_command_guard/__init__.py +14 -0
- code_muse/plugins/destructive_command_guard/detector.py +375 -0
- code_muse/plugins/destructive_command_guard/register_callbacks.py +148 -0
- code_muse/plugins/example_custom_command/README.md +280 -0
- code_muse/plugins/example_custom_command/register_callbacks.py +51 -0
- code_muse/plugins/file_permission_handler/__init__.py +4 -0
- code_muse/plugins/file_permission_handler/register_callbacks.py +441 -0
- code_muse/plugins/filter_engine/__init__.py +30 -0
- code_muse/plugins/filter_engine/classifier.py +153 -0
- code_muse/plugins/filter_engine/content_detector.py +184 -0
- code_muse/plugins/filter_engine/dispatcher.py +244 -0
- code_muse/plugins/filter_engine/register_callbacks.py +188 -0
- code_muse/plugins/filter_engine/registry.py +279 -0
- code_muse/plugins/filter_engine/strategies/__init__.py +8 -0
- code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/ast_compressor.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/filter_engine/strategies/ast_compressor.pyx +348 -0
- code_muse/plugins/filter_engine/strategies/ast_parser.py +167 -0
- code_muse/plugins/filter_engine/strategies/code.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/code.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/filter_engine/strategies/code.pyx +584 -0
- code_muse/plugins/filter_engine/strategies/git.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/git.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/filter_engine/strategies/git.pyx +438 -0
- code_muse/plugins/filter_engine/strategies/json_compressor.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/json_compressor.pyx +253 -0
- code_muse/plugins/filter_engine/strategies/json_patterns.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/json_patterns.pyx +178 -0
- code_muse/plugins/filter_engine/strategies/lint.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/lint.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/filter_engine/strategies/lint.pyx +626 -0
- code_muse/plugins/filter_engine/strategies/test.cpython-314-darwin.so +0 -0
- code_muse/plugins/filter_engine/strategies/test.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/plugins/filter_engine/strategies/test.pyx +431 -0
- code_muse/plugins/filter_engine/verbosity.py +63 -0
- code_muse/plugins/force_push_guard/__init__.py +5 -0
- code_muse/plugins/force_push_guard/detector.py +96 -0
- code_muse/plugins/force_push_guard/register_callbacks.py +144 -0
- code_muse/plugins/force_push_guard/test_detector.py +143 -0
- code_muse/plugins/frontend_emitter/__init__.py +25 -0
- code_muse/plugins/frontend_emitter/emitter.py +121 -0
- code_muse/plugins/frontend_emitter/register_callbacks.py +259 -0
- code_muse/plugins/gac/__init__.py +4 -0
- code_muse/plugins/gac/git_ops.py +136 -0
- code_muse/plugins/gac/prompt.py +191 -0
- code_muse/plugins/gac/register_callbacks.py +82 -0
- code_muse/plugins/hook_creator/__init__.py +1 -0
- code_muse/plugins/hook_creator/register_callbacks.py +34 -0
- code_muse/plugins/hook_manager/__init__.py +1 -0
- code_muse/plugins/hook_manager/config.py +289 -0
- code_muse/plugins/hook_manager/hooks_menu.py +563 -0
- code_muse/plugins/hook_manager/register_callbacks.py +227 -0
- code_muse/plugins/hook_monitor/register_callbacks.py +36 -0
- code_muse/plugins/mindpack/__init__.py +0 -0
- code_muse/plugins/mindpack/factory.py +930 -0
- code_muse/plugins/mindpack/judge.py +573 -0
- code_muse/plugins/mindpack/memory.py +100 -0
- code_muse/plugins/mindpack/mindpack_menu.py +1552 -0
- code_muse/plugins/mindpack/orchestration.py +605 -0
- code_muse/plugins/mindpack/register_callbacks.py +175 -0
- code_muse/plugins/mindpack/schemas.py +358 -0
- code_muse/plugins/mindpack/tools.py +387 -0
- code_muse/plugins/oauth_muse_html.py +226 -0
- code_muse/plugins/ollama_setup/__init__.py +5 -0
- code_muse/plugins/ollama_setup/completer.py +36 -0
- code_muse/plugins/ollama_setup/register_callbacks.py +410 -0
- code_muse/plugins/plan_command/__init__.py +0 -0
- code_muse/plugins/plan_command/register_callbacks.py +206 -0
- code_muse/plugins/plan_mode/__init__.py +37 -0
- code_muse/plugins/plan_mode/mode_cycling.py +40 -0
- code_muse/plugins/plan_mode/plan_generation.py +68 -0
- code_muse/plugins/plan_mode/plan_hooks.py +74 -0
- code_muse/plugins/plan_mode/plan_mode_tools.py +138 -0
- code_muse/plugins/plan_mode/register_callbacks.py +121 -0
- code_muse/plugins/plugin_trust/register_callbacks.py +140 -0
- code_muse/plugins/policy_engine/__init__.py +46 -0
- code_muse/plugins/policy_engine/approval_flow_integration.py +59 -0
- code_muse/plugins/policy_engine/policy_evaluator.py +75 -0
- code_muse/plugins/policy_engine/policy_file_discovery.py +90 -0
- code_muse/plugins/policy_engine/policy_toml_schema.py +115 -0
- code_muse/plugins/policy_engine/register_callbacks.py +112 -0
- code_muse/plugins/pop_command/__init__.py +1 -0
- code_muse/plugins/pop_command/register_callbacks.py +189 -0
- code_muse/plugins/prompt_newline/__init__.py +13 -0
- code_muse/plugins/prompt_newline/config.py +19 -0
- code_muse/plugins/prompt_newline/register_callbacks.py +159 -0
- code_muse/plugins/safety_status/__init__.py +0 -0
- code_muse/plugins/safety_status/register_callbacks.py +113 -0
- code_muse/plugins/semantic_compression/__init__.py +6 -0
- code_muse/plugins/semantic_compression/compressor.py +295 -0
- code_muse/plugins/semantic_compression/config.py +123 -0
- code_muse/plugins/semantic_compression/register_callbacks.py +320 -0
- code_muse/plugins/shell_minimizer/__init__.py +50 -0
- code_muse/plugins/shell_minimizer/builtin_filters.toml +393 -0
- code_muse/plugins/shell_minimizer/pipeline.py +556 -0
- code_muse/plugins/shell_minimizer/primitives.py +482 -0
- code_muse/plugins/shell_minimizer/register_callbacks.py +276 -0
- code_muse/plugins/shell_safety/__init__.py +6 -0
- code_muse/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_muse/plugins/shell_safety/command_cache.py +149 -0
- code_muse/plugins/shell_safety/register_callbacks.py +202 -0
- code_muse/plugins/synthetic_status/__init__.py +1 -0
- code_muse/plugins/synthetic_status/register_callbacks.py +128 -0
- code_muse/plugins/synthetic_status/status_api.py +145 -0
- code_muse/plugins/token_caching/__init__.py +21 -0
- code_muse/plugins/token_caching/cache_hit_tracking.py +128 -0
- code_muse/plugins/token_caching/cacheable_prefix_detection.py +28 -0
- code_muse/plugins/token_caching/register_callbacks.py +54 -0
- code_muse/plugins/token_caching/stats_display.py +35 -0
- code_muse/plugins/token_tracking/__init__.py +26 -0
- code_muse/plugins/token_tracking/database.py +381 -0
- code_muse/plugins/token_tracking/edit_analyzer.py +97 -0
- code_muse/plugins/token_tracking/record.py +55 -0
- code_muse/plugins/token_tracking/register_callbacks.py +277 -0
- code_muse/plugins/token_tracking/reports.py +329 -0
- code_muse/plugins/universal_constructor/__init__.py +13 -0
- code_muse/plugins/universal_constructor/models.py +136 -0
- code_muse/plugins/universal_constructor/register_callbacks.py +47 -0
- code_muse/plugins/universal_constructor/registry.py +390 -0
- code_muse/plugins/universal_constructor/runner.py +474 -0
- code_muse/plugins/universal_constructor/safety.py +440 -0
- code_muse/plugins/universal_constructor/sandbox.py +584 -0
- code_muse/provider_identity.py +105 -0
- code_muse/pydantic_patches.py +410 -0
- code_muse/reopenable_async_client.py +233 -0
- code_muse/round_robin_model.py +151 -0
- code_muse/secret_storage.py +74 -0
- code_muse/security/__init__.py +1 -0
- code_muse/security/redaction.cpython-314-darwin.so +0 -0
- code_muse/security/redaction.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/security/redaction.pyx +135 -0
- code_muse/session_storage.py +565 -0
- code_muse/status_display.py +261 -0
- code_muse/stream_parser/__init__.py +76 -0
- code_muse/stream_parser/assistant_text_parser.py +90 -0
- code_muse/stream_parser/citation_parser.py +76 -0
- code_muse/stream_parser/inline_hidden_tag_parser.py +236 -0
- code_muse/stream_parser/proposed_plan_parser.py +158 -0
- code_muse/stream_parser/stream_text_chunk.py +23 -0
- code_muse/stream_parser/stream_text_parser.py +27 -0
- code_muse/stream_parser/tagged_line_parser.cpython-314-darwin.so +0 -0
- code_muse/stream_parser/tagged_line_parser.pyx +251 -0
- code_muse/stream_parser/utf8_stream_parser.cpython-314-darwin.so +0 -0
- code_muse/stream_parser/utf8_stream_parser.pyx +206 -0
- code_muse/summarization_agent.py +308 -0
- code_muse/terminal_utils.cpython-314-darwin.so +0 -0
- code_muse/terminal_utils.cpython-314-x86_64-linux-gnu.so +0 -0
- code_muse/terminal_utils.pyx +483 -0
- code_muse/tools/__init__.py +459 -0
- code_muse/tools/agent_tools.py +613 -0
- code_muse/tools/ask_user_question/__init__.py +26 -0
- code_muse/tools/ask_user_question/constants.py +73 -0
- code_muse/tools/ask_user_question/demo_tui.py +55 -0
- code_muse/tools/ask_user_question/handler.py +232 -0
- code_muse/tools/ask_user_question/models.py +302 -0
- code_muse/tools/ask_user_question/registration.py +37 -0
- code_muse/tools/ask_user_question/renderers.py +336 -0
- code_muse/tools/ask_user_question/terminal_ui.py +327 -0
- code_muse/tools/ask_user_question/theme.py +156 -0
- code_muse/tools/ask_user_question/tui_loop.py +422 -0
- code_muse/tools/background_jobs.py +99 -0
- code_muse/tools/browser/__init__.py +37 -0
- code_muse/tools/browser/browser_control.py +289 -0
- code_muse/tools/browser/browser_interactions.py +545 -0
- code_muse/tools/browser/browser_locators.py +640 -0
- code_muse/tools/browser/browser_manager.py +376 -0
- code_muse/tools/browser/browser_navigation.py +251 -0
- code_muse/tools/browser/browser_screenshot.py +180 -0
- code_muse/tools/browser/browser_scripts.py +462 -0
- code_muse/tools/browser/browser_workflows.py +222 -0
- code_muse/tools/chrome_cdp/__init__.py +1070 -0
- code_muse/tools/chrome_cdp/register_callbacks.py +61 -0
- code_muse/tools/command_runner.py +1401 -0
- code_muse/tools/common.py +1407 -0
- code_muse/tools/display.py +87 -0
- code_muse/tools/file_modifications.py +1099 -0
- code_muse/tools/file_operations.py +860 -0
- code_muse/tools/image_tools.py +185 -0
- code_muse/tools/meetin_proxy/__init__.py +243 -0
- code_muse/tools/meetin_proxy/capture_addon.py +82 -0
- code_muse/tools/meetin_proxy/proxy_manager.py +326 -0
- code_muse/tools/meetin_proxy/register_callbacks.py +45 -0
- code_muse/tools/path_policy.py +219 -0
- code_muse/tools/skills_tools.py +586 -0
- code_muse/tools/subagent_context.py +158 -0
- code_muse/tools/tools_content.py +50 -0
- code_muse/tools/universal_constructor.py +965 -0
- code_muse/uvx_detection.py +241 -0
- code_muse/version_checker.py +86 -0
- code_muse-0.0.1.data/data/code_muse/models.json +66 -0
- code_muse-0.0.1.data/data/code_muse/models_dev_api.json +1 -0
- code_muse-0.0.1.dist-info/METADATA +845 -0
- code_muse-0.0.1.dist-info/RECORD +394 -0
- code_muse-0.0.1.dist-info/WHEEL +4 -0
- code_muse-0.0.1.dist-info/entry_points.txt +2 -0
- code_muse-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
"""Robust file-modification helpers + agent tools.
|
|
2
|
+
|
|
3
|
+
Key guarantees
|
|
4
|
+
--------------
|
|
5
|
+
1. **Create/edit operations emit diffs** when there are changes to show.
|
|
6
|
+
2. **Delete-file operations do not print removed content**; they only report deletion.
|
|
7
|
+
3. **Full traceback logging** for unexpected errors via `_log_error`.
|
|
8
|
+
4. Helper functions stay print-free while agent-tool wrappers handle console output.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import difflib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import traceback
|
|
16
|
+
import warnings
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, Any
|
|
19
|
+
|
|
20
|
+
import json_repair
|
|
21
|
+
from pydantic import BaseModel, BeforeValidator, WithJsonSchema
|
|
22
|
+
from pydantic_ai import RunContext
|
|
23
|
+
|
|
24
|
+
from code_muse.callbacks import (
|
|
25
|
+
on_create_file,
|
|
26
|
+
on_delete_file,
|
|
27
|
+
on_delete_snippet,
|
|
28
|
+
on_edit_file,
|
|
29
|
+
on_replace_in_file,
|
|
30
|
+
)
|
|
31
|
+
from code_muse.messaging import ( # Structured messaging types
|
|
32
|
+
DiffLine,
|
|
33
|
+
DiffMessage,
|
|
34
|
+
emit_error,
|
|
35
|
+
emit_success,
|
|
36
|
+
emit_warning,
|
|
37
|
+
get_message_bus,
|
|
38
|
+
)
|
|
39
|
+
from code_muse.tools.common import _find_best_window, generate_group_id
|
|
40
|
+
from code_muse.tools.path_policy import Operation, check_path_allowed
|
|
41
|
+
|
|
42
|
+
# Caps for edits/diffs to avoid unbounded memory use
|
|
43
|
+
MAX_EDIT_FILE_BYTES = 1_000_000
|
|
44
|
+
MAX_DIFF_BYTES = 512_000
|
|
45
|
+
|
|
46
|
+
# Fuzzy replacement fallback limits (P2-04)
|
|
47
|
+
MAX_FUZZY_FILE_LINES = 20_000 # Skip fuzzy on files with more lines
|
|
48
|
+
MAX_FUZZY_OLD_SNIPPET_CHARS = 20_000 # Skip fuzzy when old snippet exceeds this
|
|
49
|
+
MAX_FUZZY_REPLACEMENT_COUNT = 20 # Reject replacements list above this
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _create_rejection_response(file_path: str) -> dict[str, Any]:
|
|
53
|
+
"""Create a standardized rejection response with user feedback if available.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
file_path: Path to the file that was rejected
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dict containing rejection details and any user feedback
|
|
60
|
+
"""
|
|
61
|
+
# Check for user feedback from permission handler
|
|
62
|
+
try:
|
|
63
|
+
from code_muse.plugins.file_permission_handler.register_callbacks import (
|
|
64
|
+
clear_user_feedback,
|
|
65
|
+
get_last_user_feedback,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
user_feedback = get_last_user_feedback()
|
|
69
|
+
# Clear feedback after reading it
|
|
70
|
+
clear_user_feedback()
|
|
71
|
+
except ImportError:
|
|
72
|
+
user_feedback = None
|
|
73
|
+
|
|
74
|
+
rejection_message = (
|
|
75
|
+
"USER REJECTED: The user explicitly rejected these file changes."
|
|
76
|
+
)
|
|
77
|
+
if user_feedback:
|
|
78
|
+
rejection_message += f" User feedback: {user_feedback}"
|
|
79
|
+
else:
|
|
80
|
+
rejection_message += " Please do not retry the same changes or any other changes - immediately ask for clarification."
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"success": False,
|
|
84
|
+
"path": file_path,
|
|
85
|
+
"message": rejection_message,
|
|
86
|
+
"changed": False,
|
|
87
|
+
"user_rejection": True,
|
|
88
|
+
"rejection_type": "explicit_user_denial",
|
|
89
|
+
"user_feedback": user_feedback,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DeleteSnippetPayload(BaseModel):
|
|
94
|
+
file_path: str
|
|
95
|
+
delete_snippet: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Replacement(BaseModel):
|
|
99
|
+
old_str: str
|
|
100
|
+
new_str: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ReplacementsPayload(BaseModel):
|
|
104
|
+
file_path: str
|
|
105
|
+
replacements: list[Replacement]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ContentPayload(BaseModel):
|
|
109
|
+
file_path: str
|
|
110
|
+
content: str
|
|
111
|
+
overwrite: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
EditFilePayload = DeleteSnippetPayload, ReplacementsPayload, ContentPayload
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_diff_lines(diff_text: str) -> list[DiffLine]:
|
|
118
|
+
"""Parse unified diff text into structured DiffLine objects.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
diff_text: Raw unified diff text
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of DiffLine objects with line numbers and types
|
|
125
|
+
"""
|
|
126
|
+
if not diff_text or not diff_text.strip():
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
diff_lines = []
|
|
130
|
+
line_number = 0
|
|
131
|
+
|
|
132
|
+
for line in diff_text.splitlines():
|
|
133
|
+
# Determine line type based on diff markers
|
|
134
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
135
|
+
line_type = "add"
|
|
136
|
+
line_number += 1
|
|
137
|
+
content = line[1:] # Remove the + prefix
|
|
138
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
139
|
+
line_type = "remove"
|
|
140
|
+
line_number += 1
|
|
141
|
+
content = line[1:] # Remove the - prefix
|
|
142
|
+
elif line.startswith("@@"):
|
|
143
|
+
# Parse hunk header to get line number
|
|
144
|
+
# Format: @@ -start,count +start,count @@
|
|
145
|
+
import re
|
|
146
|
+
|
|
147
|
+
match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
|
|
148
|
+
if match:
|
|
149
|
+
line_number = (
|
|
150
|
+
int(match.group(1)) - 1
|
|
151
|
+
) # Will be incremented on next line
|
|
152
|
+
line_type = "context"
|
|
153
|
+
content = line
|
|
154
|
+
elif line.startswith("---") or line.startswith("+++"):
|
|
155
|
+
# File headers - treat as context
|
|
156
|
+
line_type = "context"
|
|
157
|
+
content = line
|
|
158
|
+
else:
|
|
159
|
+
line_type = "context"
|
|
160
|
+
line_number += 1
|
|
161
|
+
content = line
|
|
162
|
+
|
|
163
|
+
diff_lines.append(
|
|
164
|
+
DiffLine(
|
|
165
|
+
line_number=max(1, line_number),
|
|
166
|
+
type=line_type,
|
|
167
|
+
content=content,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return diff_lines
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _emit_diff_message(
|
|
175
|
+
file_path: str | Path,
|
|
176
|
+
operation: str,
|
|
177
|
+
diff_text: str,
|
|
178
|
+
old_content: str | None = None,
|
|
179
|
+
new_content: str | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Emit a structured DiffMessage for UI display.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
file_path: Path to the file being modified
|
|
185
|
+
operation: One of 'create', 'modify', 'delete'
|
|
186
|
+
diff_text: Raw unified diff text
|
|
187
|
+
old_content: Original file content (optional)
|
|
188
|
+
new_content: New file content (optional)
|
|
189
|
+
"""
|
|
190
|
+
# Check if diff was already shown during permission prompt
|
|
191
|
+
try:
|
|
192
|
+
from code_muse.plugins.file_permission_handler.register_callbacks import (
|
|
193
|
+
clear_diff_shown_flag,
|
|
194
|
+
was_diff_already_shown,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if was_diff_already_shown():
|
|
198
|
+
# Diff already displayed in permission panel, skip redundant display
|
|
199
|
+
clear_diff_shown_flag()
|
|
200
|
+
return
|
|
201
|
+
except ImportError:
|
|
202
|
+
pass # Permission handler not available, emit anyway
|
|
203
|
+
|
|
204
|
+
if not diff_text or not diff_text.strip():
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
diff_lines = _parse_diff_lines(diff_text)
|
|
208
|
+
|
|
209
|
+
diff_msg = DiffMessage(
|
|
210
|
+
path=str(file_path),
|
|
211
|
+
operation=operation,
|
|
212
|
+
old_content=old_content,
|
|
213
|
+
new_content=new_content,
|
|
214
|
+
diff_lines=diff_lines,
|
|
215
|
+
)
|
|
216
|
+
get_message_bus().emit(diff_msg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _log_error(
|
|
220
|
+
msg: str, exc: Exception | None = None, message_group: str | None = None
|
|
221
|
+
) -> None:
|
|
222
|
+
emit_error(f"{msg}", message_group=message_group)
|
|
223
|
+
if exc is not None:
|
|
224
|
+
emit_error(traceback.format_exc(), highlight=False, message_group=message_group)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _should_enforce_path_policy(context: RunContext | None) -> bool:
|
|
228
|
+
"""Return True if path policy should be enforced for this context.
|
|
229
|
+
|
|
230
|
+
Skips enforcement for None/MagicMock contexts used in unit tests,
|
|
231
|
+
but always enforces for real pydantic_ai RunContext objects.
|
|
232
|
+
"""
|
|
233
|
+
if context is None:
|
|
234
|
+
return False
|
|
235
|
+
# Only enforce for real RunContext instances, not test mocks
|
|
236
|
+
return isinstance(context, RunContext)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _delete_snippet_from_file(
|
|
240
|
+
context: RunContext | None,
|
|
241
|
+
file_path: str,
|
|
242
|
+
snippet: str,
|
|
243
|
+
message_group: str | None = None,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
file_path = Path(file_path).resolve()
|
|
246
|
+
|
|
247
|
+
# Enforce path policy for real agent contexts (skip for test helpers)
|
|
248
|
+
if _should_enforce_path_policy(context):
|
|
249
|
+
policy = check_path_allowed(str(file_path), Operation.WRITE)
|
|
250
|
+
if not policy.allowed:
|
|
251
|
+
return {
|
|
252
|
+
"error": policy.reason or "Edit blocked by path policy.",
|
|
253
|
+
"diff": "",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
diff_text = ""
|
|
257
|
+
try:
|
|
258
|
+
if not file_path.exists() or not file_path.is_file():
|
|
259
|
+
return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
|
|
260
|
+
|
|
261
|
+
# Huge-file gate: reject edits before full read
|
|
262
|
+
try:
|
|
263
|
+
if file_path.stat().st_size > MAX_EDIT_FILE_BYTES:
|
|
264
|
+
return {
|
|
265
|
+
"error": (
|
|
266
|
+
f"File is too large to edit safely (> {MAX_EDIT_FILE_BYTES} bytes). "
|
|
267
|
+
"Please break the edit into smaller chunks."
|
|
268
|
+
),
|
|
269
|
+
"diff": diff_text,
|
|
270
|
+
}
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
|
|
275
|
+
original = f.read()
|
|
276
|
+
# Sanitize any surrogate characters from reading
|
|
277
|
+
with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
|
|
278
|
+
original = original.encode("utf-8", errors="surrogatepass").decode(
|
|
279
|
+
"utf-8", errors="replace"
|
|
280
|
+
)
|
|
281
|
+
if snippet not in original:
|
|
282
|
+
return {
|
|
283
|
+
"error": f"Snippet not found in file '{file_path}'.",
|
|
284
|
+
"diff": diff_text,
|
|
285
|
+
}
|
|
286
|
+
modified = original.replace(snippet, "", 1)
|
|
287
|
+
from code_muse.config import get_diff_context_lines
|
|
288
|
+
|
|
289
|
+
diff_text = "".join(
|
|
290
|
+
difflib.unified_diff(
|
|
291
|
+
original.splitlines(keepends=True),
|
|
292
|
+
modified.splitlines(keepends=True),
|
|
293
|
+
fromfile=f"a/{file_path.name}",
|
|
294
|
+
tofile=f"b/{file_path.name}",
|
|
295
|
+
n=get_diff_context_lines(),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
299
|
+
f.write(modified)
|
|
300
|
+
return {
|
|
301
|
+
"success": True,
|
|
302
|
+
"path": str(file_path),
|
|
303
|
+
"message": "Snippet deleted from file.",
|
|
304
|
+
"changed": True,
|
|
305
|
+
"diff": diff_text,
|
|
306
|
+
}
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
return {"error": str(exc), "diff": diff_text}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _replace_in_file(
|
|
312
|
+
context: RunContext | None,
|
|
313
|
+
path: str,
|
|
314
|
+
replacements: list[dict[str, str]],
|
|
315
|
+
message_group: str | None = None,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
"""Robust replacement engine with explicit edge‑case reporting."""
|
|
318
|
+
file_path = Path(path).resolve()
|
|
319
|
+
|
|
320
|
+
# Enforce path policy for real agent contexts (skip for test helpers)
|
|
321
|
+
if _should_enforce_path_policy(context):
|
|
322
|
+
policy = check_path_allowed(str(file_path), Operation.WRITE)
|
|
323
|
+
if not policy.allowed:
|
|
324
|
+
return {
|
|
325
|
+
"error": policy.reason or "Edit blocked by path policy.",
|
|
326
|
+
"diff": "",
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# P2-04: cap replacement count to bound fuzzy-matching cost
|
|
330
|
+
if len(replacements) > MAX_FUZZY_REPLACEMENT_COUNT:
|
|
331
|
+
return {
|
|
332
|
+
"error": (
|
|
333
|
+
f"Too many replacements ({len(replacements)}); "
|
|
334
|
+
f"maximum is {MAX_FUZZY_REPLACEMENT_COUNT}. "
|
|
335
|
+
"Split into smaller batches."
|
|
336
|
+
),
|
|
337
|
+
"diff": "",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
diff_text = ""
|
|
341
|
+
try:
|
|
342
|
+
if not file_path.exists() or not file_path.is_file():
|
|
343
|
+
return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
|
|
344
|
+
|
|
345
|
+
# Huge-file gate: reject edits before full read
|
|
346
|
+
try:
|
|
347
|
+
if file_path.stat().st_size > MAX_EDIT_FILE_BYTES:
|
|
348
|
+
return {
|
|
349
|
+
"error": (
|
|
350
|
+
f"File is too large to edit safely (> {MAX_EDIT_FILE_BYTES} bytes). "
|
|
351
|
+
"Please break the edit into smaller chunks."
|
|
352
|
+
),
|
|
353
|
+
"diff": diff_text,
|
|
354
|
+
}
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
|
|
359
|
+
original = f.read()
|
|
360
|
+
|
|
361
|
+
# Sanitize any surrogate characters from reading
|
|
362
|
+
with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
|
|
363
|
+
original = original.encode("utf-8", errors="surrogatepass").decode(
|
|
364
|
+
"utf-8", errors="replace"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# P2-04: pre-compute file line count once for fuzzy-limit check
|
|
368
|
+
original_line_count = original.count("\n") + (
|
|
369
|
+
1 if original and not original.endswith("\n") else 0
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
modified = original
|
|
373
|
+
for rep in replacements:
|
|
374
|
+
old_snippet = rep.get("old_str", "")
|
|
375
|
+
new_snippet = rep.get("new_str", "")
|
|
376
|
+
|
|
377
|
+
if old_snippet and old_snippet in modified:
|
|
378
|
+
modified = modified.replace(old_snippet, new_snippet, 1)
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
# --- P2-04: fuzzy fallback limits ---
|
|
382
|
+
# Skip expensive Jaro-Winkler sliding window on large inputs
|
|
383
|
+
fuzzy_blocked_reasons: list[str] = []
|
|
384
|
+
if original_line_count > MAX_FUZZY_FILE_LINES:
|
|
385
|
+
fuzzy_blocked_reasons.append(
|
|
386
|
+
f"file has {original_line_count} lines "
|
|
387
|
+
f"(limit {MAX_FUZZY_FILE_LINES})"
|
|
388
|
+
)
|
|
389
|
+
if len(old_snippet) > MAX_FUZZY_OLD_SNIPPET_CHARS:
|
|
390
|
+
fuzzy_blocked_reasons.append(
|
|
391
|
+
f"old_str is {len(old_snippet)} chars "
|
|
392
|
+
f"(limit {MAX_FUZZY_OLD_SNIPPET_CHARS})"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if fuzzy_blocked_reasons:
|
|
396
|
+
return {
|
|
397
|
+
"error": (
|
|
398
|
+
f"Exact match not found and fuzzy fallback skipped: "
|
|
399
|
+
f"{'; '.join(fuzzy_blocked_reasons)}. "
|
|
400
|
+
"Provide the exact text to replace, or break the "
|
|
401
|
+
"edit into smaller chunks targeting a smaller region."
|
|
402
|
+
),
|
|
403
|
+
"fuzzy_skipped": True,
|
|
404
|
+
"diff": "",
|
|
405
|
+
}
|
|
406
|
+
# --- end P2-04 limits ---
|
|
407
|
+
|
|
408
|
+
had_trailing_newline = modified.endswith("\n")
|
|
409
|
+
# Work on a copy so we never mutate the running buffer
|
|
410
|
+
orig_lines = modified.splitlines()
|
|
411
|
+
loc, score = _find_best_window(orig_lines, old_snippet)
|
|
412
|
+
|
|
413
|
+
if score < 0.95 or loc is None:
|
|
414
|
+
return {
|
|
415
|
+
"error": "No suitable match in file (JW < 0.95)",
|
|
416
|
+
"jw_score": score,
|
|
417
|
+
"received": old_snippet,
|
|
418
|
+
"diff": "",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
start, end = loc
|
|
422
|
+
prefix = "\n".join(orig_lines[:start])
|
|
423
|
+
suffix = "\n".join(orig_lines[end:])
|
|
424
|
+
parts = []
|
|
425
|
+
if prefix:
|
|
426
|
+
parts.append(prefix)
|
|
427
|
+
parts.append(new_snippet.rstrip("\n"))
|
|
428
|
+
if suffix:
|
|
429
|
+
parts.append(suffix)
|
|
430
|
+
modified = "\n".join(parts)
|
|
431
|
+
if had_trailing_newline and not modified.endswith("\n"):
|
|
432
|
+
modified += "\n"
|
|
433
|
+
|
|
434
|
+
if modified == original:
|
|
435
|
+
emit_warning(
|
|
436
|
+
"No changes to apply – proposed content is identical.",
|
|
437
|
+
message_group=message_group,
|
|
438
|
+
)
|
|
439
|
+
return {
|
|
440
|
+
"success": False,
|
|
441
|
+
"path": str(file_path),
|
|
442
|
+
"message": "No changes to apply.",
|
|
443
|
+
"changed": False,
|
|
444
|
+
"diff": "",
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
from code_muse.config import get_diff_context_lines
|
|
448
|
+
|
|
449
|
+
diff_text = "".join(
|
|
450
|
+
difflib.unified_diff(
|
|
451
|
+
original.splitlines(keepends=True),
|
|
452
|
+
modified.splitlines(keepends=True),
|
|
453
|
+
fromfile=f"a/{file_path.name}",
|
|
454
|
+
tofile=f"b/{file_path.name}",
|
|
455
|
+
n=get_diff_context_lines(),
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
if len(diff_text) > MAX_DIFF_BYTES:
|
|
459
|
+
trunc_msg = f"\n\n[Diff truncated: exceeded {MAX_DIFF_BYTES} bytes]\n"
|
|
460
|
+
diff_text = diff_text[: MAX_DIFF_BYTES - len(trunc_msg)] + trunc_msg
|
|
461
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
462
|
+
f.write(modified)
|
|
463
|
+
return {
|
|
464
|
+
"success": True,
|
|
465
|
+
"path": str(file_path),
|
|
466
|
+
"message": "Replacements applied.",
|
|
467
|
+
"changed": True,
|
|
468
|
+
"diff": diff_text,
|
|
469
|
+
}
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
return {"error": str(exc), "diff": diff_text}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _write_to_file(
|
|
475
|
+
context: RunContext | None,
|
|
476
|
+
path: str,
|
|
477
|
+
content: str,
|
|
478
|
+
overwrite: bool = False,
|
|
479
|
+
message_group: str | None = None,
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
file_path = Path(path).resolve()
|
|
482
|
+
|
|
483
|
+
# Enforce path policy for real agent contexts (skip for test helpers)
|
|
484
|
+
if _should_enforce_path_policy(context):
|
|
485
|
+
policy = check_path_allowed(str(file_path), Operation.WRITE)
|
|
486
|
+
if not policy.allowed:
|
|
487
|
+
return {
|
|
488
|
+
"error": policy.reason or "Write blocked by path policy.",
|
|
489
|
+
"diff": "",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
exists = file_path.exists()
|
|
494
|
+
if exists and not overwrite:
|
|
495
|
+
return {
|
|
496
|
+
"success": False,
|
|
497
|
+
"path": str(file_path),
|
|
498
|
+
"message": f"Cowardly refusing to overwrite existing file: {file_path}",
|
|
499
|
+
"changed": False,
|
|
500
|
+
"diff": "",
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
from code_muse.config import get_diff_context_lines
|
|
504
|
+
|
|
505
|
+
if exists:
|
|
506
|
+
with open(file_path, encoding="utf-8", errors="surrogateescape") as f:
|
|
507
|
+
old_content = f.read()
|
|
508
|
+
with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
|
|
509
|
+
old_content = old_content.encode(
|
|
510
|
+
"utf-8", errors="surrogatepass"
|
|
511
|
+
).decode("utf-8", errors="replace")
|
|
512
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
513
|
+
else:
|
|
514
|
+
old_lines = []
|
|
515
|
+
|
|
516
|
+
diff_lines = difflib.unified_diff(
|
|
517
|
+
old_lines,
|
|
518
|
+
content.splitlines(keepends=True),
|
|
519
|
+
fromfile="/dev/null" if not exists else f"a/{file_path.name}",
|
|
520
|
+
tofile=f"b/{file_path.name}",
|
|
521
|
+
n=get_diff_context_lines(),
|
|
522
|
+
)
|
|
523
|
+
diff_text = "".join(diff_lines)
|
|
524
|
+
if len(diff_text) > MAX_DIFF_BYTES:
|
|
525
|
+
trunc_msg = f"\n\n[Diff truncated: exceeded {MAX_DIFF_BYTES} bytes]\n"
|
|
526
|
+
diff_text = diff_text[: MAX_DIFF_BYTES - len(trunc_msg)] + trunc_msg
|
|
527
|
+
|
|
528
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
530
|
+
f.write(content)
|
|
531
|
+
|
|
532
|
+
action = "overwritten" if exists else "created"
|
|
533
|
+
return {
|
|
534
|
+
"success": True,
|
|
535
|
+
"path": str(file_path),
|
|
536
|
+
"message": f"File '{file_path}' {action} successfully.",
|
|
537
|
+
"changed": True,
|
|
538
|
+
"diff": diff_text,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
except Exception as exc:
|
|
542
|
+
_log_error("Unhandled exception in write_to_file", exc)
|
|
543
|
+
return {"error": str(exc), "diff": ""}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def delete_snippet_from_file(
|
|
547
|
+
context: RunContext,
|
|
548
|
+
file_path: str | Path,
|
|
549
|
+
snippet: str,
|
|
550
|
+
message_group: str | None = None,
|
|
551
|
+
) -> dict[str, Any]:
|
|
552
|
+
# Use the plugin system for permission handling with operation data
|
|
553
|
+
from code_muse.callbacks import on_file_permission
|
|
554
|
+
|
|
555
|
+
operation_data = {"snippet": snippet}
|
|
556
|
+
permission_results = on_file_permission(
|
|
557
|
+
context, file_path, "delete snippet from", None, message_group, operation_data
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# If any permission handler denies the operation, return cancelled result
|
|
561
|
+
if permission_results and any(
|
|
562
|
+
not result for result in permission_results if result is not None
|
|
563
|
+
):
|
|
564
|
+
return _create_rejection_response(file_path)
|
|
565
|
+
|
|
566
|
+
res = _delete_snippet_from_file(
|
|
567
|
+
context, file_path, snippet, message_group=message_group
|
|
568
|
+
)
|
|
569
|
+
diff = res.get("diff", "")
|
|
570
|
+
if diff:
|
|
571
|
+
_emit_diff_message(file_path, "modify", diff)
|
|
572
|
+
return res
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def write_to_file(
|
|
576
|
+
context: RunContext,
|
|
577
|
+
path: str,
|
|
578
|
+
content: str,
|
|
579
|
+
overwrite: bool,
|
|
580
|
+
message_group: str | None = None,
|
|
581
|
+
) -> dict[str, Any]:
|
|
582
|
+
# Use the plugin system for permission handling with operation data
|
|
583
|
+
from code_muse.callbacks import on_file_permission
|
|
584
|
+
|
|
585
|
+
operation_data = {"content": content, "overwrite": overwrite}
|
|
586
|
+
permission_results = on_file_permission(
|
|
587
|
+
context, path, "write", None, message_group, operation_data
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# If any permission handler denies the operation, return cancelled result
|
|
591
|
+
if permission_results and any(
|
|
592
|
+
not result for result in permission_results if result is not None
|
|
593
|
+
):
|
|
594
|
+
return _create_rejection_response(path)
|
|
595
|
+
|
|
596
|
+
res = _write_to_file(
|
|
597
|
+
context, path, content, overwrite=overwrite, message_group=message_group
|
|
598
|
+
)
|
|
599
|
+
diff = res.get("diff", "")
|
|
600
|
+
if diff:
|
|
601
|
+
# Determine operation type based on whether file existed
|
|
602
|
+
operation = "modify" if overwrite else "create"
|
|
603
|
+
_emit_diff_message(path, operation, diff, new_content=content)
|
|
604
|
+
return res
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def replace_in_file(
|
|
608
|
+
context: RunContext,
|
|
609
|
+
path: str,
|
|
610
|
+
replacements: list[dict[str, str]],
|
|
611
|
+
message_group: str | None = None,
|
|
612
|
+
) -> dict[str, Any]:
|
|
613
|
+
# Use the plugin system for permission handling with operation data
|
|
614
|
+
from code_muse.callbacks import on_file_permission
|
|
615
|
+
|
|
616
|
+
operation_data = {"replacements": replacements}
|
|
617
|
+
permission_results = on_file_permission(
|
|
618
|
+
context, path, "replace text in", None, message_group, operation_data
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# If any permission handler denies the operation, return cancelled result
|
|
622
|
+
if permission_results and any(
|
|
623
|
+
not result for result in permission_results if result is not None
|
|
624
|
+
):
|
|
625
|
+
return _create_rejection_response(path)
|
|
626
|
+
|
|
627
|
+
res = _replace_in_file(context, path, replacements, message_group=message_group)
|
|
628
|
+
diff = res.get("diff", "")
|
|
629
|
+
if diff:
|
|
630
|
+
_emit_diff_message(path, "modify", diff)
|
|
631
|
+
return res
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _edit_file(
|
|
635
|
+
context: RunContext, payload: EditFilePayload, group_id: str | None = None
|
|
636
|
+
) -> dict[str, Any]:
|
|
637
|
+
"""
|
|
638
|
+
High-level implementation of the *edit_file* behaviour.
|
|
639
|
+
|
|
640
|
+
This function performs the heavy-lifting after the lightweight agent-exposed wrapper has
|
|
641
|
+
validated / coerced the inbound *payload* to one of the Pydantic models declared at the top
|
|
642
|
+
of this module.
|
|
643
|
+
|
|
644
|
+
Supported payload variants
|
|
645
|
+
--------------------------
|
|
646
|
+
• **ContentPayload** – full file write / overwrite.
|
|
647
|
+
• **ReplacementsPayload** – targeted in-file replacements.
|
|
648
|
+
• **DeleteSnippetPayload** – remove an exact snippet.
|
|
649
|
+
|
|
650
|
+
The helper decides which low-level routine to delegate to and ensures the resulting unified
|
|
651
|
+
diff is always returned so the caller can pretty-print it for the user.
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
path : str
|
|
656
|
+
Path to the target file (relative or absolute)
|
|
657
|
+
diff : str
|
|
658
|
+
Either:
|
|
659
|
+
* Raw file content (for file creation)
|
|
660
|
+
* A JSON string with one of the following shapes:
|
|
661
|
+
{"content": "full file contents", "overwrite": true}
|
|
662
|
+
{"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
|
|
663
|
+
{"delete_snippet": "text to remove"}
|
|
664
|
+
|
|
665
|
+
The function auto-detects the payload type and routes to the appropriate internal helper.
|
|
666
|
+
"""
|
|
667
|
+
# Extract file_path from payload
|
|
668
|
+
file_path = Path(payload.file_path).resolve()
|
|
669
|
+
|
|
670
|
+
# Use provided group_id or generate one if not provided
|
|
671
|
+
if group_id is None:
|
|
672
|
+
group_id = generate_group_id("edit_file", file_path)
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
if isinstance(payload, DeleteSnippetPayload):
|
|
676
|
+
return delete_snippet_from_file(
|
|
677
|
+
context, file_path, payload.delete_snippet, message_group=group_id
|
|
678
|
+
)
|
|
679
|
+
elif isinstance(payload, ReplacementsPayload):
|
|
680
|
+
# Convert Pydantic Replacement models to dict format for legacy compatibility
|
|
681
|
+
replacements_dict = [
|
|
682
|
+
{"old_str": rep.old_str, "new_str": rep.new_str}
|
|
683
|
+
for rep in payload.replacements
|
|
684
|
+
]
|
|
685
|
+
return replace_in_file(
|
|
686
|
+
context, str(file_path), replacements_dict, message_group=group_id
|
|
687
|
+
)
|
|
688
|
+
elif isinstance(payload, ContentPayload):
|
|
689
|
+
file_exists = file_path.exists()
|
|
690
|
+
if file_exists and not payload.overwrite:
|
|
691
|
+
return {
|
|
692
|
+
"success": False,
|
|
693
|
+
"path": str(file_path),
|
|
694
|
+
"message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
|
|
695
|
+
"changed": False,
|
|
696
|
+
}
|
|
697
|
+
return write_to_file(
|
|
698
|
+
context,
|
|
699
|
+
str(file_path),
|
|
700
|
+
payload.content,
|
|
701
|
+
payload.overwrite,
|
|
702
|
+
message_group=group_id,
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
return {
|
|
706
|
+
"success": False,
|
|
707
|
+
"path": str(file_path),
|
|
708
|
+
"message": f"Unknown payload type: {type(payload)}",
|
|
709
|
+
"changed": False,
|
|
710
|
+
}
|
|
711
|
+
except Exception as e:
|
|
712
|
+
emit_error(
|
|
713
|
+
"Unable to route file modification tool call to sub-tool",
|
|
714
|
+
message_group=group_id,
|
|
715
|
+
)
|
|
716
|
+
emit_error(str(e), message_group=group_id)
|
|
717
|
+
return {
|
|
718
|
+
"success": False,
|
|
719
|
+
"path": file_path,
|
|
720
|
+
"message": f"Something went wrong in file editing: {str(e)}",
|
|
721
|
+
"changed": False,
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _delete_file(
|
|
726
|
+
context: RunContext, file_path: str, message_group: str | None = None
|
|
727
|
+
) -> dict[str, Any]:
|
|
728
|
+
file_path = Path(file_path).resolve()
|
|
729
|
+
|
|
730
|
+
# Enforce path policy for real agent contexts (skip for test helpers)
|
|
731
|
+
if _should_enforce_path_policy(context):
|
|
732
|
+
policy = check_path_allowed(str(file_path), Operation.DELETE)
|
|
733
|
+
if not policy.allowed:
|
|
734
|
+
return {"error": policy.reason or "Delete blocked by path policy."}
|
|
735
|
+
|
|
736
|
+
# Use the plugin system for permission handling with operation data
|
|
737
|
+
from code_muse.callbacks import on_file_permission
|
|
738
|
+
|
|
739
|
+
operation_data = {} # No additional data needed for delete operations
|
|
740
|
+
permission_results = on_file_permission(
|
|
741
|
+
context, str(file_path), "delete", None, message_group, operation_data
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# If any permission handler denies the operation, return cancelled result
|
|
745
|
+
if permission_results and any(
|
|
746
|
+
not result for result in permission_results if result is not None
|
|
747
|
+
):
|
|
748
|
+
return _create_rejection_response(str(file_path))
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
if not file_path.exists() or not file_path.is_file():
|
|
752
|
+
return {"error": f"File '{file_path}' does not exist."}
|
|
753
|
+
|
|
754
|
+
os.remove(file_path)
|
|
755
|
+
with contextlib.suppress(Exception):
|
|
756
|
+
emit_success(f"Deleted file: {file_path}", message_group=message_group)
|
|
757
|
+
return {
|
|
758
|
+
"success": True,
|
|
759
|
+
"path": str(file_path),
|
|
760
|
+
"message": f"File '{file_path}' deleted successfully.",
|
|
761
|
+
"changed": True,
|
|
762
|
+
}
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
_log_error("Unhandled exception in delete_file", exc)
|
|
765
|
+
return {"error": str(exc)}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def register_edit_file(agent):
|
|
769
|
+
"""Register only the edit_file tool.
|
|
770
|
+
|
|
771
|
+
.. deprecated::
|
|
772
|
+
Use register_create_file, register_replace_in_file, and
|
|
773
|
+
register_delete_snippet instead. edit_file is auto-expanded
|
|
774
|
+
to these three tools when listed in an agent's tool config.
|
|
775
|
+
"""
|
|
776
|
+
warnings.warn(
|
|
777
|
+
"register_edit_file() is deprecated. Use register_create_file, "
|
|
778
|
+
"register_replace_in_file, and register_delete_snippet instead. "
|
|
779
|
+
"Agents listing 'edit_file' in their tools config will automatically "
|
|
780
|
+
"get the three new tools via TOOL_EXPANSIONS.",
|
|
781
|
+
DeprecationWarning,
|
|
782
|
+
stacklevel=2,
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
@agent.tool
|
|
786
|
+
def edit_file(
|
|
787
|
+
context: RunContext,
|
|
788
|
+
payload: EditFilePayload | str = "",
|
|
789
|
+
) -> dict[str, Any]:
|
|
790
|
+
"""Comprehensive file editing tool supporting multiple modification strategies.
|
|
791
|
+
|
|
792
|
+
Supports: ContentPayload (create/overwrite), ReplacementsPayload (targeted edits),
|
|
793
|
+
DeleteSnippetPayload (remove text). Prefer ReplacementsPayload for existing files.
|
|
794
|
+
"""
|
|
795
|
+
# Handle string payload parsing (for models that send JSON strings)
|
|
796
|
+
|
|
797
|
+
parse_error_message = "Payload must contain one of: 'content', 'replacements', or 'delete_snippet' with a 'file_path'."
|
|
798
|
+
|
|
799
|
+
if isinstance(payload, str):
|
|
800
|
+
try:
|
|
801
|
+
# Fallback for weird models that just can't help but send json strings...
|
|
802
|
+
payload_dict = json.loads(json_repair.repair_json(payload))
|
|
803
|
+
if "replacements" in payload_dict:
|
|
804
|
+
payload = ReplacementsPayload(**payload_dict)
|
|
805
|
+
elif "delete_snippet" in payload_dict:
|
|
806
|
+
payload = DeleteSnippetPayload(**payload_dict)
|
|
807
|
+
elif "content" in payload_dict:
|
|
808
|
+
payload = ContentPayload(**payload_dict)
|
|
809
|
+
else:
|
|
810
|
+
file_path = "Unknown"
|
|
811
|
+
if "file_path" in payload_dict:
|
|
812
|
+
file_path = payload_dict["file_path"]
|
|
813
|
+
return {
|
|
814
|
+
"success": False,
|
|
815
|
+
"path": file_path,
|
|
816
|
+
"message": parse_error_message,
|
|
817
|
+
"changed": False,
|
|
818
|
+
}
|
|
819
|
+
except Exception as e:
|
|
820
|
+
return {
|
|
821
|
+
"success": False,
|
|
822
|
+
"path": "Not retrievable in Payload",
|
|
823
|
+
"message": f"edit_file call failed: {str(e)} - {parse_error_message}",
|
|
824
|
+
"changed": False,
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
# Call _edit_file which will extract file_path from payload and handle group_id generation
|
|
828
|
+
result = _edit_file(context, payload)
|
|
829
|
+
if "diff" in result:
|
|
830
|
+
del result["diff"]
|
|
831
|
+
|
|
832
|
+
# Trigger edit_file callbacks to enhance the result with rejection details
|
|
833
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
834
|
+
if enhanced_results:
|
|
835
|
+
# Use the first non-None enhanced result
|
|
836
|
+
for enhanced_result in enhanced_results:
|
|
837
|
+
if enhanced_result is not None:
|
|
838
|
+
result = enhanced_result
|
|
839
|
+
break
|
|
840
|
+
|
|
841
|
+
return result
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def register_delete_file(agent):
|
|
845
|
+
"""Register only the delete_file tool."""
|
|
846
|
+
|
|
847
|
+
@agent.tool
|
|
848
|
+
def delete_file(context: RunContext, file_path: str = "") -> dict[str, Any]:
|
|
849
|
+
"""Safely delete a file and report the deletion.
|
|
850
|
+
|
|
851
|
+
Delete operations intentionally do not generate or print diffs of removed content.
|
|
852
|
+
"""
|
|
853
|
+
# Generate group_id for delete_file tool execution
|
|
854
|
+
group_id = generate_group_id("delete_file", file_path)
|
|
855
|
+
result = _delete_file(context, file_path, message_group=group_id)
|
|
856
|
+
if "diff" in result:
|
|
857
|
+
del result["diff"]
|
|
858
|
+
|
|
859
|
+
# Trigger delete_file callbacks to enhance the result with rejection details
|
|
860
|
+
enhanced_results = on_delete_file(context, result, file_path)
|
|
861
|
+
if enhanced_results:
|
|
862
|
+
# Use the first non-None enhanced result
|
|
863
|
+
for enhanced_result in enhanced_results:
|
|
864
|
+
if enhanced_result is not None:
|
|
865
|
+
result = enhanced_result
|
|
866
|
+
break
|
|
867
|
+
|
|
868
|
+
return result
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# Module-level aliases captured before registration functions are defined.
|
|
872
|
+
# Inside register_replace_in_file, the @agent.tool decorator creates a local
|
|
873
|
+
# function named 'replace_in_file' which shadows the module-level helper of the
|
|
874
|
+
# same name for the entire enclosing scope (Python scoping rules). We capture
|
|
875
|
+
# a reference here so the registration function can call the helper.
|
|
876
|
+
_replace_in_file_helper = replace_in_file
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def register_create_file(agent):
|
|
880
|
+
"""Register the create_file tool for creating or overwriting files."""
|
|
881
|
+
# Local alias to avoid shadowing by the @agent.tool decorated function below
|
|
882
|
+
_write_file = write_to_file
|
|
883
|
+
|
|
884
|
+
@agent.tool
|
|
885
|
+
def create_file(
|
|
886
|
+
context: RunContext,
|
|
887
|
+
file_path: str = "",
|
|
888
|
+
content: str = "",
|
|
889
|
+
overwrite: bool = False,
|
|
890
|
+
) -> dict[str, Any]:
|
|
891
|
+
"""Create a new file or overwrite an existing one with the provided content."""
|
|
892
|
+
group_id = generate_group_id("create_file", file_path)
|
|
893
|
+
result = _write_file(
|
|
894
|
+
context, file_path, content, overwrite, message_group=group_id
|
|
895
|
+
)
|
|
896
|
+
if "diff" in result:
|
|
897
|
+
del result["diff"]
|
|
898
|
+
|
|
899
|
+
# Trigger create_file callbacks
|
|
900
|
+
enhanced_results = on_create_file(context, result, file_path, content)
|
|
901
|
+
if enhanced_results:
|
|
902
|
+
for enhanced_result in enhanced_results:
|
|
903
|
+
if enhanced_result is not None:
|
|
904
|
+
result = enhanced_result
|
|
905
|
+
break
|
|
906
|
+
|
|
907
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
908
|
+
payload = ContentPayload(
|
|
909
|
+
file_path=file_path, content=content, overwrite=overwrite
|
|
910
|
+
)
|
|
911
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
912
|
+
if enhanced_results:
|
|
913
|
+
for enhanced_result in enhanced_results:
|
|
914
|
+
if enhanced_result is not None:
|
|
915
|
+
result = enhanced_result
|
|
916
|
+
break
|
|
917
|
+
|
|
918
|
+
return result
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
# Inline JSON schema for Replacement objects — avoids $defs/$ref that many
|
|
922
|
+
# LLM providers misinterpret, causing frequent validation errors and
|
|
923
|
+
# fallback to full-file rewrites.
|
|
924
|
+
_REPLACEMENT_ITEM_SCHEMA = {
|
|
925
|
+
"type": "object",
|
|
926
|
+
"properties": {
|
|
927
|
+
"old_str": {"type": "string"},
|
|
928
|
+
"new_str": {"type": "string"},
|
|
929
|
+
},
|
|
930
|
+
"required": ["old_str", "new_str"],
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
# Type alias used by the tool signature. The Annotated + WithJsonSchema
|
|
934
|
+
# tells Pydantic to emit _REPLACEMENT_ITEM_SCHEMA inline instead of a $ref.
|
|
935
|
+
InlineReplacement = Annotated[dict[str, str], WithJsonSchema(_REPLACEMENT_ITEM_SCHEMA)]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _try_json_repair(v: Any) -> Any:
|
|
939
|
+
"""Best-effort: turn a JSON-ish string into a real Python value.
|
|
940
|
+
|
|
941
|
+
Returns the parsed object on success, or the original ``v`` unchanged on
|
|
942
|
+
failure (or if ``v`` isn't a string in the first place). Used by both the
|
|
943
|
+
outer list coercion and the per-item validation in ``replace_in_file``.
|
|
944
|
+
"""
|
|
945
|
+
if not isinstance(v, str):
|
|
946
|
+
return v
|
|
947
|
+
try:
|
|
948
|
+
return json.loads(json_repair.repair_json(v))
|
|
949
|
+
except Exception:
|
|
950
|
+
return v
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _coerce_replacements_arg(v: Any) -> Any:
|
|
954
|
+
"""Coerce a stringified JSON array back into an actual list.
|
|
955
|
+
|
|
956
|
+
Some tool-call serializers (looking at you, certain LLM clients) stringify
|
|
957
|
+
list arguments into JSON before shipping them. Pydantic would otherwise
|
|
958
|
+
reject those with ``Input should be a valid array``. We intercept strings
|
|
959
|
+
here, best-effort parse them via ``json_repair``, and hand a real list to
|
|
960
|
+
the normal validator. Non-strings pass through untouched so regular list
|
|
961
|
+
inputs keep their fast path.
|
|
962
|
+
"""
|
|
963
|
+
return _try_json_repair(v)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
# List type that tolerates JSON-string-encoded arrays coming from the wire.
|
|
967
|
+
# BeforeValidator runs prior to type validation, so the advertised JSON schema
|
|
968
|
+
# (array of InlineReplacement) is unchanged — only inbound coercion is widened.
|
|
969
|
+
RepairableReplacementsList = Annotated[
|
|
970
|
+
list[InlineReplacement],
|
|
971
|
+
BeforeValidator(_coerce_replacements_arg),
|
|
972
|
+
]
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def register_replace_in_file(agent):
|
|
976
|
+
"""Register the replace_in_file tool for targeted text replacements."""
|
|
977
|
+
|
|
978
|
+
@agent.tool
|
|
979
|
+
def replace_in_file(
|
|
980
|
+
context: RunContext,
|
|
981
|
+
file_path: str = "",
|
|
982
|
+
replacements: RepairableReplacementsList | None = None,
|
|
983
|
+
) -> dict[str, Any]:
|
|
984
|
+
"""Apply targeted text replacements to an existing file.
|
|
985
|
+
|
|
986
|
+
Each replacement specifies an old_str to find and a new_str to replace it with.
|
|
987
|
+
Replacements are applied sequentially. Prefer this over full file rewrites.
|
|
988
|
+
"""
|
|
989
|
+
group_id = generate_group_id("replace_in_file", file_path)
|
|
990
|
+
replacements = replacements or []
|
|
991
|
+
if not replacements:
|
|
992
|
+
return {
|
|
993
|
+
"error": "No replacements provided. 'replacements' is required and must not be empty.",
|
|
994
|
+
}
|
|
995
|
+
try:
|
|
996
|
+
# Validate replacements up front so a malformed payload from the
|
|
997
|
+
# model returns a clean error instead of bubbling a KeyError up
|
|
998
|
+
# through pydantic_ai and tearing down the whole agent run.
|
|
999
|
+
normalized: list[dict[str, str]] = []
|
|
1000
|
+
for idx, raw in enumerate(replacements):
|
|
1001
|
+
# Per-item json_repair: some models stringify each replacement
|
|
1002
|
+
# individually (e.g. ["{\"old_str\": ...}", ...]). Heal those
|
|
1003
|
+
# before strict validation so we don't reject recoverable input.
|
|
1004
|
+
r = _try_json_repair(raw)
|
|
1005
|
+
if not isinstance(r, dict):
|
|
1006
|
+
return {
|
|
1007
|
+
"error": (
|
|
1008
|
+
f"replacements[{idx}] must be an object with "
|
|
1009
|
+
f"'old_str' and 'new_str' keys, got {type(raw).__name__}."
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
missing = [k for k in ("old_str", "new_str") if k not in r]
|
|
1013
|
+
if missing:
|
|
1014
|
+
return {
|
|
1015
|
+
"error": (
|
|
1016
|
+
f"replacements[{idx}] is missing required key(s): "
|
|
1017
|
+
f"{', '.join(missing)}. Each replacement must include "
|
|
1018
|
+
f"both 'old_str' and 'new_str'."
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
normalized.append({"old_str": r["old_str"], "new_str": r["new_str"]})
|
|
1022
|
+
|
|
1023
|
+
result = _replace_in_file_helper(
|
|
1024
|
+
context, file_path, normalized, message_group=group_id
|
|
1025
|
+
)
|
|
1026
|
+
if "diff" in result:
|
|
1027
|
+
del result["diff"]
|
|
1028
|
+
|
|
1029
|
+
# Trigger replace_in_file callbacks
|
|
1030
|
+
enhanced_results = on_replace_in_file(
|
|
1031
|
+
context, result, file_path, normalized
|
|
1032
|
+
)
|
|
1033
|
+
if enhanced_results:
|
|
1034
|
+
for enhanced_result in enhanced_results:
|
|
1035
|
+
if enhanced_result is not None:
|
|
1036
|
+
result = enhanced_result
|
|
1037
|
+
break
|
|
1038
|
+
|
|
1039
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
1040
|
+
payload = ReplacementsPayload(
|
|
1041
|
+
file_path=file_path,
|
|
1042
|
+
replacements=[
|
|
1043
|
+
Replacement(old_str=r["old_str"], new_str=r["new_str"])
|
|
1044
|
+
for r in normalized
|
|
1045
|
+
],
|
|
1046
|
+
)
|
|
1047
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
1048
|
+
if enhanced_results:
|
|
1049
|
+
for enhanced_result in enhanced_results:
|
|
1050
|
+
if enhanced_result is not None:
|
|
1051
|
+
result = enhanced_result
|
|
1052
|
+
break
|
|
1053
|
+
|
|
1054
|
+
return result
|
|
1055
|
+
except Exception as exc:
|
|
1056
|
+
# Last line of defense — never let this tool crash the agent run.
|
|
1057
|
+
_log_error(
|
|
1058
|
+
"Unhandled exception in replace_in_file",
|
|
1059
|
+
exc,
|
|
1060
|
+
message_group=group_id,
|
|
1061
|
+
)
|
|
1062
|
+
return {"error": f"replace_in_file failed: {exc}"}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def register_delete_snippet(agent):
|
|
1066
|
+
"""Register the delete_snippet tool for removing text from files."""
|
|
1067
|
+
# Local alias to avoid shadowing by the @agent.tool decorated function below
|
|
1068
|
+
_remove_snippet = delete_snippet_from_file
|
|
1069
|
+
|
|
1070
|
+
@agent.tool
|
|
1071
|
+
def delete_snippet(
|
|
1072
|
+
context: RunContext,
|
|
1073
|
+
file_path: str = "",
|
|
1074
|
+
snippet: str = "",
|
|
1075
|
+
) -> dict[str, Any]:
|
|
1076
|
+
"""Remove the first occurrence of a text snippet from a file."""
|
|
1077
|
+
group_id = generate_group_id("delete_snippet", file_path)
|
|
1078
|
+
result = _remove_snippet(context, file_path, snippet, message_group=group_id)
|
|
1079
|
+
if "diff" in result:
|
|
1080
|
+
del result["diff"]
|
|
1081
|
+
|
|
1082
|
+
# Trigger delete_snippet callbacks
|
|
1083
|
+
enhanced_results = on_delete_snippet(context, result, file_path, snippet)
|
|
1084
|
+
if enhanced_results:
|
|
1085
|
+
for enhanced_result in enhanced_results:
|
|
1086
|
+
if enhanced_result is not None:
|
|
1087
|
+
result = enhanced_result
|
|
1088
|
+
break
|
|
1089
|
+
|
|
1090
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
1091
|
+
payload = DeleteSnippetPayload(file_path=file_path, delete_snippet=snippet)
|
|
1092
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
1093
|
+
if enhanced_results:
|
|
1094
|
+
for enhanced_result in enhanced_results:
|
|
1095
|
+
if enhanced_result is not None:
|
|
1096
|
+
result = enhanced_result
|
|
1097
|
+
break
|
|
1098
|
+
|
|
1099
|
+
return result
|