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.
Files changed (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,153 @@
1
+ """Exit plan mode tool for presenting implementation plans.
2
+
3
+ This tool allows the AI to exit plan mode and present an implementation
4
+ plan to the user for approval before starting to code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from textwrap import dedent
10
+ from typing import AsyncGenerator, Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ripperdoc.core.tool import (
15
+ Tool,
16
+ ToolOutput,
17
+ ToolResult,
18
+ ToolUseContext,
19
+ ValidationResult,
20
+ )
21
+ from ripperdoc.utils.log import get_logger
22
+
23
+ logger = get_logger()
24
+
25
+ TOOL_NAME = "ExitPlanMode"
26
+
27
+ EXIT_PLAN_MODE_PROMPT = dedent(
28
+ """\
29
+ Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.
30
+
31
+ ## How This Tool Works
32
+ - You should have already written your plan to the plan file specified in the plan mode system message
33
+ - This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote
34
+ - This tool simply signals that you're done planning and ready for the user to review and approve
35
+ - The user will see the contents of your plan file when they review it
36
+
37
+ ## When to Use This Tool
38
+ IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool.
39
+
40
+ ## Handling Ambiguity in Plans
41
+ Before using this tool, ensure your plan is clear and unambiguous. If there are multiple valid approaches or unclear requirements:
42
+ 1. Use the AskUserQuestion tool to clarify with the user
43
+ 2. Ask about specific implementation choices (e.g., architectural patterns, which library to use)
44
+ 3. Clarify any assumptions that could affect the implementation
45
+ 4. Edit your plan file to incorporate user feedback
46
+ 5. Only proceed with ExitPlanMode after resolving ambiguities and updating the plan file
47
+
48
+ ## Examples
49
+
50
+ 1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
51
+ 2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
52
+ 3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach.
53
+ """
54
+ )
55
+
56
+
57
+ class ExitPlanModeToolInput(BaseModel):
58
+ """Input for the ExitPlanMode tool."""
59
+
60
+ plan: str = Field(
61
+ description="The plan you came up with, that you want to run by the user for approval. "
62
+ "Supports markdown. The plan should be pretty concise."
63
+ )
64
+
65
+
66
+ class ExitPlanModeToolOutput(BaseModel):
67
+ """Output from the ExitPlanMode tool."""
68
+
69
+ plan: str
70
+ is_agent: bool = False
71
+
72
+
73
+ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
74
+ """Tool for exiting plan mode and presenting a plan for approval."""
75
+
76
+ @property
77
+ def name(self) -> str:
78
+ return TOOL_NAME
79
+
80
+ async def description(self) -> str:
81
+ return "Prompts the user to exit plan mode and start coding"
82
+
83
+ @property
84
+ def input_schema(self) -> type[ExitPlanModeToolInput]:
85
+ return ExitPlanModeToolInput
86
+
87
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
88
+ return EXIT_PLAN_MODE_PROMPT
89
+
90
+ def user_facing_name(self) -> str:
91
+ return "Exit plan mode"
92
+
93
+ def is_read_only(self) -> bool:
94
+ return True
95
+
96
+ def is_concurrency_safe(self) -> bool:
97
+ return True
98
+
99
+ def needs_permissions(
100
+ self,
101
+ input_data: Optional[ExitPlanModeToolInput] = None, # noqa: ARG002
102
+ ) -> bool:
103
+ return True
104
+
105
+ async def validate_input(
106
+ self,
107
+ input_data: ExitPlanModeToolInput,
108
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
109
+ ) -> ValidationResult:
110
+ """Validate that plan is not empty."""
111
+ if not input_data.plan or not input_data.plan.strip():
112
+ return ValidationResult(
113
+ result=False,
114
+ message="Plan cannot be empty",
115
+ )
116
+ return ValidationResult(result=True)
117
+
118
+ def render_result_for_assistant(self, output: ExitPlanModeToolOutput) -> str:
119
+ """Render the tool output for the AI assistant."""
120
+ return f"Exit plan mode and start coding now. Plan:\n{output.plan}"
121
+
122
+ def render_tool_use_message(
123
+ self,
124
+ input_data: ExitPlanModeToolInput,
125
+ verbose: bool = False, # noqa: ARG002
126
+ ) -> str:
127
+ """Render the tool use message for display."""
128
+ plan = input_data.plan
129
+ snippet = f"{plan[:77]}..." if len(plan) > 80 else plan
130
+ return f"Share plan for approval: {snippet}"
131
+
132
+ async def call(
133
+ self,
134
+ input_data: ExitPlanModeToolInput,
135
+ context: ToolUseContext,
136
+ ) -> AsyncGenerator[ToolOutput, None]:
137
+ """Execute the tool to exit plan mode."""
138
+ # Invoke the exit plan mode callback if available
139
+ if context.on_exit_plan_mode:
140
+ try:
141
+ context.on_exit_plan_mode()
142
+ except (RuntimeError, ValueError, TypeError):
143
+ logger.debug("[exit_plan_mode_tool] Failed to call on_exit_plan_mode")
144
+
145
+ is_agent = bool(context.agent_id)
146
+ output = ExitPlanModeToolOutput(
147
+ plan=input_data.plan,
148
+ is_agent=is_agent,
149
+ )
150
+ yield ToolResult(
151
+ data=output,
152
+ result_for_assistant=self.render_result_for_assistant(output),
153
+ )
@@ -0,0 +1,346 @@
1
+ """File editing tool.
2
+
3
+ Allows the AI to edit files by replacing text.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import AsyncGenerator, List, Optional
9
+ from pydantic import BaseModel, Field
10
+
11
+ from ripperdoc.core.tool import (
12
+ Tool,
13
+ ToolUseContext,
14
+ ToolResult,
15
+ ToolOutput,
16
+ ToolUseExample,
17
+ ValidationResult,
18
+ )
19
+ from ripperdoc.utils.log import get_logger
20
+ from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.path_ignore import check_path_for_tool
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class FileEditToolInput(BaseModel):
27
+ """Input schema for FileEditTool."""
28
+
29
+ file_path: str = Field(description="Absolute path to the file to edit")
30
+ old_string: str = Field(description="The text to replace (must match exactly)")
31
+ new_string: str = Field(description="The text to replace it with")
32
+ replace_all: bool = Field(
33
+ default=False, description="Replace all occurrences (default: false, only first)"
34
+ )
35
+
36
+
37
+ class FileEditToolOutput(BaseModel):
38
+ """Output from file editing."""
39
+
40
+ file_path: str
41
+ replacements_made: int
42
+ success: bool
43
+ message: str
44
+ additions: int = 0
45
+ deletions: int = 0
46
+ diff_lines: list[str] = []
47
+ diff_with_line_numbers: list[str] = []
48
+
49
+
50
+ class FileEditTool(Tool[FileEditToolInput, FileEditToolOutput]):
51
+ """Tool for editing files by replacing text."""
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return "Edit"
56
+
57
+ async def description(self) -> str:
58
+ return """Edit a file by replacing exact string matches. The old_string must
59
+ match exactly (including whitespace and indentation)."""
60
+
61
+ @property
62
+ def input_schema(self) -> type[FileEditToolInput]:
63
+ return FileEditToolInput
64
+
65
+ def input_examples(self) -> List[ToolUseExample]:
66
+ return [
67
+ ToolUseExample(
68
+ description="Rename a function definition once",
69
+ example={
70
+ "file_path": "/repo/src/app.py",
71
+ "old_string": "def old_name(",
72
+ "new_string": "def new_name(",
73
+ "replace_all": False,
74
+ },
75
+ ),
76
+ ToolUseExample(
77
+ description="Replace every occurrence of a constant across a file",
78
+ example={
79
+ "file_path": "/repo/src/config.ts",
80
+ "old_string": 'API_BASE = "http://localhost"',
81
+ "new_string": 'API_BASE = "https://api.example.com"',
82
+ "replace_all": True,
83
+ },
84
+ ),
85
+ ]
86
+
87
+ async def prompt(self, safe_mode: bool = False) -> str:
88
+ return (
89
+ "Performs exact string replacements in files.\n\n"
90
+ "Usage:\n"
91
+ "- You must use your `View` tool at least once in the conversation to read the file before editing; edits will fail if you skip reading.\n"
92
+ "- When editing text from View output, preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix is formatted as spaces + line number + tab. Never include any part of the prefix in old_string or new_string.\n"
93
+ "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n"
94
+ "- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n"
95
+ "- The edit will FAIL if `old_string` is not unique in the file. Provide more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n"
96
+ "- Use `replace_all` when replacing or renaming strings across the file (e.g., renaming a variable)."
97
+ )
98
+
99
+ def is_read_only(self) -> bool:
100
+ return False
101
+
102
+ def is_concurrency_safe(self) -> bool:
103
+ return False
104
+
105
+ def needs_permissions(self, input_data: Optional[FileEditToolInput] = None) -> bool:
106
+ return True
107
+
108
+ async def validate_input(
109
+ self, input_data: FileEditToolInput, context: Optional[ToolUseContext] = None
110
+ ) -> ValidationResult:
111
+ # Check if file exists
112
+ if not os.path.exists(input_data.file_path):
113
+ return ValidationResult(
114
+ result=False,
115
+ message=f"File not found: {input_data.file_path}",
116
+ error_code=1,
117
+ )
118
+
119
+ # Check if it's a file
120
+ if not os.path.isfile(input_data.file_path):
121
+ return ValidationResult(
122
+ result=False,
123
+ message=f"Path is not a file: {input_data.file_path}",
124
+ error_code=2,
125
+ )
126
+
127
+ # Check that old_string and new_string are different
128
+ if input_data.old_string == input_data.new_string:
129
+ return ValidationResult(
130
+ result=False,
131
+ message="old_string and new_string must be different",
132
+ error_code=3,
133
+ )
134
+
135
+ # Check if file has been read before editing
136
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
137
+ file_path = os.path.abspath(input_data.file_path)
138
+ file_snapshot = file_state_cache.get(file_path)
139
+
140
+ if not file_snapshot:
141
+ return ValidationResult(
142
+ result=False,
143
+ message="File has not been read yet. Read it first before editing.",
144
+ error_code=4,
145
+ )
146
+
147
+ # Check if file has been modified since it was read
148
+ try:
149
+ current_mtime = os.path.getmtime(file_path)
150
+ if current_mtime > file_snapshot.timestamp:
151
+ return ValidationResult(
152
+ result=False,
153
+ message="File has been modified since read, either by the user or by a linter. "
154
+ "Read it again before attempting to edit it.",
155
+ error_code=5,
156
+ )
157
+ except OSError:
158
+ pass # File mtime check failed, proceed anyway
159
+
160
+ # Check if path is ignored (warning for edit operations)
161
+ file_path_obj = Path(file_path)
162
+ should_proceed, warning_msg = check_path_for_tool(file_path_obj, tool_name="Edit", warn_only=True)
163
+ if warning_msg:
164
+ logger.warning("[file_edit_tool] %s", warning_msg)
165
+
166
+ return ValidationResult(result=True)
167
+
168
+ def render_result_for_assistant(self, output: FileEditToolOutput) -> str:
169
+ """Format output for the AI."""
170
+ # Return simple message for AI, but include structured data for UI
171
+ # The UI will extract the structured data from the output object
172
+ return output.message
173
+
174
+ def render_tool_use_message(self, input_data: FileEditToolInput, verbose: bool = False) -> str:
175
+ """Format the tool use for display."""
176
+ return f"Editing: {input_data.file_path}"
177
+
178
+ async def call(
179
+ self, input_data: FileEditToolInput, context: ToolUseContext
180
+ ) -> AsyncGenerator[ToolOutput, None]:
181
+ """Edit the file."""
182
+
183
+ try:
184
+ # Read the file
185
+ with open(input_data.file_path, "r", encoding="utf-8") as f:
186
+ content = f.read()
187
+
188
+ # Check if old_string exists
189
+ if input_data.old_string not in content:
190
+ output = FileEditToolOutput(
191
+ file_path=input_data.file_path,
192
+ replacements_made=0,
193
+ success=False,
194
+ message=f"String not found in file: {input_data.file_path}",
195
+ )
196
+ yield ToolResult(
197
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
198
+ )
199
+ return
200
+
201
+ # Count occurrences
202
+ occurrence_count = content.count(input_data.old_string)
203
+
204
+ # Check for ambiguity if not replace_all
205
+ if not input_data.replace_all and occurrence_count > 1:
206
+ output = FileEditToolOutput(
207
+ file_path=input_data.file_path,
208
+ replacements_made=0,
209
+ success=False,
210
+ message=f"String appears {occurrence_count} times in file. "
211
+ f"Either provide a unique string or use replace_all=true",
212
+ )
213
+ yield ToolResult(
214
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
215
+ )
216
+ return
217
+
218
+ # Perform replacement
219
+ if input_data.replace_all:
220
+ new_content = content.replace(input_data.old_string, input_data.new_string)
221
+ replacements = occurrence_count
222
+ else:
223
+ new_content = content.replace(input_data.old_string, input_data.new_string, 1)
224
+ replacements = 1
225
+
226
+ # Write the file
227
+ with open(input_data.file_path, "w", encoding="utf-8") as f:
228
+ f.write(new_content)
229
+
230
+ # Use absolute path to ensure consistency with validation lookup
231
+ abs_file_path = os.path.abspath(input_data.file_path)
232
+ try:
233
+ record_snapshot(
234
+ abs_file_path,
235
+ new_content,
236
+ getattr(context, "file_state_cache", {}),
237
+ )
238
+ except (OSError, IOError, RuntimeError) as exc:
239
+ logger.warning(
240
+ "[file_edit_tool] Failed to record file snapshot: %s: %s",
241
+ type(exc).__name__, exc,
242
+ extra={"file_path": abs_file_path},
243
+ )
244
+
245
+ # Generate diff for display
246
+ import difflib
247
+
248
+ old_lines = content.splitlines(keepends=True)
249
+ new_lines = new_content.splitlines(keepends=True)
250
+
251
+ diff = list(
252
+ difflib.unified_diff(
253
+ old_lines,
254
+ new_lines,
255
+ fromfile=input_data.file_path,
256
+ tofile=input_data.file_path,
257
+ lineterm="",
258
+ )
259
+ )
260
+
261
+ # Count additions and deletions from diff
262
+ additions = sum(
263
+ 1 for line in diff if line.startswith("+") and not line.startswith("+++")
264
+ )
265
+ deletions = sum(
266
+ 1 for line in diff if line.startswith("-") and not line.startswith("---")
267
+ )
268
+
269
+ # Store diff lines for display
270
+ diff_lines = []
271
+ for line in diff[3:]: # Skip header lines
272
+ diff_lines.append(line)
273
+
274
+ # Generate diff with line numbers for better display
275
+ diff_with_line_numbers = []
276
+ old_line_num = None
277
+ new_line_num = None
278
+
279
+ for line in diff[3:]: # Skip header lines
280
+ if line.startswith("@@"):
281
+ # Parse line numbers from diff header
282
+ import re
283
+
284
+ match = re.search(r"@@ -(\d+),(\d+) \+(\d+),(\d+) @@", line)
285
+ if match:
286
+ old_line_num = int(match.group(1))
287
+ new_line_num = int(match.group(3))
288
+ diff_with_line_numbers.append(f" [dim]{line}[/dim]")
289
+ elif line.startswith("+") and not line.startswith("+++"):
290
+ if new_line_num is not None:
291
+ diff_with_line_numbers.append(
292
+ f" [green]{new_line_num:6d} + {line[1:]}[/green]"
293
+ )
294
+ new_line_num += 1
295
+ else:
296
+ diff_with_line_numbers.append(f" [green]{line}[/green]")
297
+ elif line.startswith("-") and not line.startswith("---"):
298
+ if old_line_num is not None:
299
+ diff_with_line_numbers.append(
300
+ f" [red]{old_line_num:6d} - {line[1:]}[/red]"
301
+ )
302
+ old_line_num += 1
303
+ else:
304
+ diff_with_line_numbers.append(f" [red]{line}[/red]")
305
+ elif line.strip():
306
+ if old_line_num is not None and new_line_num is not None:
307
+ diff_with_line_numbers.append(
308
+ f" {old_line_num:6d} {new_line_num:6d} {line}"
309
+ )
310
+ old_line_num += 1
311
+ new_line_num += 1
312
+ else:
313
+ diff_with_line_numbers.append(f" {line}")
314
+
315
+ output = FileEditToolOutput(
316
+ file_path=input_data.file_path,
317
+ replacements_made=replacements,
318
+ success=True,
319
+ message=f"Successfully made {replacements} replacement(s) in {input_data.file_path}",
320
+ additions=additions,
321
+ deletions=deletions,
322
+ diff_lines=diff_lines,
323
+ diff_with_line_numbers=diff_with_line_numbers,
324
+ )
325
+
326
+ yield ToolResult(
327
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
328
+ )
329
+
330
+ except (OSError, IOError, PermissionError, UnicodeDecodeError, ValueError) as e:
331
+ logger.warning(
332
+ "[file_edit_tool] Error editing file: %s: %s",
333
+ type(e).__name__, e,
334
+ extra={"file_path": input_data.file_path},
335
+ )
336
+ error_output = FileEditToolOutput(
337
+ file_path=input_data.file_path,
338
+ replacements_made=0,
339
+ success=False,
340
+ message=f"Error editing file: {str(e)}",
341
+ )
342
+
343
+ yield ToolResult(
344
+ data=error_output,
345
+ result_for_assistant=self.render_result_for_assistant(error_output),
346
+ )