diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 86414257ac..f2c29b9711 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -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 diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 6e7f813c5c..5241f2a710 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -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([ diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 5bdc17e0af..1a9862e6b3 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -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 = { ...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, }, }; }