Compare commits

...
Author SHA1 Message Date
ArafatkatzeandClaude Fable 5 47922e33c4 fix(sdk): recursively truncate structured tool results before provider requests
run_commands/read_files return ToolOperationResult[] ({query, result,
success} objects) that pass through the message codec as raw entries in
tool_result.content rather than typed text/file blocks. MessageBuilder's
per-result truncation, aggregate byte counting, and budget candidate
collection only handled typed entries, so large nested result/query
strings bypassed both the per-tool-result char limit and the total
provider-request budget entirely.

buildForApi() now recursively walks structured entries: nested strings
are middle-truncated to maxToolResultChars, counted toward the aggregate
byte budget, and collected as budget-truncation candidates. Nested
{type:

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/crck3fzjnrg3v5hklyykqez3"image"} blocks are left intact so base64 payloads survive the
downstream multimodal extraction in toAiSdkToolResultOutput. Structured
entries are deep-cloned before budget truncation so persisted
conversation objects are never mutated.

A/B with real inference (openrouter, 232KB run_commands stdout):
post-tool-result request dropped from ~102.7k to ~19.5k input tokens
(-81%) on both minimax-m2.7 and deepseek-v4-flash, with identical task
answers and byte-identical persisted history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:07:57 -05:00
2 changed files with 400 additions and 1 deletions
@@ -372,4 +372,246 @@ describe("MessageBuilder", () => {
}),
]);
});
it("truncates huge nested result strings inside structured ToolOperationResult[] content", () => {
const builder = new MessageBuilder(100);
const structuredResults = [
{ query: "echo hi", result: "x".repeat(5_000), success: true },
];
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: { commands: ["echo hi"] },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "run_commands",
content: structuredResults as never,
},
],
},
];
const result = builder.buildForApi(messages);
const block = Array.isArray(result[1].content)
? result[1].content[0]
: undefined;
if (block?.type !== "tool_result" || !Array.isArray(block.content)) {
throw new Error("expected tool_result with array content");
}
const entry = block.content[0] as unknown as {
query: string;
result: string;
success: boolean;
};
expect(entry.result.length).toBeLessThanOrEqual(100);
expect(entry.result).toContain("...[truncated");
expect(entry.query).toBe("echo hi");
expect(entry.success).toBe(true);
});
it("truncates huge nested query strings inside structured ToolOperationResult[] content", () => {
const builder = new MessageBuilder(100);
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: {},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "run_commands",
content: [
{
query: `echo ${"y".repeat(5_000)}`,
result: "ok",
success: true,
},
] as never,
},
],
},
];
const result = builder.buildForApi(messages);
const block = Array.isArray(result[1].content)
? result[1].content[0]
: undefined;
if (block?.type !== "tool_result" || !Array.isArray(block.content)) {
throw new Error("expected tool_result with array content");
}
const entry = block.content[0] as unknown as { query: string };
expect(entry.query.length).toBeLessThanOrEqual(100);
expect(entry.query).toContain("...[truncated");
});
it("counts nested structured strings toward the aggregate budget and truncates them", () => {
const builder = new MessageBuilder(
50_000,
new Set(["run_commands"]),
20_000,
);
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: {},
},
{
type: "tool_use",
id: "tool_2",
name: "run_commands",
input: {},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "run_commands",
content: [
{ query: "cmd-1", result: "a".repeat(15_000), success: true },
] as never,
},
{
type: "tool_result",
tool_use_id: "tool_2",
name: "run_commands",
content: [
{ query: "cmd-2", result: "b".repeat(15_000), success: true },
] as never,
},
],
},
];
const result = builder.buildForApi(messages);
const serialized = JSON.stringify(result[1].content);
expect(serialized).toContain("provider request budget");
expect(Buffer.byteLength(serialized, "utf8")).toBeLessThanOrEqual(25_000);
});
it("does not mutate original structured tool result objects", () => {
const builder = new MessageBuilder(100, new Set(["run_commands"]), 10_000);
const originalResult = "z".repeat(20_000);
const structured = [
{ query: "echo big", result: originalResult, success: true },
];
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: {},
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "run_commands",
content: structured as never,
},
],
},
];
const result = builder.buildForApi(messages);
expect(structured[0].result).toBe(originalResult);
expect(structured[0].query).toBe("echo big");
const block = Array.isArray(result[1].content)
? result[1].content[0]
: undefined;
if (block?.type !== "tool_result" || !Array.isArray(block.content)) {
throw new Error("expected tool_result with array content");
}
const entry = block.content[0] as unknown as { result: string };
expect(entry.result).not.toBe(originalResult);
expect(entry.result.length).toBeLessThanOrEqual(100);
});
it("leaves base64 image blocks nested in structured results intact", () => {
const builder = new MessageBuilder(100);
const imageData = "i".repeat(5_000);
const messages: Message[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "read_files",
input: { file_paths: ["/tmp/pic.png"] },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read_files",
content: [
{
query: "/tmp/pic.png",
result: [
{ type: "text", text: "Successfully read image" },
{ type: "image", data: imageData, mediaType: "image/png" },
],
success: true,
},
] as never,
},
],
},
];
const result = builder.buildForApi(messages);
const block = Array.isArray(result[1].content)
? result[1].content[0]
: undefined;
if (block?.type !== "tool_result" || !Array.isArray(block.content)) {
throw new Error("expected tool_result with array content");
}
const entry = block.content[0] as unknown as {
result: Array<{ type: string; data?: string }>;
};
const image = entry.result.find((item) => item.type === "image");
expect(image?.data).toBe(imageData);
});
});
@@ -765,6 +765,9 @@ export class MessageBuilder {
const next = this.truncateMiddle(entry.content);
return next === entry.content ? entry : { ...entry, content: next };
}
if (isStructuredToolResultEntry(entry)) {
return this.truncateStructuredValue(entry) as (typeof content)[number];
}
if (entry.type !== "text") {
return entry;
}
@@ -773,6 +776,47 @@ export class MessageBuilder {
});
}
/**
* Recursively truncates nested string fields inside structured tool
* results (e.g. the `[{query, result, success}]` `ToolOperationResult`
* shape from `run_commands`/`read_files`). Copy-on-write: returns the
* original value when nothing changes. Image blocks are left intact so
* their base64 payloads survive downstream multimodal extraction.
*/
private truncateStructuredValue(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.truncateStructuredValue(item);
if (out !== item) {
changed = true;
}
return out;
});
return changed ? next : value;
}
if (value !== null && typeof value === "object") {
if (isImageBlockLike(value)) {
return value;
}
let changed = false;
const record = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(record)) {
const out = this.truncateStructuredValue(entry);
if (out !== entry) {
changed = true;
}
next[key] = out;
}
return changed ? next : value;
}
return value;
}
private truncateMiddle(text: string): string {
return truncateMiddleByChars(
text,
@@ -852,6 +896,8 @@ export class MessageBuilder {
total += utf8ByteLength(entry.text);
} else if (entry.type === "file") {
total += utf8ByteLength(entry.content);
} else if (isStructuredToolResultEntry(entry)) {
total += countStructuredStringBytes(entry);
}
}
}
@@ -904,6 +950,8 @@ export class MessageBuilder {
entry.content = value;
},
});
} else if (isStructuredToolResultEntry(entry)) {
collectStructuredCandidates(entry, candidates);
}
}
}
@@ -971,6 +1019,115 @@ function cloneContentBlockForMutation(block: ContentBlock): ContentBlock {
}
return {
...block,
content: block.content.map((entry) => ({ ...entry })),
content: block.content.map((entry) =>
isStructuredToolResultEntry(entry)
? (cloneStructuredValue(entry) as typeof entry)
: { ...entry },
),
};
}
/**
* Tool outputs that are arrays (e.g. `ToolOperationResult[]` from
* `run_commands`/`read_files`) pass through the message codec as raw
* objects inside `tool_result.content`, not as typed `text`/`image`/`file`
* blocks. Anything without one of those recognized types is treated as
* structured data and walked recursively.
*/
function isStructuredToolResultEntry(entry: unknown): boolean {
if (entry === null || typeof entry !== "object") {
return false;
}
const type = (entry as Record<string, unknown>).type;
return type !== "text" && type !== "image" && type !== "file";
}
function isImageBlockLike(value: object): boolean {
const record = value as Record<string, unknown>;
return record.type === "image" && typeof record.data === "string";
}
function countStructuredStringBytes(value: unknown): number {
if (typeof value === "string") {
return utf8ByteLength(value);
}
if (Array.isArray(value)) {
let total = 0;
for (const item of value) {
total += countStructuredStringBytes(item);
}
return total;
}
if (value !== null && typeof value === "object") {
if (isImageBlockLike(value)) {
return 0;
}
let total = 0;
for (const entry of Object.values(value)) {
total += countStructuredStringBytes(entry);
}
return total;
}
return 0;
}
/** Deep-clones structured tool result data so budget truncation can mutate
* it in place without touching persisted conversation objects. Image blocks
* are kept by reference — they are never mutated. */
function cloneStructuredValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => cloneStructuredValue(item));
}
if (value !== null && typeof value === "object") {
if (isImageBlockLike(value)) {
return value;
}
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
next[key] = cloneStructuredValue(entry);
}
return next;
}
return value;
}
function collectStructuredCandidates(
value: unknown,
candidates: TruncationCandidate[],
): void {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item === "string") {
candidates.push({
byteLength: utf8ByteLength(item),
get: () => value[i] as string,
set: (next) => {
value[i] = next;
},
});
} else {
collectStructuredCandidates(item, candidates);
}
}
return;
}
if (value === null || typeof value !== "object" || isImageBlockLike(value)) {
return;
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
const entry = record[key];
if (typeof entry === "string") {
candidates.push({
byteLength: utf8ByteLength(entry),
get: () => record[key] as string,
set: (next) => {
record[key] = next;
},
});
} else {
collectStructuredCandidates(entry, candidates);
}
}
}