codepp 0.0.437__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_puppy/__init__.py +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""Robust, always-diff-logging file-modification helpers + agent tools.
|
|
2
|
+
|
|
3
|
+
Key guarantees
|
|
4
|
+
--------------
|
|
5
|
+
1. **A diff is printed _inline_ on every path** (success, no-op, or error) – no decorator magic.
|
|
6
|
+
2. **Full traceback logging** for unexpected errors via `_log_error`.
|
|
7
|
+
3. Helper functions stay print-free and return a `diff` key, while agent-tool wrappers handle
|
|
8
|
+
all console output.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import difflib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import traceback
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Annotated, Any, Dict, List, Union
|
|
19
|
+
|
|
20
|
+
import json_repair
|
|
21
|
+
from pydantic import BaseModel, WithJsonSchema
|
|
22
|
+
from pydantic_ai import RunContext
|
|
23
|
+
|
|
24
|
+
from code_puppy.callbacks import on_delete_file, on_edit_file
|
|
25
|
+
from code_puppy.messaging import ( # Structured messaging types
|
|
26
|
+
DiffLine,
|
|
27
|
+
DiffMessage,
|
|
28
|
+
emit_error,
|
|
29
|
+
emit_warning,
|
|
30
|
+
get_message_bus,
|
|
31
|
+
)
|
|
32
|
+
from code_puppy.tools.common import _find_best_window, generate_group_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _create_rejection_response(file_path: str) -> Dict[str, Any]:
|
|
36
|
+
"""Create a standardized rejection response with user feedback if available.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Path to the file that was rejected
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Dict containing rejection details and any user feedback
|
|
43
|
+
"""
|
|
44
|
+
# Check for user feedback from permission handler
|
|
45
|
+
try:
|
|
46
|
+
from code_puppy.plugins.file_permission_handler.register_callbacks import (
|
|
47
|
+
clear_user_feedback,
|
|
48
|
+
get_last_user_feedback,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
user_feedback = get_last_user_feedback()
|
|
52
|
+
# Clear feedback after reading it
|
|
53
|
+
clear_user_feedback()
|
|
54
|
+
except ImportError:
|
|
55
|
+
user_feedback = None
|
|
56
|
+
|
|
57
|
+
rejection_message = (
|
|
58
|
+
"USER REJECTED: The user explicitly rejected these file changes."
|
|
59
|
+
)
|
|
60
|
+
if user_feedback:
|
|
61
|
+
rejection_message += f" User feedback: {user_feedback}"
|
|
62
|
+
else:
|
|
63
|
+
rejection_message += " Please do not retry the same changes or any other changes - immediately ask for clarification."
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"success": False,
|
|
67
|
+
"path": file_path,
|
|
68
|
+
"message": rejection_message,
|
|
69
|
+
"changed": False,
|
|
70
|
+
"user_rejection": True,
|
|
71
|
+
"rejection_type": "explicit_user_denial",
|
|
72
|
+
"user_feedback": user_feedback,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DeleteSnippetPayload(BaseModel):
|
|
77
|
+
file_path: str
|
|
78
|
+
delete_snippet: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Replacement(BaseModel):
|
|
82
|
+
old_str: str
|
|
83
|
+
new_str: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ReplacementsPayload(BaseModel):
|
|
87
|
+
file_path: str
|
|
88
|
+
replacements: List[Replacement]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ContentPayload(BaseModel):
|
|
92
|
+
file_path: str
|
|
93
|
+
content: str
|
|
94
|
+
overwrite: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
EditFilePayload = Union[DeleteSnippetPayload, ReplacementsPayload, ContentPayload]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_diff_lines(diff_text: str) -> List[DiffLine]:
|
|
101
|
+
"""Parse unified diff text into structured DiffLine objects.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
diff_text: Raw unified diff text
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of DiffLine objects with line numbers and types
|
|
108
|
+
"""
|
|
109
|
+
if not diff_text or not diff_text.strip():
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
diff_lines = []
|
|
113
|
+
line_number = 0
|
|
114
|
+
|
|
115
|
+
for line in diff_text.splitlines():
|
|
116
|
+
# Determine line type based on diff markers
|
|
117
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
118
|
+
line_type = "add"
|
|
119
|
+
line_number += 1
|
|
120
|
+
content = line[1:] # Remove the + prefix
|
|
121
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
122
|
+
line_type = "remove"
|
|
123
|
+
line_number += 1
|
|
124
|
+
content = line[1:] # Remove the - prefix
|
|
125
|
+
elif line.startswith("@@"):
|
|
126
|
+
# Parse hunk header to get line number
|
|
127
|
+
# Format: @@ -start,count +start,count @@
|
|
128
|
+
import re
|
|
129
|
+
|
|
130
|
+
match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
|
|
131
|
+
if match:
|
|
132
|
+
line_number = (
|
|
133
|
+
int(match.group(1)) - 1
|
|
134
|
+
) # Will be incremented on next line
|
|
135
|
+
line_type = "context"
|
|
136
|
+
content = line
|
|
137
|
+
elif line.startswith("---") or line.startswith("+++"):
|
|
138
|
+
# File headers - treat as context
|
|
139
|
+
line_type = "context"
|
|
140
|
+
content = line
|
|
141
|
+
else:
|
|
142
|
+
line_type = "context"
|
|
143
|
+
line_number += 1
|
|
144
|
+
content = line
|
|
145
|
+
|
|
146
|
+
diff_lines.append(
|
|
147
|
+
DiffLine(
|
|
148
|
+
line_number=max(1, line_number),
|
|
149
|
+
type=line_type,
|
|
150
|
+
content=content,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return diff_lines
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _emit_diff_message(
|
|
158
|
+
file_path: str,
|
|
159
|
+
operation: str,
|
|
160
|
+
diff_text: str,
|
|
161
|
+
old_content: str | None = None,
|
|
162
|
+
new_content: str | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Emit a structured DiffMessage for UI display.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
file_path: Path to the file being modified
|
|
168
|
+
operation: One of 'create', 'modify', 'delete'
|
|
169
|
+
diff_text: Raw unified diff text
|
|
170
|
+
old_content: Original file content (optional)
|
|
171
|
+
new_content: New file content (optional)
|
|
172
|
+
"""
|
|
173
|
+
# Check if diff was already shown during permission prompt
|
|
174
|
+
try:
|
|
175
|
+
from code_puppy.plugins.file_permission_handler.register_callbacks import (
|
|
176
|
+
clear_diff_shown_flag,
|
|
177
|
+
was_diff_already_shown,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if was_diff_already_shown():
|
|
181
|
+
# Diff already displayed in permission panel, skip redundant display
|
|
182
|
+
clear_diff_shown_flag()
|
|
183
|
+
return
|
|
184
|
+
except ImportError:
|
|
185
|
+
pass # Permission handler not available, emit anyway
|
|
186
|
+
|
|
187
|
+
if not diff_text or not diff_text.strip():
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
diff_lines = _parse_diff_lines(diff_text)
|
|
191
|
+
|
|
192
|
+
diff_msg = DiffMessage(
|
|
193
|
+
path=file_path,
|
|
194
|
+
operation=operation,
|
|
195
|
+
old_content=old_content,
|
|
196
|
+
new_content=new_content,
|
|
197
|
+
diff_lines=diff_lines,
|
|
198
|
+
)
|
|
199
|
+
get_message_bus().emit(diff_msg)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _log_error(
|
|
203
|
+
msg: str, exc: Exception | None = None, message_group: str | None = None
|
|
204
|
+
) -> None:
|
|
205
|
+
emit_error(f"{msg}", message_group=message_group)
|
|
206
|
+
if exc is not None:
|
|
207
|
+
emit_error(traceback.format_exc(), highlight=False, message_group=message_group)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _delete_snippet_from_file(
|
|
211
|
+
context: RunContext | None,
|
|
212
|
+
file_path: str,
|
|
213
|
+
snippet: str,
|
|
214
|
+
message_group: str | None = None,
|
|
215
|
+
) -> Dict[str, Any]:
|
|
216
|
+
file_path = os.path.abspath(file_path)
|
|
217
|
+
diff_text = ""
|
|
218
|
+
try:
|
|
219
|
+
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
220
|
+
return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
|
|
221
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
222
|
+
original = f.read()
|
|
223
|
+
# Sanitize any surrogate characters from reading
|
|
224
|
+
try:
|
|
225
|
+
original = original.encode("utf-8", errors="surrogatepass").decode(
|
|
226
|
+
"utf-8", errors="replace"
|
|
227
|
+
)
|
|
228
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
229
|
+
pass
|
|
230
|
+
if snippet not in original:
|
|
231
|
+
return {
|
|
232
|
+
"error": f"Snippet not found in file '{file_path}'.",
|
|
233
|
+
"diff": diff_text,
|
|
234
|
+
}
|
|
235
|
+
modified = original.replace(snippet, "", 1)
|
|
236
|
+
from code_puppy.config import get_diff_context_lines
|
|
237
|
+
|
|
238
|
+
diff_text = "".join(
|
|
239
|
+
difflib.unified_diff(
|
|
240
|
+
original.splitlines(keepends=True),
|
|
241
|
+
modified.splitlines(keepends=True),
|
|
242
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
243
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
244
|
+
n=get_diff_context_lines(),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
248
|
+
f.write(modified)
|
|
249
|
+
return {
|
|
250
|
+
"success": True,
|
|
251
|
+
"path": file_path,
|
|
252
|
+
"message": "Snippet deleted from file.",
|
|
253
|
+
"changed": True,
|
|
254
|
+
"diff": diff_text,
|
|
255
|
+
}
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
return {"error": str(exc), "diff": diff_text}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _replace_in_file(
|
|
261
|
+
context: RunContext | None,
|
|
262
|
+
path: str,
|
|
263
|
+
replacements: List[Dict[str, str]],
|
|
264
|
+
message_group: str | None = None,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Robust replacement engine with explicit edge‑case reporting."""
|
|
267
|
+
file_path = os.path.abspath(path)
|
|
268
|
+
diff_text = ""
|
|
269
|
+
try:
|
|
270
|
+
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
271
|
+
return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
|
|
272
|
+
|
|
273
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
274
|
+
original = f.read()
|
|
275
|
+
|
|
276
|
+
# Sanitize any surrogate characters from reading
|
|
277
|
+
try:
|
|
278
|
+
original = original.encode("utf-8", errors="surrogatepass").decode(
|
|
279
|
+
"utf-8", errors="replace"
|
|
280
|
+
)
|
|
281
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
modified = original
|
|
285
|
+
for rep in replacements:
|
|
286
|
+
old_snippet = rep.get("old_str", "")
|
|
287
|
+
new_snippet = rep.get("new_str", "")
|
|
288
|
+
|
|
289
|
+
if old_snippet and old_snippet in modified:
|
|
290
|
+
modified = modified.replace(old_snippet, new_snippet, 1)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
had_trailing_newline = modified.endswith("\n")
|
|
294
|
+
orig_lines = modified.splitlines()
|
|
295
|
+
loc, score = _find_best_window(orig_lines, old_snippet)
|
|
296
|
+
|
|
297
|
+
if score < 0.95 or loc is None:
|
|
298
|
+
return {
|
|
299
|
+
"error": "No suitable match in file (JW < 0.95)",
|
|
300
|
+
"jw_score": score,
|
|
301
|
+
"received": old_snippet,
|
|
302
|
+
"diff": "",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
start, end = loc
|
|
306
|
+
prefix = "\n".join(orig_lines[:start])
|
|
307
|
+
suffix = "\n".join(orig_lines[end:])
|
|
308
|
+
parts = []
|
|
309
|
+
if prefix:
|
|
310
|
+
parts.append(prefix)
|
|
311
|
+
parts.append(new_snippet.rstrip("\n"))
|
|
312
|
+
if suffix:
|
|
313
|
+
parts.append(suffix)
|
|
314
|
+
modified = "\n".join(parts)
|
|
315
|
+
if had_trailing_newline and not modified.endswith("\n"):
|
|
316
|
+
modified += "\n"
|
|
317
|
+
|
|
318
|
+
if modified == original:
|
|
319
|
+
emit_warning(
|
|
320
|
+
"No changes to apply – proposed content is identical.",
|
|
321
|
+
message_group=message_group,
|
|
322
|
+
)
|
|
323
|
+
return {
|
|
324
|
+
"success": False,
|
|
325
|
+
"path": file_path,
|
|
326
|
+
"message": "No changes to apply.",
|
|
327
|
+
"changed": False,
|
|
328
|
+
"diff": "",
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
from code_puppy.config import get_diff_context_lines
|
|
332
|
+
|
|
333
|
+
diff_text = "".join(
|
|
334
|
+
difflib.unified_diff(
|
|
335
|
+
original.splitlines(keepends=True),
|
|
336
|
+
modified.splitlines(keepends=True),
|
|
337
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
338
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
339
|
+
n=get_diff_context_lines(),
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
343
|
+
f.write(modified)
|
|
344
|
+
return {
|
|
345
|
+
"success": True,
|
|
346
|
+
"path": file_path,
|
|
347
|
+
"message": "Replacements applied.",
|
|
348
|
+
"changed": True,
|
|
349
|
+
"diff": diff_text,
|
|
350
|
+
}
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
return {"error": str(exc), "diff": diff_text}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _write_to_file(
|
|
356
|
+
context: RunContext | None,
|
|
357
|
+
path: str,
|
|
358
|
+
content: str,
|
|
359
|
+
overwrite: bool = False,
|
|
360
|
+
message_group: str | None = None,
|
|
361
|
+
) -> Dict[str, Any]:
|
|
362
|
+
file_path = os.path.abspath(path)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
exists = os.path.exists(file_path)
|
|
366
|
+
if exists and not overwrite:
|
|
367
|
+
return {
|
|
368
|
+
"success": False,
|
|
369
|
+
"path": file_path,
|
|
370
|
+
"message": f"Cowardly refusing to overwrite existing file: {file_path}",
|
|
371
|
+
"changed": False,
|
|
372
|
+
"diff": "",
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
from code_puppy.config import get_diff_context_lines
|
|
376
|
+
|
|
377
|
+
if exists:
|
|
378
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
379
|
+
old_content = f.read()
|
|
380
|
+
try:
|
|
381
|
+
old_content = old_content.encode(
|
|
382
|
+
"utf-8", errors="surrogatepass"
|
|
383
|
+
).decode("utf-8", errors="replace")
|
|
384
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
385
|
+
pass
|
|
386
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
387
|
+
else:
|
|
388
|
+
old_lines = []
|
|
389
|
+
|
|
390
|
+
diff_lines = difflib.unified_diff(
|
|
391
|
+
old_lines,
|
|
392
|
+
content.splitlines(keepends=True),
|
|
393
|
+
fromfile="/dev/null" if not exists else f"a/{os.path.basename(file_path)}",
|
|
394
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
395
|
+
n=get_diff_context_lines(),
|
|
396
|
+
)
|
|
397
|
+
diff_text = "".join(diff_lines)
|
|
398
|
+
|
|
399
|
+
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
|
400
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
401
|
+
f.write(content)
|
|
402
|
+
|
|
403
|
+
action = "overwritten" if exists else "created"
|
|
404
|
+
return {
|
|
405
|
+
"success": True,
|
|
406
|
+
"path": file_path,
|
|
407
|
+
"message": f"File '{file_path}' {action} successfully.",
|
|
408
|
+
"changed": True,
|
|
409
|
+
"diff": diff_text,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
_log_error("Unhandled exception in write_to_file", exc)
|
|
414
|
+
return {"error": str(exc), "diff": ""}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def delete_snippet_from_file(
|
|
418
|
+
context: RunContext, file_path: str, snippet: str, message_group: str | None = None
|
|
419
|
+
) -> Dict[str, Any]:
|
|
420
|
+
# Use the plugin system for permission handling with operation data
|
|
421
|
+
from code_puppy.callbacks import on_file_permission
|
|
422
|
+
|
|
423
|
+
operation_data = {"snippet": snippet}
|
|
424
|
+
permission_results = on_file_permission(
|
|
425
|
+
context, file_path, "delete snippet from", None, message_group, operation_data
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# If any permission handler denies the operation, return cancelled result
|
|
429
|
+
if permission_results and any(
|
|
430
|
+
not result for result in permission_results if result is not None
|
|
431
|
+
):
|
|
432
|
+
return _create_rejection_response(file_path)
|
|
433
|
+
|
|
434
|
+
res = _delete_snippet_from_file(
|
|
435
|
+
context, file_path, snippet, message_group=message_group
|
|
436
|
+
)
|
|
437
|
+
diff = res.get("diff", "")
|
|
438
|
+
if diff:
|
|
439
|
+
_emit_diff_message(file_path, "modify", diff)
|
|
440
|
+
return res
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def write_to_file(
|
|
444
|
+
context: RunContext,
|
|
445
|
+
path: str,
|
|
446
|
+
content: str,
|
|
447
|
+
overwrite: bool,
|
|
448
|
+
message_group: str | None = None,
|
|
449
|
+
) -> Dict[str, Any]:
|
|
450
|
+
# Use the plugin system for permission handling with operation data
|
|
451
|
+
from code_puppy.callbacks import on_file_permission
|
|
452
|
+
|
|
453
|
+
operation_data = {"content": content, "overwrite": overwrite}
|
|
454
|
+
permission_results = on_file_permission(
|
|
455
|
+
context, path, "write", None, message_group, operation_data
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# If any permission handler denies the operation, return cancelled result
|
|
459
|
+
if permission_results and any(
|
|
460
|
+
not result for result in permission_results if result is not None
|
|
461
|
+
):
|
|
462
|
+
return _create_rejection_response(path)
|
|
463
|
+
|
|
464
|
+
res = _write_to_file(
|
|
465
|
+
context, path, content, overwrite=overwrite, message_group=message_group
|
|
466
|
+
)
|
|
467
|
+
diff = res.get("diff", "")
|
|
468
|
+
if diff:
|
|
469
|
+
# Determine operation type based on whether file existed
|
|
470
|
+
operation = "modify" if overwrite else "create"
|
|
471
|
+
_emit_diff_message(path, operation, diff, new_content=content)
|
|
472
|
+
return res
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def replace_in_file(
|
|
476
|
+
context: RunContext,
|
|
477
|
+
path: str,
|
|
478
|
+
replacements: List[Dict[str, str]],
|
|
479
|
+
message_group: str | None = None,
|
|
480
|
+
) -> Dict[str, Any]:
|
|
481
|
+
# Use the plugin system for permission handling with operation data
|
|
482
|
+
from code_puppy.callbacks import on_file_permission
|
|
483
|
+
|
|
484
|
+
operation_data = {"replacements": replacements}
|
|
485
|
+
permission_results = on_file_permission(
|
|
486
|
+
context, path, "replace text in", None, message_group, operation_data
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# If any permission handler denies the operation, return cancelled result
|
|
490
|
+
if permission_results and any(
|
|
491
|
+
not result for result in permission_results if result is not None
|
|
492
|
+
):
|
|
493
|
+
return _create_rejection_response(path)
|
|
494
|
+
|
|
495
|
+
res = _replace_in_file(context, path, replacements, message_group=message_group)
|
|
496
|
+
diff = res.get("diff", "")
|
|
497
|
+
if diff:
|
|
498
|
+
_emit_diff_message(path, "modify", diff)
|
|
499
|
+
return res
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _edit_file(
|
|
503
|
+
context: RunContext, payload: EditFilePayload, group_id: str | None = None
|
|
504
|
+
) -> Dict[str, Any]:
|
|
505
|
+
"""
|
|
506
|
+
High-level implementation of the *edit_file* behaviour.
|
|
507
|
+
|
|
508
|
+
This function performs the heavy-lifting after the lightweight agent-exposed wrapper has
|
|
509
|
+
validated / coerced the inbound *payload* to one of the Pydantic models declared at the top
|
|
510
|
+
of this module.
|
|
511
|
+
|
|
512
|
+
Supported payload variants
|
|
513
|
+
--------------------------
|
|
514
|
+
• **ContentPayload** – full file write / overwrite.
|
|
515
|
+
• **ReplacementsPayload** – targeted in-file replacements.
|
|
516
|
+
• **DeleteSnippetPayload** – remove an exact snippet.
|
|
517
|
+
|
|
518
|
+
The helper decides which low-level routine to delegate to and ensures the resulting unified
|
|
519
|
+
diff is always returned so the caller can pretty-print it for the user.
|
|
520
|
+
|
|
521
|
+
Parameters
|
|
522
|
+
----------
|
|
523
|
+
path : str
|
|
524
|
+
Path to the target file (relative or absolute)
|
|
525
|
+
diff : str
|
|
526
|
+
Either:
|
|
527
|
+
* Raw file content (for file creation)
|
|
528
|
+
* A JSON string with one of the following shapes:
|
|
529
|
+
{"content": "full file contents", "overwrite": true}
|
|
530
|
+
{"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
|
|
531
|
+
{"delete_snippet": "text to remove"}
|
|
532
|
+
|
|
533
|
+
The function auto-detects the payload type and routes to the appropriate internal helper.
|
|
534
|
+
"""
|
|
535
|
+
# Extract file_path from payload
|
|
536
|
+
file_path = os.path.abspath(payload.file_path)
|
|
537
|
+
|
|
538
|
+
# Use provided group_id or generate one if not provided
|
|
539
|
+
if group_id is None:
|
|
540
|
+
group_id = generate_group_id("edit_file", file_path)
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
if isinstance(payload, DeleteSnippetPayload):
|
|
544
|
+
return delete_snippet_from_file(
|
|
545
|
+
context, file_path, payload.delete_snippet, message_group=group_id
|
|
546
|
+
)
|
|
547
|
+
elif isinstance(payload, ReplacementsPayload):
|
|
548
|
+
# Convert Pydantic Replacement models to dict format for legacy compatibility
|
|
549
|
+
replacements_dict = [
|
|
550
|
+
{"old_str": rep.old_str, "new_str": rep.new_str}
|
|
551
|
+
for rep in payload.replacements
|
|
552
|
+
]
|
|
553
|
+
return replace_in_file(
|
|
554
|
+
context, file_path, replacements_dict, message_group=group_id
|
|
555
|
+
)
|
|
556
|
+
elif isinstance(payload, ContentPayload):
|
|
557
|
+
file_exists = os.path.exists(file_path)
|
|
558
|
+
if file_exists and not payload.overwrite:
|
|
559
|
+
return {
|
|
560
|
+
"success": False,
|
|
561
|
+
"path": file_path,
|
|
562
|
+
"message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
|
|
563
|
+
"changed": False,
|
|
564
|
+
}
|
|
565
|
+
return write_to_file(
|
|
566
|
+
context,
|
|
567
|
+
file_path,
|
|
568
|
+
payload.content,
|
|
569
|
+
payload.overwrite,
|
|
570
|
+
message_group=group_id,
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
return {
|
|
574
|
+
"success": False,
|
|
575
|
+
"path": file_path,
|
|
576
|
+
"message": f"Unknown payload type: {type(payload)}",
|
|
577
|
+
"changed": False,
|
|
578
|
+
}
|
|
579
|
+
except Exception as e:
|
|
580
|
+
emit_error(
|
|
581
|
+
"Unable to route file modification tool call to sub-tool",
|
|
582
|
+
message_group=group_id,
|
|
583
|
+
)
|
|
584
|
+
emit_error(str(e), message_group=group_id)
|
|
585
|
+
return {
|
|
586
|
+
"success": False,
|
|
587
|
+
"path": file_path,
|
|
588
|
+
"message": f"Something went wrong in file editing: {str(e)}",
|
|
589
|
+
"changed": False,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _delete_file(
|
|
594
|
+
context: RunContext, file_path: str, message_group: str | None = None
|
|
595
|
+
) -> Dict[str, Any]:
|
|
596
|
+
file_path = os.path.abspath(file_path)
|
|
597
|
+
|
|
598
|
+
# Use the plugin system for permission handling with operation data
|
|
599
|
+
from code_puppy.callbacks import on_file_permission
|
|
600
|
+
|
|
601
|
+
operation_data = {} # No additional data needed for delete operations
|
|
602
|
+
permission_results = on_file_permission(
|
|
603
|
+
context, file_path, "delete", None, message_group, operation_data
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# If any permission handler denies the operation, return cancelled result
|
|
607
|
+
if permission_results and any(
|
|
608
|
+
not result for result in permission_results if result is not None
|
|
609
|
+
):
|
|
610
|
+
return _create_rejection_response(file_path)
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
614
|
+
res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
|
|
615
|
+
else:
|
|
616
|
+
with open(file_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
|
617
|
+
original = f.read()
|
|
618
|
+
# Sanitize any surrogate characters from reading
|
|
619
|
+
try:
|
|
620
|
+
original = original.encode("utf-8", errors="surrogatepass").decode(
|
|
621
|
+
"utf-8", errors="replace"
|
|
622
|
+
)
|
|
623
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
624
|
+
pass
|
|
625
|
+
from code_puppy.config import get_diff_context_lines
|
|
626
|
+
|
|
627
|
+
diff_text = "".join(
|
|
628
|
+
difflib.unified_diff(
|
|
629
|
+
original.splitlines(keepends=True),
|
|
630
|
+
[],
|
|
631
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
632
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
633
|
+
n=get_diff_context_lines(),
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
os.remove(file_path)
|
|
637
|
+
res = {
|
|
638
|
+
"success": True,
|
|
639
|
+
"path": file_path,
|
|
640
|
+
"message": f"File '{file_path}' deleted successfully.",
|
|
641
|
+
"changed": True,
|
|
642
|
+
"diff": diff_text,
|
|
643
|
+
}
|
|
644
|
+
except Exception as exc:
|
|
645
|
+
_log_error("Unhandled exception in delete_file", exc)
|
|
646
|
+
res = {"error": str(exc), "diff": ""}
|
|
647
|
+
|
|
648
|
+
diff = res.get("diff", "")
|
|
649
|
+
if diff:
|
|
650
|
+
_emit_diff_message(file_path, "delete", diff)
|
|
651
|
+
return res
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def register_edit_file(agent):
|
|
655
|
+
"""Register only the edit_file tool.
|
|
656
|
+
|
|
657
|
+
.. deprecated::
|
|
658
|
+
Use register_create_file, register_replace_in_file, and
|
|
659
|
+
register_delete_snippet instead. edit_file is auto-expanded
|
|
660
|
+
to these three tools when listed in an agent's tool config.
|
|
661
|
+
"""
|
|
662
|
+
warnings.warn(
|
|
663
|
+
"register_edit_file() is deprecated. Use register_create_file, "
|
|
664
|
+
"register_replace_in_file, and register_delete_snippet instead. "
|
|
665
|
+
"Agents listing 'edit_file' in their tools config will automatically "
|
|
666
|
+
"get the three new tools via TOOL_EXPANSIONS.",
|
|
667
|
+
DeprecationWarning,
|
|
668
|
+
stacklevel=2,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
@agent.tool
|
|
672
|
+
def edit_file(
|
|
673
|
+
context: RunContext,
|
|
674
|
+
payload: EditFilePayload | str = "",
|
|
675
|
+
) -> Dict[str, Any]:
|
|
676
|
+
"""Comprehensive file editing tool supporting multiple modification strategies.
|
|
677
|
+
|
|
678
|
+
Supports: ContentPayload (create/overwrite), ReplacementsPayload (targeted edits),
|
|
679
|
+
DeleteSnippetPayload (remove text). Prefer ReplacementsPayload for existing files.
|
|
680
|
+
"""
|
|
681
|
+
# Handle string payload parsing (for models that send JSON strings)
|
|
682
|
+
|
|
683
|
+
parse_error_message = "Payload must contain one of: 'content', 'replacements', or 'delete_snippet' with a 'file_path'."
|
|
684
|
+
|
|
685
|
+
if isinstance(payload, str):
|
|
686
|
+
try:
|
|
687
|
+
# Fallback for weird models that just can't help but send json strings...
|
|
688
|
+
payload_dict = json.loads(json_repair.repair_json(payload))
|
|
689
|
+
if "replacements" in payload_dict:
|
|
690
|
+
payload = ReplacementsPayload(**payload_dict)
|
|
691
|
+
elif "delete_snippet" in payload_dict:
|
|
692
|
+
payload = DeleteSnippetPayload(**payload_dict)
|
|
693
|
+
elif "content" in payload_dict:
|
|
694
|
+
payload = ContentPayload(**payload_dict)
|
|
695
|
+
else:
|
|
696
|
+
file_path = "Unknown"
|
|
697
|
+
if "file_path" in payload_dict:
|
|
698
|
+
file_path = payload_dict["file_path"]
|
|
699
|
+
return {
|
|
700
|
+
"success": False,
|
|
701
|
+
"path": file_path,
|
|
702
|
+
"message": parse_error_message,
|
|
703
|
+
"changed": False,
|
|
704
|
+
}
|
|
705
|
+
except Exception as e:
|
|
706
|
+
return {
|
|
707
|
+
"success": False,
|
|
708
|
+
"path": "Not retrievable in Payload",
|
|
709
|
+
"message": f"edit_file call failed: {str(e)} - {parse_error_message}",
|
|
710
|
+
"changed": False,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
# Call _edit_file which will extract file_path from payload and handle group_id generation
|
|
714
|
+
result = _edit_file(context, payload)
|
|
715
|
+
if "diff" in result:
|
|
716
|
+
del result["diff"]
|
|
717
|
+
|
|
718
|
+
# Trigger edit_file callbacks to enhance the result with rejection details
|
|
719
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
720
|
+
if enhanced_results:
|
|
721
|
+
# Use the first non-None enhanced result
|
|
722
|
+
for enhanced_result in enhanced_results:
|
|
723
|
+
if enhanced_result is not None:
|
|
724
|
+
result = enhanced_result
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
return result
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def register_delete_file(agent):
|
|
731
|
+
"""Register only the delete_file tool."""
|
|
732
|
+
|
|
733
|
+
@agent.tool
|
|
734
|
+
def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
|
|
735
|
+
"""Safely delete files with comprehensive logging and diff generation.
|
|
736
|
+
|
|
737
|
+
Shows exactly what content was removed via diff output.
|
|
738
|
+
"""
|
|
739
|
+
# Generate group_id for delete_file tool execution
|
|
740
|
+
group_id = generate_group_id("delete_file", file_path)
|
|
741
|
+
result = _delete_file(context, file_path, message_group=group_id)
|
|
742
|
+
if "diff" in result:
|
|
743
|
+
del result["diff"]
|
|
744
|
+
|
|
745
|
+
# Trigger delete_file callbacks to enhance the result with rejection details
|
|
746
|
+
enhanced_results = on_delete_file(context, result, file_path)
|
|
747
|
+
if enhanced_results:
|
|
748
|
+
# Use the first non-None enhanced result
|
|
749
|
+
for enhanced_result in enhanced_results:
|
|
750
|
+
if enhanced_result is not None:
|
|
751
|
+
result = enhanced_result
|
|
752
|
+
break
|
|
753
|
+
|
|
754
|
+
return result
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# Module-level aliases captured before registration functions are defined.
|
|
758
|
+
# Inside register_replace_in_file, the @agent.tool decorator creates a local
|
|
759
|
+
# function named 'replace_in_file' which shadows the module-level helper of the
|
|
760
|
+
# same name for the entire enclosing scope (Python scoping rules). We capture
|
|
761
|
+
# a reference here so the registration function can call the helper.
|
|
762
|
+
_replace_in_file_helper = replace_in_file
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def register_create_file(agent):
|
|
766
|
+
"""Register the create_file tool for creating or overwriting files."""
|
|
767
|
+
# Local alias to avoid shadowing by the @agent.tool decorated function below
|
|
768
|
+
_write_file = write_to_file
|
|
769
|
+
|
|
770
|
+
@agent.tool
|
|
771
|
+
def create_file(
|
|
772
|
+
context: RunContext,
|
|
773
|
+
file_path: str = "",
|
|
774
|
+
content: str = "",
|
|
775
|
+
overwrite: bool = False,
|
|
776
|
+
) -> Dict[str, Any]:
|
|
777
|
+
"""Create a new file or overwrite an existing one with the provided content."""
|
|
778
|
+
group_id = generate_group_id("create_file", file_path)
|
|
779
|
+
result = _write_file(
|
|
780
|
+
context, file_path, content, overwrite, message_group=group_id
|
|
781
|
+
)
|
|
782
|
+
if "diff" in result:
|
|
783
|
+
del result["diff"]
|
|
784
|
+
|
|
785
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
786
|
+
payload = ContentPayload(
|
|
787
|
+
file_path=file_path, content=content, overwrite=overwrite
|
|
788
|
+
)
|
|
789
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
790
|
+
if enhanced_results:
|
|
791
|
+
for enhanced_result in enhanced_results:
|
|
792
|
+
if enhanced_result is not None:
|
|
793
|
+
result = enhanced_result
|
|
794
|
+
break
|
|
795
|
+
|
|
796
|
+
return result
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# Inline JSON schema for Replacement objects — avoids $defs/$ref that many
|
|
800
|
+
# LLM providers misinterpret, causing frequent validation errors and
|
|
801
|
+
# fallback to full-file rewrites. See _sanitize_schema_for_gemini and
|
|
802
|
+
# _inline_refs in the antigravity plugin for prior art.
|
|
803
|
+
_REPLACEMENT_ITEM_SCHEMA = {
|
|
804
|
+
"type": "object",
|
|
805
|
+
"properties": {
|
|
806
|
+
"old_str": {"type": "string"},
|
|
807
|
+
"new_str": {"type": "string"},
|
|
808
|
+
},
|
|
809
|
+
"required": ["old_str", "new_str"],
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
# Type alias used by the tool signature. The Annotated + WithJsonSchema
|
|
813
|
+
# tells Pydantic to emit _REPLACEMENT_ITEM_SCHEMA inline instead of a $ref.
|
|
814
|
+
InlineReplacement = Annotated[Dict[str, str], WithJsonSchema(_REPLACEMENT_ITEM_SCHEMA)]
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def register_replace_in_file(agent):
|
|
818
|
+
"""Register the replace_in_file tool for targeted text replacements."""
|
|
819
|
+
|
|
820
|
+
@agent.tool
|
|
821
|
+
def replace_in_file(
|
|
822
|
+
context: RunContext,
|
|
823
|
+
file_path: str = "",
|
|
824
|
+
replacements: List[InlineReplacement] = [],
|
|
825
|
+
) -> Dict[str, Any]:
|
|
826
|
+
"""Apply targeted text replacements to an existing file.
|
|
827
|
+
|
|
828
|
+
Each replacement specifies an old_str to find and a new_str to replace it with.
|
|
829
|
+
Replacements are applied sequentially. Prefer this over full file rewrites.
|
|
830
|
+
"""
|
|
831
|
+
group_id = generate_group_id("replace_in_file", file_path)
|
|
832
|
+
# replacements arrive as plain dicts — pass them straight through
|
|
833
|
+
replacements_dict = [
|
|
834
|
+
{"old_str": r["old_str"], "new_str": r["new_str"]} for r in replacements
|
|
835
|
+
]
|
|
836
|
+
result = _replace_in_file_helper(
|
|
837
|
+
context, file_path, replacements_dict, message_group=group_id
|
|
838
|
+
)
|
|
839
|
+
if "diff" in result:
|
|
840
|
+
del result["diff"]
|
|
841
|
+
|
|
842
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
843
|
+
payload = ReplacementsPayload(
|
|
844
|
+
file_path=file_path,
|
|
845
|
+
replacements=[
|
|
846
|
+
Replacement(old_str=r["old_str"], new_str=r["new_str"])
|
|
847
|
+
for r in replacements
|
|
848
|
+
],
|
|
849
|
+
)
|
|
850
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
851
|
+
if enhanced_results:
|
|
852
|
+
for enhanced_result in enhanced_results:
|
|
853
|
+
if enhanced_result is not None:
|
|
854
|
+
result = enhanced_result
|
|
855
|
+
break
|
|
856
|
+
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def register_delete_snippet(agent):
|
|
861
|
+
"""Register the delete_snippet tool for removing text from files."""
|
|
862
|
+
# Local alias to avoid shadowing by the @agent.tool decorated function below
|
|
863
|
+
_remove_snippet = delete_snippet_from_file
|
|
864
|
+
|
|
865
|
+
@agent.tool
|
|
866
|
+
def delete_snippet(
|
|
867
|
+
context: RunContext,
|
|
868
|
+
file_path: str = "",
|
|
869
|
+
snippet: str = "",
|
|
870
|
+
) -> Dict[str, Any]:
|
|
871
|
+
"""Remove the first occurrence of a text snippet from a file."""
|
|
872
|
+
group_id = generate_group_id("delete_snippet", file_path)
|
|
873
|
+
result = _remove_snippet(context, file_path, snippet, message_group=group_id)
|
|
874
|
+
if "diff" in result:
|
|
875
|
+
del result["diff"]
|
|
876
|
+
|
|
877
|
+
# Trigger legacy edit_file callbacks for backward compatibility
|
|
878
|
+
payload = DeleteSnippetPayload(file_path=file_path, delete_snippet=snippet)
|
|
879
|
+
enhanced_results = on_edit_file(context, result, payload)
|
|
880
|
+
if enhanced_results:
|
|
881
|
+
for enhanced_result in enhanced_results:
|
|
882
|
+
if enhanced_result is not None:
|
|
883
|
+
result = enhanced_result
|
|
884
|
+
break
|
|
885
|
+
|
|
886
|
+
return result
|