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.
Files changed (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. 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