Skip to content

add: a large tool result is saved to a file and read as a preview, on Claude Code's measured terms - #517

Merged
Yiannis128 merged 3 commits into
masterfrom
feat/tool-output-files
Nov 10, 2026
Merged

Yiannis128 merged 3 commits into
masterfrom
feat/tool-output-files

Conversation

@Yiannis128

@Yiannis128 Yiannis128 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

A long DeepSeek V4 Pro chat filled its context with raw command and file output and lost output to the 3-minute command cap. This PR makes Entoli handle tool output the way Claude Code does. The behaviour was measured, not recalled: I probed Claude Code 2.1.321's own Bash and Read tools from a live session and checked each result in the JS bundled in its binary.

What Claude Code does (measured)

Persisted output (every tool, in its tool-result layer)

  • A result longer than the tool's maxResultSizeChars, capped at a global 50000, is persisted. For Bash the limit is 30000 chars (BASH_MAX_OUTPUT_LENGTH default 30000). Grep's is 20000, most tools' 50000. Read's is Infinity, since Read bounds itself by tokens. Probes of 30001 and 30118 chars were persisted.
  • The notice, verbatim:
    <persisted-output>
    Output too large (49.8KB). Full output saved to: ~/.claude/projects/<project>/<session>/tool-results/<id>.txt
    
    Preview (first 2KB):
    …
    ...
    </persisted-output>
    
    Sizes are KiB to one decimal with a trailing .0 dropped (Qt). The ... line appears only when there is more.
  • The preview is the first 2000 chars, cut back to the last newline if that newline is past char 1000, otherwise a hard cut (lAe). With 51-char lines it showed 39 lines (1988 chars).

Timeouts

  • Default 120000 ms, maximum 600000 ms (BASH_DEFAULT_TIMEOUT_MS, BASH_MAX_TIMEOUT_MS).
  • A command that overran a 4 s timeout returned its partial stdout and stderr, then: Command did not complete within its 4s timeout and was moved to the background (ID: …). Output is being written to: <tasks>/<id>.output. You will be notified when it completes. …. It was not killed: it finished later and a completion notification arrived. Where a command cannot be backgrounded it is killed, and the result ends Command timed out after <duration>.

Background

  • run_in_background answers Command running in background with ID: <id>. Output is being written to: <path>. You will be notified when it completes — do not poll that file or sleep while waiting.
  • On completion a <task-notification> arrives as its own event: task id, output file, status, and Background command "<cmd>" completed (exit code N).
  • A non-zero exit reads Exit code 3\nout\nerr.

