A deployment reported that write_file -> read_file (full) -> patch -> write_file refuses the last write with stale_write_blocked and "exists but this task has not seen its full current content", in 50 of 50 trials. No other writer took part. When the model does not reread before the turn ends, the refusal surfaces as the "File-mutation verifier: N file edit(s) FAILED" footer. Mechanism. The write_file baseline is an exact file version: (st_dev, st_ino, st_size, st_mtime_ns, st_ctime_ns, sha256) (tools/file_tools_read_tracking.py _file_version, _mark_full_write_baseline, _has_full_write_baseline). write_file_tool marks a fresh baseline from the bytes it wrote. patch_tool only ran _note_edited and _reset_patch_failures, so the task's own patch changed the version and left the baseline it held stale. _stale_overwrite_blocker then treated the task like an external writer. Invariant: after a task's own successful patch, the task may replace the file whole exactly when it held a current full baseline for the bytes the patch read and the bytes the patch wrote are what is on disk. Lineage.a8af57fcd2added the guard.dddefaefaecounted paged, extracted and post-compaction reads.e636aedebfkept a baseline across narrower rereads of unchanged bytes and bound it to the sha256. That is the change that made this sequence refuse (its parent lets the write through). Own patches are the missing case of e636aedebf's principle that unchanged knowledge survives. Shape: - Under the per-path locks, before the patch reads, patch_tool records the sha256 of each path whose baseline still matches disk (_known_full_content_sha256). - read_file_raw and patch_replace report the sha256 of the exact bytes they read, BOM included. Each file write records (path, read sha256, written sha256) in PatchResult._writes for replace, V4A Update and V4A Add. - _carry_full_write_baselines marks the baseline only when the read sha256 equals the known one (or the patch created the file). The mark goes through _mark_full_write_baseline with the written sha256 as expected_sha256. A writer that lands before the patch reads leaves a read sha256 the task never knew. One that lands after the patch writes fails the expected-sha check. Neither case gets a baseline. Several operations on one file in one V4A patch chain in order. - A blind patch (no baseline, or a partial view) is noted per task in blind_patches. While disk still holds exactly those bytes, the refusal says "Your patch changed this file after a partial view. Read the current file in full before a whole-file overwrite, or continue with targeted patches." Sibling, external and never-read refusals keep their wording. Remote backends: the baseline logic already stats the host path. On a sandbox backend (non-LocalEnvironment env, verified with the remote-binary-guard transport double), read_file records no baseline, and _stale_overwrite_blocker lets the write through because the host path does not exist. The carry hashes the same host path, so it records nothing there. Paths the backend rewrites (translated mounts) do not match the resolved host path and are skipped. Behaviour on those backends is byte-identical to before. Hint contradiction: after three failed replace patches, option (3) said "use write_file to replace the entire file", which is the call the guard refuses after a patch. It now says "re-read the file in full, then use write_file to replace it". Out of scope: enforcing recovery from a stale_write_blocked refusal before a turn ends. The stale_write_blocked receipt and the file-mutation footer are unchanged.
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""Result dataclasses and pure text helpers shared by ``tools.file_operations``
|
|
and its search/lint mixins. Leaf module (imports nothing from ``tools`` at module
|
|
scope) so nothing cycles. The ``to_dict`` output of every class here IS tool
|
|
behavior — key names, order and omission rules are pinned by tests and read by
|
|
the model.
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
|
|
|
|
|
@dataclass
|
|
class ReadResult:
|
|
"""Result from reading a file."""
|
|
content: str = ""
|
|
total_lines: int = 0
|
|
file_size: int = 0
|
|
truncated: bool = False
|
|
truncated_lines: Optional[bool] = None
|
|
hint: Optional[str] = None
|
|
is_binary: bool = False
|
|
is_image: bool = False
|
|
base64_content: Optional[str] = None
|
|
mime_type: Optional[str] = None
|
|
dimensions: Optional[str] = None # For images: "WIDTHxHEIGHT"
|
|
error: Optional[str] = None
|
|
#: True only when the path is genuinely absent. An error with this False is a read that
|
|
#: FAILED (transport down, no byte transport installed); callers deciding whether a path
|
|
#: is free must not read that as "absent". See patch_parser._apply_add.
|
|
not_found: bool = False
|
|
similar_files: List[str] = field(default_factory=list)
|
|
_snapshot: Optional[tuple] = None
|
|
# sha256 of the exact on-disk bytes ``read_file_raw`` decoded (BOM included).
|
|
_content_sha256: Optional[str] = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {k: v for k, v in self.__dict__.items() if not k.startswith("_") and v is not None and v != []}
|
|
|
|
|
|
@dataclass
|
|
class WriteResult:
|
|
"""Result from writing a file."""
|
|
bytes_written: int = 0
|
|
dirs_created: bool = False
|
|
# True when the on-disk sha256 matched the intended content; None when the
|
|
# backend couldn't verify (no sha256sum). A mismatch is a hard error, never a flag.
|
|
verified: Optional[bool] = None
|
|
_content_sha256: Optional[str] = None
|
|
lint: Optional[Dict[str, Any]] = None
|
|
# LSP semantic diagnostics, kept separate from ``lint`` (syntax) so the model
|
|
# reads the two as independent signals. None when LSP is off/inapplicable.
|
|
lsp_diagnostics: Optional[str] = None
|
|
error: Optional[str] = None
|
|
warning: Optional[str] = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {k: v for k, v in self.__dict__.items() if not k.startswith("_") and v is not None}
|
|
|
|
|
|
@dataclass
|
|
class PatchResult:
|
|
"""Result from patching a file."""
|
|
success: bool = False
|
|
diff: str = ""
|
|
files_modified: List[str] = field(default_factory=list)
|
|
files_created: List[str] = field(default_factory=list)
|
|
files_deleted: List[str] = field(default_factory=list)
|
|
lint: Optional[Dict[str, Any]] = None
|
|
lsp_diagnostics: Optional[str] = None # see WriteResult.lsp_diagnostics
|
|
error: Optional[str] = None
|
|
# Success-shaped no-op: the edit was already present, nothing written; ``note`` says why.
|
|
no_change: bool = False
|
|
note: Optional[str] = None
|
|
# One ``(path, read_sha256, written_sha256)`` per file write, in order: the sha256 of the
|
|
# bytes the edit read ("" when it created the file, None when unknown) and of the bytes it
|
|
# wrote (None when unknown). patch_tool carries a task's full-file baseline across them.
|
|
_writes: List[Tuple[str, Optional[str], Optional[str]]] = field(default_factory=list)
|
|
|
|
# Emission order is part of the output contract.
|
|
_DICT_FIELDS: ClassVar[tuple] = (
|
|
"diff", "files_modified", "files_created", "files_deleted",
|
|
"lint", "lsp_diagnostics", "error",
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
result: Dict[str, Any] = {"success": self.success}
|
|
if self.no_change:
|
|
result["no_change"] = True
|
|
if self.note:
|
|
result["note"] = self.note
|
|
for key in self._DICT_FIELDS:
|
|
value = getattr(self, key)
|
|
if value:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class SearchMatch:
|
|
"""A single search match."""
|
|
path: str
|
|
line_number: int
|
|
content: str
|
|
mtime: float = 0.0 # Modification time for sorting
|
|
|
|
|
|
@dataclass
|
|
class SearchResult:
|
|
"""Result from searching."""
|
|
matches: List[SearchMatch] = field(default_factory=list)
|
|
files: List[str] = field(default_factory=list)
|
|
counts: Dict[str, int] = field(default_factory=dict)
|
|
total_count: int = 0
|
|
truncated: bool = False
|
|
limit_reason: Optional[str] = None
|
|
warning: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
# Below this many matches the verbose array is already compact enough that
|
|
# a path-grouping header would cost more tokens than it saves.
|
|
_DENSIFY_MIN_MATCHES: ClassVar[int] = 5
|
|
|
|
def _densify_matches(self) -> Optional[str]:
|
|
"""Lossless path-grouped text block: path once, then `` <line>: <content>``
|
|
rows. Relies on rg/grep emitting a file's hits consecutively. None when
|
|
too few matches to be worth it."""
|
|
if len(self.matches) < self._DENSIFY_MIN_MATCHES:
|
|
return None
|
|
lines: list[str] = []
|
|
current_path: Optional[str] = None
|
|
for m in self.matches:
|
|
if m.path != current_path:
|
|
lines.append(m.path)
|
|
current_path = m.path
|
|
# rstrip only: leading indentation is meaningful code and kept verbatim.
|
|
lines.append(f" {m.line_number}: {m.content.rstrip()}")
|
|
return "\n".join(lines)
|
|
|
|
def to_dict(self, densify: bool = False) -> dict:
|
|
result: dict[str, object] = {"total_count": self.total_count}
|
|
if self.matches:
|
|
dense = self._densify_matches() if densify else None
|
|
if dense is not None:
|
|
# Self-describing so the model never guesses the block's shape.
|
|
result["matches_format"] = (
|
|
"path-grouped: each file path on its own line, followed by "
|
|
"indented '<line>: <content>' rows for matches in that file"
|
|
)
|
|
result["matches_text"] = dense
|
|
else:
|
|
result["matches"] = [
|
|
{"path": m.path, "line": m.line_number, "content": m.content} for m in self.matches
|
|
]
|
|
if self.files:
|
|
result["files"] = self.files
|
|
if self.counts:
|
|
result["counts"] = self.counts
|
|
if self.truncated:
|
|
result["truncated"] = True
|
|
result["total_count_is_lower_bound"] = True
|
|
for key in ("limit_reason", "warning", "error"):
|
|
value = getattr(self, key)
|
|
if value:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class LintResult:
|
|
"""Result from linting a file."""
|
|
success: bool = True
|
|
skipped: bool = False
|
|
output: str = ""
|
|
message: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
if self.skipped:
|
|
return {"status": "skipped", "message": self.message}
|
|
result = {"status": "ok" if self.success else "error", "output": self.output}
|
|
if self.message:
|
|
result["message"] = self.message
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class ExecuteResult:
|
|
"""Result from executing a shell command."""
|
|
stdout: str = ""
|
|
exit_code: int = 0
|
|
# Set when the backend's own ``builtin cd -- <cwd> || exit 126`` wrapper failed:
|
|
# the command never ran, so the verdict is about the working directory, not
|
|
# the requested path (callers must neither cache it nor blame the path).
|
|
cwd_error: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure text helpers (no I/O)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_OSC_SEQUENCE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
_FENCE_MARKER_RE = re.compile(r"'?\x07?__HERMES_FENCE_[A-Za-z0-9]+__\x07?'?")
|
|
|
|
|
|
_CONFLICT_OPEN = re.compile(r"^\s*\d+\|<<<<<<< ", re.M)
|
|
_CONFLICT_CLOSE = re.compile(r"^\s*\d+\|>>>>>>> ", re.M)
|
|
|
|
|
|
def count_conflict_blocks(formatted_content: str) -> int:
|
|
"""Unresolved git merge-conflict blocks in a ``LINE|CONTENT`` read; 0 when the page has no
|
|
balanced ``<<<<<<< `` / ``>>>>>>> `` pair (a lone marker in prose or a test fixture is not a
|
|
conflict). Reported on read so the model resolves the conflict instead of editing around it."""
|
|
opens = len(_CONFLICT_OPEN.findall(formatted_content))
|
|
return min(opens, len(_CONFLICT_CLOSE.findall(formatted_content))) if opens else 0
|
|
|
|
|
|
def _strip_terminal_fence_leaks(text: str) -> str:
|
|
"""Strip leaked terminal fence wrappers (OSC sequences, fence markers) from
|
|
command output; drops lines that were nothing but wrapper."""
|
|
if not text:
|
|
return text
|
|
cleaned_lines: List[str] = []
|
|
for line in text.splitlines(keepends=True):
|
|
had_terminal_wrapper = "__HERMES_FENCE_" in line or "\x1b]" in line
|
|
cleaned = _FENCE_MARKER_RE.sub("", _OSC_SEQUENCE_RE.sub("", line)).replace("\x07", "")
|
|
if had_terminal_wrapper and cleaned.strip("'\r\n\t ") == "":
|
|
continue
|
|
cleaned_lines.append(cleaned)
|
|
return "".join(cleaned_lines)
|
|
|
|
|
|
def _detect_line_ending(sample: str) -> Optional[str]:
|
|
"""Dominant line ending of ``sample`` (``\\r\\n`` if any CRLF in the first 4KB,
|
|
else ``\\n``), or None for empty/single-line content. Preserves a file's
|
|
endings across write_file/patch: bare-LF tool args would otherwise silently
|
|
normalize CRLF files, and patch would produce mixed endings."""
|
|
head = sample[:4096] if sample else ""
|
|
if "\r\n" in head:
|
|
return "\r\n"
|
|
if "\n" in head:
|
|
return "\n"
|
|
return None
|
|
|
|
|
|
def _normalize_line_endings(text: str, target: str) -> str:
|
|
"""Convert every line ending (CRLF, lone CR, LF) in ``text`` to ``target``.
|
|
Idempotent. Collapses to LF first — separate replacements would
|
|
double-convert CRLF → LFLF."""
|
|
lf_normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
if target == "\n":
|
|
return lf_normalized
|
|
if target == "\r\n":
|
|
return lf_normalized.replace("\n", "\r\n")
|
|
return text
|
|
|
|
|
|
# UTF-8 BOM (EF BB BF == U+FEFF), prepended by some Windows editors. Stripped on
|
|
# read so the model never sees a phantom first character (and patch's first-line
|
|
# match works), restored on write when the on-disk file had one.
|
|
_UTF8_BOM = "\ufeff"
|
|
|
|
|
|
def _strip_bom(text: str) -> tuple[str, bool]:
|
|
"""Return (text-without-leading-BOM, had_bom). Only a leading BOM is
|
|
stripped; mid-content U+FEFF is legitimate data."""
|
|
if _has_bom(text):
|
|
return text[len(_UTF8_BOM):], True
|
|
return text, False
|
|
|
|
|
|
def _has_bom(text: Optional[str]) -> bool:
|
|
"""True if ``text`` begins with a UTF-8 BOM."""
|
|
return bool(text) and text.startswith(_UTF8_BOM)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pagination clamps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_READ_OFFSET = 1
|
|
DEFAULT_READ_LIMIT = 2000
|
|
DEFAULT_SEARCH_OFFSET = 0
|
|
DEFAULT_SEARCH_LIMIT = 50
|
|
|
|
|
|
def _coerce_int(value: Any, default: int) -> int:
|
|
"""Best-effort integer coercion for tool pagination inputs."""
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def normalize_read_pagination(offset: Any = DEFAULT_READ_OFFSET,
|
|
limit: Any = DEFAULT_READ_LIMIT) -> tuple[int, int]:
|
|
"""Clamp read_file pagination so invalid values can never reach a sed range
|
|
like ``0,-1p`` (schemas declare bounds, but not every caller enforces them).
|
|
The ``limit`` ceiling is ``tool_output.max_lines`` from config.yaml."""
|
|
from tools.tool_output_limits import get_max_lines
|
|
normalized_offset = max(1, _coerce_int(offset, DEFAULT_READ_OFFSET))
|
|
normalized_limit = max(1, min(_coerce_int(limit, DEFAULT_READ_LIMIT), get_max_lines()))
|
|
return normalized_offset, normalized_limit
|
|
|
|
|
|
def normalize_search_pagination(offset: Any = DEFAULT_SEARCH_OFFSET,
|
|
limit: Any = DEFAULT_SEARCH_LIMIT) -> tuple[int, int]:
|
|
"""Return safe search pagination bounds for shell head/tail pipelines."""
|
|
return max(0, _coerce_int(offset, DEFAULT_SEARCH_OFFSET)), max(1, _coerce_int(limit, DEFAULT_SEARCH_LIMIT))
|