fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder (#11465)

* test(sdk): add regression tests for structured ToolOperationResult truncation

MessageBuilder tests only covered string and {type:

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo"text"} tool-result
content, not the structured ToolOperationResult[] shape the default tools
(run_commands, read_files, search_codebase) actually emit. Those entries
are plain {query, result, success} objects with no type discriminator, so
the token-bloat path they create was unprotected by tests.

Adds regression tests using the real structured shape: huge result, huge
query, huge read_files payload, aggregate budget across multiple results,
mutation safety, and provider-formatted AI SDK payload size. Assertions
are on actual serialized payload sizes, not transcript shape.

The new tests fail at this commit by design; the following commit makes
them pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder

The runtime stores structured tool outputs (ToolOperationResult[] from
run_commands/read_files/search_codebase) directly as the tool_result
content array (agentPartToContentBlock casts the array straight through).
Those entries have no type discriminator, so MessageBuilder's per-result
truncation, aggregate byte counting, and budget truncation all skipped
them — multi-megabyte command outputs and file reads were JSON-serialized
in full into every subsequent provider request.

MessageBuilder now deep-truncates nested strings inside structured
entries (middle truncation, preserving head and tail), counts them
against the aggregate text budget, collects them as budget-truncation
candidates, and deep-clones them before mutation so the original
conversation history stays untouched. Image blocks are skipped so base64
payloads survive intact.

Real-inference A/B on openrouter:minimax/minimax-m2.7 with realistic
structured payloads: 58.7% overall input-token reduction (82.6% on a
single huge command output) with identical answer correctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

* fix(sdk): include fetch_web_content in MessageBuilder truncation targets

Review feedback: fetch_web_content also returns ToolOperationResult[] and
its executor allows responses up to 5MB, but the tool was missing from
TARGET_TOOL_NAMES, so a single web fetch could still bloat every
subsequent provider request. Adds the tool to the truncation target set
with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ara
2026-06-11 11:34:43 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 7f9d5461f1
commit 7934d367a9
2 changed files with 456 additions and 6 deletions
@@ -1,5 +1,11 @@
import type { Message } from "@cline/shared";
import {
type AiSdkFormatterMessage,
formatMessagesForAiSdk,
type Message,
type ToolResultContent,
} from "@cline/shared";
import { describe, expect, it } from "vitest";
import { messagesToAgentMessages } from "../../runtime/config/agent-message-codec";
import { MessageBuilder } from "./message-builder";
describe("MessageBuilder", () => {
@@ -373,3 +379,296 @@ describe("MessageBuilder", () => {
]);
});
});
/**
* Regression coverage for the real `ToolOperationResult[]` shape emitted by
* the default Cline tools (run_commands, read_files, search_codebase).
*
* The runtime stores these structured results directly as the tool_result
* `content` array (see `agentPartToContentBlock` in
* `runtime/config/agent-message-codec.ts`): the entries are plain
* `{query, result, success, ...}` objects with no `type` discriminator, so
* they bypass the text/file-entry truncation paths unless MessageBuilder
* handles them explicitly.
*/
describe("MessageBuilder with structured ToolOperationResult content", () => {
const MIDDLE_SENTINEL = "__MIDDLE_SENTINEL_MUST_BE_TRUNCATED__";
const HEAD_MARKER = "__HEAD_MARKER__";
const TAIL_MARKER = "__TAIL_MARKER__";
interface ToolOperationResultLike {
query: string;
result: unknown;
error?: string;
success: boolean;
duration?: number;
}
function hugeText(size = 400_000): string {
const fillerLength = Math.floor(
(size -
MIDDLE_SENTINEL.length -
HEAD_MARKER.length -
TAIL_MARKER.length) /
2,
);
const filler = "x".repeat(fillerLength);
return `${HEAD_MARKER}${filler}${MIDDLE_SENTINEL}${filler}${TAIL_MARKER}`;
}
function toolUseMessage(
id: string,
name: string,
input: Record<string, unknown>,
): Message {
return {
role: "assistant",
content: [{ type: "tool_use", id, name, input }],
};
}
function structuredToolResultMessage(
toolUseId: string,
name: string,
operations: ToolOperationResultLike[],
): Message {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUseId,
name,
// The runtime casts ToolOperationResult[] straight into the
// content array; mirror that here.
content: operations as unknown as ToolResultContent["content"],
},
],
};
}
function sumStringBytes(value: unknown): number {
if (typeof value === "string") {
return Buffer.byteLength(value, "utf8");
}
if (Array.isArray(value)) {
return value.reduce<number>((sum, item) => sum + sumStringBytes(item), 0);
}
if (value !== null && typeof value === "object") {
return Object.values(value).reduce<number>(
(sum, item) => sum + sumStringBytes(item),
0,
);
}
return 0;
}
it("truncates a huge nested `result` string in run_commands structured output", () => {
const builder = new MessageBuilder();
const messages: Message[] = [
toolUseMessage("call_1", "run_commands", {
commands: ["cat big.log"],
}),
structuredToolResultMessage("call_1", "run_commands", [
{
query: "cat big.log",
result: hugeText(),
success: true,
duration: 1234,
},
]),
];
const rawSerializedLength = JSON.stringify(messages).length;
const result = builder.buildForApi(messages);
const serialized = JSON.stringify(result);
expect(rawSerializedLength).toBeGreaterThan(390_000);
expect(serialized.length).toBeLessThan(120_000);
expect(serialized).not.toContain(MIDDLE_SENTINEL);
// Middle truncation must preserve the head and tail of the output.
expect(serialized).toContain(HEAD_MARKER);
expect(serialized).toContain(TAIL_MARKER);
});
it("truncates a huge nested `query` string in run_commands structured output", () => {
const builder = new MessageBuilder();
const messages: Message[] = [
toolUseMessage("call_1", "run_commands", {
commands: ["bash -c '...giant heredoc...'"],
}),
structuredToolResultMessage("call_1", "run_commands", [
{
query: hugeText(),
result: "ok",
success: true,
},
]),
];
const result = builder.buildForApi(messages);
const serialized = JSON.stringify(result);
expect(serialized.length).toBeLessThan(120_000);
expect(serialized).not.toContain(MIDDLE_SENTINEL);
expect(serialized).toContain(HEAD_MARKER);
expect(serialized).toContain(TAIL_MARKER);
});
it("truncates a huge file payload in read_files structured output", () => {
const builder = new MessageBuilder();
const messages: Message[] = [
toolUseMessage("call_1", "read_files", {
files: [{ path: "/tmp/big.txt" }],
}),
structuredToolResultMessage("call_1", "read_files", [
{
query: "/tmp/big.txt",
result: hugeText(),
success: true,
},
]),
];
const result = builder.buildForApi(messages);
const serialized = JSON.stringify(result);
expect(serialized.length).toBeLessThan(120_000);
expect(serialized).not.toContain(MIDDLE_SENTINEL);
expect(serialized).toContain(HEAD_MARKER);
expect(serialized).toContain(TAIL_MARKER);
// The latest read of a file must not be rewritten as outdated.
expect(serialized).not.toContain("[outdated");
});
it("truncates a huge fetch_web_content structured result", () => {
// The web-fetch executor allows responses up to 5MB, so this tool must
// be covered by the truncation targets like the other bulk-output tools.
const builder = new MessageBuilder();
const messages: Message[] = [
toolUseMessage("call_1", "fetch_web_content", {
requests: [{ url: "https://example.com/huge-page" }],
}),
structuredToolResultMessage("call_1", "fetch_web_content", [
{
query: "https://example.com/huge-page",
result: hugeText(),
success: true,
},
]),
];
const result = builder.buildForApi(messages);
const serialized = JSON.stringify(result);
expect(serialized.length).toBeLessThan(120_000);
expect(serialized).not.toContain(MIDDLE_SENTINEL);
expect(serialized).toContain(HEAD_MARKER);
expect(serialized).toContain(TAIL_MARKER);
});
it("applies the aggregate text budget to nested structured strings", () => {
const builder = new MessageBuilder(
50_000,
new Set(["run_commands", "read_files"]),
100_000,
);
// Five results of ~40k chars each: every nested string is below the
// per-result limit, but the aggregate (~200k) exceeds the budget.
const messages: Message[] = [];
for (let i = 0; i < 5; i++) {
const name = i % 2 === 0 ? "run_commands" : "read_files";
messages.push(
toolUseMessage(`call_${i}`, name, { commands: [`cmd ${i}`] }),
structuredToolResultMessage(`call_${i}`, name, [
{
query: `cmd ${i}`,
result: `chunk_${i}_`.repeat(4_000),
success: true,
},
]),
);
}
const result = builder.buildForApi(messages);
let toolResultStringBytes = 0;
for (const message of result) {
if (!Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (block.type === "tool_result") {
toolResultStringBytes += sumStringBytes(block.content);
}
}
}
expect(toolResultStringBytes).toBeLessThanOrEqual(100_000);
expect(JSON.stringify(result)).toContain("provider request budget");
});
it("does not mutate the original structured tool results", () => {
const builder = new MessageBuilder(
10_000,
new Set(["run_commands"]),
20_000,
);
const messages: Message[] = [
toolUseMessage("call_1", "run_commands", { commands: ["cat a.log"] }),
structuredToolResultMessage("call_1", "run_commands", [
{
query: "cat a.log",
result: hugeText(50_000),
success: true,
},
]),
toolUseMessage("call_2", "run_commands", { commands: ["cat b.log"] }),
structuredToolResultMessage("call_2", "run_commands", [
{
query: "cat b.log",
result: hugeText(50_000),
success: true,
},
]),
];
const snapshot = structuredClone(messages);
const result = builder.buildForApi(messages);
expect(messages).toEqual(snapshot);
expect(JSON.stringify(result)).not.toContain(MIDDLE_SENTINEL);
});
it("keeps huge nested strings out of provider-formatted AI SDK messages", () => {
const builder = new MessageBuilder();
const messages: Message[] = [
toolUseMessage("call_1", "run_commands", {
commands: ["cat big.log"],
}),
structuredToolResultMessage("call_1", "run_commands", [
{
query: "cat big.log",
result: hugeText(),
success: true,
},
]),
];
const built = builder.buildForApi(messages);
const agentMessages = messagesToAgentMessages(built);
const aiSdkMessages = formatMessagesForAiSdk(
undefined,
agentMessages.map(({ role, content }) => ({
role,
content,
})) as unknown as AiSdkFormatterMessage[],
);
const serialized = JSON.stringify(aiSdkMessages);
expect(serialized.length).toBeLessThan(130_000);
expect(serialized).not.toContain(MIDDLE_SENTINEL);
expect(serialized).toContain(HEAD_MARKER);
expect(serialized).toContain(TAIL_MARKER);
});
});
@@ -27,6 +27,7 @@ const TARGET_TOOL_NAMES = new Set([
"search_codebase",
"bash",
"run_commands",
"fetch_web_content",
]);
const READ_TOOL_NAMES = new Set(["read", "read_files"]);
const OUTDATED_FILE_CONTENT = "[outdated - see the latest file content]";
@@ -765,14 +766,56 @@ export class MessageBuilder {
const next = this.truncateMiddle(entry.content);
return next === entry.content ? entry : { ...entry, content: next };
}
if (entry.type !== "text") {
return entry;
if (entry.type === "text") {
const next = this.truncateMiddle(entry.text);
return next === entry.text ? entry : { ...entry, text: next };
}
const next = this.truncateMiddle(entry.text);
return next === entry.text ? entry : { ...entry, text: next };
if (isStructuredToolResultEntry(entry)) {
return this.truncateNestedStrings(entry) as typeof entry;
}
return entry;
});
}
/**
* Deep-truncates string values inside structured tool outputs (e.g.
* `ToolOperationResult[]` from run_commands/read_files), which carry the
* payload in untyped `{query, result, ...}` fields rather than text
* blocks. Image blocks are left intact so base64 payloads survive.
*/
private truncateNestedStrings(value: unknown): unknown {
if (typeof value === "string") {
return this.truncateMiddle(value);
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item) => {
const out = this.truncateNestedStrings(item);
if (out !== item) {
changed = true;
}
return out;
});
return changed ? next : value;
}
if (value !== null && typeof value === "object") {
if (isImageContentLike(value)) {
return value;
}
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const out = this.truncateNestedStrings(item);
if (out !== item) {
changed = true;
}
next[key] = out;
}
return changed ? next : value;
}
return value;
}
private truncateMiddle(text: string): string {
return truncateMiddleByChars(
text,
@@ -852,6 +895,8 @@ export class MessageBuilder {
total += utf8ByteLength(entry.text);
} else if (entry.type === "file") {
total += utf8ByteLength(entry.content);
} else if (isStructuredToolResultEntry(entry)) {
total += countNestedStringBytes(entry);
}
}
}
@@ -904,6 +949,8 @@ export class MessageBuilder {
entry.content = value;
},
});
} else if (isStructuredToolResultEntry(entry)) {
collectNestedStringCandidates(entry, candidates);
}
}
}
@@ -971,6 +1018,110 @@ function cloneContentBlockForMutation(block: ContentBlock): ContentBlock {
}
return {
...block,
content: block.content.map((entry) => ({ ...entry })),
// Structured entries can nest the payload strings arbitrarily deep, so
// a shallow copy would leak budget-truncation mutations back into the
// original conversation history.
content: block.content.map((entry) =>
isStructuredToolResultEntry(entry)
? (deepCloneJsonLike(entry) as typeof entry)
: { ...entry },
),
};
}
/**
* True for tool_result content entries that are not the typed text/image/file
* blocks — i.e. structured tool outputs such as `ToolOperationResult[]`
* entries that the runtime stores directly in the content array.
*/
function isStructuredToolResultEntry(entry: unknown): boolean {
if (entry === null || typeof entry !== "object") {
return false;
}
const type = (entry as { type?: unknown }).type;
return type !== "text" && type !== "image" && type !== "file";
}
function isImageContentLike(value: object): boolean {
return (value as { type?: unknown }).type === "image";
}
function countNestedStringBytes(value: unknown): number {
if (typeof value === "string") {
return utf8ByteLength(value);
}
if (Array.isArray(value)) {
let total = 0;
for (const item of value) {
total += countNestedStringBytes(item);
}
return total;
}
if (value !== null && typeof value === "object") {
if (isImageContentLike(value)) {
return 0;
}
let total = 0;
for (const item of Object.values(value)) {
total += countNestedStringBytes(item);
}
return total;
}
return 0;
}
function collectNestedStringCandidates(
container: unknown,
candidates: TruncationCandidate[],
): void {
if (Array.isArray(container)) {
container.forEach((item, index) => {
if (typeof item === "string") {
candidates.push({
byteLength: utf8ByteLength(item),
get: () => container[index] as string,
set: (value) => {
container[index] = value;
},
});
} else {
collectNestedStringCandidates(item, candidates);
}
});
return;
}
if (container !== null && typeof container === "object") {
if (isImageContentLike(container)) {
return;
}
const record = container as Record<string, unknown>;
for (const key of Object.keys(record)) {
const item = record[key];
if (typeof item === "string") {
candidates.push({
byteLength: utf8ByteLength(item),
get: () => record[key] as string,
set: (value) => {
record[key] = value;
},
});
} else {
collectNestedStringCandidates(item, candidates);
}
}
}
}
function deepCloneJsonLike(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(deepCloneJsonLike);
}
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = deepCloneJsonLike(item);
}
return out;
}
return value;
}