fix(tools): refuse patch/write_file when content contains a truncation placeholder - #83752
fix(tools): refuse patch/write_file when content contains a truncation placeholder#83752djbclark wants to merge 1 commit into
Conversation
…n placeholder Fixes NousResearch#83714. write_file_tool and patch_tool (both replace and V4A modes) wrote the model's function-call arguments straight to disk with no check for AI truncation-placeholder markers like "...[truncated]". When a model (observed with deepseek-v4-pro via deepseek) abbreviated a long new_string instead of emitting it in full, the literal marker text landed in the file, corrupting it. No length-based truncation of `content`/`new_string`/`patch` exists anywhere between JSON-parsing the tool call and the write/patch calls, so this rules out Hermes truncating the parameter in transit. The likely mechanism: Hermes itself uses this exact marker to signal truncated tool *output* elsewhere (todo_tool.py, file_operations.py's per-line cap, mcp_tool.py), and the model is imitating a pattern from its own context. Adds `_find_truncation_placeholder()` and wires it into: - write_file_tool: scans `content` - patch_tool replace mode: scans `new_string`, skipping markers already present in `old_string` (avoids false positives on legitimate edits to text that mentions the marker) - patch_tool V4A mode: scans only ADDED content via `_extract_v4a_added_content()` (parses the patch and joins '+' hunk lines plus Add-File bodies), not the whole patch text, so a patch that removes or merely anchors context on a placeholder-like literal isn't flagged Falls back to raw-text scanning if the V4A patch fails to parse, so detection degrades gracefully instead of silently skipping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
f90552b to
c484358
Compare
… into replayed tool_calls Root cause for NousResearch#83714 (write_file/patch_tool writing literal "...[truncated]" into files, PR NousResearch#83752's guard is the safety net, not the fix): _truncate_tool_call_args_json() in the compression pass shrinks long string values inside a PAST assistant message's tool_calls[].function.arguments — the exact field that represents the model's own prior generated output, replayed back to it verbatim on every subsequent turn. The old marker, a bare "...[truncated]" suffix, is indistinguishable from something the model itself could have written (it's exactly the kind of terse ellipsis abbreviation models already produce). A model conditioned on seeing itself "get away with" that pattern in its own history imitates it in a new tool call, writing the literal marker instead of real content. This is the second bug from the same root text. The first (NousResearch#11762, MiniMax 400s from unterminated JSON) was fixed by shrinking inside the parsed structure so the JSON stays valid, but kept the same visible marker text — fixing the syntax problem while leaving the imitation problem untouched. Fix: replace the marker with one deliberately NOT shaped like prose a model would write — distinctive non-ASCII delimiters, an explicit "not part of the original tool call" disclaimer, and a per-instance char-count that won't match the next omission point even if copied verbatim. The shrunk value stays a plain string (not a nested object) so the NousResearch#11762 valid-JSON/matching-shape contract is unchanged — only the marker text changed. Checked context_compressor.py's other "...[truncated]" call sites (_serialize_for_summary, _compact_fallback_turn, the user-message-only one near _ACTIVE_TASK_MAX_CHARS) — none of them write into a value that gets replayed as the main model's own assistant/tool_calls history, so they don't share this priming risk and were left as-is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review follow-up after root-cause confirmation (#83843) + local retestCode quality here looks solid (V4A added-only scan, old_string exception, parse fallback, good regression tests). A few clear residual gaps worth addressing before/with merge — not blockers on the idea, just tightening defense-in-depth now that we know the real primer. 1. Also refuse the new compressor marker (after #83843)#83843 replaces bare
Suggestion: add patterns for 2. PR body framing is slightly stale vs #83843Investigation still emphasizes “model imitating truncated tool output.” That remains a secondary source (see below), but the confirmed primary primer for #83714 is Pass‑3 3. Why this guard stays necessary even after #83843Many model-visible tool results still emit the old marker family, independent of the compressor tool_call-arg path, e.g.:
Those land as 4. Optional: bare
|
|
Follow-up implemented on combined branch djbclark:fix/truncation-combined-83714 (shared |
|
Housekeeping: description rewritten for a cleaner review path. Canonical pair for #83714: #83843 first (root cause) → this PR second (write guard). The combined stack PR #83858 is closed as redundant (local-deploy convenience only). Please ignore it. Overlap with #68512 is called out in the description with credit to @ygd58; happy for maintainers to fold any missing signatures rather than landing two guards. Thank you for your time reviewing. |
|
Independent verification on exact head
We encountered the reported failure class in real use: a long file-tool argument was replaced by a literal truncation placeholder, while the surrounding tool-call JSON remained valid. The added replace/V4A/write guards and untouched-target regressions passed on the exact PR head. This is useful defense-in-depth even with #83843, because already-compacted histories and model-visible tool-result markers can remain. No additional blocker found in this verification. |
fix(tools): refuse patch/write_file when content contains a truncation placeholder Good safety net with carefully reasoned false-positive handling (added-only scan for V4A, old_string exemption for patch). The tests cover the important branches. A few observations:
|
Summary
Defense-in-depth fix for #83714 (and related AI “omitted the rest” write corruption).
write_file/patch(replace + V4A) previously wrote model-supplied string arguments straight to disk. When a model emitted a truncation placeholder (observed as literal...[truncated]innew_string), the file was corrupted with no error.Change
contentnew_string, allowing markers already present inold_string(legitimate docs/edits)+lines / Add-File bodies) via_extract_v4a_added_content(), so removing or anchoring on a pre-existing placeholder-like line is not a false positiveRelationship to root cause
The confirmed primer for the bare
...[truncated]pattern in long sessions is context compression rewriting past assistant tool-call args — fixed in #83843. This PR remains useful even after that lands because:... [truncated],… [truncated]) in several toolsOverlap with #68512
#68512 (open, currently CONFLICTING) proposes a broader signature list and a count-based original-vs-content check at the same pipeline points. This PR is a narrower, current-on-
mainfix with regression coverage from the #83714 repro. Maintainers should pick one write-guard path rather than landing both; folding any missing signatures / count-based logic from #68512 into this PR is welcome.Testing
pytest tests/tools/test_file_tools.py -q— green aside from 2 pre-existing macOS/tmp→/private/tmpmock path mismatches (reproduced onmainwithout this change)Suggested landing order
Related
Thank you for reviewing — happy to adjust patterns or wording.