Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/streaming-flight-reconciler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@agent-bundle/runtime": minor
---

Add incremental Flight decoding and an invocation-local Suspense reconciler.
`dispatcher.stream()` emits bounded `shell | progress | replace | error | complete`
events with stable-within-invocation boundary IDs, real backpressure, and
AbortSignal cancellation; `dispatcher.dispatch()` remains the default final-only
public API so existing generated entries keep working.
21 changes: 13 additions & 8 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ Agent Document contracts and React-owned Flight execution for Agent Bundle route
No npm release is cut yet; install the pkg.pr.new preview of any `main` commit or pull
request — see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md).

The runtime now executes route models through React-owned RSC/Flight behind
the `AgentRenderDispatcher` execution-host seam. This first renderer slice is
final-only: it buffers one Flight result, decodes only intrinsic `Agent.*`
protocol elements into one immutable `AgentDocument`, and propagates the
request `AbortSignal` through the host and decoder. Streaming Suspense shell
and replacement events arrive in stage 3.
The runtime executes route models through React-owned RSC/Flight behind the
`AgentRenderDispatcher` execution-host seam. Incremental Flight decoding
commits immutable `AgentDocument` snapshots as Suspense boundaries resolve
and emits the `shell | progress | replace | error | complete` render-event
stream from `dispatcher.stream()`. `dispatcher.dispatch()` stays the default
public behavior: it drains that stream and returns only the canonical final
document. The request `AbortSignal` aborts pending boundaries and closes the
stream; a post-completion producer is rejected with a typed
`handoff-required` outcome. Depth, node count, bytes, event rate, and
elapsed time are bounded on the reconciler.

The existing lowerers remain synchronous compatibility APIs. `lowerMcpResult`
walks an MCP element tree, calling function components itself, and lowers it
Expand All @@ -36,8 +40,9 @@ error. These contracts land beside the existing `Hook`/`Mcp` lowerers; those
synchronous compatibility APIs remain operative.

