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
7 changes: 7 additions & 0 deletions .changeset/semantic-event-route-descriptors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
"@agent-bundle/runtime": minor
---

Add the seven v1 semantic event-route descriptors and the `Agent.Context`
document vocabulary used for immediate host guidance.
20 changes: 19 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,25 @@ import { emptyCompiledRouteGraph } from './routes/graph.ts';
import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts';
import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts';
export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts';
export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts';
export { canonicalAgentEvents } from './routes/public.ts';
export type {
AgentEventCanonicalIdentity,
AgentEventDelivery,
AgentEventFallbackMode,
AgentEventNativePayload,
AgentEventProvenance,
AgentEventRouteConfig,
AgentEventRouteProps,
AgentEventRuntimeMode,
AppRouteConfig,
CanonicalAgentEvent,
PromptConfig,
ResourceConfig,
RouteSchema,
RouteSchemaOutput,
ToolConfig,
ToolRouteProps,
} from './routes/public.ts';
export { inspectRouteGraph } from './routes/inspect.ts';
export type { RouteGraphInspection } from './routes/inspect.ts';
export { emptyRouteConfig } from './routes/types.ts';
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti
'const appendNode = (node, content) => {',
' switch (node.kind) {',
" case 'result': for (const child of node.children) appendNode(child, content); break;",
" case 'context':",
" case 'markdown':",
" case 'text': content.push({ text: node.text, type: 'text' }); break;",
" case 'json': content.push({ text: JSON.stringify(node.value), type: 'text' }); break;",
Expand Down
20 changes: 19 additions & 1 deletion packages/agent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,25 @@ import type { PortableConfigExtension } from './adapters/portable.ts';
import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts';

export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts';
export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts';
export { canonicalAgentEvents } from './routes/public.ts';
export type {
AgentEventCanonicalIdentity,
AgentEventDelivery,
AgentEventFallbackMode,
AgentEventNativePayload,
AgentEventProvenance,
AgentEventRouteConfig,
AgentEventRouteProps,
AgentEventRuntimeMode,
AppRouteConfig,
CanonicalAgentEvent,
PromptConfig,
ResourceConfig,
RouteSchema,
RouteSchemaOutput,
ToolConfig,
ToolRouteProps,
} from './routes/public.ts';
export { compareEvals, runEvals, startDevServer } from './api.ts';
export {
createCodexEvalHarness,
Expand Down
49 changes: 45 additions & 4 deletions packages/agent-bundle/src/routes/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ const diagnostic = (
recovery: string,
): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath });

/** Validates G8's one executable route contract without evaluating the module. */
export const validateRouteModuleContract = (
interface RouteModuleShape {
readonly asyncDefault: boolean;
readonly named: ReadonlySet<string>;
readonly splitExport: boolean;
}

const inspectRouteModule = (
moduleText: string,
relativePath: string,
sourcePath: string,
): readonly Diagnostic[] => {
): RouteModuleShape => {
const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const named = new Set<string>();
let asyncDefault = false;
Expand Down Expand Up @@ -71,6 +75,16 @@ export const validateRouteModuleContract = (
}
}

return { asyncDefault, named, splitExport };
};

/** Validates G8's one executable MCP route contract without evaluating the module. */
export const validateRouteModuleContract = (
moduleText: string,
relativePath: string,
sourcePath: string,
): readonly Diagnostic[] => {
const { asyncDefault, named, splitExport } = inspectRouteModule(moduleText, relativePath);
const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name));
const diagnostics: Diagnostic[] = [];
if (missing.length > 0 || !asyncDefault) {
Expand All @@ -95,3 +109,30 @@ export const validateRouteModuleContract = (
}
return Object.freeze(diagnostics);
};

/** Validates an event route's single async component contract without requiring MCP schemas. */
export const validateEventRouteModuleContract = (
moduleText: string,
relativePath: string,
sourcePath: string,
): readonly Diagnostic[] => {
const { asyncDefault, splitExport } = inspectRouteModule(moduleText, relativePath);
const diagnostics: Diagnostic[] = [];
if (!asyncDefault) {
diagnostics.push(diagnostic(
'AB4810',
`Event route module ${relativePath} does not satisfy the public route contract: default export is not an async function component.`,
sourcePath,
'Export one async default Server Component receiving { canonical, native, signal }.',
));
}
if (splitExport) {
diagnostics.push(diagnostic(
'AB4811',
`Event route module ${relativePath} exports execute or render; routed modules use one async default Server Component instead of an execute/render split.`,
sourcePath,
'Move execution into the async default component and render Agent.* elements from that component.',
));
}
return Object.freeze(diagnostics);
};
38 changes: 34 additions & 4 deletions packages/agent-bundle/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import fastGlob from 'fast-glob';

