fix(tools): stabilize sandbox read_file results - #94875
Christopher-Schulze wants to merge 10 commits into
Conversation
The core diagnosis is right and the fix is well-scoped: a programmatic RPC caller should get idempotent, display-free reads, and the chat-facing dedup/line-number contract is preserved verbatim (the Three concerns, mostly about the gutter-stripping path:
Solid tests; the pagination-without-gutters case (:90-102) is the right shape to keep. |
87d3d1f to
d45a861
Compare
|
Thanks for the structured review — all three points are addressed on the updated branch (head 1. Add-then-strip is fragile → native no-gutter path. 2. Two construction paths could drift → byte-identity pinned by test. 3. No read-loop breaker with deduplicate=False → documented backstop. Verification: focused programmatic-read suite 9 passed; ruff clean; project checker passes all blocking gates on the final head. |
6ef1bcb to
e68b8d1
Compare
|
Follow-up on the CI runs after the last update: three rounds of test fakes/assertions were still matching the old |
e68b8d1 to
a6a98f3
Compare
|
Rebased onto current Conflict in Session kernels copy cell Context via Head |
6a50f92 to
bb277c3
Compare
…l cells Session kernels dispatch through CellAuthority.ctx.run(), which copies the cell Context and drops RPC-thread ContextVars. Wrap handle_function_call for read_file after that copy so sandbox reads stay raw and stable.
bb277c3 to
a33563a
Compare
kvnloo
left a comment
There was a problem hiding this comment.
Scripts calling hermes_tools.read_file inside execute_code got three different shapes back for the same call — raw content, line-numbered display text, or a dedup stub with no content key at all (#93749). The numbered-text case is the nasty one: parsing 21|text as data corrupts silently instead of erroring. This pins the sandbox read to one stable raw shape while the chat path keeps its gutters and dedup. Approving.
Mechanics verified on the code and by running the suites. The fork is a ContextVar (_programmatic_read) consulted in the dispatcher (_handle_read_file), applied at all three sandbox entry points: the RPC handler (code_execution_rpc.py), _dispatch_sandbox_tool_call (per-call sandboxes), and CellAuthority._invoke — the last one is load-bearing, because session kernels run handle_function_call inside ctx.run() which copies the cell context and drops RPC-thread ContextVars, so the flag has to be re-applied per call (verified: the outer wrap in _dispatch_sandbox_tool_call does not propagate into the cell copy; _invoke's inner wrap does the work). The native no-gutter path is add-not-strip: _clamp_read_file_lines applies the identical per-line clamp as _add_line_numbers (same max_line_length, same "... [truncated]" marker — checked character for character) without the gutter, and the structured-document early branch now \n-terminates its raw page to match sed/cut output. Test runs on the PR head: 10/10 new programmatic-read tests, plus 63 read-guard/staleness/loop, 44 code-execution, 71 file-operations — all green, including the three-way byte-identity pinning test.
Non-blocking, the sharpest one: deduplicate=False skips the whole _record_successful_read, not just the loop breaker the body documents. That function also establishes full_write_baselines, updates read_timestamps, and calls file_state.record_read. So a script that reads a file via hermes_tools.read_file no longer counts as "this task has seen the file" — a later chat write_file on that path now hits the "exists but this task has not seen its full current content" refusal where it previously passed (admittedly on a guttered baseline). If the refusal is the intended new contract, name it in the body; otherwise consider keeping the loop-breaker bypass but still recording the baseline and timestamps.
Non-blocking: _read_extracted_document builds the chat page with page_text.rstrip("\n"), which strips all trailing newlines — but _add_line_numbers deliberately drops exactly one terminator so a genuinely selected trailing blank line keeps its gutter number. A chat read of a structured document whose page ends with a blank line now loses that blank line's number (base rendered N|a\n(N+1)|). One-line fix: strip one, not all, before the gutter join.
Non-blocking: tests/tools/test_programmatic_read_raw_repeat.py duplicates test_programmatic_read_returns_raw_content_on_repeated_calls in the main test file, and its if programmatic is not None fallback branch can never fire post-merge. Dead weight; fold it into the main file.
One pattern thread, offered not required: the raw-read contract now lives in an ambient ContextVar with three wiring sites that must each remember to set it. The next consumer of "read with the raw contract" (MCP server reads, a future batch tool) grows a fourth. If the programmatic variant were a first-class registry entry instead of a flag on the dispatcher, the contract would travel with the call instead of the thread.
What does this PR do?
Makes
hermes_tools.read_file()a stable programmatic API insideexecute_code.Sandbox reads now return raw content without chat display gutters on every call, including repeated reads. They still traverse the standard tool dispatcher, middleware, hooks, registry, file safety checks, redaction, and pagination. The chat-facing
read_filebehavior remains unchanged.Related Issue
Fixes #93749
Type of Change
Changes Made
tools/file_tools.pythat disables chat-only line gutters and repeated-read dedup responses.ctx.run(), which dropsContextVars, soCellAuthority._invokeapplies the same programmatic wrap on the per-call path.ShellFileOperations.read_file(..., line_numbers=False)builds raw content directly (same per-line clamp, no gutter) instead of add-then-strip; the old_strip_read_file_gutterhelper is removed.\n, byte-identical to the sed/cut output of the native file_ops path on the same window.deduplicate=Falsethe consecutive-loop breaker does not apply to sandbox RPC callers — the RPC call limit and per-script timeout remain the guard.ShellFileOperations.read_file(line_numbers=False)).How to Test
scripts/run_tests.sh tests/tools/test_code_execution_programmatic_read.py tests/tools/test_code_execution.py tests/tools/test_file_operations.py -q.ruff check .anduv lock --check.hermes_tools.read_file(path)twice and confirm both dicts contain identical rawcontent.Checklist
Code
fix(scope):,feat(scope):, etc.)Documentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
Focused programmatic-read suite: 9 passed. CI surfaced three rounds of test fakes/assertions still matching the old
read_filesignature (noline_numberskwarg) intest_file_read_guards.py,test_file_staleness.py,test_read_loop_detection.py, andtest_file_tools.py— all updated; the final head passes the full project checker with every blocking gate green. The broader file-tools suite otherwise shows only the known macOS/tmp-vs-/private/tmppath-expectation failures that reproduce identically onorigin/main.The complete suite was also started and reached 1,170 passes before being stopped after three unrelated failures caused by the optional
anthropicSDK being absent from the lockedall/devenvironment. The first failure reproduces identically onorigin/main(tests/agent/test_auxiliary_transport_autodetect.py: 15 passed, 1 failed).