refactor(server): slice oversized tool output directly

This commit is contained in:
Mohamed Boudra
2026-07-12 16:01:11 +02:00
parent 0a398833d8
commit 928c124356
3 changed files with 12 additions and 74 deletions

View File

@@ -9,10 +9,10 @@ The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
Tool output is bounded before it enters either delivery path. Canonical shell tool output keeps
the head and tail within a 64 KiB UTF-8 budget, and the same bounded item is used for durable
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
an agent cannot restore an oversized tool payload.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
tool payload.
## Presence is not delivery

View File

@@ -30,8 +30,7 @@ import type {
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "\n...<tool output truncated in the middle>...\n";
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -85,15 +84,6 @@ function toolCall(options?: {
};
}
function boundedToolCallOutput(head: string, tail: string): string {
const availableBytes =
TOOL_CALL_CONTENT_MAX_BYTES - Buffer.byteLength(TOOL_CALL_CONTENT_TRUNCATION_MARKER);
const headBytes = Math.floor(availableBytes / 2);
return `${head.repeat(headBytes)}${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail.repeat(
availableBytes - headBytes,
)}`;
}
class TestAgentSession implements AgentSession {
readonly capabilities = TEST_CAPABILITIES;
readonly id: string;
@@ -395,7 +385,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: boundedToolCallOutput("a", "z"),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
@@ -420,7 +410,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: boundedToolCallOutput("a", "z"),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
@@ -444,7 +434,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: boundedToolCallOutput("a", "z"),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
@@ -465,7 +455,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: boundedToolCallOutput("a", "z"),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
await harness.manager.emitLiveTimelineItem(

View File

@@ -1,57 +1,6 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "\n...<tool output truncated in the middle>...\n";
function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
function takeFirstUtf8Bytes(text: string, maxBytes: number): string {
let low = 0;
let high = text.length;
while (low < high) {
const midpoint = Math.ceil((low + high) / 2);
if (utf8ByteLength(text.slice(0, midpoint)) <= maxBytes) {
low = midpoint;
} else {
high = midpoint - 1;
}
}
if (low > 0 && low < text.length && /[\uD800-\uDBFF]/.test(text[low - 1] ?? "")) {
low -= 1;
}
return text.slice(0, low);
}
function takeLastUtf8Bytes(text: string, maxBytes: number): string {
let low = 0;
let high = text.length;
while (low < high) {
const length = Math.ceil((low + high) / 2);
if (utf8ByteLength(text.slice(text.length - length)) <= maxBytes) {
low = length;
} else {
high = length - 1;
}
}
let start = text.length - low;
if (start > 0 && start < text.length && /[\uDC00-\uDFFF]/.test(text[start] ?? "")) {
start += 1;
}
return text.slice(start);
}
function limitToolCallText(text: string): string {
if (utf8ByteLength(text) <= TOOL_CALL_CONTENT_MAX_BYTES) {
return text;
}
const availableBytes =
TOOL_CALL_CONTENT_MAX_BYTES - utf8ByteLength(TOOL_CALL_CONTENT_TRUNCATION_MARKER);
const headBytes = Math.floor(availableBytes / 2);
const tailBytes = availableBytes - headBytes;
return `${takeFirstUtf8Bytes(text, headBytes)}${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${takeLastUtf8Bytes(text, tailBytes)}`;
}
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
if (
@@ -61,15 +10,14 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
) {
return item;
}
const output = limitToolCallText(item.detail.output);
if (output === item.detail.output) {
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
return item;
}
return {
...item,
detail: {
...item.detail,
output,
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}