fix(file-tools): abort write/patch when AI truncation placeholders detected - #68512
fix(file-tools): abort write/patch when AI truncation placeholders detected#68512ygd58 wants to merge 2 commits into
Conversation
…tected Ports NousResearch#64233 forward onto current main per teknium1's review. AI models sometimes emit placeholder strings like '// ... unchanged ...' or '/* ... full function ... */' when they omit sections they consider unmodified. Writing these to disk produces corrupt files (issue NousResearch#20805). _check_truncation_signatures() applied at three backend-aware pipeline points: 1. write_file_tool: unconditional (no original to compare against). 2. patch_replace (new_string): compared against old_string so a pre-existing placeholder in the replaced region isn't re-flagged. 3. patch_v4a (patch content): checked against only the ADDED content. Per review: the V4A check previously scanned the complete serialized patch text, which would false-positive on a legitimate edit that removes, or merely anchors context on, a pre-existing placeholder-like literal (V4A hunks represent added '+', removed '-', and context ' ' lines distinctly). Added _extract_v4a_added_content(), which parses the patch via tools.patch_parser.parse_v4a_patch() and joins only '+' hunk lines and full Add-file content, falling back to raw-text scanning if the patch fails to parse (so a malformed patch doesn't silently skip the check). Added direct patch_tool coverage for both changed branches (replace and V4A) plus the V4A false-positive regression cases (placeholder on a removed line, on a context line, and in a brand-new Add-File). 16/16 new tests pass; 64/64 in the full tests/tools/test_file_tools.py file.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for carrying the guard forward and addressing the earlier V4A false-positive review. Current main still forwards raw content through write_file_tool (tools/file_tools.py:1600,1621) and patch_tool (tools/file_tools.py:1766,1770), so the premise remains valid. The V4A extraction matches the parser’s application model (tools/patch_parser.py:477-484), which writes Add File content from + lines only.
Problems
tools/file_tools.py:1602treats an occurrence inoriginalas permission for every same-signature occurrence incontent. Ifold_stringalready contains one literal// ... unchanged ..., anew_stringthat retains it and adds a second omitted section passes the guard and reachespatch_replaceattools/file_tools.py:1862.
Suggested changes
- Compare per-signature occurrence counts after case normalization, rejecting when proposed content adds an occurrence beyond
original. - Add a replace-path regression for one existing occurrence plus one introduced duplicate, asserting the backend is not called.
Automated hermes-sweeper review.
| for sig in _TRUNCATION_SIGNATURES: | ||
| sig_lower = sig.lower() | ||
| if sig_lower in content_lower: | ||
| if original is None or sig_lower not in original.lower(): |
There was a problem hiding this comment.
This boolean membership check allows a newly introduced duplicate placeholder whenever original already contains the same literal once. Compare normalized occurrence counts (and reject an increase) so retaining one pre-existing literal remains allowed but adding a second omitted section is blocked.
…boolean membership Follow-up per review of NousResearch#68512. _check_truncation_signatures() used a boolean membership check -- 'is this signature present in original at all' -- to decide whether an occurrence in content was pre-existing (allowed) or newly introduced (blocked). This meant that if original already contained ONE literal occurrence of a signature (e.g. a legitimate '// ... unchanged ...' comment), the check treated EVERY occurrence in content as pre-existing and allowed, regardless of count. A new_string that retained the one existing occurrence and added a second, genuinely truncated occurrence passed the guard undetected and reached patch_replace. Fixed: compare per-signature occurrence COUNTS (case-normalized) between content and original, rejecting when content's count exceeds original's. A pre-existing occurrence retained unchanged still passes (count unchanged); adding any occurrence beyond what original had is now blocked, regardless of how many already existed. Added the requested replace-path regression (one existing occurrence plus one introduced duplicate, asserting the backend is not called), plus direct unit tests for _check_truncation_signatures itself covering the count-exceeds-original block case and a same-count (moved, not added) sanity case. 16/16 pass in TestTruncationSignatureGuard; 67/67 in the full tests/tools/test_file_tools.py file (no regression).
|
Fixed -- now compares per-signature occurrence COUNTS (case-normalized) between content and original, rejecting when content's count exceeds original's, instead of a boolean membership check. A retained pre-existing occurrence still passes (count unchanged); adding any occurrence beyond what original had is now blocked regardless of how many already existed. Added the requested replace-path regression (one existing occurrence plus one introduced duplicate, asserting the backend is not called), plus direct unit tests for the count-exceeds and same-count (moved, not added) cases. 16/16 pass in TestTruncationSignatureGuard; 67/67 in the full file. |
SummaryThree PRs address the truncation-overwrite portion of #20849 by adding guards against AI omission placeholders; their diffs touch file-edit tooling and tests, not the issue’s separate handoff/context-retrieval, destructive-action, or memory concerns. #20857 is the initial guard, #64233 extends it across backend-aware edit paths, and #68512 corrects the V4A behavior and occurrence-count bypass with broader regression coverage. Related pull requests
Duplicates#20857, #64233, and #68512 implement the same truncation-placeholder guard; #64233 supersedes the initial shape of #20857, and #68512 is the corrected forward port that supersedes both closed PRs. Suggested consolidationKeep #68512 open with a salvage path: retain its count-based replacement guard, parsed V4A added-content validation, and direct write/replace/V4A regression tests, then obtain maintainer confirmation that the keep_open review’s occurrence-count blocker is resolved at the current head. Keep #20857 and #64233 closed as superseded duplicates of #68512, and track the other architectural causes in #20849 separately. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I20849(["issue #20849 (open)"])
subgraph Dup20857 ["PRs duplicating each other"]
P20857["PR #20857 (closed)"]
P64233["PR #64233 (closed)"]
P68512["PR #68512 (open)"]
end
P68512 -->|best fix| I20849
class I20849 open
class P20857 closed
class P64233 closed
class P68512 open
class P68512 best
class P68512 target
click I20849 "https://github.com/NousResearch/hermes-agent/issues/20849"
click P20857 "https://github.com/NousResearch/hermes-agent/pull/20857"
click P64233 "https://github.com/NousResearch/hermes-agent/pull/64233"
click P68512 "https://github.com/NousResearch/hermes-agent/pull/68512"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 3 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 30 kB of PR diffs, 7 kB of issue/PR text, 7 kB of discussion (9 comments), 4 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
|
Thanks for the earlier work here — especially the count-based original-vs-content comparison and the AI "unchanged"/"rest of file" signature list. Those pieces are folded into the combined stack for #83714 (branch |
|
Quick housekeeping note from the #83714 thread (no action needed from you unless you want it): We hit a related failure mode where models wrote a literal Your PR was genuinely useful prior art — especially:
#83752 covers the same three pipeline points on current
Either way, thank you — the design points above influenced how we described residual risk and follow-ups. No expectation that you chase the rebase unless you want to. |
Context
Ports #64233 forward onto current main per @teknium1's review.
Problem
AI models sometimes emit placeholder strings like
// ... unchanged ...or/* ... full function ... */when they omit sections they consider unmodified. Writing these produces corrupt files (issue #20805).Fix
_check_truncation_signatures()applied at three backend-aware pipeline points:write_file_tool(unconditional),patch_replacenew_string (compared against old_string),patch_v4apatch content (only the ADDED content).Per review: the V4A check previously scanned the complete serialized patch text, which would false-positive on a legitimate edit that removes, or merely anchors context on, a pre-existing placeholder-like literal. Added
_extract_v4a_added_content(), which parses the patch viatools.patch_parser.parse_v4a_patch()and joins only+hunk lines and full Add-file content, falling back to raw-text scanning if the patch fails to parse.Verification
Added direct
patch_toolcoverage for both changed branches (replace and V4A) plus the V4A false-positive regression cases (placeholder on a removed line, on a context line, and in a brand-new Add-File). 16/16 new tests pass; 64/64 in the fulltests/tools/test_file_tools.pyfile.