mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user