The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store
APIs, the Agent Document contracts, `createAgentRenderDispatcher`, and the
`@agent-bundle/runtime/flight/server` render entry. The Flight-facing versions
APIs, the Agent Document contracts, `createAgentRenderDispatcher`,
`decodeAgentFlightStream`, and the `@agent-bundle/runtime/flight/server`
render entry. The Flight-facing versions
are exact compatibility pins: React/React DOM `19.2.8` and
`react-server-dom-rspack` `0.1.0`; the proof example compiles them with
`rsbuild-plugin-rsc` `0.1.1`. The package does not own application state,
Expand Down
2 changes: 1 addition & 1 deletion packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@agent-bundle/runtime",
"version": "0.0.0",
"description": "Agent Document contracts and final-only React Flight dispatch for Agent Bundle runtimes.",
"description": "Agent Document contracts and streaming React Flight dispatch for Agent Bundle runtimes.",
"license": "MIT",
"keywords": [
"agent-bundle",
Expand Down
26 changes: 26 additions & 0 deletions packages/rsc-runtime/src/agent-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,19 @@ export interface AgentRenderLimits {
readonly maxDocumentBytes: number;
readonly maxDocumentDepth: number;
readonly maxDocumentNodes: number;
readonly maxElapsedMs: number;
readonly maxEventBytes: number;
readonly maxEventRate: number;
readonly maxEvents: number;
}

export const DEFAULT_AGENT_RENDER_LIMITS: AgentRenderLimits = Object.freeze({
maxDocumentBytes: 1024 * 1024,
maxDocumentDepth: 64,
maxDocumentNodes: 10_000,
maxElapsedMs: 60_000,
maxEventBytes: 1024 * 1024 + 1024,
maxEventRate: 1_000,
maxEvents: 10_000,
});

Expand All @@ -138,6 +142,8 @@ export type AgentContractErrorCode =
| 'document-bytes-exceeded'
| 'event-count-exceeded'
| 'event-bytes-exceeded'
| 'event-rate-exceeded'
| 'elapsed-time-exceeded'
| 'handoff-required';

export class AgentContractError extends Error {
Expand Down Expand Up @@ -415,6 +421,8 @@ export const createAgentRenderEventSequence = (
limitOverrides: Partial<AgentRenderLimits> = {},
): AgentRenderEventSequence => {
const limits = resolveLimits(limitOverrides);
const startedAt = Date.now();
const recentTimes: number[] = [];
let completed = false;
let nextSequence = 0;
return Object.freeze({
Expand All @@ -428,6 +436,24 @@ export const createAgentRenderEventSequence = (
'The render is complete; later work requires a new invocation handoff',
);
}
const now = Date.now();
if (now - startedAt > limits.maxElapsedMs) {
throw new AgentContractError(
'elapsed-time-exceeded',
`Agent render elapsed time exceeds ${String(limits.maxElapsedMs)}ms`,
);
}
recentTimes.push(now);
const windowStart = now - 1000;
while (recentTimes[0] !== undefined && recentTimes[0] < windowStart) {
recentTimes.shift();
}
if (recentTimes.length > limits.maxEventRate) {
throw new AgentContractError(
'event-rate-exceeded',
`Agent render event rate exceeds ${String(limits.maxEventRate)} per second`,
);
}
if (nextSequence >= limits.maxEvents) {
throw new AgentContractError(
'event-count-exceeded',
Expand Down
125 changes: 125 additions & 0 deletions packages/rsc-runtime/src/decode-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Children, isValidElement, type ReactNode } from 'react';

import {
AgentContractError,
createAgentDocument,
type AgentDocument,
type AgentDocumentNode,
type AgentRenderLimits,
} from './agent-document.js';
import type { JsonValue } from './lower-mcp.js';

const agentElementTypes = Object.freeze([
'agent-result',
'agent-markdown',
'agent-text',
'agent-json',
'agent-progress',
'agent-image',
'agent-audio',
'agent-resource',
'agent-error',
] as const);

type AgentElementType = typeof agentElementTypes[number];

interface AgentProtocolElement {
readonly props: Record<string, unknown>;
readonly type: AgentElementType;
}

const isAgentElementType = (value: string): value is AgentElementType =>
(agentElementTypes as readonly string[]).includes(value);

const protocolElement = (node: ReactNode): AgentProtocolElement => {
if (
!isValidElement(node) ||
typeof node.type !== 'string' ||
!isAgentElementType(node.type)
) {
throw new AgentContractError(
'invalid-document',
'Flight output must contain only Agent protocol elements; function components and HTML are unsupported',
);
}
return { props: node.props as Record<string, unknown>, type: node.type };
};

const textChild = (children: unknown, type: AgentElementType): string => {
const values = Children.toArray(children as ReactNode);
if (values.length !== 1 || typeof values[0] !== 'string') {
throw new AgentContractError('invalid-document', `${type} requires exactly one string child`);
}
return values[0];
};

interface DecodeState {
representedError: boolean;
}

const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => {
const element = protocolElement(node);
const { props } = element;
switch (element.type) {
case 'agent-result':
return {
children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)),
kind: 'result',
...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }),
};
case 'agent-markdown':
return { kind: 'markdown', text: textChild(props.children, element.type) };
case 'agent-text':
return { kind: 'text', text: textChild(props.children, element.type) };
case 'agent-json':
return { kind: 'json', value: props.value as JsonValue };
case 'agent-progress':
return {
completed: props.completed as number,
kind: 'progress',
...(props.message === undefined ? {} : { message: props.message as string }),
...(props.total === undefined ? {} : { total: props.total as number }),
};
case 'agent-image':
return { data: props.data as string, kind: 'image', mimeType: props.mimeType as string };
case 'agent-audio':
return { data: props.data as string, kind: 'audio', mimeType: props.mimeType as string };
case 'agent-resource':
return {
kind: 'resource',
...(props.mimeType === undefined ? {} : { mimeType: props.mimeType as string }),
name: props.name as string,
uri: props.uri as string,
};
case 'agent-error':
state.representedError = true;
return {
code: props.code as string,
kind: 'error',
message: textChild(props.children, element.type),
};
default: {
const exhaustive: never = element.type;
throw new AgentContractError('invalid-document', `Unsupported Agent protocol element: ${String(exhaustive)}`);
}
}
};

export const decodeAgentDocument = (
node: ReactNode,
limits: Partial<AgentRenderLimits> = {},
): AgentDocument => {
const root = protocolElement(node);
if (root.type !== 'agent-result') {
throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root');
}
const state: DecodeState = { representedError: false };
const documentRoot = decodeNode(node, state);
return createAgentDocument({
root: documentRoot,
status: state.representedError ? 'represented-error' : 'success',
...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }),
version: 1,
}, limits);
};

Loading
Loading