-
Notifications
You must be signed in to change notification settings - Fork 931
Expand file tree
/
Copy pathreplace.py
More file actions
160 lines (134 loc) · 5.46 KB
/
replace.py
File metadata and controls
160 lines (134 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
from pathlib import Path
from typing import override
from kaos.path import KaosPath
from kosong.tooling import CallableTool2, ToolError, ToolReturnValue
from pydantic import BaseModel, Field
from kimi_cli.soul.agent import Runtime
from kimi_cli.soul.approval import Approval
from kimi_cli.tools.display import DisplayBlock
from kimi_cli.tools.file import FileActions
from kimi_cli.tools.utils import ToolRejectedError, load_desc
from kimi_cli.utils.diff import build_diff_blocks
from kimi_cli.utils.path import is_within_workspace
class Edit(BaseModel):
old: str = Field(description="The old string to replace. Can be multi-line.")
new: str = Field(description="The new string to replace with. Can be multi-line.")
replace_all: bool = Field(description="Whether to replace all occurrences.", default=False)
class Params(BaseModel):
path: str = Field(
description=(
"The path to the file to edit. Absolute paths are required when editing files "
"outside the working directory."
)
)
edit: Edit | list[Edit] = Field(
description=(
"The edit(s) to apply to the file. "
"You can provide a single edit or a list of edits here."
)
)
class StrReplaceFile(CallableTool2[Params]):
name: str = "StrReplaceFile"
description: str = load_desc(Path(__file__).parent / "replace.md")
params: type[Params] = Params
def __init__(self, runtime: Runtime, approval: Approval):
super().__init__()
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
self._additional_dirs = runtime.additional_dirs
self._approval = approval
async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to edit."""
resolved_path = path.canonical()
if (
not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)
and not path.is_absolute()
):
return ToolError(
message=(
f"`{path}` is not an absolute path. "
"You must provide an absolute path to edit a file "
"outside the working directory."
),
brief="Invalid path",
)
return None
def _apply_edit(self, content: str, edit: Edit) -> str:
"""Apply a single edit to the content."""
if edit.replace_all:
return content.replace(edit.old, edit.new)
else:
return content.replace(edit.old, edit.new, 1)
@override
async def __call__(self, params: Params) -> ToolReturnValue:
if not params.path:
return ToolError(
message="File path cannot be empty.",
brief="Empty file path",
)
try:
p = KaosPath(params.path).expanduser()
if err := await self._validate_path(p):
return err
p = p.canonical()
if not await p.exists():
return ToolError(
message=f"`{params.path}` does not exist.",
brief="File not found",
)
if not await p.is_file():
return ToolError(
message=f"`{params.path}` is not a file.",
brief="Invalid path",
)
# Read the file content
content = await p.read_text(errors="replace")
original_content = content
edits = [params.edit] if isinstance(params.edit, Edit) else params.edit
# Apply all edits
for edit in edits:
content = self._apply_edit(content, edit)
# Check if any changes were made
if content == original_content:
return ToolError(
message="No replacements were made. The old string was not found in the file.",
brief="No replacements made",
)
diff_blocks: list[DisplayBlock] = list(
build_diff_blocks(str(p), original_content, content)
)
action = (
FileActions.EDIT
if is_within_workspace(p, self._work_dir, self._additional_dirs)
else FileActions.EDIT_OUTSIDE
)
# Request approval
if not await self._approval.request(
self.name,
action,
f"Edit file `{p}`",
display=diff_blocks,
):
return ToolRejectedError()
# Write the modified content back to the file
await p.write_text(content, errors="replace")
# Count changes for success message
total_replacements = 0
for edit in edits:
if edit.replace_all:
total_replacements += original_content.count(edit.old)
else:
total_replacements += 1 if edit.old in original_content else 0
return ToolReturnValue(
is_error=False,
output="",
message=(
f"File successfully edited. "
f"Applied {len(edits)} edit(s) with {total_replacements} total replacement(s)."
),
display=diff_blocks,
)
except Exception as e:
return ToolError(
message=f"Failed to edit. Error: {e}",
brief="Failed to edit file",
)