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
8 changes: 8 additions & 0 deletions .changeset/final-only-flight-dispatcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@agent-bundle/runtime": minor
---

Add React-owned final-only Flight execution behind the public render-dispatcher
and execution-host seam. Decode intrinsic `Agent.*` output into one immutable
Agent Document and propagate request cancellation without changing the existing
synchronous MCP lowerer path.
29 changes: 16 additions & 13 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,19 @@ validation cannot drift between surfaces. Only the last step differs:

## Why `.tsx`, and current renderer status

`@agent-bundle/runtime` does **not yet execute React Server Components or
Flight**. Its current compatibility lowerers do not stream a component tree,
hydrate, or hold server component state. What the MCP projection uses is an **MCP
result DSL**: `render` returns ordinary React elements, and
`lowerMcpResult` walks that tree synchronously — function components are
simply called — to produce the `CallToolResult` the MCP SDK sends. The
package owns no transport, and operations receive no implicit storage:
persistent application state exists only through the opt-in state kernel
subpath (`@agent-bundle/runtime/state`, issue #98), which stateless projects
never import.
The operation model shown above still uses the **synchronous MCP result DSL**:
`render` returns ordinary React elements and `lowerMcpResult` walks that tree
to produce the `CallToolResult` the MCP SDK sends. That compatibility path does
not involve Flight and remains the operative MCP projection.

Separately, `@agent-bundle/runtime` now exposes a final-only React-owned Flight
dispatcher for generated routes. An execution host supplies Flight bytes, the
dispatcher decodes intrinsic `Agent.*` elements into one immutable
`AgentDocument`, and cancellation follows the request `AbortSignal`. Streaming
Suspense replacement and public filesystem-route authoring are later stages.
Operations receive no implicit storage: persistent application state exists
only through the opt-in `@agent-bundle/runtime/state` kernel, which stateless
projects never import.

Operation modules are `.tsx` for exactly one reason: the `render` callback
returns JSX. Everything else in an operation — schemas, argv parsing, MCP
Expand All @@ -150,9 +153,9 @@ For a new reader, in one breath:
around them).
3. **Which projection consumes `render`?** Only MCP, though every operation
must declare one. The CLI serializes the validated result as JSON.
4. **Is any React Server Components renderer or Flight transport
involved?** No. `lowerMcpResult` is a synchronous element-tree lowering,
not a renderer or transport.
4. **Is Flight involved in this operation projection?** No.
`lowerMcpResult` remains synchronous. The separate generated-route path uses
the final-only `AgentRenderDispatcher` described above.
5. **Why are operation modules `.tsx`?** Only because `render` returns JSX.

## Rendered skills (power tier, never required)
Expand Down
14 changes: 7 additions & 7 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha
| RSC render | Hook and MCP result component trees, lowered from Flight | One request |
| MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance |

Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker, lowers the Flight result, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls.
Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker through the runtime dispatcher seam, projects the final Agent Document, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls.

