mirror of
https://github.com/cline/cline.git
synced 2026-10-29 17:42:34 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user