Skip to content

fix(agent-core): cap all tool result text at the agent-loop chokepoint - #175

Closed
jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-tool-result-cap
Closed

jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-tool-result-cap

Conversation

@jasonkneen

Copy link
Copy Markdown

Problem

Built-in tools cap their own output at 50KB (packages/agent-core/src/harness/utils/truncate.ts DEFAULT_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.ts passes results through unchanged, coding-agent/src/core/agent-session.ts afterToolCall (~533-564) only normalizes images, and packages/agent-core/src/agent-loop.ts createToolResultMessage (~814, pre-fix) wrote content as-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 a ToolResultMessage, regardless of whether it came from a built-in tool, an extension, an MCP server, or a custom tool:

  • createToolResultMessage() now runs capToolResultContent() over finalized.result.content before constructing the message. All three call sites (executeToolCallsSequential, executeToolCallsParallel, failToolCallsFromTruncatedMessage) now pass config.maxToolResultBytes through.
  • capToolResultContent() sums the UTF-8 byte length of all text content 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.
  • New AgentLoopConfig.maxToolResultBytes (also threaded through AgentOptions / Agent.maxToolResultBytes in packages/agent-core/src/agent.ts): undefined applies 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.
  • Added truncateStringToBytesFromStart() next to the existing truncateStringToBytesFromEnd() in packages/agent-core/src/harness/utils/truncate.ts (both now exported, along with utf8ByteLength), so head-truncation is exactly as surrogate-pair-safe as the existing tail-truncation logic used elsewhere in the file.
  • isError, details, usage, and terminate are all preserved unchanged — only content is touched.

Test

Added to packages/agent-core/test/agent-loop.test.ts (describe("tool result capping")):

  • A tool returning ~10MB of text → asserted that the toolResult message emitted at message_end has combined text ≤ DEFAULT_MAX_TOOL_RESULT_BYTES, contains the marker with the original byte count (10485760) and the tool name, and that isError/details are 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).
  • A small ("hello world") tool result stays byte-identical to the original content array (toEqual), proving the common case is untouched.
  • A custom maxToolResultBytes: 1000 on AgentLoopConfig is honoured (≤ 1000 bytes, still mentions the original size).

Added to packages/agent-core/test/harness/truncate.test.ts (describe("truncateStringToBytesFromStart")): exhaustive per-byte-limit checks against a naive Buffer-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 for truncateTail.

packages/agent-core 472/472 passed (1 pre-existing skip), including the new tests above. packages/providers fully green and untouched (422/422, 6 pre-existing skips) — no files in that package were touched.

packages/coding-agent full suite (--maxWorkers=2, per COMMON.md, since ~8 sibling agents were running full test suites on this machine concurrently — ps showed 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:

  • None of the 7 failing files import or exercise agent-loop.ts, agent.ts, types.ts, or truncate.ts (the only files touched by this PR).
  • Every failure is a timeout / 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.
  • Re-running just those 6 files in isolation: 3 files (session-id-readonly.test.ts, subagent-invocation.test.ts, src/step/mcp-startup.test.ts) passed outright, and the specific failing tests inside feedback.test.ts and footer-data-provider.test.ts changed between the two runs (full-suite run failed re-limits events at a complete line...; isolated rerun instead failed redacts tail echoes... and a third, previously-passing does not notify listeners...). Different tests flipping within the same file across runs is the signature of timing flakiness, not a deterministic regression.
  • ps aux showed 16-21 concurrent vitest processes 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

  • Any tool result whose combined text exceeds 128KB (previously unbounded for extension/MCP/custom tools) will now be truncated with a visible elision marker. Built-in tools are unaffected since they already cap at 50KB.
  • tool_execution_end events 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).
  • Images are explicitly out of scope for this PR (per the assigned issue) and are never touched or counted toward the cap — that remains a known gap and a natural follow-up.
  • A [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.
  • Pre-existing oversized results already persisted in old session JSONLs before this fix are not retroactively capped; the cap only applies to results captured after this change ships.
  • The 7 coding-agent test failures beyond the documented baseline (see Test section) are believed to be contention flakiness from concurrent sibling-agent test runs, not caused by this change, but should be re-verified on a quiet machine before merge.

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 current main; whichever merges later needs a small rebase.

https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Copilot was unable to run its full agentic suite in this review.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 2 Medium severity

Open (3)
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 maxToolResultBytes configuration plumbing via AgentOptionsAgentLoopConfig.
  • Implement tool-result text capping in agent-loop with 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);
}
});
@ZouR-Ma ZouR-Ma closed this Sep 23, 2026
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.

3 participants