Skip to content
Closed
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: 0 additions & 5 deletions .changeset/661-preflight-data.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/restore-route-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Restore the prior route event preflight contract after reverting the broken event-data change (#668).
19 changes: 9 additions & 10 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, preflight) => {',
'const runStandalone = async (native, signal, observation) => {',
' 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, ...(preflight === undefined ? {} : { preflight }) } } }, signal));',
' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native } } }, signal));',
' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);',
'};',
]
Expand All @@ -826,30 +826,29 @@ const eventRouteHookWrapperSource = (
...(deferredExecution
? [
' if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) fail("deferred input must be an object");',
' const { native: nativeInput, observedAt, preflight, sequence } = parsed;',
' const { native: nativeInput, observedAt, 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, preflight);']
? [' output = await runStandalone(native, controller.signal, observation);']
: [' fail("standalone runtime was not compiled");']),
' } else {',
' try {',
' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });',
' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, 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, preflight);',
' output = await runStandalone(native, controller.signal, observation);',
]
: [' throw error;']),
' }',
Expand Down Expand Up @@ -911,7 +910,7 @@ const eventRoutePreflightWrapperSource = (
' signal,',
' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },',
' }, trace);',
' const projected = gate === "execute" || gate.outcome === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);',
' const projected = gate === "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 @@ -944,12 +943,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" && gate.outcome !== "execute") {',
' if (gate !== "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, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));',
' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, 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 @@ -1167,7 +1167,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), ...(message.invocation.props.payload.preflight === undefined ? {} : { preflight: Object.freeze(message.invocation.props.payload.preflight) }), signal: controller.signal })',
' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), 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,7 +15,6 @@ 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 @@ -39,7 +38,7 @@ interface CompiledCliInvocationModule {
}

interface CompiledEventPreflight {
readonly gate: EventPreflightResult;
readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>;
readonly native: JsonObject;
readonly projected?: JsonObject;
readonly props: Readonly<{ readonly canonical: JsonObject }>;
Expand Down Expand Up @@ -170,13 +169,7 @@ 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,
...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute'
? { preflight: preflight.gate.data }
: {}),
},
input: { canonical: preflight.props.canonical, native: preflight.native },
preflight,
};
};
Expand Down Expand Up @@ -422,9 +415,6 @@ 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 @@ -511,11 +501,7 @@ export const renderProductionRoute = async (
} catch (error) {
throw preparationFailure(error);
}
if (
prepared.preflight !== undefined
&& prepared.preflight.gate !== 'execute'
&& prepared.preflight.gate.outcome !== 'execute'
) {
if (prepared.preflight !== undefined && prepared.preflight.gate !== 'execute') {
const value = prepared.preflight.gate as JsonValue;
return Object.freeze({
document: completeDocument(value),
Expand Down
5 changes: 0 additions & 5 deletions packages/agent-bundle/src/events/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ 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 @@ -66,7 +65,6 @@ 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 @@ -132,7 +130,6 @@ 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 @@ -345,7 +342,6 @@ 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 @@ -1147,7 +1143,6 @@ 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: 14 additions & 24 deletions packages/agent-bundle/src/events/preflight.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { settleBeforeAbort } from '../core/abort.ts';
import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts';
import { isRecord } 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).
* 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.
* `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.
*/
export type EventPreflightResult<Data extends JsonValue = JsonValue> =
export type EventPreflightResult =
| 'execute'
| { readonly outcome: 'execute'; readonly data: Data }
| { readonly outcome: 'continue' }
| { readonly outcome: 'deny'; readonly reason: string };

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

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

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

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

const unsupportedResult = (detail: string): never => {
throw new TypeError(`Event preflight result ${detail}`);
Expand Down Expand Up @@ -104,16 +100,13 @@ export const validateEventPreflightResult = (
): EventPreflightResult => {
if (value === 'execute') return 'execute';
if (!isRecord(value)) {
return unsupportedResult('must be "execute" or an execute/continue/deny object.');
return unsupportedResult('must be "execute" or a 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 @@ -138,14 +131,11 @@ 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,
Data extends JsonValue = JsonValue,
>(
preflight: EventPreflight<E, Data>,
export const executeEventPreflight = async <E extends CanonicalAgentEvent>(
preflight: EventPreflight<E>,
context: EventPreflightContext<E>,
trace?: EventTracer,
): Promise<EventPreflightResult<Data>> => {
): Promise<EventPreflightResult> => {
trace?.preflightStart();
try {
context.signal.throwIfAborted();
Expand All @@ -157,7 +147,7 @@ export const executeEventPreflight = async <
});
const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal);
context.signal.throwIfAborted();
const result = validateEventPreflightResult(value, context.canonical.event) as EventPreflightResult<Data>;
const result = validateEventPreflightResult(value, context.canonical.event);
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: Extract<EventPreflightResult, { readonly outcome: 'continue' | 'deny' }>,
result: Exclude<EventPreflightResult, 'execute'>,
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' || result.outcome === 'execute') return 'execute';
if (result === 'execute') return 'execute';
switch (result.outcome) {
case 'continue':
return 'continue';
Expand Down
9 changes: 1 addition & 8 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,14 +1007,7 @@ 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,
...(request.preflight === undefined ? {} : { preflight: request.preflight }),
} as never,
},
props: { event, payload: { canonical: props.canonical, native: props.native } as never },
},
signal,
}),
Expand Down
9 changes: 2 additions & 7 deletions packages/agent-bundle/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ 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; `preflight` is strict JSON
* returned by the route's gate with an execute outcome.
* host-specific fields the payload does not model.
*
* Read transport-owned request context with `await agent()` from
* `@agent-bundle/runtime`. The invocation, host, session, actor, workspace,
Expand All @@ -79,13 +78,9 @@ 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,
Preflight extends JsonValue = JsonValue,
> {
export interface AgentEventRouteProps<E extends CanonicalAgentEvent = CanonicalAgentEvent> {
readonly canonical: AgentEventCanonicalIdentity<E>;
readonly native: AgentEventNativePayload;
readonly preflight?: Preflight;
readonly signal: AbortSignal;
}

Expand Down
4 changes: 1 addition & 3 deletions packages/agent-bundle/src/test/event-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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 @@ -21,11 +20,10 @@ export interface CreateEventRouteInputOptions {
readonly validate?: boolean;
}

/** The `{ canonical, native, preflight? }` half of `AgentEventRouteProps<E>`; the harness supplies `signal`. */
/** The `{ canonical, native }` 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: 2 additions & 7 deletions packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,14 +396,9 @@ 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 preflight?: unknown };
readonly payload?: { readonly canonical?: unknown; readonly native?: unknown };
}).payload ?? {};
return {
canonical: payload.canonical,
native: payload.native,
...(payload.preflight === undefined ? {} : { preflight: payload.preflight }),
signal,
};
return { canonical: payload.canonical, native: payload.native, signal };
}
case 'cli':
return { input: options.input ?? {}, signal };
Expand Down
Loading
Loading