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,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
|
+
)
|