Skip to content

Commit ffd0573

Browse files
authored
Improvements to file editing. Commonly confused characters fix. Throw only outer errors (#83)
1 parent 4b85704 commit ffd0573

File tree

7 files changed

+123
-26
lines changed

7 files changed

+123
-26
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
authors = [{ name = "Aman Rusia", email = "[email protected]" }]
33
name = "wcgw"
4-
version = "5.5.1"
4+
version = "5.5.2"
55
description = "Shell and coding agent for Claude and other mcp clients"
66
readme = "README.md"
77
requires-python = ">=3.11"

src/wcgw/client/file_ops/diff_edit.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ def __init__(self, message: str):
1111
message = f"""
1212
{message}
1313
---
14-
Edit failed, no changes are applied. You'll have to reapply all search/replace blocks again.
15-
Retry immediately with same "percentage_to_change" using search replace blocks fixing above error.
14+
Last edit failed, no changes are applied. None of the search/replace blocks applied in the last tool call.
15+
Recommendations:
16+
- Retry immediately with same "percentage_to_change" using search replace blocks fixing above error.
17+
- If you are still unsure you may re-read the file and then proceed accordingly.
18+
19+
If your search failed due to updates in the file content, remember that these
20+
are the changes that the user made and you should preserve them
21+
by updating your replace blocks too.
1622
"""
1723
super().__init__(message)
1824

@@ -88,7 +94,7 @@ def replace_or_throw(
8894
if score > 1000:
8995
display = (list(warnings) + list(info))[:max_errors]
9096
raise SearchReplaceMatchError(
91-
"Too many warnings generated, not apply the edits\n"
97+
"Too many warnings generated, not applying the edits\n"
9298
+ "\n".join(display)
9399
)
94100

@@ -123,7 +129,44 @@ def line_process_max_space_tolerance(line: str) -> str:
123129
return re.sub(r"\s", "", line)
124130

125131

126-
REMOVE_INDENTATION = "Warning: matching after removing all spaces in lines."
132+
REMOVE_INDENTATION = (
133+
"Warning: matching without considering indentation (leading spaces)."
134+
)
135+
REMOVE_LINE_NUMS = "Warning: you gave search/replace blocks with leading line numbers, do not give them from the next time."
136+
137+
COMMON_MISTAKE_TRANSLATION = str.maketrans(
138+
{
139+
"‘": "'",
140+
"’": "'",
141+
"‚": ",",
142+
"‛": "'",
143+
"′": "'",
144+
"“": '"',
145+
"”": '"',
146+
"‟": '"',
147+
"″": '"',
148+
"‹": "<",
149+
"›": ">",
150+
"‐": "-",
151+
"‑": "-",
152+
"‒": "-",
153+
"–": "-",
154+
"—": "-",
155+
"―": "-",
156+
"−": "-",
157+
"…": "...",
158+
}
159+
)
160+
161+
162+
def remove_leading_linenums(string: str) -> str:
163+
return re.sub(r"^\d+ ", "", string).rstrip()
164+
165+
166+
def normalize_common_mistakes(string: str) -> str:
167+
"""Normalize unicode chars which are commonly confused by their ascii variants"""
168+
return string.translate(COMMON_MISTAKE_TRANSLATION).rstrip()
169+
127170

128171
DEFAULT_TOLERANCES = [
129172
Tolerance(
@@ -136,17 +179,35 @@ def line_process_max_space_tolerance(line: str) -> str:
136179
line_process=str.lstrip,
137180
severity_cat="WARNING",
138181
score_multiplier=10,
139-
error_name="Warning: matching without considering indentation (leading spaces).",
182+
error_name=REMOVE_INDENTATION,
183+
),
184+
Tolerance(
185+
line_process=remove_leading_linenums,
186+
severity_cat="WARNING",
187+
score_multiplier=5,
188+
error_name=REMOVE_LINE_NUMS,
189+
),
190+
Tolerance(
191+
line_process=normalize_common_mistakes,
192+
severity_cat="WARNING",
193+
score_multiplier=5,
194+
error_name="Warning: matching after normalizing commonly confused characters (quotes, dashes, ellipsis).",
140195
),
141196
Tolerance(
142197
line_process=line_process_max_space_tolerance,
143198
severity_cat="WARNING",
144199
score_multiplier=50,
145-
error_name=REMOVE_INDENTATION,
200+
error_name="Warning: matching after removing all spaces in lines.",
146201
),
147202
]
148203

149204

205+
def fix_line_nums(
206+
matched_lines: list[str], searched_lines: list[str], replaced_lines: list[str]
207+
) -> list[str]:
208+
return [remove_leading_linenums(line) for line in replaced_lines]
209+
210+
150211
def fix_indentation(
151212
matched_lines: list[str], searched_lines: list[str], replaced_lines: list[str]
152213
) -> list[str]:
@@ -312,6 +373,14 @@ def edit_file(self) -> list[FileEditOutput]:
312373
matches_with_tolerances = [(match, []) for match in matches]
313374

314375
for match, tolerances in matches_with_tolerances:
376+
if any(
377+
tolerance.error_name == REMOVE_LINE_NUMS for tolerance in tolerances
378+
):
379+
replace_by = fix_line_nums(
380+
self.file_lines[match.start : match.stop],
381+
first_block[0],
382+
replace_by,
383+
)
315384
if any(
316385
tolerance.error_name == REMOVE_INDENTATION for tolerance in tolerances
317386
):
@@ -454,6 +523,9 @@ def match_with_tolerance(
454523
tolerance_index_by_content_line[search_idx][content_idx]
455524
].count += 1
456525

526+
# Remove 0 counts
527+
tolerances_counts = [[x for x in y if x.count > 0] for y in tolerances_counts]
528+
457529
return list(zip(matched_slices, tolerances_counts))
458530

459531

src/wcgw/client/file_ops/search_replace.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,15 @@ def search_replace_edit(
101101
)
102102

103103
edited_content, comments_ = edit_with_individual_fallback(
104-
original_lines, search_replace_blocks
104+
original_lines, search_replace_blocks, False
105105
)
106106

107107
edited_file = "\n".join(edited_content)
108108
if not comments_:
109-
comments = "Edited successfully"
109+
comments = "File edited successfully."
110110
else:
111111
comments = (
112-
"Edited successfully. However, following warnings were generated while matching search blocks.\n"
112+
"File edited successfully. However, following warnings were generated while matching search blocks.\n"
113113
+ "\n".join(comments_)
114114
)
115115
return edited_file, comments
@@ -152,27 +152,45 @@ def identify_first_differing_block(
152152

153153

154154
def edit_with_individual_fallback(
155-
original_lines: list[str], search_replace_blocks: list[tuple[list[str], list[str]]]
155+
original_lines: list[str],
156+
search_replace_blocks: list[tuple[list[str], list[str]]],
157+
replace_all: bool,
156158
) -> tuple[list[str], set[str]]:
157159
outputs = FileEditInput(original_lines, 0, search_replace_blocks, 0).edit_file()
158160
best_matches = FileEditOutput.get_best_match(outputs)
159-
160161
try:
161162
edited_content, comments_ = best_matches[0].replace_or_throw(3)
162163
except SearchReplaceMatchError:
163164
if len(search_replace_blocks) > 1:
164-
# Try one at a time
165-
all_comments = set[str]()
166-
running_lines = list(original_lines)
167-
for block in search_replace_blocks:
168-
running_lines, comments_ = edit_with_individual_fallback(
169-
running_lines, [block]
170-
)
171-
all_comments |= comments_
172-
return running_lines, all_comments
165+
try:
166+
# Try one at a time
167+
all_comments = set[str]()
168+
running_lines = list(original_lines)
169+
for block in search_replace_blocks:
170+
running_lines, comments_ = edit_with_individual_fallback(
171+
running_lines, [block], replace_all
172+
)
173+
all_comments |= comments_
174+
return running_lines, all_comments
175+
except SearchReplaceMatchError:
176+
# Raise the outer error instead
177+
# Otherwise the suggested search block will be
178+
# after applying previous N search blocks and that
179+
# would signal to LLM that we've updated the file
180+
pass
173181
raise
174182

175-
if len(best_matches) > 1:
183+
if replace_all and len(best_matches) > 1 and len(search_replace_blocks) == 1:
184+
# For only one search/replace block only replace all
185+
try:
186+
edited_content, comments__ = edit_with_individual_fallback(
187+
edited_content, search_replace_blocks, replace_all
188+
)
189+
comments_ |= comments__
190+
except SearchReplaceMatchError:
191+
# Will not happen ideally, but still no use of throwing error here
192+
pass
193+
elif len(best_matches) > 1:
176194
# Find the first block that differs across matches
177195
first_diff_block = identify_first_differing_block(best_matches)
178196
if first_diff_block is not None:

src/wcgw/client/tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1064,7 +1064,7 @@ def get_tool_output(
10641064
arg.type,
10651065
context,
10661066
arg.any_workspace_path,
1067-
arg.initial_files_to_read,
1067+
arg.initial_files_to_read or [],
10681068
arg.task_id_to_resume,
10691069
coding_max_tokens,
10701070
noncoding_max_tokens,

src/wcgw/types_.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class Initialize(BaseModel):
5252
any_workspace_path: str = Field(
5353
description="Workspce to initialise in. Don't use ~ by default, instead use empty string"
5454
)
55-
initial_files_to_read: list[str]
55+
initial_files_to_read: list[str] | None
5656
task_id_to_resume: str
5757
mode_name: Literal["wcgw", "architect", "code_writer"]
5858
thread_id: str = Field(

tests/test_mcp_server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,14 @@ async def test_handle_list_tools():
136136
assert "any_workspace_path" in properties
137137
assert properties["any_workspace_path"]["type"] == "string"
138138
assert "initial_files_to_read" in properties
139-
assert properties["initial_files_to_read"]["type"] == "array"
139+
# initial_files_to_read is list[str] | None, so it uses anyOf
140+
assert "anyOf" in properties["initial_files_to_read"]
141+
any_of = properties["initial_files_to_read"]["anyOf"]
142+
assert len(any_of) == 2
143+
# Check for array type
144+
assert any(item.get("type") == "array" for item in any_of)
145+
# Check for null type
146+
assert any(item.get("type") == "null" for item in any_of)
140147
elif tool.name == "BashCommand":
141148
properties = tool.inputSchema["properties"]
142149
assert "action_json" in properties

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)