Read

  • Default window: 2000 lines. Token cap: 25000.
  • A whole-file read over the cap returns the largest prefix of whole lines that fits, then [Truncated: PARTIAL view — <path>: showing lines 1-253 of 501 total (42006 tokens, cap 25000). Call Read with offset=254 limit=253 for the next page, or Grep to find a specific section. … Do NOT answer from this page alone if the answer may be further in the file.]
  • A read with an explicit offset and limit still over the cap is an error: File content (33606 tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.

What this changes in Entoli

  • One persistence point for every tool. BuiltinAgent._savingLarge wraps each call's events, batched calls included, and saveIfLarge is the single size test (the ! command uses it too, with BashTool.outputLimit). A result over the tool's inlineLimit is written to .entoli-output/<call id>.txt in the chat's workspace: bash 30000, other tools 50000, read and ask_user none. The path rides the result as a fact, ToolResult.outputFile, and the projection words Claude Code's notice from it, so a reload words what the live turn saw. The log keeps the whole output, and the chat shows all of it.
  • Where the files live. In the chat's workspace beside .entoli-jobs/, so read and tail reach them from every shell. The model is given an absolute path (shellPath), the guest's inside a guest, so a tail after a cd still works. Background-job logs are now named the same way; they used to be a chat:-relative name that only read understood. Both are named by one sanitiser, fileStemOf. The files go when the chat does, and a sub-agent's land in the parent chat's workspace. They are not in .entoli-tmp, which is the model's own scratch and may be cleared. Names never collide: providers that send no ids get call_$i, so a repeat id saves as call_0-2.txt.
  • read has a 2000-line default window and a 25000-token cap (estimated at 4 chars per token, as estimateTokens does everywhere else). It gives Claude Code's partial page for a whole-file read and its error for an explicit range. One line longer than the cap is cut by characters.
  • Timeouts. commandTimeout is 2 min (the default) and the new maxCommandTimeout is 10 min (the cap). The ! command gets the 10. ShellResult.timedOut records why a command stopped, and bash adds that a command that long belongs in background: true.
  • Tool descriptions. command says the whole output comes back, is saved past 30000, and that | tail/| head loses everything when the timeout kills the pipeline (14 of the chat's 21 empty timeouts were like that). timeout and background say long builds go to a job.
  • A job's end reaching the model. It didn't always. An end that landed while a turn was running waited for that turn's next tool call. When the turn made no further call (the usual case: the end arrives while the last reply is being written), the agent was never told and nothing woke it. Now ChatJobs keeps each end until a tool result carries it or a wake tells it (ChatShellJobs.unheard), and once a turn is over and the room settled, the room asks for that list and wakes the agent for the first one. The room keeps no list of its own. Stopped and failed turns still wake nothing.
  • The ! command saves on bash's terms (ShellRunPart.outputFile).

Deliberate differences

  • A timed-out command is killed, not moved to the background. Every Claude Code command is its own process, so it costs nothing to stop waiting on one. A chat's commands here run in one persistent shell, and a command left running in it would block every later command. So the kill stays, with its output kept, plus the advice to rerun in the background.
  • The upstream head-and-tail elision is kept only as a fallback, for results that have no file (older logs, failed saves). Its threshold rises from 10000 to 50000 to match the largest inline limit, so a 20k bash result is no longer cut.
  • The 2000-line read window says when it stops short of the end. I found no such note in Claude Code's source (its description states the limit), and a silent cut reads as the whole file.
  • Read's tokens are estimated at 4 chars each, since Entoli has no tokenizer.
  • The notice text sits inside the observation JSON as the output value, keeping Entoli's JSON result shape.

Sync and mirrors

A mirror's turns run on the home device ("Driving a turn from the other end"), and the mirror reads the log, which keeps the whole output. So the mirror's chat shows everything, and the path is only ever given to a model on the device that has the file. Older peers ignore the new field.

Tests

  • builtin_agent_test: a 51000-char result is saved, and the model reads exactly Claude Code's notice. 30000 chars is worded whole and 30001 is saved. The default and maximum timeouts reach the shell.
  • projection_test: a result with outputFile words as the notice on a reload. A long result with no file is cut past 50000 and worded whole below it.
  • background_jobs_test: a timed-out command keeps its output and is pointed at the background.
  • read_page_test: the partial page, the range refusal, the 2000-line window, and a very long line.
  • saved_output_test: a repeated call id, guest paths, and a failed save.
  • local_shell_test: timedOut on a real timeout, and not on a stop.
  • chat_room_test: a job ending mid-turn with no later call wakes the agent after the turn, and an end a later call heard does not.
  • background_jobs_test: an end stays unheard until a call carries it or a wake tells it.
  • formatting_test: safeCut never splits a surrogate pair.

A /simplify review was applied in a follow-up commit: one size test, the jobs as the only record of unheard ends, a timedOut getter on the run, one switch in observation, safeCut, charsPerToken beside estimateTokens, and readPage numbering lines only up to the cap.

flutter analyze is clean. Only the files touched were run locally; CI runs the rest.

… Claude Code's measured terms

A result over its tool's limit (bash 30,000 characters, other tools
50,000) is written whole to .entoli-output/<call id>.txt in the chat's
workspace and worded for the model as Claude Code's <persisted-output>
notice: the size, the path and a 2,000-character preview. The path rides
the result as ToolResult.outputFile; the log keeps the whole output.

read returns 2,000 lines by default and is held to 25,000 tokens: a
whole-file read over the cap is the lines that fit, with Claude Code's
PARTIAL view notice, and a range over it is refused in Claude Code's
words.

Foreground commands get two minutes by default and ten at most. A
timed-out command keeps its output and is pointed at background jobs.
The descriptions warn against piping through tail. A job that ends
during a turn with no later tool call now wakes the agent when the turn
is over.
…e cut

- saveIfLarge is the one length test; the ! command reads
  BashTool.outputLimit rather than a template constant.
- ChatJobs keeps the unheard ends (ChatShellJobs.unheard), and the room
  asks at settle instead of holding a second list.
- _RunState.timedOut, one switch in observation, safeCut in
  core/formatting, charsPerToken beside estimateTokens, fileStemOf for
  job logs and saved results alike.
- A job's log is named to the model by the same absolute path a saved
  result is (shellPath).
- readPage numbers lines only up to the cap.
@Yiannis128
Yiannis128 merged commit 7a8a072 into master Nov 10, 2026
2 checks passed
@Yiannis128
Yiannis128 deleted the feat/tool-output-files branch November 10, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant