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
5 changes: 5 additions & 0 deletions .changeset/661-preflight-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Pass typed JSON data from an event preflight gate to its rendered route through `AgentEventRouteProps.preflight` (#664)
19 changes: 10 additions & 9 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ const eventRouteHookWrapperSource = (
'};',
]
: []),
'const runStandalone = async (native, signal, observation) => {',
'const runStandalone = async (native, signal, observation, preflight) => {',
' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation);',
...(retiresLineage ? [' await retireLineage(native, resolved.canonical.idempotencyKey, resolved.canonical.observedAt);'] : []),
' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;',
Expand All @@ -808,7 +808,7 @@ const eventRouteHookWrapperSource = (
// A hook's stdout is its host envelope: no terminal, never probed (#511).
' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),',
' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),',
' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native } } }, signal));',
' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native, ...(preflight === undefined ? {} : { preflight }) } } }, signal));',
' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);',
'};',
]
Expand All @@ -826,29 +826,30 @@ const eventRouteHookWrapperSource = (
...(deferredExecution
? [
' if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) fail("deferred input must be an object");',
' const { native: nativeInput, observedAt, sequence } = parsed;',
' const { native: nativeInput, observedAt, preflight, sequence } = parsed;',
' if (typeof observedAt !== "string" || !Number.isInteger(sequence) || sequence < 1) fail("deferred input has an invalid canonical observation");',
' const observation = { observedAt, sequence };',
]
: [
' const nativeInput = parsed;',
' const observation = undefined;',
' const preflight = undefined;',
]),
' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });',
' const controller = new AbortController();',
' let output;',
' if (runtimeMode === "standalone") {',
...(standalone
? [' output = await runStandalone(native, controller.signal, observation);']
? [' output = await runStandalone(native, controller.signal, observation, preflight);']
: [' fail("standalone runtime was not compiled");']),
' } else {',
' try {',
' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });',
' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });',
' } catch (error) {',
...(standalone
? [
' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;',
' output = await runStandalone(native, controller.signal, observation);',
' output = await runStandalone(native, controller.signal, observation, preflight);',
]
: [' throw error;']),
' }',
Expand Down Expand Up @@ -910,7 +911,7 @@ const eventRoutePreflightWrapperSource = (
' signal,',
' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },',
' }, trace);',
' const projected = gate === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);',
' const projected = gate === "execute" || gate.outcome === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);',
' return Object.freeze({ gate, native, projected, props, runtime: runtimeMode, trace });',
'};',
'const runExecutor = (input, signal) => new Promise((resolve, reject) => {',
Expand Down Expand Up @@ -943,12 +944,12 @@ const eventRoutePreflightWrapperSource = (
' const controller = new AbortController();',
' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);',
' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);',
' if (gate !== "execute") {',
' if (gate !== "execute" && gate.outcome !== "execute") {',
' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));',
' return;',
' }',
' trace.executeStart(runtimeMode);',
' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, sequence: props.canonical.sequence }));',
' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));',
' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];',
' const terminate = () => controller.abort();',
' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);',
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
' }, async () => {',
' let validationError;',
" const props = message.invocation.kind === 'event'",
' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })',
' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), ...(message.invocation.props.payload.preflight === undefined ? {} : { preflight: Object.freeze(message.invocation.props.payload.preflight) }), signal: controller.signal })',
// The MCP server hands the worker input the SDK already validated; only
// the Workbench, which bypasses the SDK, asks the worker to validate.
' : message.validateInput !== true ? { input: message.invocation.props.input, signal: controller.signal } : (() => {',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@agent-bundle/runtime';

import { renderedDocumentExitCode } from '../../cli-entry.ts';
import type { EventPreflightResult } from '../../events/preflight.ts';
import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts';
import type { JsonObject, JsonValue } from '../../core/strict-json.ts';
import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../../core/types.ts';
Expand All @@ -38,7 +39,7 @@ interface CompiledCliInvocationModule {
}

interface CompiledEventPreflight {
readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>;
readonly gate: EventPreflightResult;
readonly native: JsonObject;
readonly projected?: JsonObject;
readonly props: Readonly<{ readonly canonical: JsonObject }>;
Expand Down Expand Up @@ -169,7 +170,13 @@ const prepareInput = async (
const native = (request.input as { readonly native?: JsonObject }).native ?? {};
const preflight = await wrapper.prepareRouteInvocation(native, signal, (event) => traceEvents.push(event));
return {
input: { canonical: preflight.props.canonical, native: preflight.native },
input: {
canonical: preflight.props.canonical,
native: preflight.native,
...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute'
? { preflight: preflight.gate.data }
: {}),
},
preflight,
};
};
Expand Down Expand Up @@ -415,6 +422,9 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly<Reco
? {
canonical: (input as { readonly canonical?: unknown }).canonical,
native: (input as { readonly native?: unknown }).native,
...((input as { readonly preflight?: unknown }).preflight === undefined
? {}
: { preflight: (input as { readonly preflight: unknown }).preflight }),
}
: { input };
};
Expand Down Expand Up @@ -501,7 +511,11 @@ export const renderProductionRoute = async (
} catch (error) {
throw preparationFailure(error);
}
if (prepared.preflight !== undefined && prepared.preflight.gate !== 'execute') {
if (
prepared.preflight !== undefined
&& prepared.preflight.gate !== 'execute'
&& prepared.preflight.gate.outcome !== 'execute'
) {
const value = prepared.preflight.gate as JsonValue;
return Object.freeze({
document: completeDocument(value),
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/events/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { StringDecoder } from 'node:string_decoder';
import { Context, Duration, Effect, Exit, Fiber, Layer, Random, Ref, type Scope } from 'effect';
import { z } from 'zod';

import { snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts';
import { isAbortError, makeScopedEffectRuntime, runPromise, type ScopedEffectRuntime } from '../effect/boundary.ts';
import { liftPromise } from '../effect/lift.ts';

Expand Down Expand Up @@ -65,6 +66,7 @@ const eventRequestSchema = z.object({
hostContractRevision: z.string().min(1),
native: z.record(z.string(), z.unknown()),
observedAt: z.string().min(1).optional(),
preflight: z.unknown().optional(),
protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION),
sequence: z.number().int().positive().optional(),
target: z.string().min(1),
Expand Down Expand Up @@ -130,6 +132,7 @@ export interface EventRuntimeRequest {
readonly hostContractRevision: string;
readonly native: Readonly<Record<string, unknown>>;
readonly observedAt?: string;
readonly preflight?: JsonValue;
readonly sequence?: number;
readonly target: string;
}
Expand Down Expand Up @@ -342,6 +345,7 @@ const handleConnection = Effect.fnUntraced(function*(
hostContractRevision: parsed.data.hostContractRevision,
native: parsed.data.native,
observedAt: parsed.data.observedAt,
...(parsed.data.preflight === undefined ? {} : { preflight: snapshotStrictJsonValue(parsed.data.preflight) }),
sequence: parsed.data.sequence,
target: parsed.data.target,
}, signal)).pipe(Effect.exit);
Expand Down Expand Up @@ -1143,6 +1147,7 @@ const requestProgram = (
hostContractRevision: options.hostContractRevision,
native: options.native,
observedAt: options.observedAt,
preflight: options.preflight,
protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION,
sequence: options.sequence,
target: options.target,
Expand Down
38 changes: 24 additions & 14 deletions packages/agent-bundle/src/events/preflight.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { settleBeforeAbort } from '../core/abort.ts';
import { isRecord } from '../core/strict-json.ts';
import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts';
import type { CanonicalAgentEvent } from '../routes/events.ts';
import type { AgentEventCanonicalIdentity } from '../routes/public.ts';
import type { AgentTerminal } from '../terminal-capability.ts';
import type { EventTracer } from './trace.ts';

/**
* The gate result a conventional event route's re-exported preflight may return (#595).
* `execute` is the only value that loads the rendered route; `continue` is a
* pass-through with no host decision; `deny` blocks through the existing
* canonical event outcome projection and always carries a nonempty reason.
* The bare `execute` value or an `execute` object carrying JSON data loads the
* rendered route; `continue` passes through with no host decision; `deny`
* blocks through the existing canonical event outcome projection.
*/
export type EventPreflightResult =
export type EventPreflightResult<Data extends JsonValue = JsonValue> =
| 'execute'
| { readonly outcome: 'execute'; readonly data: Data }
| { readonly outcome: 'continue' }
| { readonly outcome: 'deny'; readonly reason: string };

Expand All @@ -29,14 +30,17 @@ export interface EventPreflightContext<E extends CanonicalAgentEvent = Canonical
readonly terminal: AgentTerminal;
}

export type EventPreflight<E extends CanonicalAgentEvent = CanonicalAgentEvent> = (
export type EventPreflight<
E extends CanonicalAgentEvent = CanonicalAgentEvent,
Data extends JsonValue = JsonValue,
> = (
context: EventPreflightContext<E>,
) => EventPreflightResult | Promise<EventPreflightResult>;
) => EventPreflightResult<Data> | Promise<EventPreflightResult<Data>>;

type PreflightObjectOutcome = 'continue' | 'deny';
type PreflightObjectOutcome = 'continue' | 'deny' | 'execute';

const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome =>
value === 'continue' || value === 'deny';
value === 'continue' || value === 'deny' || value === 'execute';

const unsupportedResult = (detail: string): never => {
throw new TypeError(`Event preflight result ${detail}`);
Expand Down Expand Up @@ -100,13 +104,16 @@ export const validateEventPreflightResult = (
): EventPreflightResult => {
if (value === 'execute') return 'execute';
if (!isRecord(value)) {
return unsupportedResult('must be "execute" or a continue/deny object.');
return unsupportedResult('must be "execute" or an execute/continue/deny object.');
}
const outcome = value.outcome;
if (!isPreflightObjectOutcome(outcome)) {
return unsupportedResult(`outcome ${JSON.stringify(outcome)} is not supported.`);
}
switch (outcome) {
case 'execute':
unexpectedFields(value, new Set(['outcome', 'data']));
return Object.freeze({ data: snapshotStrictJsonValue(value.data), outcome: 'execute' });
case 'continue':
unexpectedFields(value, new Set(['outcome']));
return Object.freeze({ outcome: 'continue' });
Expand All @@ -131,11 +138,14 @@ export const validateEventPreflightResult = (
* Runs the gate inside the common event kernel and validates its result before
* any caller projects host output or loads the rendered route runtime.
*/
export const executeEventPreflight = async <E extends CanonicalAgentEvent>(
preflight: EventPreflight<E>,
export const executeEventPreflight = async <
E extends CanonicalAgentEvent,
Data extends JsonValue = JsonValue,
>(
preflight: EventPreflight<E, Data>,
context: EventPreflightContext<E>,
trace?: EventTracer,
): Promise<EventPreflightResult> => {
): Promise<EventPreflightResult<Data>> => {
trace?.preflightStart();
try {
context.signal.throwIfAborted();
Expand All @@ -147,7 +157,7 @@ export const executeEventPreflight = async <E extends CanonicalAgentEvent>(
});
const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal);
context.signal.throwIfAborted();
const result = validateEventPreflightResult(value, context.canonical.event);
const result = validateEventPreflightResult(value, context.canonical.event) as EventPreflightResult<Data>;
trace?.preflightOutcome(result);
return result;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/events/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,7 @@ export const projectEventDocument = (
* decision rules as a rendered Agent.Result, without loading the renderer.
*/
export const projectEventPreflightResult = (
result: Exclude<EventPreflightResult, 'execute'>,
result: Extract<EventPreflightResult, { readonly outcome: 'continue' | 'deny' }>,
event: CanonicalAgentEvent,
target: string,
nativeEvent: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/events/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export const summarizeEventTraceError = (error: unknown): EventTraceErrorSummary
};

const preflightOutcomeOf = (result: EventPreflightResult): EventTracePreflightOutcome => {
if (result === 'execute') return 'execute';
if (result === 'execute' || result.outcome === 'execute') return 'execute';
switch (result.outcome) {
case 'continue':
return 'continue';
Expand Down
9 changes: 8 additions & 1 deletion packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,7 +1007,14 @@ const startEventRuntime = async (
kind: 'event',
// The event payload crosses the render boundary as data; the route
// props type is what gives it shape on the other side.
props: { event, payload: { canonical: props.canonical, native: props.native } as never },
props: {
event,
payload: {
canonical: props.canonical,
native: props.native,
...(request.preflight === undefined ? {} : { preflight: request.preflight }),
} as never,
},
},
signal,
}),
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-bundle/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export type AgentEventNativePayload = Readonly<Record<string, unknown>>;
* Props received by an event route's async default Server Component.
* `canonical.payload` is the cross-host reading of the envelope for the
* route's family; `native` is the frozen host envelope itself, for the
* host-specific fields the payload does not model.
* host-specific fields the payload does not model; `preflight` is strict JSON
* returned by the route's gate with an execute outcome.
*
* Read transport-owned request context with `await agent()` from
* `@agent-bundle/runtime`. The invocation, host, session, actor, workspace,
Expand All @@ -78,9 +79,13 @@ export type AgentEventNativePayload = Readonly<Record<string, unknown>>;
* is unavailable on hook-driven event scopes. The framework never derives or
* surfaces the operator's identity from a host payload.
*/
export interface AgentEventRouteProps<E extends CanonicalAgentEvent = CanonicalAgentEvent> {
export interface AgentEventRouteProps<
E extends CanonicalAgentEvent = CanonicalAgentEvent,
Preflight extends JsonValue = JsonValue,
> {
readonly canonical: AgentEventCanonicalIdentity<E>;
readonly native: AgentEventNativePayload;
readonly preflight?: Preflight;
readonly signal: AbortSignal;
}

Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/test/event-input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createCanonicalEventProps, validateNativeEventEnvelope } from '../events/project.ts';
import type { JsonValue } from '../core/strict-json.ts';
import type { AgentEventCanonicalIdentity, AgentEventNativePayload, CanonicalAgentEvent } from '../routes/public.ts';
import { AgentTestError, captured } from './errors.ts';

Expand All @@ -20,10 +21,11 @@ export interface CreateEventRouteInputOptions {
readonly validate?: boolean;
}

/** The `{ canonical, native }` half of `AgentEventRouteProps<E>`; the harness supplies `signal`. */
/** The `{ canonical, native, preflight? }` half of `AgentEventRouteProps<E>`; the harness supplies `signal`. */
export interface AgentEventRouteInput<E extends CanonicalAgentEvent = CanonicalAgentEvent> {
readonly canonical: AgentEventCanonicalIdentity<E>;
readonly native: AgentEventNativePayload;
readonly preflight?: JsonValue;
}

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,14 @@ const componentProps = (
// The public event-route contract is `{ canonical, native, signal }`,
// and the generated Flight worker unwraps the payload into exactly that.
const payload = (invocation.props as {
readonly payload?: { readonly canonical?: unknown; readonly native?: unknown };
readonly payload?: { readonly canonical?: unknown; readonly native?: unknown; readonly preflight?: unknown };
}).payload ?? {};
return { canonical: payload.canonical, native: payload.native, signal };
return {
canonical: payload.canonical,
native: payload.native,
...(payload.preflight === undefined ? {} : { preflight: payload.preflight }),
signal,
};
}
case 'cli':
return { input: options.input ?? {}, signal };
Expand Down
Loading
Loading