Compare commits

...

1 Commits

Author SHA1 Message Date
John Choi 8c8d5f3e2c fix(sdk): bound tool outputs before model requests
Cap every tool result string at the SDK conversation boundary so a
single oversized output cannot exceed provider per-string limits
(Vercel/OpenAI 10 MB) or push the request past context-window
ceilings (OpenRouter 1M-token endpoints). Once an oversized result
lands in history, every subsequent turn fails — and auto-compaction
fails because it re-sends the same history.

Two enforcement points:

- Durable chokepoint in agent-runtime.ts: a recursive walker caps
  strings at any depth in result.output before createMessage("tool",
  ...). Image content blocks ({type, data, mediaType}) pass through
  verbatim so multimodal data is not mangled.
- Wire-format final guard in ai-sdk-format.ts: toAiSdkToolResultOutput
  caps text / content-array / headerText / JSON-fallback /
  String-fallback branches. Oversized JSON outputs become truncated
  text so the marker is visible to the model.

Helper MAX_TOOL_OUTPUT_CHARS / truncateToolOutput in shared/parse,
head+tail (matching OpenAI Codex CLI), marker reserved inside the
budget so output length is <= cap literally.

Scope: SDK/TUI AgentRuntime path only. The VS Code extension
Task -> ToolExecutor -> ToolResultUtils path has the same class of
bug and should receive the same shared cap in a follow-up PR.
2026-05-13 23:26:40 -07:00
8 changed files with 271 additions and 8 deletions
@@ -536,6 +536,103 @@ describe("AgentRuntime", () => {
expect(result.outputText).toBe("preserved");
});
it("caps strings deep inside structured tool outputs", async () => {
const huge = "x".repeat(2 * 1024 * 1024);
const structuredOutput = [
{ query: "huge.txt", result: huge, success: true },
];
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "call_huge",
toolName: "read_files",
inputText: '{"files":[{"path":"huge.txt"}]}',
},
{ type: "finish", reason: "tool-calls" },
],
(request) => {
const toolMessage = request.messages.at(-1) as AgentMessage;
expect(toolMessage.role).toBe("tool");
const part = toolMessage.content[0] as {
type: string;
output: Array<{ query: string; result: string; success: boolean }>;
};
expect(part.type).toBe("tool-result");
expect(part.output).toHaveLength(1);
expect(part.output[0].query).toBe("huge.txt");
expect(part.output[0].success).toBe(true);
expect(part.output[0].result.length).toBeLessThanOrEqual(400 * 1024);
expect(part.output[0].result).toContain("[OUTPUT TRUNCATED:");
return [
{ type: "text-delta", text: "ok" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
model,
tools: [
{
name: "read_files",
description: "Mimic default read_files shape",
inputSchema: { type: "object" },
execute: async () => structuredOutput,
},
],
});
const result = await runtime.run("Read huge");
expect(result.status).toBe("completed");
});
it("preserves image content blocks inside structured tool outputs", async () => {
const imageBlock = {
type: "image",
data: "BASE64IMAGEDATA",
mediaType: "image/jpeg",
};
const structuredOutput = [
{ query: "/tmp/img.jpg", result: [imageBlock], success: true },
];
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "call_img",
toolName: "read_files",
inputText: '{"files":[{"path":"/tmp/img.jpg"}]}',
},
{ type: "finish", reason: "tool-calls" },
],
(request) => {
const toolMessage = request.messages.at(-1) as AgentMessage;
const part = toolMessage.content[0] as {
output: Array<{ result: typeof imageBlock[] }>;
};
expect(part.output[0].result[0]).toEqual(imageBlock);
return [
{ type: "text-delta", text: "ok" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
model,
tools: [
{
name: "read_files",
description: "Mimic image read",
inputSchema: { type: "object" },
execute: async () => structuredOutput,
},
],
});
const result = await runtime.run("Read image");
expect(result.status).toBe("completed");
});
it("requests approval when a tool policy disables auto-approval", async () => {
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
const requestToolApproval = vi.fn(async () => ({
+34 -2
View File
@@ -23,7 +23,11 @@ import type {
ToolApprovalResult,
ToolPolicy,
} from "@cline/shared";
import { captureSdkError, estimateTokens } from "@cline/shared";
import {
captureSdkError,
estimateTokens,
truncateToolOutput,
} from "@cline/shared";
import { nanoid } from "nanoid";
// Local `createUID` helper. The clinee source imports this from
@@ -1276,12 +1280,14 @@ export class AgentRuntime {
}
}
const cappedOutput = capToolOutputDeep(result.output);
const message = createMessage("tool", [
{
type: "tool-result",
toolCallId: prepared.toolCall.toolCallId,
toolName: prepared.toolCall.toolName,
output: result.output,
output: cappedOutput,
isError: result.isError,
},
]);
@@ -1541,3 +1547,29 @@ export type Agent = AgentRuntime;
export function createAgent(config: AgentRuntimeConfig): AgentRuntime {
return new AgentRuntime(config);
}
function capToolOutputDeep(value: unknown): unknown {
if (typeof value === "string") {
return truncateToolOutput(value);
}
if (Array.isArray(value)) {
return value.map((item) => capToolOutputDeep(item));
}
if (value !== null && typeof value === "object") {
const obj = value as Record<string, unknown>;
// Image blocks: base64 data must reach the model intact.
if (
obj.type === "image" &&
typeof obj.data === "string" &&
typeof obj.mediaType === "string"
) {
return value;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = capToolOutputDeep(v);
}
return out;
}
return value;
}
+4
View File
@@ -145,6 +145,10 @@ export {
safeJsonParse,
safeJsonStringify,
} from "./parse/json";
export {
MAX_TOOL_OUTPUT_CHARS,
truncateToolOutput,
} from "./parse/content-limits";
export { getDefaultShell, getShellArgs } from "./parse/shell";
export {
maskSecret,
+4
View File
@@ -159,6 +159,10 @@ export {
safeJsonParse,
safeJsonStringify,
} from "./parse/json";
export {
MAX_TOOL_OUTPUT_CHARS,
truncateToolOutput,
} from "./parse/content-limits";
export { getDefaultShell, getShellArgs } from "./parse/shell";
export {
maskSecret,
@@ -682,6 +682,42 @@ describe("formatMessagesForAiSdk", () => {
value: "contents",
});
});
it("caps an oversized string tool result before wire format", () => {
const huge = "x".repeat(24 * 1024 * 1024);
const out = toAiSdkToolResultOutput(huge);
expect(out.type).toBe("text");
const value = out.value as string;
expect(value.length).toBeLessThan(1 * 1024 * 1024);
expect(value).toContain("[OUTPUT TRUNCATED:");
});
it("caps oversized text blocks in content array tool result", () => {
const huge = "y".repeat(5 * 1024 * 1024);
const out = toAiSdkToolResultOutput([{ type: "text", text: huge }]);
expect(out.type).toBe("content");
const blocks = out.value as Array<{ type: string; text: string }>;
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("text");
expect(blocks[0].text.length).toBeLessThan(1 * 1024 * 1024);
expect(blocks[0].text).toContain("[OUTPUT TRUNCATED:");
});
it("converts oversized json output to text with marker", () => {
const huge = { payload: "z".repeat(5 * 1024 * 1024) };
const out = toAiSdkToolResultOutput(huge);
expect(out.type).toBe("text");
const value = out.value as string;
expect(value.length).toBeLessThan(1 * 1024 * 1024);
expect(value).toContain("[OUTPUT TRUNCATED:");
});
it("honors a hard cap on truncated output length", () => {
const huge = "q".repeat(2 * 1024 * 1024);
const out = toAiSdkToolResultOutput(huge);
const value = out.value as string;
expect(value.length).toBeLessThanOrEqual(400 * 1024);
});
});
describe("sanitizeSurrogates", () => {
+24 -6
View File
@@ -1,3 +1,4 @@
import { truncateToolOutput } from "../parse/content-limits";
import { formatFileContentBlock } from "../prompt/format";
/**
@@ -192,7 +193,7 @@ export function toAiSdkToolResultOutput(
if (typeof output === "string") {
return {
type: isError ? "error-text" : "text",
value: sanitizeSurrogates(output),
value: truncateToolOutput(sanitizeSurrogates(output)),
};
}
@@ -212,7 +213,10 @@ export function toAiSdkToolResultOutput(
data: block.data,
mediaType: block.mediaType,
}
: { type: "text", text: sanitizeSurrogates(block.text) },
: {
type: "text",
text: truncateToolOutput(sanitizeSurrogates(block.text)),
},
),
};
}
@@ -229,10 +233,11 @@ export function toAiSdkToolResultOutput(
const images: AiSdkImageContentBlock[] = [];
const stripped = stripImagesFromOutput(output, images);
if (images.length > 0) {
const headerText =
const headerText = truncateToolOutput(
typeof stripped === "string"
? sanitizeSurrogates(stripped)
: JSON.stringify(sanitizeDeepStrings(stripped));
: JSON.stringify(sanitizeDeepStrings(stripped)),
);
return {
type: "content",
value: [
@@ -253,15 +258,28 @@ export function toAiSdkToolResultOutput(
typeof output === "number" ||
typeof output === "object"
) {
const sanitized = sanitizeDeepStrings(output);
// Stringify and cap so an object that serializes past the cap can't slip
// through; switch to text type so the truncation marker is visible.
const serialized = JSON.stringify(sanitized);
if (typeof serialized === "string" && serialized.length > 0) {
const capped = truncateToolOutput(serialized);
if (capped !== serialized) {
return {
type: isError ? "error-text" : "text",
value: capped,
};
}
}
return {
type: isError ? "error-json" : "json",
value: sanitizeDeepStrings(output),
value: sanitized,
};
}
return {
type: isError ? "error-text" : "text",
value: sanitizeSurrogates(String(output)),
value: truncateToolOutput(sanitizeSurrogates(String(output))),
};
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
MAX_TOOL_OUTPUT_CHARS,
truncateToolOutput,
} from "./content-limits";
describe("truncateToolOutput", () => {
it("returns input unchanged when at or below the limit", () => {
expect(truncateToolOutput("command output")).toBe("command output");
});
it("preserves both ends and inserts a marker when oversized", () => {
const head = `HEAD-MARKER-${"a".repeat(1000)}`;
const middle = "m".repeat(MAX_TOOL_OUTPUT_CHARS);
const tail = `${"z".repeat(1000)}-TAIL-MARKER`;
const truncated = truncateToolOutput(head + middle + tail);
expect(truncated).toContain("HEAD-MARKER-");
expect(truncated).toContain("-TAIL-MARKER");
expect(truncated).toContain("[OUTPUT TRUNCATED:");
});
it("caps a 24 MB output well under 1 MB", () => {
const huge = "x".repeat(24 * 1024 * 1024);
const truncated = truncateToolOutput(huge);
expect(truncated.length).toBeLessThan(1 * 1024 * 1024);
});
it("is a no-op for non-string input", () => {
const notAString = undefined as unknown as string;
expect(truncateToolOutput(notAString)).toBe(notAString);
});
it("respects a custom maxSize", () => {
const oversized = "y".repeat(2000);
const truncated = truncateToolOutput(oversized, 500);
expect(truncated.length).toBeLessThan(oversized.length);
expect(truncated).toContain("[OUTPUT TRUNCATED:");
});
});
@@ -0,0 +1,32 @@
// UTF-16 code units, not UTF-8 bytes — worst-case multi-byte stays under the
// 10 MB provider per-string ceiling.
export const MAX_TOOL_OUTPUT_CHARS = 400 * 1024;
// Reserved so the final output (head + marker + tail) is <= maxSize.
const MARKER_RESERVE = 512;
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function truncateToolOutput(
content: string,
maxSize: number = MAX_TOOL_OUTPUT_CHARS,
): string {
if (typeof content !== "string" || content.length <= maxSize) {
return content;
}
const halfSize = Math.floor(Math.max(0, maxSize - MARKER_RESERVE) / 2);
const head = content.slice(0, halfSize);
const tail = content.slice(content.length - halfSize);
const omitted = content.length - halfSize * 2;
return `${head}\n\n---\n\n[OUTPUT TRUNCATED: ${formatBytes(content.length)} total; ${formatBytes(omitted)} omitted from the middle. Narrow scope (filters, head/tail/grep, exclude node_modules) to see the missing portion.]\n\n---\n\n${tail}`;
}