ripperdoc 0.2.6__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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Multi-edit tool.
|
|
2
|
+
|
|
3
|
+
Allows performing multiple exact string replacements in a single file atomically.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import difflib
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncGenerator, Optional, List
|
|
10
|
+
from textwrap import dedent
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from ripperdoc.core.tool import (
|
|
14
|
+
Tool,
|
|
15
|
+
ToolUseContext,
|
|
16
|
+
ToolResult,
|
|
17
|
+
ToolOutput,
|
|
18
|
+
ToolUseExample,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
)
|
|
21
|
+
from ripperdoc.utils.log import get_logger
|
|
22
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
23
|
+
|
|
24
|
+
logger = get_logger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_ACTION = "Edit"
|
|
28
|
+
TOOL_NAME_READ = "View"
|
|
29
|
+
NOTEBOOK_EDIT_TOOL_NAME = "NotebookEdit"
|
|
30
|
+
|
|
31
|
+
MULTI_EDIT_DESCRIPTION = dedent(
|
|
32
|
+
f"""\
|
|
33
|
+
This is a tool for making multiple edits to a single file in one operation. It is built on top of the {DEFAULT_ACTION} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {DEFAULT_ACTION} tool when you need to make multiple edits to the same file.
|
|
34
|
+
|
|
35
|
+
Before using this tool:
|
|
36
|
+
|
|
37
|
+
1. Use the {TOOL_NAME_READ} tool to understand the file's contents and context
|
|
38
|
+
2. Verify the directory path is correct
|
|
39
|
+
|
|
40
|
+
To make multiple file edits, provide the following:
|
|
41
|
+
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
|
42
|
+
2. edits: An array of edit operations to perform, where each edit contains:
|
|
43
|
+
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
|
44
|
+
- new_string: The edited text to replace the old_string
|
|
45
|
+
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
|
|
46
|
+
|
|
47
|
+
IMPORTANT:
|
|
48
|
+
- All edits are applied in sequence, in the order they are provided
|
|
49
|
+
- Each edit operates on the result of the previous edit
|
|
50
|
+
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
|
51
|
+
- This tool is ideal when you need to make several changes to different parts of the same file
|
|
52
|
+
- For Jupyter notebooks (.ipynb files), use the {NOTEBOOK_EDIT_TOOL_NAME} instead
|
|
53
|
+
|
|
54
|
+
CRITICAL REQUIREMENTS:
|
|
55
|
+
1. All edits follow the same requirements as the single Edit tool
|
|
56
|
+
2. The edits are atomic - either all succeed or none are applied
|
|
57
|
+
3. Plan your edits carefully to avoid conflicts between sequential operations
|
|
58
|
+
|
|
59
|
+
WARNING:
|
|
60
|
+
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
|
|
61
|
+
- The tool will fail if edits.old_string and edits.new_string are the same
|
|
62
|
+
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
|
|
63
|
+
|
|
64
|
+
When making edits:
|
|
65
|
+
- Ensure all edits result in idiomatic, correct code
|
|
66
|
+
- Do not leave the code in a broken state
|
|
67
|
+
- Always use absolute file paths (starting with /)
|
|
68
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
69
|
+
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
|
70
|
+
|
|
71
|
+
If you want to create a new file, use:
|
|
72
|
+
- A new file path, including dir name if needed
|
|
73
|
+
- First edit: empty old_string and the new file's contents as new_string
|
|
74
|
+
- Subsequent edits: normal edit operations on the created content"""
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EditOperation(BaseModel):
|
|
79
|
+
"""Single edit operation."""
|
|
80
|
+
|
|
81
|
+
old_string: str = Field(description="The text to replace (must match exactly)")
|
|
82
|
+
new_string: str = Field(description="The text to replace it with")
|
|
83
|
+
replace_all: bool = Field(
|
|
84
|
+
default=False,
|
|
85
|
+
description="Replace all occurrences of old_string (default: false, only first)",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MultiEditToolInput(BaseModel):
|
|
90
|
+
"""Input schema for MultiEditTool."""
|
|
91
|
+
|
|
92
|
+
file_path: str = Field(description="Absolute path to the file to edit")
|
|
93
|
+
edits: List[EditOperation] = Field(
|
|
94
|
+
description="Array of edit operations to apply sequentially",
|
|
95
|
+
min_length=1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MultiEditToolOutput(BaseModel):
|
|
100
|
+
"""Output from multi-edit."""
|
|
101
|
+
|
|
102
|
+
file_path: str
|
|
103
|
+
replacements_made: int
|
|
104
|
+
success: bool
|
|
105
|
+
message: str
|
|
106
|
+
additions: int = 0
|
|
107
|
+
deletions: int = 0
|
|
108
|
+
diff_lines: list[str] = []
|
|
109
|
+
diff_with_line_numbers: list[str] = []
|
|
110
|
+
applied_edits: List[EditOperation] = []
|
|
111
|
+
created: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
115
|
+
"""Tool for applying multiple edits to a file atomically."""
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def name(self) -> str:
|
|
119
|
+
return "MultiEdit"
|
|
120
|
+
|
|
121
|
+
async def description(self) -> str:
|
|
122
|
+
return MULTI_EDIT_DESCRIPTION
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def input_schema(self) -> type[MultiEditToolInput]:
|
|
126
|
+
return MultiEditToolInput
|
|
127
|
+
|
|
128
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
129
|
+
return [
|
|
130
|
+
ToolUseExample(
|
|
131
|
+
description="Apply multiple replacements in one pass",
|
|
132
|
+
example={
|
|
133
|
+
"file_path": "/repo/src/app.py",
|
|
134
|
+
"edits": [
|
|
135
|
+
{"old_string": "DEBUG = True", "new_string": "DEBUG = False"},
|
|
136
|
+
{"old_string": "old_fn(", "new_string": "new_fn("},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
ToolUseExample(
|
|
141
|
+
description="Create a new file then adjust content",
|
|
142
|
+
example={
|
|
143
|
+
"file_path": "/repo/docs/notes.txt",
|
|
144
|
+
"edits": [
|
|
145
|
+
{"old_string": "", "new_string": "Line one\nLine two\n"},
|
|
146
|
+
{"old_string": "Line two", "new_string": "Second line"},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
153
|
+
return MULTI_EDIT_DESCRIPTION
|
|
154
|
+
|
|
155
|
+
def is_read_only(self) -> bool:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def is_concurrency_safe(self) -> bool:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def needs_permissions(self, input_data: Optional[MultiEditToolInput] = None) -> bool:
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
async def validate_input(
|
|
165
|
+
self,
|
|
166
|
+
input_data: MultiEditToolInput,
|
|
167
|
+
context: Optional[ToolUseContext] = None,
|
|
168
|
+
) -> ValidationResult:
|
|
169
|
+
path = Path(input_data.file_path).expanduser()
|
|
170
|
+
if not path.is_absolute():
|
|
171
|
+
path = Path.cwd() / path
|
|
172
|
+
resolved_path = str(path.resolve())
|
|
173
|
+
|
|
174
|
+
# Ensure edits differ.
|
|
175
|
+
for edit in input_data.edits:
|
|
176
|
+
if edit.old_string == edit.new_string:
|
|
177
|
+
return ValidationResult(
|
|
178
|
+
result=False,
|
|
179
|
+
message="old_string and new_string must be different",
|
|
180
|
+
error_code=1,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# If the file exists, ensure it is not a directory.
|
|
184
|
+
if path.exists() and path.is_dir():
|
|
185
|
+
return ValidationResult(
|
|
186
|
+
result=False,
|
|
187
|
+
message=f"Path is a directory, not a file: {path}",
|
|
188
|
+
error_code=2,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Check if this is a file creation (first edit has empty old_string)
|
|
192
|
+
is_creation = (
|
|
193
|
+
not path.exists()
|
|
194
|
+
and len(input_data.edits) > 0
|
|
195
|
+
and input_data.edits[0].old_string == ""
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# If file exists, check if it has been read before editing
|
|
199
|
+
if path.exists() and not is_creation:
|
|
200
|
+
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
201
|
+
file_snapshot = file_state_cache.get(resolved_path)
|
|
202
|
+
|
|
203
|
+
if not file_snapshot:
|
|
204
|
+
return ValidationResult(
|
|
205
|
+
result=False,
|
|
206
|
+
message="File has not been read yet. Read it first before editing.",
|
|
207
|
+
error_code=3,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Check if file has been modified since it was read
|
|
211
|
+
try:
|
|
212
|
+
current_mtime = os.path.getmtime(resolved_path)
|
|
213
|
+
if current_mtime > file_snapshot.timestamp:
|
|
214
|
+
return ValidationResult(
|
|
215
|
+
result=False,
|
|
216
|
+
message="File has been modified since read, either by the user or by a linter. "
|
|
217
|
+
"Read it again before attempting to edit it.",
|
|
218
|
+
error_code=4,
|
|
219
|
+
)
|
|
220
|
+
except OSError:
|
|
221
|
+
pass # File mtime check failed, proceed anyway
|
|
222
|
+
|
|
223
|
+
return ValidationResult(result=True)
|
|
224
|
+
|
|
225
|
+
def render_result_for_assistant(self, output: MultiEditToolOutput) -> str:
|
|
226
|
+
return output.message
|
|
227
|
+
|
|
228
|
+
def render_tool_use_message(
|
|
229
|
+
self,
|
|
230
|
+
input_data: MultiEditToolInput,
|
|
231
|
+
verbose: bool = False,
|
|
232
|
+
) -> str:
|
|
233
|
+
return f"Multi-editing: {input_data.file_path} ({len(input_data.edits)} edits)"
|
|
234
|
+
|
|
235
|
+
def _apply_edits(self, content: str, edits: List[EditOperation]) -> tuple[str, int]:
|
|
236
|
+
"""Apply edits in-memory. Raises ValueError on failure."""
|
|
237
|
+
current = content
|
|
238
|
+
total_replacements = 0
|
|
239
|
+
|
|
240
|
+
for edit in edits:
|
|
241
|
+
# Creation workflow: old_string empty means write provided content when file is empty.
|
|
242
|
+
if edit.old_string == "":
|
|
243
|
+
if current != "":
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"old_string was empty but the file already has content; "
|
|
246
|
+
"use replace_all=true with an explicit old_string instead."
|
|
247
|
+
)
|
|
248
|
+
current = edit.new_string
|
|
249
|
+
total_replacements += 1 if edit.new_string else 0
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
occurrences = current.count(edit.old_string)
|
|
253
|
+
if occurrences == 0:
|
|
254
|
+
raise ValueError(f"String not found: {edit.old_string!r}")
|
|
255
|
+
|
|
256
|
+
if not edit.replace_all and occurrences > 1:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"String appears {occurrences} times. "
|
|
259
|
+
"Provide a unique string or set replace_all=true."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if edit.replace_all:
|
|
263
|
+
current = current.replace(edit.old_string, edit.new_string)
|
|
264
|
+
total_replacements += occurrences
|
|
265
|
+
else:
|
|
266
|
+
current = current.replace(edit.old_string, edit.new_string, 1)
|
|
267
|
+
total_replacements += 1
|
|
268
|
+
|
|
269
|
+
return current, total_replacements
|
|
270
|
+
|
|
271
|
+
def _build_diff(
|
|
272
|
+
self, original: str, updated: str, file_path: str
|
|
273
|
+
) -> tuple[list[str], list[str], int, int]:
|
|
274
|
+
old_lines = original.splitlines(keepends=True)
|
|
275
|
+
new_lines = updated.splitlines(keepends=True)
|
|
276
|
+
|
|
277
|
+
diff = list(
|
|
278
|
+
difflib.unified_diff(
|
|
279
|
+
old_lines,
|
|
280
|
+
new_lines,
|
|
281
|
+
fromfile=file_path,
|
|
282
|
+
tofile=file_path,
|
|
283
|
+
lineterm="",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
additions = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
|
|
288
|
+
deletions = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
|
|
289
|
+
|
|
290
|
+
diff_lines = [line for line in diff[3:]] # skip headers
|
|
291
|
+
|
|
292
|
+
diff_with_line_numbers: list[str] = []
|
|
293
|
+
old_line_num: Optional[int] = None
|
|
294
|
+
new_line_num: Optional[int] = None
|
|
295
|
+
|
|
296
|
+
for line in diff[3:]:
|
|
297
|
+
if line.startswith("@@"):
|
|
298
|
+
import re
|
|
299
|
+
|
|
300
|
+
match = re.search(r"@@ -(\d+),(\d+) \+(\d+),(\d+) @@", line)
|
|
301
|
+
if match:
|
|
302
|
+
old_line_num = int(match.group(1))
|
|
303
|
+
new_line_num = int(match.group(3))
|
|
304
|
+
diff_with_line_numbers.append(f" [dim]{line}[/dim]")
|
|
305
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
306
|
+
if new_line_num is not None:
|
|
307
|
+
diff_with_line_numbers.append(
|
|
308
|
+
f" [green]{new_line_num:6d} + {line[1:]}[/green]"
|
|
309
|
+
)
|
|
310
|
+
new_line_num += 1
|
|
311
|
+
else:
|
|
312
|
+
diff_with_line_numbers.append(f" [green]{line}[/green]")
|
|
313
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
314
|
+
if old_line_num is not None:
|
|
315
|
+
diff_with_line_numbers.append(
|
|
316
|
+
f" [red]{old_line_num:6d} - {line[1:]}[/red]"
|
|
317
|
+
)
|
|
318
|
+
old_line_num += 1
|
|
319
|
+
else:
|
|
320
|
+
diff_with_line_numbers.append(f" [red]{line}[/red]")
|
|
321
|
+
elif line.strip():
|
|
322
|
+
if old_line_num is not None and new_line_num is not None:
|
|
323
|
+
diff_with_line_numbers.append(
|
|
324
|
+
f" {old_line_num:6d} {new_line_num:6d} {line}"
|
|
325
|
+
)
|
|
326
|
+
old_line_num += 1
|
|
327
|
+
new_line_num += 1
|
|
328
|
+
else:
|
|
329
|
+
diff_with_line_numbers.append(f" {line}")
|
|
330
|
+
|
|
331
|
+
return diff_lines, diff_with_line_numbers, additions, deletions
|
|
332
|
+
|
|
333
|
+
async def call(
|
|
334
|
+
self,
|
|
335
|
+
input_data: MultiEditToolInput,
|
|
336
|
+
context: ToolUseContext,
|
|
337
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
338
|
+
"""Apply multiple edits atomically."""
|
|
339
|
+
file_path = Path(input_data.file_path).expanduser()
|
|
340
|
+
if not file_path.is_absolute():
|
|
341
|
+
file_path = Path.cwd() / file_path
|
|
342
|
+
file_path = file_path.resolve()
|
|
343
|
+
|
|
344
|
+
existing = file_path.exists()
|
|
345
|
+
original_content = ""
|
|
346
|
+
try:
|
|
347
|
+
if existing:
|
|
348
|
+
original_content = file_path.read_text(encoding="utf-8")
|
|
349
|
+
except (OSError, IOError, PermissionError) as exc:
|
|
350
|
+
# pragma: no cover - unlikely permission issue
|
|
351
|
+
logger.warning(
|
|
352
|
+
"[multi_edit_tool] Error reading file before edits: %s: %s",
|
|
353
|
+
type(exc).__name__, exc,
|
|
354
|
+
extra={"file_path": str(file_path)},
|
|
355
|
+
)
|
|
356
|
+
output = MultiEditToolOutput(
|
|
357
|
+
file_path=str(file_path),
|
|
358
|
+
replacements_made=0,
|
|
359
|
+
success=False,
|
|
360
|
+
message=f"Error reading file: {exc}",
|
|
361
|
+
)
|
|
362
|
+
yield ToolResult(
|
|
363
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
applied = input_data.edits
|
|
368
|
+
try:
|
|
369
|
+
updated_content, total_replacements = self._apply_edits(original_content, applied)
|
|
370
|
+
except ValueError as exc:
|
|
371
|
+
output = MultiEditToolOutput(
|
|
372
|
+
file_path=str(file_path),
|
|
373
|
+
replacements_made=0,
|
|
374
|
+
success=False,
|
|
375
|
+
message=str(exc),
|
|
376
|
+
applied_edits=applied,
|
|
377
|
+
created=not existing and original_content == "",
|
|
378
|
+
)
|
|
379
|
+
yield ToolResult(
|
|
380
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
if updated_content == original_content:
|
|
385
|
+
output = MultiEditToolOutput(
|
|
386
|
+
file_path=str(file_path),
|
|
387
|
+
replacements_made=0,
|
|
388
|
+
success=False,
|
|
389
|
+
message="Edits produced no changes.",
|
|
390
|
+
applied_edits=applied,
|
|
391
|
+
created=not existing and original_content == "",
|
|
392
|
+
)
|
|
393
|
+
yield ToolResult(
|
|
394
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# Ensure parent exists (validated earlier) and write the file.
|
|
399
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
try:
|
|
401
|
+
file_path.write_text(updated_content, encoding="utf-8")
|
|
402
|
+
try:
|
|
403
|
+
record_snapshot(
|
|
404
|
+
str(file_path),
|
|
405
|
+
updated_content,
|
|
406
|
+
getattr(context, "file_state_cache", {}),
|
|
407
|
+
)
|
|
408
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
409
|
+
logger.warning(
|
|
410
|
+
"[multi_edit_tool] Failed to record file snapshot: %s: %s",
|
|
411
|
+
type(exc).__name__, exc,
|
|
412
|
+
extra={"file_path": str(file_path)},
|
|
413
|
+
)
|
|
414
|
+
except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
|
|
415
|
+
logger.warning(
|
|
416
|
+
"[multi_edit_tool] Error writing edited file: %s: %s",
|
|
417
|
+
type(exc).__name__, exc,
|
|
418
|
+
extra={"file_path": str(file_path)},
|
|
419
|
+
)
|
|
420
|
+
output = MultiEditToolOutput(
|
|
421
|
+
file_path=str(file_path),
|
|
422
|
+
replacements_made=0,
|
|
423
|
+
success=False,
|
|
424
|
+
message=f"Error writing file: {exc}",
|
|
425
|
+
applied_edits=applied,
|
|
426
|
+
created=not existing and original_content == "",
|
|
427
|
+
)
|
|
428
|
+
yield ToolResult(
|
|
429
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
|
|
434
|
+
original_content, updated_content, str(file_path)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
output = MultiEditToolOutput(
|
|
438
|
+
file_path=str(file_path),
|
|
439
|
+
replacements_made=total_replacements,
|
|
440
|
+
success=True,
|
|
441
|
+
message=(
|
|
442
|
+
f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
|
|
443
|
+
f"to {file_path}"
|
|
444
|
+
),
|
|
445
|
+
additions=additions,
|
|
446
|
+
deletions=deletions,
|
|
447
|
+
diff_lines=diff_lines,
|
|
448
|
+
diff_with_line_numbers=diff_with_line_numbers,
|
|
449
|
+
applied_edits=applied,
|
|
450
|
+
created=not existing and original_content == "",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
yield ToolResult(
|
|
454
|
+
data=output,
|
|
455
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
456
|
+
)
|