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.
This commit is contained in:
Mohamed Boudra
2026-10-30 09:40:57 +01:00
parent 40ba2cee69
commit 87f5472901
3 changed files with 62 additions and 11 deletions
+4 -1
View File
@@ -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.
@@ -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",
@@ -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;
}