Skip to content

Commit 99eb375

Browse files
fix(runtime): review follow-ups — traversal bounds, demand-bounded progress, failure conversion (#172)
Enforce document limits during JSON and Flight decode walks, bound live progress by consumer demand, close the progress queue on setup failure, and convert synchronous host throws into stream failures.
1 parent 48cdcd2 commit 99eb375

8 files changed

Lines changed: 550 additions & 114 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@agent-bundle/runtime": patch
3+
---
4+
5+
Enforce Agent Document bounds during JSON and Flight decode walks, bound live
6+
progress by downstream demand, close the progress queue on setup failure, and
7+
convert synchronous host throws into stream failures.

‎packages/rsc-runtime/src/agent-document.ts‎

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Buffer } from 'node:buffer';
22

3-
import { snapshotJsonValue, type JsonValue } from './lower-mcp.js';
3+
import { snapshotJsonValue, type JsonSnapshotBudget, type JsonValue } from './lower-mcp.js';
44

55
export const AGENT_DOCUMENT_VERSION = 1 as const;
66

@@ -163,7 +163,7 @@ export class AgentContractError extends Error {
163163
}
164164
}
165165

166-
const resolveLimits = (overrides: Partial<AgentRenderLimits>): AgentRenderLimits => {
166+
export const resolveAgentRenderLimits = (overrides: Partial<AgentRenderLimits> = {}): AgentRenderLimits => {
167167
const limits = { ...DEFAULT_AGENT_RENDER_LIMITS, ...overrides };
168168
for (const [name, value] of Object.entries(limits)) {
169169
if (!Number.isSafeInteger(value) || value <= 0) {
@@ -190,10 +190,51 @@ const text = (value: unknown, field: string): string => {
190190
const optionalString = (value: unknown, field: string): string | undefined =>
191191
value === undefined ? undefined : requiredString(value, field);
192192

193-
const snapshotJson = (value: unknown, message: string): JsonValue => {
193+
export const elapsedTimeExceeded = (maxElapsedMs: number): AgentContractError =>
194+
new AgentContractError(
195+
'elapsed-time-exceeded',
196+
`Agent render elapsed time exceeds ${String(maxElapsedMs)}ms`,
197+
);
198+
199+
const jsonBudget = (state: NodeSnapshotState): JsonSnapshotBudget => ({
200+
addBytes(n) {
201+
state.bytes += n;
202+
if (state.bytes > state.limits.maxDocumentBytes) {
203+
throw new AgentContractError(
204+
'document-bytes-exceeded',
205+
`Agent Document bytes exceed ${String(state.limits.maxDocumentBytes)}`,
206+
);
207+
}
208+
},
209+
addNode() {
210+
state.nodes += 1;
211+
if (state.nodes > state.limits.maxDocumentNodes) {
212+
throw new AgentContractError(
213+
'document-node-count-exceeded',
214+
`Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`,
215+
);
216+
}
217+
},
218+
checkDepth(depth) {
219+
if (depth > state.limits.maxDocumentDepth) {
220+
throw new AgentContractError(
221+
'document-depth-exceeded',
222+
`Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`,
223+
);
224+
}
225+
},
226+
});
227+
228+
const snapshotJson = (
229+
value: unknown,
230+
message: string,
231+
depth: number,
232+
state: NodeSnapshotState,
233+
): JsonValue => {
194234
try {
195-
return snapshotJsonValue(value, message);
235+
return snapshotJsonValue(value, message, { depth, limits: jsonBudget(state) });
196236
} catch (error) {
237+
if (error instanceof AgentContractError) throw error;
197238
throw new AgentContractError('invalid-document', error instanceof Error ? error.message : message, { cause: error });
198239
}
199240
};
@@ -208,6 +249,7 @@ const progressNumber = (value: unknown, field: string): number => {
208249
interface NodeSnapshotState {
209250
readonly ancestors: Set<object>;
210251
readonly limits: AgentRenderLimits;
252+
bytes: number;
211253
nodes: number;
212254
}
213255

@@ -242,7 +284,7 @@ const snapshotNode = (node: AgentDocumentNode, depth: number, state: NodeSnapsho
242284
const children = Object.freeze(node.children.map((child) => snapshotNode(child, depth + 1, state)));
243285
const metadata = node.metadata === undefined
244286
? undefined
245-
: snapshotJson(node.metadata, 'Agent result metadata must be JSON-serializable');
287+
: snapshotJson(node.metadata, 'Agent result metadata must be JSON-serializable', depth, state);
246288
return Object.freeze({
247289
children,
248290
kind: 'result',
@@ -258,7 +300,7 @@ const snapshotNode = (node: AgentDocumentNode, depth: number, state: NodeSnapsho
258300
case 'json':
259301
return Object.freeze({
260302
kind: 'json',
261-
value: snapshotJson(node.value, 'Agent JSON node value must be JSON-serializable'),
303+
value: snapshotJson(node.value, 'Agent JSON node value must be JSON-serializable', depth, state),
262304
});
263305
case 'progress': {
264306
const completed = progressNumber(node.completed, 'Agent progress completed');
@@ -337,11 +379,12 @@ export const createAgentDocument = (
337379
`Unsupported Agent Document version: ${String(input.version)}`,
338380
);
339381
}
340-
const limits = resolveLimits(limitOverrides);
341-
const root = snapshotNode(input.root, 1, { ancestors: new Set(), limits, nodes: 0 });
382+
const limits = resolveAgentRenderLimits(limitOverrides);
383+
const state: NodeSnapshotState = { ancestors: new Set(), bytes: 0, limits, nodes: 0 };
384+
const root = snapshotNode(input.root, 1, state);
342385
const value = input.value === undefined
343386
? undefined
344-
: snapshotJson(input.value, 'Agent Document value must be JSON-serializable');
387+
: snapshotJson(input.value, 'Agent Document value must be JSON-serializable', 1, state);
345388
const document: AgentDocument = Object.freeze({
346389
root,
347390
status: documentStatus(input.status),
@@ -358,10 +401,15 @@ export const createAgentDocument = (
358401
return document;
359402
};
360403

361-
const snapshotRenderError = (error: AgentRenderError): AgentRenderError => {
404+
const snapshotRenderError = (error: AgentRenderError, limits: AgentRenderLimits): AgentRenderError => {
362405
const data = error.data === undefined
363406
? undefined
364-
: snapshotJson(error.data, 'Agent render error data must be JSON-serializable');
407+
: snapshotJson(
408+
error.data,
409+
'Agent render error data must be JSON-serializable',
410+
1,
411+
{ ancestors: new Set(), bytes: 0, limits, nodes: 0 },
412+
);
365413
return Object.freeze({
366414
code: requiredString(error.code, 'Agent render error code'),
367415
...(data === undefined ? {} : { data }),
@@ -403,7 +451,7 @@ const snapshotEvent = (
403451
const boundaryId = optionalString(input.boundaryId, 'Agent render boundaryId');
404452
return Object.freeze({
405453
...(boundaryId === undefined ? {} : { boundaryId }),
406-
error: snapshotRenderError(input.error),
454+
error: snapshotRenderError(input.error, limits),
407455
sequence,
408456
type: 'error',
409457
});
@@ -422,14 +470,16 @@ const snapshotEvent = (
422470

423471
export interface AgentRenderEventSequence {
424472
readonly completed: boolean;
473+
readonly maxElapsedMs: number;
425474
readonly nextSequence: number;
475+
readonly remainingMs: number;
426476
readonly emit: (input: AgentRenderEventInput) => AgentRenderEvent;
427477
}
428478

429479
export const createAgentRenderEventSequence = (
430480
limitOverrides: Partial<AgentRenderLimits> = {},
431481
): AgentRenderEventSequence => {
432-
const limits = resolveLimits(limitOverrides);
482+
const limits = resolveAgentRenderLimits(limitOverrides);
433483
const startedAt = Date.now();
434484
const recentTimes: number[] = [];
435485
let completed = false;
@@ -438,6 +488,9 @@ export const createAgentRenderEventSequence = (
438488
get completed() {
439489
return completed;
440490
},
491+
get maxElapsedMs() {
492+
return limits.maxElapsedMs;
493+
},
441494
emit(input: AgentRenderEventInput): AgentRenderEvent {
442495
if (completed) {
443496
throw new AgentContractError(
@@ -447,10 +500,7 @@ export const createAgentRenderEventSequence = (
447500
}
448501
const now = Date.now();
449502
if (now - startedAt > limits.maxElapsedMs) {
450-
throw new AgentContractError(
451-
'elapsed-time-exceeded',
452-
`Agent render elapsed time exceeds ${String(limits.maxElapsedMs)}ms`,
453-
);
503+
throw elapsedTimeExceeded(limits.maxElapsedMs);
454504
}
455505
recentTimes.push(now);
456506
const windowStart = now - 1000;
@@ -484,5 +534,8 @@ export const createAgentRenderEventSequence = (
484534
get nextSequence() {
485535
return nextSequence;
486536
},
537+
get remainingMs() {
538+
return limits.maxElapsedMs - (Date.now() - startedAt);
539+
},
487540
});
488541
};

‎packages/rsc-runtime/src/decode-document.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Children, isValidElement, type ReactNode } from 'react';
33
import {
44
AgentContractError,
55
createAgentDocument,
6+
resolveAgentRenderLimits,
67
type AgentDocument,
78
type AgentDocumentNode,
89
type AgentRenderLimits,
@@ -55,16 +56,35 @@ const textChild = (children: unknown, type: AgentElementType): string => {
5556
};
5657

5758
interface DecodeState {
59+
readonly limits: AgentRenderLimits;
60+
nodes: number;
5861
representedError: boolean;
5962
}
6063

61-
const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => {
64+
const enterDecodeNode = (depth: number, state: DecodeState): void => {
65+
if (depth > state.limits.maxDocumentDepth) {
66+
throw new AgentContractError(
67+
'document-depth-exceeded',
68+
`Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`,
69+
);
70+
}
71+
state.nodes += 1;
72+
if (state.nodes > state.limits.maxDocumentNodes) {
73+
throw new AgentContractError(
74+
'document-node-count-exceeded',
75+
`Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`,
76+
);
77+
}
78+
};
79+
80+
const decodeNode = (node: ReactNode, depth: number, state: DecodeState): AgentDocumentNode => {
81+
enterDecodeNode(depth, state);
6282
const element = protocolElement(node);
6383
const { props } = element;
6484
switch (element.type) {
6585
case 'agent-result':
6686
return {
67-
children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)),
87+
children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, depth + 1, state)),
6888
kind: 'result',
6989
...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }),
7090
};
@@ -112,17 +132,18 @@ export const decodeAgentDocument = (
112132
node: ReactNode,
113133
limits: Partial<AgentRenderLimits> = {},
114134
): AgentDocument => {
135+
const resolved = resolveAgentRenderLimits(limits);
115136
const root = protocolElement(node);
116137
if (root.type !== 'agent-result') {
117138
throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root');
118139
}
119-
const state: DecodeState = { representedError: false };
120-
const documentRoot = decodeNode(node, state);
140+
const state: DecodeState = { limits: resolved, nodes: 0, representedError: false };
141+
const documentRoot = decodeNode(node, 1, state);
121142
return createAgentDocument({
122143
root: documentRoot,
123144
status: state.representedError ? 'represented-error' : 'success',
124145
...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }),
125146
version: 1,
126-
}, limits);
147+
}, resolved);
127148
};
128149

‎packages/rsc-runtime/src/dispatcher.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,19 @@ export const createAgentRenderDispatcher = (
9292
limits: options.limits,
9393
signal: request.signal,
9494
});
95-
pendingFlight.current = host.execute({
96-
invocation: request.invocation,
97-
progress: session.progress,
98-
signal: request.signal,
99-
});
95+
const rememberFlight = (flight: Promise<ReadableStream<Uint8Array>>): Promise<ReadableStream<Uint8Array>> => {
96+
void flight.catch(() => undefined);
97+
return flight;
98+
};
99+
try {
100+
pendingFlight.current = rememberFlight(host.execute({
101+
invocation: request.invocation,
102+
progress: session.progress,
103+
signal: request.signal,
104+
}));
105+
} catch (error) {
106+
pendingFlight.current = rememberFlight(Promise.reject(request.signal.aborted ? abortError() : error));
107+
}
100108
return toPublicEventStream(session.events, demand, request.signal);
101109
};
102110

0 commit comments

Comments
 (0)