From 87f5472901a240933951e6df7877ec240f421da9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 09:40:57 +0100 Subject: [PATCH] fix(server): keep short strings whole and typed arrays valid when limiting items Strings up to 1 KiB are never cut, so a tool call keeps its callId and an item limits to the same result twice. Arrays drop trailing elements without inserting a marker element, which typed arrays such as todo items would reject. --- docs/timeline-sync.md | 5 ++- .../agent/agent-timeline-content.test.ts | 45 ++++++++++++++++++- .../server/agent/agent-timeline-content.ts | 23 ++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index fab30e2395..3d6af249bf 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -24,7 +24,10 @@ The invariants are: Every item is bounded before it enters either delivery path, because a timeline page is one WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get 64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a -`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. The same +`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up +to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker +element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The +budget does not bound wide objects or multi-byte text. The same bounded item is used for runtime timeline rows and live stream events. Provider history hydration applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it again to assistant and reasoning rows the projection merges from many chunks. diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index 4ed2c20b14..1461d0aeef 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -1,3 +1,4 @@ +import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; @@ -91,10 +92,52 @@ describe("agent timeline content", () => { }); if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); const output = item.detail.output as unknown[]; - expect(output.at(-1)).toMatch(/^…\[truncated \d+ items\]$/); + expect(output.length).toBeLessThan(1_000_000); + expect(output.every((value) => value === 1)).toBe(true); expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); }); + test("keeps short strings whole and limits idempotently when an item has many strings", () => { + const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId, + name: "mcp__search__files", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { query: "useWorkspaceSomethingVeryDescriptive" }, + output: Array.from( + { length: 10_000 }, + (_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`, + ), + }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + expect(item.callId).toBe(callId); + expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" }); + const output = item.detail.output as string[]; + expect(output.length).toBeLessThan(10_000); + expect(output.every((path) => path.length === 33)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + + test("keeps a long typed array valid against the wire schema", () => { + const item = limitAgentTimelineItemContent({ + type: "todo", + items: Array.from({ length: 20_000 }, (_, index) => ({ + text: `Task ${index}`, + completed: false, + })), + }); + if (item.type !== "todo") throw new Error("shape"); + expect(item.items.length).toBeLessThan(20_000); + expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + test("is stable on an already-limited item", () => { const item = limitAgentTimelineItemContent({ type: "reasoning", diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 541fedd1df..5117073e14 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -4,11 +4,14 @@ import type { JsonValue } from "@getpaseo/protocol/agent-types"; export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024; // One timeline fetch sends its entries in one WebSocket message, so every item must stay small. -// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. +// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound +// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text. const TOOL_CALL_BUDGET = 64 * 1024; const ITEM_BUDGET = 256 * 1024; const NODE_COST = 8; const MARKER_COST = 32; +// Ids, names, paths and queries stay whole, so an item keeps its identity across passes. +const SHORT_STRING = 1024; const MAX_DEPTH = 64; export function assertPluginTimelineDataSize(data: JsonValue): void { @@ -94,13 +97,15 @@ function cut(value: unknown, depth: number, state: CutState): unknown { if (Array.isArray(value)) { let changed = false; const output: unknown[] = []; - for (const [index, element] of value.entries()) { - if (state.remaining <= 0) { - output.push(`…[truncated ${value.length - index} items]`); + for (const element of value) { + const remainingBefore = state.remaining; + const next = cut(element, depth + 1, state); + if (state.remaining < 0) { + // Drop this and later elements. A marker element would break typed arrays. + state.remaining = remainingBefore; changed = true; break; } - const next = cut(element, depth + 1, state); changed ||= next !== element; output.push(next); } @@ -130,11 +135,11 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item; // Strings get what structure leaves, but at least half the budget, less a margin for JSON - // escaping. Arrays stop once the copy has spent the budget, which bounds items whose - // structure alone is too big. + // escaping. Arrays drop the elements that do not fit, which bounds items whose short strings + // or structure alone are too big. const markerCost = - MARKER_COST * size.stringLengths.filter((length) => length > MARKER_COST).length; + MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length; const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost; - const stringCap = Math.max(MARKER_COST, findStringCap(size.stringLengths, available)); + const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available)); return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem; }