refactor(apply_patch): remove deprecated patch mode, keep edits-only

Drop the legacy unified-diff patch parameter and all related parsing/
generation logic (_parse_patch, _generate_patch, _apply_hunks, etc.).
The tool now accepts only the structured `edits` array, eliminating the
intermediate diff-string round-trip.

Also update file_edit_events tracking and tests to work exclusively
with edits.

Benchmark (zhipu glm-5.1, edits mode): 15/15 cases passed.
This commit is contained in:
chengyongru 2026-05-22 16:24:31 +08:00 committed by Xubin Ren
parent effc1efd92
commit 3d9f50a0cc
4 changed files with 459 additions and 640 deletions

View File

@ -1,4 +1,4 @@
"""Structured patch editing tool for coding workflows.""" """Apply file edits by providing structured edit instructions."""
from __future__ import annotations from __future__ import annotations
@ -6,29 +6,17 @@ import difflib
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any
from nanobot.agent.tools.base import tool_parameters from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.filesystem import _FsTool from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import BooleanSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
PatchKind = Literal["add", "delete", "update"] ObjectSchema,
StringSchema,
tool_parameters_schema,
@dataclass(slots=True) )
class _Hunk:
header: str | None
lines: list[tuple[str, str]]
@dataclass(slots=True)
class _PatchOp:
kind: PatchKind
path: str
new_path: str | None = None
add_lines: list[str] | None = None
hunks: list[_Hunk] | None = None
@dataclass(slots=True) @dataclass(slots=True)
@ -37,22 +25,13 @@ class _PatchSummary:
path: str path: str
added: int = 0 added: int = 0
deleted: int = 0 deleted: int = 0
new_path: str | None = None
class _PatchError(ValueError): class _PatchError(ValueError):
pass pass
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]") _ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\/]")
def _is_file_header(line: str) -> bool:
return (
line.startswith("*** Add File: ")
or line.startswith("*** Delete File: ")
or line.startswith("*** Update File: ")
)
def _validate_relative_path(path: str) -> str: def _validate_relative_path(path: str) -> str:
@ -63,7 +42,7 @@ def _validate_relative_path(path: str) -> str:
raise _PatchError(f"patch path contains a null byte: {path!r}") raise _PatchError(f"patch path contains a null byte: {path!r}")
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized): if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
raise _PatchError(f"patch path must be relative: {path}") raise _PatchError(f"patch path must be relative: {path}")
if any(part == ".." for part in re.split(r"[\\/]+", normalized)): if any(part == ".." for part in re.split(r"[\/]+", normalized)):
raise _PatchError(f"patch path must not contain '..': {path}") raise _PatchError(f"patch path must not contain '..': {path}")
return normalized return normalized
@ -97,198 +76,43 @@ def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
def _format_summary(summary: _PatchSummary) -> str: def _format_summary(summary: _PatchSummary) -> str:
path = (
f"{summary.path} -> {summary.new_path}"
if summary.new_path
else summary.path
)
stats = "" stats = ""
if summary.added or summary.deleted: if summary.added or summary.deleted:
stats = f" (+{summary.added}/-{summary.deleted})" stats = f" (+{summary.added}/-{summary.deleted})"
return f"- {summary.action} {path}{stats}" return f"- {summary.action} {summary.path}{stats}"
def _parse_patch(patch: str) -> list[_PatchOp]:
lines = patch.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if lines and lines[-1] == "":
lines.pop()
if not lines or lines[0] != "*** Begin Patch":
raise _PatchError("patch must start with '*** Begin Patch'")
if len(lines) < 2 or lines[-1] != "*** End Patch":
raise _PatchError("patch must end with '*** End Patch'")
ops: list[_PatchOp] = []
i = 1
end = len(lines) - 1
while i < end:
line = lines[i]
if line.startswith("*** Add File: "):
path = _validate_relative_path(line.removeprefix("*** Add File: "))
i += 1
add_lines: list[str] = []
while i < end and not _is_file_header(lines[i]):
if not lines[i].startswith("+"):
raise _PatchError(f"Add File lines must start with '+': {lines[i]!r}")
add_lines.append(lines[i][1:])
i += 1
ops.append(_PatchOp(kind="add", path=path, add_lines=add_lines))
continue
if line.startswith("*** Delete File: "):
path = _validate_relative_path(line.removeprefix("*** Delete File: "))
ops.append(_PatchOp(kind="delete", path=path))
i += 1
continue
if line.startswith("*** Update File: "):
path = _validate_relative_path(line.removeprefix("*** Update File: "))
i += 1
new_path: str | None = None
if i < end and lines[i].startswith("*** Move to: "):
new_path = _validate_relative_path(lines[i].removeprefix("*** Move to: "))
i += 1
hunks: list[_Hunk] = []
while i < end and not _is_file_header(lines[i]):
if not lines[i].startswith("@@"):
raise _PatchError(f"Update File sections require '@@' hunks: {lines[i]!r}")
header = lines[i][2:].strip() or None
i += 1
hunk_lines: list[tuple[str, str]] = []
while i < end and not lines[i].startswith("@@") and not _is_file_header(lines[i]):
if lines[i] == "*** End of File":
i += 1
break
if lines[i] == r"\ No newline at end of file":
i += 1
continue
if not lines[i] or lines[i][0] not in {" ", "+", "-"}:
raise _PatchError(f"Hunk lines must start with ' ', '+', or '-': {lines[i]!r}")
hunk_lines.append((lines[i][0], lines[i][1:]))
i += 1
if not hunk_lines:
raise _PatchError(f"Update File hunk is empty: {path}")
hunks.append(_Hunk(header=header, lines=hunk_lines))
if not hunks and new_path is None:
raise _PatchError(f"Update File requires at least one hunk or Move to: {path}")
ops.append(_PatchOp(kind="update", path=path, new_path=new_path, hunks=hunks))
continue
raise _PatchError(f"unknown patch header: {line!r}")
if not ops:
raise _PatchError("patch contains no file operations")
return ops
def _find_with_eof_fallback(content: str, needle: str, start: int) -> tuple[int, int]:
pos = content.find(needle, start)
if pos >= 0:
return pos, len(needle)
if needle.endswith("\n"):
trimmed = needle[:-1]
pos = content.find(trimmed, start)
if pos >= 0 and pos + len(trimmed) == len(content):
return pos, len(trimmed)
return -1, 0
def _line_offset(content: str, line_number: int) -> int:
if line_number <= 1:
return 0
offset = 0
for current, line in enumerate(content.splitlines(keepends=True), start=1):
if current >= line_number:
return offset
offset += len(line)
return len(content)
def _line_hint(header: str | None) -> int | None:
if not header:
return None
match = re.search(r"-(\d+)(?:,\d+)?", header)
return int(match.group(1)) if match else None
def _hunk_mismatch(path: str, old_text: str, content: str, header: str | None) -> str:
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = max(1, len(old_lines))
best_ratio, best_start = -1.0, 0
best_lines: list[str] = []
for i in range(max(1, len(lines) - window + 1)):
current = lines[i : i + window]
ratio = difflib.SequenceMatcher(None, "".join(old_lines), "".join(current)).ratio()
if ratio > best_ratio:
best_ratio, best_start, best_lines = ratio, i, current
label = f" after header {header!r}" if header else ""
if best_ratio <= 0:
return f"hunk does not match {path}{label}"
diff = "\n".join(difflib.unified_diff(
old_lines,
best_lines,
fromfile="patch hunk",
tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
))
return (
f"hunk does not match {path}{label}. "
f"Best match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
)
def _apply_hunks(path: str, content: str, hunks: list[_Hunk]) -> str:
cursor = 0
for hunk in hunks:
old_lines = [text for marker, text in hunk.lines if marker in {" ", "-"}]
new_lines = [text for marker, text in hunk.lines if marker in {" ", "+"}]
old_text = _lines_to_text(old_lines)
new_text = _lines_to_text(new_lines)
search_start = cursor
line_hint = None
if hunk.header:
line_hint = _line_hint(hunk.header)
if line_hint is not None:
search_start = _line_offset(content, line_hint)
else:
header_pos = content.find(hunk.header, cursor)
if header_pos >= 0:
search_start = header_pos
if old_text:
pos, match_len = _find_with_eof_fallback(content, old_text, search_start)
if pos < 0 and search_start != 0 and line_hint is None:
pos, match_len = _find_with_eof_fallback(content, old_text, 0)
if pos < 0:
raise _PatchError(_hunk_mismatch(path, old_text, content, hunk.header))
else:
pos = search_start
match_len = 0
content = content[:pos] + new_text + content[pos + match_len:]
cursor = pos + len(new_text)
return content
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
patch=StringSchema( edits=ArraySchema(
"Full patch text. Use *** Begin Patch / *** End Patch and file sections " items=ObjectSchema(
"for Add File, Update File, Delete File, and optional Move to.", path=StringSchema("Relative path to the file to edit."),
min_length=1, action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
enum=["replace", "add", "delete"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace and delete.",
nullable=True,
),
new_text=StringSchema(
"Text to replace with or append. Required for replace and add.",
nullable=True,
),
required=["path", "action"],
),
description="List of edits to apply. Each edit specifies a file and the change to make.",
min_items=1,
max_items=20,
), ),
dry_run=BooleanSchema( dry_run=BooleanSchema(
description="Validate and summarize the patch without writing files.", description="Validate and summarize the patch without writing files.",
default=False, default=False,
), ),
required=["patch"],
) )
) )
class ApplyPatchTool(_FsTool): class ApplyPatchTool(_FsTool):
"""Apply a structured multi-file patch.""" """Apply file edits by providing structured edit instructions."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
@property @property
@ -298,102 +122,190 @@ class ApplyPatchTool(_FsTool):
@property @property
def description(self) -> str: def description(self) -> str:
return ( return (
"Default tool for code edits. Apply a structured patch with " "Default tool for code edits. Supports multi-file changes in a single call. "
"*** Begin Patch and *** End Patch. Supports Add File, Update File, " "Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
"Delete File, and Move to across one or more files. Use this for " "Paths must be relative. Set dry_run=true to validate and preview without writing files. "
"multi-file changes, structural edits, generated code, or any edit " "Use edit_file only for small exact replacements on a single file."
"where a reviewable patch is clearer than an exact replacement. "
"Paths must be relative. Set dry_run=true to validate and preview "
"the change summary without writing files. Use edit_file only for "
"small exact replacements copied from read_file."
) )
async def execute(self, patch: str, dry_run: bool = False, **kwargs: Any) -> str: async def execute(
self,
edits: list[dict] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
try: try:
ops = _parse_patch(patch) if not edits:
raise _PatchError("must provide edits")
writes: dict[Path, str] = {} writes: dict[Path, str] = {}
deletes: set[Path] = set() deletes: set[Path] = set()
summaries: list[_PatchSummary] = [] summaries: list[_PatchSummary] = []
for op in ops: for edit in edits:
source = self._resolve(op.path) path = _validate_relative_path(edit["path"])
if op.kind == "add": action = edit["action"]
if source.exists() or source in writes: source = self._resolve(path)
raise _PatchError(f"file to add already exists: {op.path}")
new_content = _lines_to_text(op.add_lines or [])
writes[source] = new_content
deletes.discard(source)
summaries.append(_PatchSummary(
action="add",
path=op.path,
added=_text_line_count(new_content),
))
continue
if op.kind == "delete": if action == "add":
pending_content = writes.get(source) new_text = edit.get("new_text")
if pending_content is None and not source.exists(): if new_text is None:
raise _PatchError(f"file to delete does not exist: {op.path}") raise _PatchError(f"new_text required for add: {path}")
if pending_content is None and not source.is_file():
raise _PatchError(f"path to delete is not a file: {op.path}") pending = writes.get(source)
deleted_lines = 0 if pending is not None:
if pending_content is not None: content = pending
deleted_lines = _text_line_count(pending_content) exists = True
else: elif source.exists():
raw = source.read_bytes() raw = source.read_bytes()
try: try:
deleted_lines = _text_line_count(raw.decode("utf-8")) content = raw.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
deleted_lines = 0 raise _PatchError(f"file is not UTF-8 text: {path}")
deletes.add(source) exists = True
writes.pop(source, None) else:
summaries.append(_PatchSummary( content = ""
action="delete", exists = False
path=op.path,
deleted=deleted_lines, if exists:
)) uses_crlf = "\r\n" in content
continue new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
new_norm = _lines_to_text(new_text.splitlines())
writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
summaries.append(
_PatchSummary(
action=action_name, path=path, added=added, deleted=deleted
)
)
elif action == "replace":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
new_norm = (
norm_content[:pos]
+ new_text.replace("\r\n", "\n")
+ norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
elif action == "delete":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for delete: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
if norm_old.strip() == norm_content.strip():
deletes.add(source)
writes.pop(source, None)
added, deleted = 0, _text_line_count(content)
summaries.append(
_PatchSummary(
action="delete", path=path, added=added, deleted=deleted
)
)
else:
new_norm = (
norm_content[:pos] + norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
pending_content = writes.get(source)
if pending_content is None and not source.exists():
raise _PatchError(f"file to update does not exist: {op.path}")
if pending_content is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {op.path}")
if pending_content is not None:
content = pending_content
else: else:
raw = source.read_bytes() raise _PatchError(f"unknown action: {action}")
try:
content = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise _PatchError(f"file to update is not UTF-8 text: {op.path}") from exc
uses_crlf = "\r\n" in content
content = content.replace("\r\n", "\n")
new_content = _apply_hunks(op.path, content, op.hunks or [])
added, deleted = _line_diff_stats(content, new_content)
if uses_crlf:
new_content = new_content.replace("\n", "\r\n")
target = self._resolve(op.new_path) if op.new_path else source
if op.new_path and (target.exists() or target in writes) and target != source:
raise _PatchError(f"move target already exists: {op.new_path}")
writes[target] = new_content
deletes.discard(target)
if target != source:
deletes.add(source)
writes.pop(source, None)
summaries.append(_PatchSummary(
action="move" if op.new_path else "update",
path=op.path,
new_path=op.new_path,
added=added,
deleted=deleted,
))
if dry_run: if dry_run:
return ( return "Patch dry-run succeeded:\n" + "\n".join(
"Patch dry-run succeeded:\n" _format_summary(summary) for summary in summaries
+ "\n".join(_format_summary(summary) for summary in summaries)
) )
backups: dict[Path, bytes | None] = {} backups: dict[Path, bytes | None] = {}
@ -419,9 +331,8 @@ class ApplyPatchTool(_FsTool):
for path in set(writes) | deletes: for path in set(writes) | deletes:
self._file_states.record_write(path) self._file_states.record_write(path)
return ( return "Patch applied:\n" + "\n".join(
"Patch applied:\n" _format_summary(summary) for summary in summaries
+ "\n".join(_format_summary(summary) for summary in summaries)
) )
except PermissionError as exc: except PermissionError as exc:
return f"Error: {exc}" return f"Error: {exc}"

View File

@ -215,26 +215,24 @@ def _resolve_apply_patch_paths(
) -> list[Path]: ) -> list[Path]:
if not isinstance(params, dict): if not isinstance(params, dict):
return [] return []
patch = params.get("patch") edits = params.get("edits")
if not isinstance(patch, str) or not patch.strip(): if not isinstance(edits, list) or not edits:
return [] return []
if params.get("dry_run") is True: if params.get("dry_run") is True:
return [] return []
try:
from nanobot.agent.tools.apply_patch import _parse_patch
ops = _parse_patch(patch)
except Exception:
return []
resolved: list[Path] = [] resolved: list[Path] = []
for op in ops: seen: set[Path] = set()
for raw_path in (op.path, op.new_path): for edit in edits:
if not raw_path: if not isinstance(edit, dict):
continue continue
path = _resolve_raw_file_edit_path(tool, workspace, raw_path) raw_path = edit.get("path")
if path is not None: if not isinstance(raw_path, str) or not raw_path.strip():
resolved.append(path) continue
path = _resolve_raw_file_edit_path(tool, workspace, raw_path)
if path is not None and path not in seen:
seen.add(path)
resolved.append(path)
return resolved return resolved
@ -438,16 +436,34 @@ class StreamingFileEditTracker:
async def _update_apply_patch(self, state: _StreamingFileEditState) -> None: async def _update_apply_patch(self, state: _StreamingFileEditState) -> None:
if _json_bool_true(state.arguments, "dry_run"): if _json_bool_true(state.arguments, "dry_run"):
return return
patch = _extract_json_string_prefix(state.arguments, "patch")
if not patch:
return
tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None
events: list[dict[str, Any]] = [] events: list[dict[str, Any]] = []
now = time.monotonic() now = time.monotonic()
for raw_path, added, deleted, delete_file in _streaming_apply_patch_stats(patch):
path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments))
if not path_matches:
return
for i, m in enumerate(path_matches):
raw_path = m.group(1)
path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path) path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path)
if path is None: if path is None:
continue continue
segment_start = m.start()
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end]
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_text") or ""
added = _text_line_count(new_text) if action in ("replace", "add") else 0
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
delete_file = action == "delete"
file_state = state.patch_files.get(raw_path) file_state = state.patch_files.get(raw_path)
if file_state is None: if file_state is None:
tracker = FileEditTracker( tracker = FileEditTracker(
@ -779,9 +795,10 @@ class _StreamingFileEditState:
arguments = getattr(tool_call, "arguments", None) arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
return False return False
patch = arguments.get("patch") edits = arguments.get("edits")
streamed_patch = _extract_complete_json_string(self.arguments, "patch") if not isinstance(edits, list):
return isinstance(patch, str) and streamed_patch == patch return False
return '"edits"' in self.arguments
arguments = getattr(tool_call, "arguments", None) arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
return False return False
@ -849,65 +866,6 @@ def _extract_json_string_prefix(source: str, key: str) -> str | None:
return "".join(out) return "".join(out)
def _streaming_apply_patch_stats(patch: str) -> list[tuple[str, int, int, bool]]:
stats: dict[str, list[Any]] = {}
order: list[str] = []
current: str | None = None
def ensure(path: str, *, delete_file: bool = False) -> list[Any]:
if path not in stats:
stats[path] = [0, 0, False]
order.append(path)
if delete_file:
stats[path][2] = True
return stats[path]
lines = patch.splitlines()
tail = ""
if patch and not patch.endswith(("\n", "\r")) and lines:
tail = lines.pop()
for line in lines:
if line.startswith("*** Add File: "):
current = line[len("*** Add File: "):].strip()
if current:
ensure(current)
continue
if line.startswith("*** Update File: "):
current = line[len("*** Update File: "):].strip()
if current:
ensure(current)
continue
if line.startswith("*** Delete File: "):
current = line[len("*** Delete File: "):].strip()
if current:
ensure(current, delete_file=True)
continue
if line.startswith("*** Move to: "):
moved = line[len("*** Move to: "):].strip()
if moved:
current = moved
ensure(current)
continue
if line.startswith("*** "):
current = None
continue
if not current:
continue
if line.startswith("+") and not line.startswith("+++"):
ensure(current)[0] += 1
elif line.startswith("-") and not line.startswith("---"):
ensure(current)[1] += 1
if current and tail:
if tail.startswith("+") and not tail.startswith("+++"):
ensure(current)[0] += 1
elif tail.startswith("-") and not tail.startswith("---"):
ensure(current)[1] += 1
return [(path, int(stats[path][0]), int(stats[path][1]), bool(stats[path][2])) for path in order]
def _extract_complete_json_string(source: str, key: str) -> str | None: def _extract_complete_json_string(source: str, key: str) -> str | None:
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source) match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
if match is None: if match is None:

View File

@ -5,283 +5,249 @@ import asyncio
from nanobot.agent.tools.apply_patch import ApplyPatchTool from nanobot.agent.tools.apply_patch import ApplyPatchTool
def test_apply_patch_adds_file(tmp_path): def test_apply_patch_edits_replace(tmp_path):
target = tmp_path / "calc.py"
target.write_text("def add(a, b):\n return a + b\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Add File: hello.txt edits=[
+Hello {
+world "path": "calc.py",
*** End Patch "action": "replace",
""" "old_text": " return a + b",
)) "new_text": " return a - b",
}
]
)
)
assert "Patch applied" in result assert "update calc.py" in result
assert (tmp_path / "hello.txt").read_text() == "Hello\nworld\n" assert target.read_text() == "def add(a, b):\n return a - b\n"
def test_apply_patch_updates_multiple_hunks(tmp_path): def test_apply_patch_edits_add_new_file(tmp_path):
target = tmp_path / "multi.txt"
target.write_text("line1\nline2\nline3\nline4\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Update File: multi.txt edits=[
@@ {
-line2 "path": "config.py",
+changed2 "action": "add",
@@ "new_text": "DEBUG = True",
-line4 }
+changed4 ]
*** End Patch )
""" )
))
assert "update multi.txt" in result assert "add config.py" in result
assert "(+2/-2)" in result assert (tmp_path / "config.py").read_text() == "DEBUG = True\n"
assert target.read_text() == "line1\nchanged2\nline3\nchanged4\n"
def test_apply_patch_dry_run_validates_without_writing(tmp_path): def test_apply_patch_edits_add_to_existing_file(tmp_path):
target = tmp_path / "dry.txt" target = tmp_path / "log.py"
target.write_text("before\n") target.write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Update File: dry.txt edits=[
@@ {
-before "path": "log.py",
+after "action": "add",
*** Add File: added.txt "new_text": "def debug(msg):\n logger.debug(msg)",
+new }
*** End Patch ]
""", )
dry_run=True, )
))
assert "Patch dry-run succeeded" in result assert "update log.py" in result
assert "- update dry.txt (+1/-1)" in result assert (
assert "- add added.txt (+1/-0)" in result target.read_text()
assert target.read_text() == "before\n" == "import logging\n\nlogger = logging.getLogger(__name__)\ndef debug(msg):\n logger.debug(msg)\n"
assert not (tmp_path / "added.txt").exists() )
def test_apply_patch_applies_repeated_update_sections_sequentially(tmp_path): def test_apply_patch_edits_delete(tmp_path):
target = tmp_path / "repeat.txt" target = tmp_path / "utils.py"
target.write_text("one\ntwo\nthree\n") target.write_text("def unused():\n pass\ndef used():\n return 1\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Update File: repeat.txt edits=[
@@ {
-one "path": "utils.py",
+ONE "action": "delete",
*** Update File: repeat.txt "old_text": "def unused():\n pass\n",
@@ }
-three ]
+THREE )
*** End Patch )
"""
))
assert result.count("update repeat.txt") == 2 assert "update utils.py" in result
assert target.read_text() == "ONE\ntwo\nTHREE\n" assert target.read_text() == "def used():\n return 1\n"
def test_apply_patch_ignores_standard_no_newline_marker(tmp_path): def test_apply_patch_edits_delete_entire_file(tmp_path):
target = tmp_path / "plain.txt"
target.write_text("before")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: plain.txt
@@ -1,1 +1,1 @@
-before
\\ No newline at end of file
+after
\\ No newline at end of file
*** End Patch
"""
))
assert "update plain.txt" in result
assert target.read_text() == "after\n"
def test_apply_patch_rejects_empty_hunk(tmp_path):
target = tmp_path / "plain.txt"
target.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: plain.txt
@@
*** End Patch
"""
))
assert "hunk is empty" in result
assert target.read_text() == "before\n"
def test_apply_patch_uses_unified_diff_line_hint(tmp_path):
target = tmp_path / "repeated.txt"
target.write_text("target\nmiddle\ntarget\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: repeated.txt
@@ -3,1 +3,1 @@
-target
+changed
*** End Patch
"""
))
assert "update repeated.txt" in result
assert target.read_text() == "target\nmiddle\nchanged\n"
def test_apply_patch_line_hint_does_not_fallback_to_earlier_match(tmp_path):
target = tmp_path / "repeated.txt"
target.write_text("target\nmiddle\nother\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: repeated.txt
@@ -3,1 +3,1 @@
-target
+changed
*** End Patch
"""
))
assert "hunk does not match repeated.txt" in result
assert target.read_text() == "target\nmiddle\nother\n"
def test_apply_patch_mismatch_reports_best_match(tmp_path):
target = tmp_path / "near.txt"
target.write_text("alpha\nbeta\ngamma\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: near.txt
@@ -2,1 +2,1 @@
-betx
+delta
*** End Patch
"""
))
assert "hunk does not match near.txt" in result
assert "Best match" in result
assert "line 2" in result
assert target.read_text() == "alpha\nbeta\ngamma\n"
def test_apply_patch_moves_and_updates_file(tmp_path):
source = tmp_path / "old" / "name.txt"
source.parent.mkdir()
source.write_text("old content\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-old content
+new content
*** End Patch
"""
))
assert "move old/name.txt -> renamed/dir/name.txt" in result
assert not source.exists()
assert (tmp_path / "renamed" / "dir" / "name.txt").read_text() == "new content\n"
def test_apply_patch_deletes_file(tmp_path):
target = tmp_path / "obsolete.txt" target = tmp_path / "obsolete.txt"
target.write_text("remove me\n") target.write_text("remove me\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Delete File: obsolete.txt edits=[
*** End Patch {
""" "path": "obsolete.txt",
)) "action": "delete",
"old_text": "remove me\n",
}
]
)
)
assert "delete obsolete.txt" in result assert "delete obsolete.txt" in result
assert not target.exists() assert not target.exists()
def test_apply_patch_rejects_absolute_and_parent_paths(tmp_path): def test_apply_patch_edits_batch_multiple_files(tmp_path):
a = tmp_path / "a.py"
a.write_text("X = 1\n")
b = tmp_path / "b.py"
b.write_text("from a import X\nprint(X)\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
absolute = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Add File: /tmp/owned.txt edits=[
+nope {
*** End Patch "path": "a.py",
""" "action": "replace",
)) "old_text": "X = 1",
parent = asyncio.run(tool.execute( "new_text": "Y = 1",
patch="""*** Begin Patch },
*** Add File: ../owned.txt {
+nope "path": "b.py",
*** End Patch "action": "replace",
""" "old_text": "from a import X",
)) "new_text": "from a import Y",
},
]
)
)
assert "update a.py" in result
assert "update b.py" in result
assert a.read_text() == "Y = 1\n"
assert b.read_text() == "from a import Y\nprint(X)\n"
def test_apply_patch_edits_rejects_ambiguous_old_text(tmp_path):
target = tmp_path / "repeated.txt"
target.write_text("target\nmiddle\ntarget\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "repeated.txt",
"action": "replace",
"old_text": "target",
"new_text": "changed",
}
]
)
)
assert "old_text appears multiple times" in result
assert target.read_text() == "target\nmiddle\ntarget\n"
def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
target = tmp_path / "dry.txt"
target.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "dry.txt",
"action": "replace",
"old_text": "before",
"new_text": "after",
},
{
"path": "added.txt",
"action": "add",
"new_text": "new",
},
],
dry_run=True,
)
)
assert "Patch dry-run succeeded" in result
assert target.read_text() == "before\n"
assert not (tmp_path / "added.txt").exists()
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
absolute = asyncio.run(
tool.execute(
edits=[
{
"path": "/tmp/owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
parent = asyncio.run(
tool.execute(
edits=[
{
"path": "../owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
assert "must be relative" in absolute assert "must be relative" in absolute
assert "must not contain '..'" in parent assert "must not contain '..'" in parent
assert not (tmp_path.parent / "owned.txt").exists() assert not (tmp_path.parent / "owned.txt").exists()
def test_apply_patch_does_not_overwrite_existing_file_with_add(tmp_path): def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
target = tmp_path / "existing.txt"
target.write_text("keep me\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
patch="""*** Begin Patch
*** Add File: existing.txt
+replace me
*** End Patch
"""
))
assert "file to add already exists" in result
assert target.read_text() == "keep me\n"
def test_apply_patch_rolls_back_when_late_operation_fails(tmp_path):
first = tmp_path / "first.txt" first = tmp_path / "first.txt"
first.write_text("before\n") first.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path) tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(tool.execute( result = asyncio.run(
patch="""*** Begin Patch tool.execute(
*** Update File: first.txt edits=[
@@ {
-before "path": "first.txt",
+after "action": "replace",
*** Delete File: missing.txt "old_text": "before",
*** End Patch "new_text": "after",
""" },
)) {
"path": "missing.txt",
"action": "delete",
"old_text": "remove me",
},
]
)
)
assert "file to delete does not exist" in result assert "file to update does not exist: missing.txt" in result
assert first.read_text() == "before\n" assert first.read_text() == "before\n"

View File

@ -89,23 +89,18 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
delete_me = tmp_path / "src" / "delete_me.py" delete_me = tmp_path / "src" / "delete_me.py"
delete_me.write_text("gone\n", encoding="utf-8") delete_me.write_text("gone\n", encoding="utf-8")
patch = """*** Begin Patch edits = [
*** Add File: src/new.py {"path": "src/new.py", "action": "add", "new_text": "fresh"},
+fresh {"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
*** Update File: src/existing.py {"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
@@ ]
-old
+new
keep
*** Delete File: src/delete_me.py
*** End Patch"""
trackers = prepare_file_edit_trackers( trackers = prepare_file_edit_trackers(
call_id="call-patch", call_id="call-patch",
tool_name="apply_patch", tool_name="apply_patch",
tool=None, tool=None,
workspace=tmp_path, workspace=tmp_path,
params={"patch": patch}, params={"edits": edits},
) )
assert [tracker.display_path for tracker in trackers] == [ assert [tracker.display_path for tracker in trackers] == [
@ -118,7 +113,7 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
existing.write_text("new\nkeep\n", encoding="utf-8") existing.write_text("new\nkeep\n", encoding="utf-8")
delete_me.unlink() delete_me.unlink()
events = [build_file_edit_end_event(tracker, {"patch": patch}) for tracker in trackers] events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
by_path = {event["path"]: event for event in events} by_path = {event["path"]: event for event in events}
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0) assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1) assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
@ -135,12 +130,9 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
workspace=tmp_path, workspace=tmp_path,
params={ params={
"dry_run": True, "dry_run": True,
"patch": """*** Begin Patch "edits": [
*** Update File: file.txt {"path": "file.txt", "action": "replace", "old_text": "old", "new_text": "new"}
@@ ],
-old
+new
*** End Patch""",
}, },
) )
@ -221,14 +213,8 @@ def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path
"call_id": "call-patch", "call_id": "call-patch",
"name": "apply_patch", "name": "apply_patch",
"arguments_delta": ( "arguments_delta": (
'{"patch":"*** Begin Patch\\n' '{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}'
'*** Update File: src/existing.py\\n' ',{"path":"src/new.py","action":"add","new_text":"fresh"}]}'
'@@\\n'
'-old\\n'
'+new\\n'
' keep\\n'
'*** Add File: src/new.py\\n'
'+fresh\\n'
), ),
}) })
@ -255,9 +241,7 @@ def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None:
"call_id": "call-patch", "call_id": "call-patch",
"name": "apply_patch", "name": "apply_patch",
"arguments_delta": ( "arguments_delta": (
'{"dry_run":true,"patch":"*** Begin Patch\\n' '{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}'
'*** Add File: dry.md\\n'
'+preview\\n'
), ),
}) })