Skip to content

fix(read): don't flag UTF-8 text as binary when byte 1000 cuts a multibyte char - #80261

Closed
luntion wants to merge 1 commit into
NousResearch:mainfrom
luntion:fix/read-file-utf8-boundary-cut
Closed

fix(read): don't flag UTF-8 text as binary when byte 1000 cuts a multibyte char#80261
luntion wants to merge 1 commit into
NousResearch:mainfrom
luntion:fix/read-file-utf8-boundary-cut

Conversation

@luntion

@luntion luntion commented Aug 6, 2026

Copy link
Copy Markdown

Summary

read_file / read_file_raw misclassify legitimate UTF-8 text files as binary
when the 1000-byte sampling window lands mid-way through a multi-byte character.

Symptoms

A UTF-8 text file whose byte 1000 falls inside a multi-byte char (CJK, Cyrillic,
emoji — anything 2–4 bytes) gets flagged is_binary: true and refuses to read.
Concretely on a Chinese-language markdown file:

read_file → error: "Binary file - cannot display as text."

Same file reads fine on a retry because the byte position of the truncation
depends on the file's exact byte length — it is a ~50% lottery per file, not a
content property.

Root cause

tools/file_operations.py samples the first 1000 bytes with
head -c 1000, then decodes that sample with errors="replace". When byte
1000 cuts a multi-byte UTF-8 character in half, the truncated tail decodes to
U+FFFD (replacement char). _is_likely_binary sees U+FFFD and — correctly, for
genuine mojibake — treats the file as binary and blocks the read. The sample
boundary is byte-aligned, not character-aligned, so any UTF-8 text can trip it.

Verified: for the affected files, head -c 1000 ends with a continuation byte
(0x80–0xBF); decoding that sample yields exactly one U+FFFD at the tail;
_is_likely_binary then returns True.

Changes

tools/file_operations.py (both call sites — read_file and read_file_raw):

Replace the raw head -c 1000 sample with a small python probe that reads the
first 1000 bytes and extends the window to the next UTF-8 character
boundary
before writing the sample:

  • while n < len(d) and (d[n] & 0xC0) == 0x80: n += 1 — skip continuation
    bytes so the sample always ends on a complete character.
  • Output is written via sys.stdout.buffer.write (raw bytes, no re-encoding),
    matching how the terminal env already decodes stdout as UTF-8.
  • sys.executable is shell-escaped via the existing _escape_shell_arg.

Why this approach (not errors="ignore"): decoding the sample with
errors="ignore" would drop the U+FFFD signal entirely, so genuinely
undecodable content (latin-1 blobs, raw binary without a known extension) would
pass _is_likely_binary and get returned as lossy text — enabling a
read→edit→write round-trip to silently corrupt the original bytes. That
protection is the entire point of the U+FFFD branch (see its docstring). The
boundary-extension fix removes only the artifact of the byte-aligned cut;
real mojibake still produces U+FFFD mid-sample and is still caught.

Testing

  • New TestReadUtf8BoundaryCut class (4 tests, real subprocess via
    LocalEnvironment):
    • 3-byte CJK char straddling byte 1000 → read_file returns text, not binary
    • same for read_file_raw
    • 4-byte emoji spanning the window tail stays intact
    • genuine binary (NUL + high-bit garbage) still flagged binary
  • Updated the existing test_read_file_uses_bash_safe_windows_paths command
    assertions to the new python sampler command shape.
  • tests/tools/test_file_operations.py + test_file_operations_edge_cases.py:
    63 passed, 8 failed — all 8 failures are pre-existing on clean main on this
    Windows box (TestSearchFilesFallbackHiddenPaths ×2, atomic-write umask
    permissions ×4, symlink writes ×2 — local-environment limitations, unrelated
    to this change; verified by stashing the change and re-running).

Related

Risks

  • The sample command is now python -c … instead of head -c 1000. It runs
    once per read call, reads at most ~1003 bytes, and exits immediately — cost
    is negligible. sys.executable is escaped like every other shell arg in the
    file. Non-UTF-8 text files (e.g. latin-1) keep the existing behavior: sample
    decodes with replacement chars → flagged binary → read-only, preventing
    silent corruption. No config, schema, or env changes.

…ibyte char

The binary-content sampler in read_file/read_file_raw took the first 1000
bytes with head -c 1000 and decoded with errors="replace". When the
byte-aligned cut landed inside a multi-byte UTF-8 character, the truncated
tail decoded to U+FFFD, and _is_likely_binary — correctly, for genuine
mojibake — flagged the whole file as binary and refused to read it.
Legitimate UTF-8 text (CJK, Cyrillic, emoji) was blocked roughly half the
time depending on exact byte length.

Fix both call sites to sample via python and extend the window to the next
UTF-8 character boundary before writing the sample, so the probe never
contains a truncated character. Genuinely undecodable content still
produces U+FFFD mid-sample and is still caught by _is_likely_binary,
preserving the read→edit→write corruption guard that errors="ignore"
would have defeated.

Adds TestReadUtf8BoundaryCut regression tests (3-byte CJK straddling byte
1000, 4-byte emoji spanning the tail, genuine binary still flagged) and
updates the command-shape assertion in
test_read_file_uses_bash_safe_windows_paths.
@alt-glitch alt-glitch added type/bug Something isn't working comp/tools Tool registry, model_tools, toolsets tool/file File tools (read, write, patch, search) P2 Medium — degraded but workaround exists needs-decision Awaiting maintainer decision before any implementation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 6, 2026
@kyssta-exe

Copy link
Copy Markdown
Contributor

Triage note: this appears to duplicate three other PRs that fix the same issue — UTF-8 files whose 1000-byte read_file sample lands mid-multibyte-char (trailing U+FFFD) get misclassified as binary. Same files (tools/file_operations.py + tests), same fix. The cluster: #80186, #80188, #80250. Recommend picking one to champion and closing the rest as duplicates.

