fix(server): cap shell timeline text by UTF-8 bytes

This commit is contained in:
Mohamed Boudra
2026-07-12 14:28:33 +00:00
parent 8fdc41f06c
commit 0c05f69402
3 changed files with 70 additions and 23 deletions
+4 -4
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 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.
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
shell error text are capped at 64 KiB of UTF-8 data, 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
@@ -30,7 +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_LENGTH = 64 * 1024;
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -385,7 +385,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
@@ -403,6 +403,27 @@ describe("target coalesced behavior", () => {
}
});
test("bounds tool output by UTF-8 bytes", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
const expectedItem = toolCall({
status: "completed",
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds appended tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
@@ -410,7 +431,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
@@ -434,7 +455,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
@@ -455,7 +476,7 @@ describe("target coalesced behavior", () => {
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.emitLiveTimelineItem(
@@ -473,17 +494,22 @@ describe("target coalesced behavior", () => {
}
});
test("bounds failed shell output carried in the error", async () => {
test("bounds failed shell output carried in error content and message", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "failed",
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
error: {
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
},
});
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
session.pushEvent(
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
);
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
@@ -1,6 +1,15 @@
import { StringDecoder } from "node:string_decoder";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
function limitTextContent(value: string): string {
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
return value;
}
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
return new StringDecoder("utf8").write(bytes);
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
@@ -8,19 +17,30 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null ||
!("content" in item.error) ||
typeof item.error.content !== "string" ||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
item.error === null
) {
return item;
}
const error: Record<string, unknown> = { ...item.error };
let changed = false;
for (const key of ["content", "message"] as const) {
const value = error[key];
if (typeof value !== "string") {
continue;
}
const limitedValue = limitTextContent(value);
if (limitedValue !== value) {
error[key] = limitedValue;
changed = true;
}
}
if (!changed) {
return item;
}
return {
...item,
error: {
...item.error,
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
error,
};
}
@@ -33,14 +53,15 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
) {
return item;
}
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
const output = limitTextContent(item.detail.output);
if (output === item.detail.output) {
return item;
}
return {
...item,
detail: {
...item.detail,
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
output,
},
};
}