import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts';
import { extractRouteConfig } from './config-extract.ts';
import { validateRouteModuleContract } from './contract.ts';
import { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { digest } from '../core/digest.ts';
import { deepFreeze } from '../core/freeze.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleConfig } from '../core/types.ts';
import { canonicalAgentEvents, type CanonicalAgentEvent } from './public.ts';
import {
emptyRouteConfig,
type CompiledAgentRoute,
Expand All @@ -35,6 +36,7 @@ type ProjectIgnoreRules = Awaited<ReturnType<typeof readProjectIgnoreRules>>;
const routeGlobs = [
'src/mcp/*/{tools,resources,prompts,apps}/*.{ts,tsx}',
'src/events/*/*.{ts,tsx}',
'src/events/stop.{ts,tsx}',
'src/providers/*.{ts,tsx}',
'src/cli/**/*.{ts,tsx}',
// Scripts also discover .jsx: the stage-1 script gate judges rendered
Expand Down Expand Up @@ -105,6 +107,7 @@ interface DiscoveredProviderModule {
}

interface DiscoveredRouteModule {
readonly event?: CanonicalAgentEvent;
readonly id: string;
/** The path-derived name segments; each must satisfy the safe-identity rule. */
readonly identitySegments: readonly string[];
Expand Down Expand Up @@ -138,10 +141,11 @@ const classifyModule = (source: string, relativePath: string): DiscoveredModule
};
}
if (collection === 'events') {
const family = segments[2]!;
const event = segments.length === 3 ? stem : `${segments[2]!}/${stem}`;
return {
id: `event:${family}/${stem}`,
identitySegments: [family, stem],
event: event as CanonicalAgentEvent,
id: `event:${event}`,
identitySegments: event.split('/'),
kind: 'event-route',
relativePath,
source,
Expand Down Expand Up @@ -302,6 +306,7 @@ const compiledRoute = (
config: Readonly<Record<string, unknown>>,
): CompiledAgentRoute => ({
config,
...(module.event === undefined ? {} : { event: module.event }),
id: module.id,
kind: module.kind,
provenance: { kind: 'conventional', relativePath: module.relativePath },
Expand Down Expand Up @@ -331,6 +336,7 @@ const extractedModuleConfig = async (

const routeIdentity = (route: CompiledAgentRoute): Readonly<Record<string, unknown>> => ({
config: route.config,
...(route.event === undefined ? {} : { event: route.event }),
id: route.id,
kind: route.kind,
relativePath: route.provenance.relativePath,
Expand Down Expand Up @@ -392,6 +398,19 @@ export const compileRouteGraph = async (
const relativePath = toPosixPath(relative(projectRoot, source));
if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue;
const module = classifyModule(source, relativePath);
if (
module.surface === 'route' &&
module.kind === 'event-route' &&
!canonicalAgentEvents.includes(module.event!)
) {
diagnostics.push(routeError(
'AB4813',
`Event route ${relativePath} declares ${JSON.stringify(module.event)}, which is outside the #97 v1 event vocabulary.`,
`Use one of: ${canonicalAgentEvents.join(', ')}.`,
source,
));
continue;
}
const unsafeSegment = module.identitySegments.find((segment) => !safeIdentitySegment.test(segment));
if (unsafeSegment !== undefined) {
diagnostics.push(routeError(
Expand Down Expand Up @@ -432,6 +451,17 @@ export const compileRouteGraph = async (
continue;
}
const route = compiledRoute(module, await extractedModuleConfig(module, diagnostics));
if (route.kind === 'event-route') {
try {
diagnostics.push(...validateEventRouteModuleContract(
await readFile(route.source, 'utf8'),
route.provenance.relativePath,
route.source,
));
} catch {
// Racing deletion is handled by the next source snapshot.
}
}
switch (route.kind) {
case 'tool':
case 'resource':
Expand Down
22 changes: 20 additions & 2 deletions packages/agent-bundle/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,23 @@ export type {
RouteProvenance,
} from './types.ts';
export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts';
export { validateRouteModuleContract } from './contract.ts';
export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './public.ts';
export { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts';
export { canonicalAgentEvents } from './public.ts';
export type {
AgentEventCanonicalIdentity,
AgentEventDelivery,
AgentEventFallbackMode,
AgentEventNativePayload,
AgentEventProvenance,
AgentEventRouteConfig,
AgentEventRouteProps,
AgentEventRuntimeMode,
AppRouteConfig,
CanonicalAgentEvent,
PromptConfig,
ResourceConfig,
RouteSchema,
RouteSchemaOutput,
ToolConfig,
ToolRouteProps,
} from './public.ts';
55 changes: 55 additions & 0 deletions packages/agent-bundle/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,61 @@ export interface RouteSchema<Output = unknown> {

export type RouteSchemaOutput<Schema> = Schema extends RouteSchema<infer Output> ? Output : never;

/** The event-route families admitted by the recorded #97 v1/G10 decision. */
export const canonicalAgentEvents = Object.freeze([
'session/start',
'tool/before',
'tool/after',
'stop',
'agent/start',
'agent/stop',
'workspace/open',
] as const);

export type CanonicalAgentEvent = (typeof canonicalAgentEvents)[number];

export interface AgentEventProvenance {
readonly host: string;
readonly hostContractRevision: string;
readonly nativeEvent: string;
readonly source: 'native';
}

/** Cross-host identity supplied to an event route without fabricated host fields. */
export interface AgentEventCanonicalIdentity {
readonly event: CanonicalAgentEvent;
readonly idempotencyKey: string;
readonly observedAt: string;
readonly provenance: AgentEventProvenance;
readonly sequence: number;
}

/** Complete host envelope after the adapter's schema and byte-bound validation. */
export type AgentEventNativePayload = Readonly<Record<string, unknown>>;

/** Props received by an event route's async default Server Component. */
export interface AgentEventRouteProps {
readonly canonical: AgentEventCanonicalIdentity;
readonly native: AgentEventNativePayload;
readonly signal: AbortSignal;
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the published event props from the route test harness

Consumers authoring against this new public contract cannot exercise their event routes through agent-bundle/test: componentProps in src/test/render.ts still passes { event, payload, signal } for event-route, rather than { canonical, native, signal }, and its event value is the prefixed route ID such as event:tool/after. Consequently, routes that destructure the newly documented canonical or native props receive undefined and may fail during route-unit rendering; the harness needs to construct the same canonical/native envelope promised here.

Useful? React with 👍 / 👎.

}

export type AgentEventDelivery = 'immediate';
export type AgentEventRuntimeMode = 'shared' | 'standalone';
export type AgentEventFallbackMode = 'none' | 'standalone';

/** Statically extractable event-route configuration. */
export interface AgentEventRouteConfig {
readonly delivery?: readonly AgentEventDelivery[];
readonly fallback?: AgentEventFallbackMode;
readonly runtime?: AgentEventRuntimeMode;
readonly targets?: readonly string[];
/** Route budget within the adapter's stricter native-host deadline. */
readonly timeoutMs?: number;
/** Canonical tool selectors for tool/before and tool/after routes. */
readonly tools?: readonly string[];
}

/** Props received by every executable MCP route's async default Server Component. */
export interface ToolRouteProps<InputSchema extends RouteSchema> {
readonly input: RouteSchemaOutput<InputSchema>;
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/routes/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
' input: SchemaOutput<InputSchema>;',
' result: SchemaOutput<ResultSchema>;',
'}>;',
'export type EventRouteContract<Component, Event extends string> = Readonly<{',
' component: Component;',
' event: Event;',
'}>;',
'',
'export interface AgentBundleRoutes {',
...routes.map((route, index) =>
` ${JSON.stringify(route.id)}: RouteContract<typeof route${String(index)}.inputSchema, typeof route${String(index)}.resultSchema>;`),
route.kind === 'event-route'
? ` ${JSON.stringify(route.id)}: EventRouteContract<typeof route${String(index)}.default, ${JSON.stringify(route.event)}>;`
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Separate event routes from input/result aliases

When a graph contains any newly admitted event route, this branch adds an EventRouteContract containing only component and event to AgentBundleRoutes, while the emitted RouteInput and RouteResult aliases at lines 47–48 still unconditionally index every RouteId by input and result. TypeScript therefore reports TS2536 while checking the generated routes.d.ts, preventing consumers from type-checking generated declarations for projects with event routes. Restrict those aliases to schema-based route IDs or extract the properties conditionally.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #198 (merged as d992838). The generated declarations now route RouteInput/RouteResult through conditional ContractInput/ContractResult helpers: schema routes keep their schema-derived types, and event routes resolve the component's props and awaited return type instead of erroring on the missing input/result members. Covered by a typegen fixture test plus a type-level test that compiles the generated routes.d.ts and asserts exact resolution for both route kinds.

: ` ${JSON.stringify(route.id)}: RouteContract<typeof route${String(index)}.inputSchema, typeof route${String(index)}.resultSchema>;`),
'}',
'',
'export type RouteId = keyof AgentBundleRoutes;',
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/routes/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Diagnostic } from '../core/diagnostics.ts';
import type { CanonicalAgentEvent } from './public.ts';

/**
* Every route kind the conventional source tree can declare. Context
Expand Down Expand Up @@ -39,6 +40,8 @@ export const emptyRouteConfig: Readonly<Record<string, unknown>> = Object.freeze
export interface CompiledAgentRoute {
/** Statically extracted from the module's `export const config` declaration; {@link emptyRouteConfig} when absent or rejected. */
readonly config: Readonly<Record<string, unknown>>;
/** Canonical event identity; present only when {@link kind} is `event-route`. */
readonly event?: CanonicalAgentEvent;
readonly id: string;
readonly kind: CompiledRouteKind;
readonly provenance: RouteProvenance;
Expand Down
Loading
Loading