teknium1 pushed a commit that referenced this pull request Aug 8, 2026
…ort-lossy text

Fixes the read_file half of #80308 and the class behind #80261, #80250,

The binary sniff sampled files via 'head -c 1000' through the terminal
transport, which decodes stdout with errors="replace". A multibyte
character cut at byte 1000 therefore arrived as U+FFFD, and
_is_likely_binary treated any U+FFFD as binary — flagging valid CJK and
emoji text as unreadable. At the text layer a stored replacement char
and a transport-manufactured one are indistinguishable, which is why
per-callsite adjustments kept leaving siblings open.

Sample as 'head -c 1000 | base64' so raw bytes survive the transport
(fail-open to the legacy heuristic when the transport cannot produce
clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing
one incomplete multibyte sequence at the sample end => text; mid-stream
invalid UTF-8 (latin-1, true binaries) => read-only, preserving the
anti-mojibake guarantee the old check existed for. Files legitimately
containing U+FFFD become readable.
@teknium1

teknium1 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closing — superseded by #81961, which fixed this bug class (1000-byte sample cutting a multibyte char → binary false positive) at the byte layer across read_file, patch, and search_files, with regression tests for truncated-CJK/BOM/UTF-16/NUL cases. ~24 PRs raced on this one; earliest diagnosis credit to @webtecnica (#76924), merged implementation from @ayushnangia (#80440). Thanks for jumping on it.

@teknium1 teknium1 closed this Aug 8, 2026
@webtecnica

Copy link
Copy Markdown
Contributor

Thanks @teknium1 for the credit — glad the diagnosis held up as the earliest of the ~24. The byte-layer detection in #81961 covering both file-ops sites is the right contract, and the truncated-CJK/BOM/UTF-16/NUL regressions make it solid. Appreciate the recognition!

ma1138569845 pushed a commit to ma1138569845/dechnicAuditor-agent that referenced this pull request Aug 10, 2026
…ort-lossy text

Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250,

The binary sniff sampled files via 'head -c 1000' through the terminal
transport, which decodes stdout with errors="replace". A multibyte
character cut at byte 1000 therefore arrived as U+FFFD, and
_is_likely_binary treated any U+FFFD as binary — flagging valid CJK and
emoji text as unreadable. At the text layer a stored replacement char
and a transport-manufactured one are indistinguishable, which is why
per-callsite adjustments kept leaving siblings open.

Sample as 'head -c 1000 | base64' so raw bytes survive the transport
(fail-open to the legacy heuristic when the transport cannot produce
clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing
one incomplete multibyte sequence at the sample end => text; mid-stream
invalid UTF-8 (latin-1, true binaries) => read-only, preserving the
anti-mojibake guarantee the old check existed for. Files legitimately
containing U+FFFD become readable.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ort-lossy text

Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250,

The binary sniff sampled files via 'head -c 1000' through the terminal
transport, which decodes stdout with errors="replace". A multibyte
character cut at byte 1000 therefore arrived as U+FFFD, and
_is_likely_binary treated any U+FFFD as binary — flagging valid CJK and
emoji text as unreadable. At the text layer a stored replacement char
and a transport-manufactured one are indistinguishable, which is why
per-callsite adjustments kept leaving siblings open.

Sample as 'head -c 1000 | base64' so raw bytes survive the transport
(fail-open to the legacy heuristic when the transport cannot produce
clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing
one incomplete multibyte sequence at the sample end => text; mid-stream
invalid UTF-8 (latin-1, true binaries) => read-only, preserving the
anti-mojibake guarantee the old check existed for. Files legitimately
containing U+FFFD become readable.
blut-agent pushed a commit to blut-agent/hermes-agent-fork that referenced this pull request Aug 11, 2026
…ort-lossy text

Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250,

The binary sniff sampled files via 'head -c 1000' through the terminal
transport, which decodes stdout with errors="replace". A multibyte
character cut at byte 1000 therefore arrived as U+FFFD, and
_is_likely_binary treated any U+FFFD as binary — flagging valid CJK and
emoji text as unreadable. At the text layer a stored replacement char
and a transport-manufactured one are indistinguishable, which is why
per-callsite adjustments kept leaving siblings open.

Sample as 'head -c 1000 | base64' so raw bytes survive the transport
(fail-open to the legacy heuristic when the transport cannot produce
clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing
one incomplete multibyte sequence at the sample end => text; mid-stream
invalid UTF-8 (latin-1, true binaries) => read-only, preserving the
anti-mojibake guarantee the old check existed for. Files legitimately
containing U+FFFD become readable.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…ort-lossy text

Fixes the read_file half of NousResearch#80308 and the class behind NousResearch#80261, NousResearch#80250,

The binary sniff sampled files via 'head -c 1000' through the terminal
transport, which decodes stdout with errors="replace". A multibyte
character cut at byte 1000 therefore arrived as U+FFFD, and
_is_likely_binary treated any U+FFFD as binary — flagging valid CJK and
emoji text as unreadable. At the text layer a stored replacement char
and a transport-manufactured one are indistinguishable, which is why
per-callsite adjustments kept leaving siblings open.

Sample as 'head -c 1000 | base64' so raw bytes survive the transport
(fail-open to the legacy heuristic when the transport cannot produce
clean base64), then classify bytes: NUL => binary; valid UTF-8 allowing
one incomplete multibyte sequence at the sample end => text; mid-stream
invalid UTF-8 (latin-1, true binaries) => read-only, preserving the
anti-mojibake guarantee the old check existed for. Files legitimately
containing U+FFFD become readable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/file File tools (read, write, patch, search) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants