fix(agent-core): cap all tool result text at the agent-loop chokepoint - #175
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: every tool result's text content must be bounded before it enters session history and gets re-sent to the model on every subsequent turn. Cause: built-in tools cap their own output at 50KB (harness/utils/truncate.ts DEFAULT_MAX_BYTES; coding-agent/src/core/tools/truncate.ts), but extension, MCP, and custom tool results were never capped anywhere - tool-definition-wrapper.ts passes them through, agent-session.ts's afterToolCall only normalizes images, and agent-loop.ts's createToolResultMessage wrote content as-is. A multi-megabyte MCP/extension result would be persisted to the session JSONL forever and re-sent on every turn. Fix: createToolResultMessage() - the single chokepoint through which every finalized tool result passes on its way to becoming a ToolResultMessage, regardless of source - now runs the result's text content blocks through capToolResultContent() before constructing the message. When the combined UTF-8 byte size of all text blocks exceeds the cap, a head/tail of the text is kept (using new byte-safe, surrogate-pair-safe truncateStringToBytesFromStart/End helpers) and an elision marker is inserted stating the tool name, original size, cap, and instructing the model to re-call with narrower arguments or pagination. Images pass through untouched. isError/details/usage/terminate are preserved unchanged. New AgentLoopConfig.maxToolResultBytes (threaded through AgentOptions / Agent.maxToolResultBytes): undefined applies the default (DEFAULT_MAX_TOOL_RESULT_BYTES = 128KB, comfortably above the built-in 50KB caps so built-in tool output is unaffected); 0 (or any non-positive value) disables capping; a positive number sets a custom cap.
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
Review effort: Lite
Findings: 1
Open (3)
As written, the cap can be violated whenmarkerBytes > effectiveMaxBytes:budgetbecomes 0, but… · New For oversized tool results, this creates an additionalcombinedTextstring containing the entire… · New The test description/comment claims coverage for ‘lone surrogate halves’, but theinputshown… · New
What changed in this PR
This PR introduces a global byte cap for tool-result text so oversized extension/MCP/custom tool outputs don’t bloat session history or get re-sent on subsequent turns.
Changes:
- Add
maxToolResultBytesconfiguration plumbing viaAgentOptions→AgentLoopConfig. - Implement tool-result text capping in
agent-loopwith elision marker + head/tail truncation. - Export and test UTF-8-safe truncation helpers.
| File | Description |
|---|---|
| packages/agent-core/test/harness/truncate.test.ts | Adds tests for new UTF-8-safe “truncate from start” helper. |
| packages/agent-core/test/agent-loop.test.ts | Adds coverage for tool-result byte capping behavior and custom cap override. |
| packages/agent-core/src/types.ts | Documents and adds AgentLoopConfig.maxToolResultBytes. |
| packages/agent-core/src/harness/utils/truncate.ts | Exports UTF-8 byte-length and truncation helpers; adds start-truncation helper. |
| packages/agent-core/src/agent.ts | Wires maxToolResultBytes option through Agent to the loop config. |
| packages/agent-core/src/agent-loop.ts | Implements the actual tool-result text capping and exports the default cap constant. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+86
to
+93
| const markerBytes = utf8ByteLength(marker); | ||
| const budget = Math.max(0, effectiveMaxBytes - markerBytes); | ||
| const headBudget = Math.ceil(budget * TOOL_RESULT_HEAD_FRACTION); | ||
| const tailBudget = budget - headBudget; | ||
|
|
||
| const head = truncateStringToBytesFromStart(combinedText, headBudget); | ||
| const tail = tailBudget > 0 ? truncateStringToBytesFromEnd(combinedText, tailBudget) : ""; | ||
| const cappedText = head + marker + tail; |
Comment on lines
+71
to
+84
| const textIndices: number[] = []; | ||
| let totalTextBytes = 0; | ||
| for (let i = 0; i < content.length; i++) { | ||
| const block = content[i]; | ||
| if (block.type === "text") { | ||
| textIndices.push(i); | ||
| totalTextBytes += utf8ByteLength(block.text); | ||
| } | ||
| } | ||
| if (textIndices.length === 0 || totalTextBytes <= effectiveMaxBytes) { | ||
| return content; | ||
| } | ||
|
|
||
| const combinedText = textIndices.map((i) => (content[i] as TextContent).text).join("\n"); |
Comment on lines
+190
to
+207
| it("never splits a multi-byte character or an unpaired surrogate, and stays valid UTF-8", () => { | ||
| // Mix of ASCII, 2/3/4-byte code points, an emoji (surrogate pair), and lone surrogate halves. | ||
| const input = `hello ${"é".repeat(3)}${"中".repeat(3)}${"😀".repeat(5)}𐀀world`; | ||
| const totalBytes = Buffer.byteLength(input, "utf8"); | ||
|
|
||
| for (let maxBytes = 0; maxBytes <= totalBytes + 5; maxBytes++) { | ||
| const result = truncateStringToBytesFromStart(input, maxBytes); | ||
|
|
||
| // Bounded by the cap. | ||
| expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(maxBytes); | ||
|
|
||
| // Round-trips through UTF-8 without producing/leaving unpaired surrogates | ||
| // (an unpaired surrogate would either throw or come back as U+FFFD from Buffer). | ||
| const roundTripped = Buffer.from(result, "utf8").toString("utf8"); | ||
| expect(roundTripped).toBe(result); | ||
| expect(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(result)).toBe(false); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
Built-in tools cap their own output at 50KB (
packages/agent-core/src/harness/utils/truncate.tsDEFAULT_MAX_BYTES;coding-agent/src/core/tools/truncate.ts), but extension/MCP/custom tool results are never capped anywhere:packages/coding-agent/src/core/tools/tool-definition-wrapper.tspasses results through unchanged,coding-agent/src/core/agent-session.tsafterToolCall(~533-564) only normalizes images, andpackages/agent-core/src/agent-loop.tscreateToolResultMessage(~814, pre-fix) wrotecontentas-is. A multi-megabyte MCP/extension result was persisted to the session JSONL forever and re-sent to the model on every subsequent turn.Fix
Added a single capture-time cap in
packages/agent-core/src/agent-loop.ts— the one chokepoint through which every finalized tool result passes on its way to becoming aToolResultMessage, regardless of whether it came from a built-in tool, an extension, an MCP server, or a custom tool:createToolResultMessage()now runscapToolResultContent()overfinalized.result.contentbefore constructing the message. All three call sites (executeToolCallsSequential,executeToolCallsParallel,failToolCallsFromTruncatedMessage) now passconfig.maxToolResultBytesthrough.capToolResultContent()sums the UTF-8 byte length of alltextcontent blocks. If the total is within the cap, the content is returned completely unchanged (byte-identical, no reallocation). If it exceeds the cap, the text blocks are combined, a head (80% of budget) and tail (20% of budget) are kept using two new byte-safe, surrogate-pair-safe string trimmers, and an elision marker is inserted between them stating the tool name, the original size in bytes, the cap, that the middle was elided, and instructing the model to re-call with narrower arguments or pagination. Non-text (image) content blocks pass through completely untouched, in their original relative position.AgentLoopConfig.maxToolResultBytes(also threaded throughAgentOptions/Agent.maxToolResultBytesinpackages/agent-core/src/agent.ts):undefinedapplies the default (DEFAULT_MAX_TOOL_RESULT_BYTES = 128 * 1024, comfortably above the built-in 50KB per-tool caps so built-in tool output is unaffected);0(or any non-positive value) explicitly disables capping; a positive number sets a custom cap.truncateStringToBytesFromStart()next to the existingtruncateStringToBytesFromEnd()inpackages/agent-core/src/harness/utils/truncate.ts(both now exported, along withutf8ByteLength), so head-truncation is exactly as surrogate-pair-safe as the existing tail-truncation logic used elsewhere in the file.isError,details,usage, andterminateare all preserved unchanged — onlycontentis touched.Test
Added to
packages/agent-core/test/agent-loop.test.ts(describe("tool result capping")):toolResultmessage emitted atmessage_endhas combined text≤ DEFAULT_MAX_TOOL_RESULT_BYTES, contains the marker with the original byte count (10485760) and the tool name, and thatisError/detailsare preserved. Before the fix this failed:expected 10485760 to be less than or equal to 133120(i.e. the full 10MB text passed straight through uncapped).toEqual), proving the common case is untouched.maxToolResultBytes: 1000onAgentLoopConfigis honoured (≤ 1000bytes, still mentions the original size).Added to
packages/agent-core/test/harness/truncate.test.ts(describe("truncateStringToBytesFromStart")): exhaustive per-byte-limit checks against a naiveBuffer-based reference implementation over a mixed ASCII/2-byte/3-byte/4-byte/emoji/lone-surrogate string, asserting the result never exceeds the cap, round-trips through UTF-8 unchanged, and never contains an unpaired surrogate half — mirroring the existing fuzz coverage fortruncateTail.packages/agent-core472/472 passed (1 pre-existing skip), including the new tests above.packages/providersfully green and untouched (422/422, 6 pre-existing skips) — no files in that package were touched.packages/coding-agentfull suite (--maxWorkers=2, per COMMON.md, since ~8 sibling agents were running full test suites on this machine concurrently —psshowed 16-21 concurrent vitest processes throughout both runs): 3459 passed, 15 failed. 8 of those 15 match the COMMON.md baseline exactly:context-projection.test.ts(3),resource-loader.test.ts(3),step-tool-profile.test.ts(1),2791-fswatch-error-crash.test.ts(1). The remaining 7 are new relative to the named baseline:feedback.test.ts(1),footer-data-provider.test.ts(2),session-id-readonly.test.ts(1),startup-session-name.test.ts(1),subagent-invocation.test.ts(1),src/step/mcp-startup.test.ts(1) — one of these is presumably the baseline's unnamed "plus 1 more", but it can't be identified from the file alone.These 7 are judged to be contention flakiness rather than regressions from this change:
agent-loop.ts,agent.ts,types.ts, ortruncate.ts(the only files touched by this PR).waitFor/ subprocess-exit-code assertion (e.g.Timed out waiting for condition,expected null to be 1), never a content/value assertion on tool results or capping.session-id-readonly.test.ts,subagent-invocation.test.ts,src/step/mcp-startup.test.ts) passed outright, and the specific failing tests insidefeedback.test.tsandfooter-data-provider.test.tschanged between the two runs (full-suite run failedre-limits events at a complete line...; isolated rerun instead failedredacts tail echoes...and a third, previously-passingdoes not notify listeners...). Different tests flipping within the same file across runs is the signature of timing flakiness, not a deterministic regression.ps auxshowed 16-21 concurrentvitestprocesses from other worktrees during both runs.These 7 should be re-verified on a quiet machine before merge, but nothing in the evidence ties them to this change.
Risk / behaviour change
tool_execution_endevents still carry the uncapped result (this event is UI-facing only, not persisted to session history or re-sent to the model, so it is intentionally left alone).[text, image, text]content array collapses to[merged_text, image]only in the rare case where the combined text exceeds the cap and there is more than one text block; when under the cap, content is returned unchanged.Merge note
This is one of five independent PRs that edit
packages/agent-core/src/agent-loop.ts(tool-result cap, strict tool args, leak-retry transcript, turn cap, agentLoop rejection). Each is based on currentmain; whichever merges later needs a small rebase.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9