```tsx
// A Hook JSX route reads request-scoped context.
import { Hook, agent } from '@agent-bundle/runtime';
// An Agent Document route reads request-scoped context.
import { Agent, agent } from '@agent-bundle/runtime';
import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';

export async function AfterFileEdit() {
const context = await agent();
const edit = context.services.edit as CanonicalPostToolUse;
const snapshot = context.services.snapshot as RuntimeSnapshot;
return (
<Hook.Result>
<Hook.AdditionalContext>
<Agent.Result>
<Agent.Text>
{`Recorded ${edit.path}; ${snapshot.edits.length} edits exist.`}
</Hook.AdditionalContext>
</Hook.Result>
</Agent.Text>
</Agent.Result>
);
}
```
Expand Down
44 changes: 25 additions & 19 deletions examples/rsc-agent-runtime/src/dev/invocation-worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { requestFlightRenderWithFlight } from '../flight/request-render.js';
import { requestAgentDocumentWithFlight, requestFlightRenderWithFlight } from '../flight/request-render.js';
import { writeSync } from 'node:fs';
import { lowerHookResult, lowerMcpResult } from '@agent-bundle/runtime';
import { lowerMcpResult } from '@agent-bundle/runtime';
import type {
DevRuntimeInspectionRequest,
DevRuntimeInspectionResponse,
Expand All @@ -9,6 +9,7 @@ import type {
RuntimeSnapshot,
} from '../runtime/contracts.js';
import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js';
import { projectHookDocument } from '../hook/project-document.js';

import { hasInspectionCredential, isInspectionSensitiveKey } from './inspection-security.js';
import { serializeInspection } from './serialize-inspection.js';
Expand Down Expand Up @@ -163,41 +164,46 @@ interface InvocationOutput {

const invoke = async (signal?: AbortSignal): Promise<InvocationOutput> => {
const request = await readRequest();
const rendered = await requestFlightRenderWithFlight(renderRequestFor(request), {
maximumFlightBytes: maximumInvocationFlightBytes,
signal,
});
const renderRequest = renderRequestFor(request);

if (request.type === 'hook/after-file-edit') {
const native = lowerHookResult(rendered.node);
const rendered = await requestAgentDocumentWithFlight(renderRequest, {
maximumFlightBytes: maximumInvocationFlightBytes,
signal,
});
const native = projectHookDocument(rendered.document);
return Object.freeze({
flight: Buffer.from(rendered.flight),
response: Object.freeze({
flightBytes: rendered.flight.byteLength,
inspection: serializeInspection({
agentVisible: native.hookSpecificOutput.additionalContext,
flight: rendered.flight,
native,
node: rendered.node,
stateStoreId: request.stateStoreId,
stateVersion: rendered.stateVersion,
agentVisible: native.hookSpecificOutput.additionalContext,
flight: rendered.flight,
native,
node: rendered.node,
stateStoreId: request.stateStoreId,
stateVersion: rendered.stateVersion,
}),
}),
});
}

const rendered = await requestFlightRenderWithFlight(renderRequest, {
maximumFlightBytes: maximumInvocationFlightBytes,
signal,
});
const protocol = lowerMcpResult(rendered.node);
return Object.freeze({
flight: Buffer.from(rendered.flight),
response: Object.freeze({
flightBytes: rendered.flight.byteLength,
inspection: serializeInspection({
flight: rendered.flight,
modelVisible: protocol.content,
node: rendered.node,
protocol,
stateStoreId: request.stateStoreId,
stateVersion: rendered.stateVersion,
flight: rendered.flight,
modelVisible: protocol.content,
node: rendered.node,
protocol,
stateStoreId: request.stateStoreId,
stateVersion: rendered.stateVersion,
}),
}),
});
Expand Down
65 changes: 65 additions & 0 deletions examples/rsc-agent-runtime/src/flight/request-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
import { createFromReadableStream } from 'react-server-dom-rspack/client.node';
import type { ReactNode } from 'react';

import { createAgentRenderDispatcher, type AgentDocument, type AgentRenderInvocation } from '@agent-bundle/runtime';

import type { RenderRequest } from '../runtime/contracts.js';
import { redactInspectionDiagnostics } from '../dev/inspection-security.js';

Expand All @@ -22,6 +24,10 @@ export interface FlightRenderResult {
readonly stateVersion: number;
}

export interface AgentDocumentFlightRenderResult extends FlightRenderResult {
readonly document: AgentDocument;
}

export interface FlightRenderOptions {
readonly maximumFlightBytes?: number;
readonly maximumStderrBytes?: number;
Expand Down Expand Up @@ -188,3 +194,62 @@ export const requestFlightRenderWithFlight = async (

export const requestFlightRender = async (request: RenderRequest): Promise<ReactNode> =>
(await requestFlightRenderWithFlight(request)).node;

const renderInvocationFor = (request: RenderRequest): AgentRenderInvocation => {
switch (request.type) {
case 'hook/after-file-edit':
return {
kind: 'event',
props: {
event: request.type,
payload: { event: { ...request.event }, stateFile: request.stateFile },
},
};
case 'mcp/render-timeline':
return {
kind: 'tool',
props: {
input: {
snapshot: {
edits: request.snapshot.edits.map((edit) => ({ ...edit })),
...(request.snapshot.seed === undefined ? {} : { seed: request.snapshot.seed }),
stateVersion: request.snapshot.stateVersion,
},
stateFile: request.stateFile,
},
operationId: request.type,
},
};
case 'mcp/runtime-status':
return {
kind: 'tool',
props: { input: { stateFile: request.stateFile }, operationId: request.type },
};
default: {
const exhaustive: never = request;
return exhaustive;
}
}
};

export const requestAgentDocumentWithFlight = async (
request: RenderRequest,
options: FlightRenderOptions = {},
): Promise<AgentDocumentFlightRenderResult> => {
let rendered: FlightRenderResult | undefined;
const signal = options.signal ?? new AbortController().signal;
const dispatcher = createAgentRenderDispatcher({
execute: async (dispatch) => {
rendered = await requestFlightRenderWithFlight(request, { ...options, signal: dispatch.signal });
return Readable.toWeb(Readable.from([rendered.flight])) as ReadableStream<Uint8Array>;
},
});
const document = await dispatcher.dispatch({ invocation: renderInvocationFor(request), signal });
if (rendered === undefined) throw new Error('Flight execution host returned no render result');
return Object.freeze({ ...rendered, document });
};

export const requestAgentDocument = async (
request: RenderRequest,
options: FlightRenderOptions = {},
): Promise<AgentDocument> => (await requestAgentDocumentWithFlight(request, options)).document;
22 changes: 15 additions & 7 deletions examples/rsc-agent-runtime/src/hook/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { appendFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { requestFlightRender } from '../flight/request-render.js';
import { lowerHookResult } from '@agent-bundle/runtime';
import { requestAgentDocument } from '../flight/request-render.js';
import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js';
import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js';
import { projectHookDocument } from './project-document.js';

let probeInput: Record<string, unknown> | undefined;

Expand Down Expand Up @@ -59,7 +59,7 @@ const readHost = (): 'claude' | 'codex' => {
return host;
};

const run = async (): Promise<void> => {
const run = async (signal: AbortSignal): Promise<void> => {
const host = readHost();
const input = await readInput();
probeInput = input;
Expand All @@ -69,18 +69,26 @@ const run = async (): Promise<void> => {
? await resolveImplicitRuntimeStateFile(event.cwd)
: resolve(configuredStateFile);

const result = await requestFlightRender({
const document = await requestAgentDocument({
event,
stateFile,
type: 'hook/after-file-edit',
});
process.stdout.write(`${JSON.stringify(lowerHookResult(result))}\n`);
}, { signal });
process.stdout.write(`${JSON.stringify(projectHookDocument(document))}\n`);
await writeEvalProbe(input, 0);
};

run().catch(async (error: unknown) => {
const controller = new AbortController();
const abort = (): void => controller.abort();
process.once('SIGINT', abort);
process.once('SIGTERM', abort);

run(controller.signal).catch(async (error: unknown) => {
if (probeInput !== undefined) await writeEvalProbe(probeInput, 1).catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}).finally(() => {
process.removeListener('SIGINT', abort);
process.removeListener('SIGTERM', abort);
});
16 changes: 16 additions & 0 deletions examples/rsc-agent-runtime/src/hook/project-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { AgentDocument, NativePostToolUseOutput } from '@agent-bundle/runtime';

export const projectHookDocument = (document: AgentDocument): NativePostToolUseOutput => {
if (document.status === 'failed' || document.root.kind !== 'result') {
throw new Error('Hook render requires a successful Agent.Result document');
}
if (document.root.children.length !== 1 || document.root.children[0]?.kind !== 'text') {
throw new Error('Hook render requires exactly one Agent.Text child');
}
return {
hookSpecificOutput: {
additionalContext: document.root.children[0].text,
hookEventName: 'PostToolUse',
},
};
};
10 changes: 5 additions & 5 deletions examples/rsc-agent-runtime/src/rsc/components.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { basename } from 'node:path';

import { Hook, Mcp, agent } from '@agent-bundle/runtime';
import { Agent, Mcp, agent } from '@agent-bundle/runtime';
import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';

const hookServices = async (): Promise<{ edit: CanonicalPostToolUse; snapshot: RuntimeSnapshot }> => {
Expand All @@ -22,11 +22,11 @@ export const AfterFileEdit = async () => {
const editNoun = editCount === 1 ? 'edit' : 'edits';

return (
<Hook.Result>
<Hook.AdditionalContext>
<Agent.Result>
<Agent.Text>
{`Recorded ${basename(edit.path)} from ${edit.host}. Shared state now contains ${editCount} ${editNoun}.`}
</Hook.AdditionalContext>
</Hook.Result>
</Agent.Text>
</Agent.Result>
);
};

Expand Down
18 changes: 14 additions & 4 deletions examples/rsc-agent-runtime/src/rsc/worker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { writeSync } from 'node:fs';

import { available, runAgentRequest } from '@agent-bundle/runtime';
import { renderToReadableStream } from 'react-server-dom-rspack/server.node';
import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';

import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js';
import { createFileRuntimeKernel } from '../runtime/state-file.js';
Expand Down Expand Up @@ -103,7 +103,7 @@ const readRequest = async (): Promise<RenderRequest> => {
return parseRequest(JSON.parse(contents));
};

const render = async (): Promise<void> => {
const render = async (signal: AbortSignal): Promise<void> => {
const request = await readRequest();
const runtime = createFileRuntimeKernel({ stateFile: request.stateFile });
const snapshot =
Expand All @@ -120,7 +120,7 @@ const render = async (): Promise<void> => {
: await runtime.readSnapshot();

const renderFlight = async (): Promise<void> => {
const flight = renderToReadableStream(renderRoute(request, snapshot));
const flight = renderAgentFlight(renderRoute(request, snapshot), { signal });
const output = Readable.from(flight);
output.pipe(process.stdout, { end: false });
await finished(output);
Expand All @@ -145,6 +145,7 @@ const render = async (): Promise<void> => {
session: available({ sessionId: request.event.sessionId }, 'native'),
services: { edit: request.event, snapshot },
workspace: available({ root: request.event.cwd }, 'native'),
signal,
}, renderFlight);
} else {
await runAgentRequest({
Expand All @@ -153,13 +154,22 @@ const render = async (): Promise<void> => {
surface: request.type,
},
services: { snapshot },
signal,
}, renderFlight);
}
writeSnapshotMetadata();
};

render().catch((error: unknown) => {
const controller = new AbortController();
const abort = (): void => controller.abort();
process.once('SIGINT', abort);
process.once('SIGTERM', abort);

render(controller.signal).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}).finally(() => {
process.removeListener('SIGINT', abort);
process.removeListener('SIGTERM', abort);
});
Loading
Loading