From f93b2a7a09b9d0aac8f13ff2536d952ba205a505 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 01:50:28 +0000 Subject: [PATCH] feat(routes): execute conventional context providers (#95 remainder) Discover and validate src/providers modules, then execute their sorted factories per request before runAgentRequest while composing with generated state and notice bindings. Register AB4940-AB4942 and preserve byte-identical generated worker output when no providers are present. --- .changeset/generated-context-providers.md | 13 ++++ docs/diagnostics.md | 5 +- docs/entry-conventions.md | 18 ++++++ packages/agent-bundle/src/api.ts | 2 + packages/agent-bundle/src/build/build.ts | 1 + packages/agent-bundle/src/build/entries.ts | 4 +- .../agent-bundle/src/build/entry-shell.ts | 46 ++++++++++++- .../agent-bundle/src/build/inspect-bundler.ts | 1 + packages/agent-bundle/src/config/index.ts | 12 +++- packages/agent-bundle/src/config/normalize.ts | 2 + .../agent-bundle/src/core/project-context.ts | 9 +++ packages/agent-bundle/src/core/types.ts | 4 +- packages/agent-bundle/src/index.ts | 2 + packages/agent-bundle/src/routes/contract.ts | 63 +++++++++++++++--- packages/agent-bundle/src/routes/graph.ts | 40 +++++++++++- packages/agent-bundle/src/routes/index.ts | 9 ++- packages/agent-bundle/src/routes/providers.ts | 9 +++ packages/agent-bundle/src/routes/public.ts | 11 ++++ .../agent-bundle/tests/entry-shell.test.ts | 64 +++++++++++++++++++ .../tests/generated-route-server.test.ts | 51 +++++++++++++-- .../agent-bundle/tests/route-graph.test.ts | 43 +++++++++++++ packages/rsc-runtime/src/index.ts | 1 + .../rsc-runtime/tests/state-packaging.test.ts | 5 ++ 23 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 .changeset/generated-context-providers.md create mode 100644 packages/agent-bundle/src/routes/providers.ts diff --git a/.changeset/generated-context-providers.md b/.changeset/generated-context-providers.md new file mode 100644 index 000000000..e59904a5e --- /dev/null +++ b/.changeset/generated-context-providers.md @@ -0,0 +1,13 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": patch +--- + +Execute conventional `src/providers/*.{ts,tsx}` factories once per generated +MCP or event request and mount their values at +`(await agent()).providers.`. Provider execution is deterministic, +sequential, abort-aware, and fail-closed; duplicate, reserved, and invalid +provider exports report `AB4940`–`AB4942`. + +Export `AgentRenderInvocation` as a type from the runtime package root so +provider authoring types do not require an internal import. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7570a88f9..bd3da4ffc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -120,7 +120,7 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | -## Route graph and state convention (`AB4800`–`AB4820`) +## Route graph, state, and provider conventions (`AB4800`–`AB4820`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -233,6 +233,9 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4818` | error | `src/state.ts` is present but does not default-export one direct `defineState({ ... })` call, or `state` config is not the supported `false` opt-out. | | `AB4819` | error | The state definition's `id` or `lifetime` is missing, non-literal, empty, duplicated, or outside the state lifetime vocabulary. | | `AB4820` | error | A generated project selects `external` state lifetime; v1 generated mounting supports only `request`, `process`, and `workspace-durable` because external drivers require embedder wiring. | +| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | +| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | +| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | ## Development package build (`AB7103`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index aff4e38e6..85f62306d 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -63,6 +63,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | +| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes. | Prefix the file with `_` | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. @@ -86,6 +87,23 @@ directory. Routed CLI bins and rendered scripts use in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger, while application authorization policy is deferred. +### Request context providers (power tier) + +Each direct child of `src/providers/` derives its key by camel-casing the file +stem: for example, `src/providers/project-auth.ts` mounts at +`(await agent()).providers.projectAuth`. Every module default-exports a factory +with the contract `(context: { invocation, signal }) => value | +Promise`, where `invocation` is the current route invocation and +`signal` is its request abort signal. + +The generated shared Flight worker executes providers once per request, +sequentially in deterministic key order, before entering `runAgentRequest`. +The returned values join the request's provider map. A thrown or rejected +factory fails the request closed; expected degradation should return an honest +unavailable-shaped value instead of throwing. `processLifetime` is reserved +for the framework-owned process identity and hit counter, so provider filenames +must not derive that key. + ### Migration nudges Source validation reports **informational** nudges (never errors — migrations diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 7b9ecfd43..73b4d14a3 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -24,6 +24,8 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + AgentProviderContext, + AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, PromptConfig, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 7a42afe22..1af815848 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -383,6 +383,7 @@ export const build = async (options: BuildOptions): Promise => { .map((entry) => entry.hook), outDir: target.root, plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, + providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), target: target.name, ...tools, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 0c8a69539..32f0229c9 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -36,7 +36,7 @@ import { mcpServerRuntimePath, mcpServerRuntimeSpecifier, } from './entry-shell.ts'; -import { emptyRouteConfig } from '../routes/types.ts'; +import { emptyRouteConfig, type CompiledProvider } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; import { buildWithRslib } from './rslib.ts'; @@ -309,6 +309,7 @@ export const compileMcpEntries = async ( readonly eventHooks: readonly NormalizedHook[]; readonly outDir: string; readonly plugin: { readonly name: string; readonly version: string }; + readonly providers?: readonly CompiledProvider[]; readonly state?: NormalizedStateDefinition; readonly target: string; readonly tools?: AgentBundleToolsConfig; @@ -357,6 +358,7 @@ export const compileMcpEntries = async ( : generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch(options.plugin), eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + providers: options.providers ?? [], routes: server.generatedRoutes, serverName: server.name, ...(options.state === undefined ? {} : { state: options.state }), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 81c6bd14c..8f7769c0b 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts'; -import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; +import { providerKeyFromName } from '../routes/providers.ts'; +import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider } from '../routes/types.ts'; /** * Generated-entry templates: the framework-provided entry files consumers @@ -453,6 +454,7 @@ export interface GeneratedRouteMcpEntryOptions { export interface GeneratedRouteFlightWorkerOptions { readonly artifactEpoch: string; readonly eventRoutes?: readonly NormalizedHook[]; + readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly serverName: string; readonly state?: NormalizedStateDefinition; @@ -488,10 +490,25 @@ const eventRouteRecords = ( ): readonly string[] => routes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ event: ${JSON.stringify(route.eventRoute!.event)}, id: ${JSON.stringify(route.id)}, kind: 'event-route', module: route${String(offset + index)}, name: ${JSON.stringify(route.eventRoute!.event)} }),`); +const orderedProviders = (providers: readonly CompiledProvider[]): readonly CompiledProvider[] => + [...providers].sort((left, right) => { + const byKey = providerKeyFromName(left.name).localeCompare(providerKeyFromName(right.name)); + return byKey === 0 ? left.source.localeCompare(right.source) : byKey; + }); + +const providerImports = (providers: readonly CompiledProvider[]): readonly string[] => + providers.map((provider, index) => + `import * as provider${String(index)} from ${JSON.stringify(provider.source)};`); + +const providerRecords = (providers: readonly CompiledProvider[]): readonly string[] => + providers.map((provider, index) => + ` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, module: provider${String(index)}, source: ${JSON.stringify(provider.provenance.relativePath)} }),`); + /** The long-lived react-server worker used by one generated MCP process. */ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { const routes = executableMcpRoutes(options.routes); const eventRoutes = options.eventRoutes ?? []; + const providers = orderedProviders(options.providers ?? []); return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", @@ -500,6 +517,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...generatedStateImports(options.state, 'artifact'), ...routeImports(routes), ...eventRouteImports(eventRoutes, routes.length), + ...providerImports(providers), '', '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', @@ -508,6 +526,13 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...generatedStateOwner(options.state, 'artifact'), + ...(providers.length === 0 + ? [] + : [ + 'const providers = Object.freeze([', + ...providerRecords(providers), + ']);', + ]), 'const routes = Object.freeze({', ...routeRecords(routes), ...eventRouteRecords(eventRoutes, routes.length), @@ -529,13 +554,30 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']), + ...(providers.length === 0 + ? [] + : [ + ' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };', + ' for (const provider of providers) {', + ' if (typeof provider.module.default !== \'function\') {', + ' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);', + ' }', + ' try {', + ' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });', + ' } catch (error) {', + ' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });', + ' }', + ' }', + ]), ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', ' ...(message.host === undefined ? {} : { host: message.host }),', ' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', - ' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },', + ...(providers.length === 0 + ? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },'] + : [' providers: providerValues,']), ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 3b0d6b9c1..bb5c07530 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -222,6 +222,7 @@ const mcpEntryEntries = async ( sourceInputs: [], virtualSource: generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }), + providers: model.providers ?? [], routes: generatedRoutes, serverName, ...(model.state === undefined ? {} : { state: model.state }), diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 73d031c9d..421da1dae 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -5,7 +5,17 @@ import type { AgentBundleConfig as CoreAgentBundleConfig } from '../core/types.t export { discoverProject } from './discover.ts'; export { defineConfig } from '../core/types.ts'; -export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from '../routes/public.ts'; +export type { + AgentProviderContext, + AgentProviderFactory, + AppRouteConfig, + PromptConfig, + ResourceConfig, + RouteSchema, + RouteSchemaOutput, + ToolConfig, + ToolRouteProps, +} from '../routes/public.ts'; export type { AgentBundleRuntimeConfig, ConfigFactory, ConfigFactoryContext } from '../core/types.ts'; export type { DiscoveredProject } from './discover.ts'; export { loadConfig } from './load.ts'; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index c6f873c0b..58613d9e6 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -1010,6 +1010,7 @@ export const normalizeProject = async ( const scripts = normalizeScripts(loaded, discovered, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const commands = normalizeCommands(discovered, targetNames); + const providers = discovered.routeGraph?.providers ?? []; const rules = normalizeRules(discovered, targetNames); const state: NormalizedStateDefinition | undefined = discovered.state?.definition === undefined ? undefined @@ -1044,6 +1045,7 @@ export const normalizeProject = async ( ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), ...(payloads.length === 0 ? {} : { payloads }), + ...(providers.length === 0 ? {} : { providers }), ...(rules.length === 0 ? {} : { rules }), runtime: normalizeRuntime(loaded), scripts, diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 703a8e195..616f1bcac 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -255,6 +255,7 @@ const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ model.metadata.provenance.sourcePath, ...(model.assets ?? []).flatMap((asset) => [asset.provenance.sourcePath, asset.source]), ...(model.commands ?? []).flatMap((command) => [command.provenance.sourcePath, command.source]), + ...(model.providers ?? []).map((provider) => provider.source), ...(model.rules ?? []).flatMap((rule) => [rule.provenance.sourcePath, rule.source]), ...Object.values(model.extensions).map((extension) => extension.provenance.sourcePath), ...model.targets.map((target) => target.provenance.sourcePath), @@ -349,6 +350,14 @@ export const canonicalizeNormalizedModel = ( source: canonicalCompilerPath(root, command.source, 'Command source path'), })), }), + ...(detached.providers === undefined + ? {} + : { + providers: detached.providers.map((provider) => ({ + ...provider, + source: canonicalCompilerPath(root, provider.source, 'Provider source path'), + })), + }), extensions: Object.fromEntries(Object.entries(detached.extensions) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, extension]) => [key, { diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 1e97f4d7e..ad604c269 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -5,7 +5,7 @@ import type { AgentEventRuntimeMode, CanonicalAgentEvent, } from '../routes/public.ts'; -import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; +import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider } from '../routes/types.ts'; import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts'; import type { CapabilityState } from './capabilities.ts'; @@ -547,6 +547,8 @@ export interface NormalizedPlugin { * models predating prebuilt payloads stay valid. */ readonly payloads?: readonly NormalizedPayload[]; + /** Conventional context providers executed for every generated render request. */ + readonly providers?: readonly CompiledProvider[]; /** * Conventional `rules/*.mdc` documents. Present only when rules are * discovered; optional so hand-constructed models predating rules remain valid. diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 329c63eee..5d409e1e2 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -29,6 +29,8 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + AgentProviderContext, + AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, CliRouteConfig, diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 311aebdea..7d2adb71f 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -22,7 +22,7 @@ const unwrappedExpression = (expression: ts.Expression): ts.Expression => { }; const diagnostic = ( - code: 'AB4810' | 'AB4811', + code: 'AB4810' | 'AB4811' | 'AB4940', message: string, sourcePath: string, recovery: string, @@ -32,6 +32,8 @@ const diagnostic = ( export interface RouteModuleExports { /** True when the default export is an async function or arrow function. */ readonly asyncDefault: boolean; + /** True when the default export is a function or arrow function. */ + readonly defaultFunction: boolean; readonly named: ReadonlySet; /** True when the module exports `execute` or `render` (the retired split contract). */ readonly splitExport: boolean; @@ -43,23 +45,39 @@ export const scanRouteModuleExports = ( relativePath: string, ): RouteModuleExports => { const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const asyncFunctionBindings = new Set(); + const functionBindings = new Set(); const named = new Set(); let asyncDefault = false; + let defaultFunction = false; + let defaultIdentifier: string | undefined; let splitExport = false; for (const statement of sourceFile.statements) { - if (ts.isVariableStatement(statement) && exported(statement)) { + if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name)) continue; - named.add(declaration.name.text); - if (declaration.name.text === 'execute' || declaration.name.text === 'render') splitExport = true; + const initializer = declaration.initializer === undefined ? undefined : unwrappedExpression(declaration.initializer); + if (initializer !== undefined && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) { + functionBindings.add(declaration.name.text); + if (asynchronous(initializer)) asyncFunctionBindings.add(declaration.name.text); + } + if (exported(statement)) { + named.add(declaration.name.text); + if (declaration.name.text === 'execute' || declaration.name.text === 'render') splitExport = true; + } } continue; } - if (ts.isFunctionDeclaration(statement) && exported(statement)) { - if (modifier(statement, ts.SyntaxKind.DefaultKeyword)) { + if (ts.isFunctionDeclaration(statement)) { + if (statement.name !== undefined) { + functionBindings.add(statement.name.text); + if (asynchronous(statement)) asyncFunctionBindings.add(statement.name.text); + } + if (exported(statement) && modifier(statement, ts.SyntaxKind.DefaultKeyword)) { + defaultFunction = true; asyncDefault = asynchronous(statement); - } else if (statement.name !== undefined) { + } else if (exported(statement) && statement.name !== undefined) { named.add(statement.name.text); if (statement.name.text === 'execute' || statement.name.text === 'render') splitExport = true; } @@ -67,19 +85,30 @@ export const scanRouteModuleExports = ( } if (ts.isExportAssignment(statement) && !statement.isExportEquals) { const expression = unwrappedExpression(statement.expression); - asyncDefault = (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) && asynchronous(expression); + defaultFunction = ts.isArrowFunction(expression) || ts.isFunctionExpression(expression); + asyncDefault = defaultFunction && asynchronous(expression); + if (ts.isIdentifier(expression)) defaultIdentifier = expression.text; continue; } if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause)) { for (const element of statement.exportClause.elements) { const name = element.name.text; + if (name === 'default' && statement.moduleSpecifier === undefined) { + defaultIdentifier = element.propertyName?.text ?? name; + continue; + } named.add(name); if (name === 'execute' || name === 'render') splitExport = true; } } } - return Object.freeze({ asyncDefault, named, splitExport }); + if (defaultIdentifier !== undefined) { + defaultFunction = functionBindings.has(defaultIdentifier); + asyncDefault = asyncFunctionBindings.has(defaultIdentifier); + } + + return Object.freeze({ asyncDefault, defaultFunction, named, splitExport }); }; /** Validates G8's one executable MCP route contract without evaluating the module. */ @@ -140,3 +169,19 @@ export const validateEventRouteModuleContract = ( } return Object.freeze(diagnostics); }; + +/** Validates one context provider's default factory export without evaluating the module. */ +export const validateProviderModuleContract = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): readonly Diagnostic[] => { + const { defaultFunction } = scanRouteModuleExports(moduleText, relativePath); + if (defaultFunction) return Object.freeze([]); + return Object.freeze([diagnostic( + 'AB4940', + `Provider module ${relativePath} does not satisfy the public provider contract: default export is not a function.`, + sourcePath, + 'Default-export a provider factory receiving { invocation, signal }.', + )]); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 9414d32fa..2332de3d9 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -7,8 +7,13 @@ import fastGlob from 'fast-glob'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; import { compileCliCommands } from './cli-commands.ts'; import { extractRouteConfig } from './config-extract.ts'; -import { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; +import { + validateEventRouteModuleContract, + validateProviderModuleContract, + validateRouteModuleContract, +} from './contract.ts'; import { extractInputSchema } from './input-schema.ts'; +import { providerKeyFromName } from './providers.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -408,6 +413,7 @@ export const compileRouteGraph = async ( const claimed = configClaimedSources(projectRoot, config); const modules: DiscoveredModule[] = []; const modulesById = new Map(); + const providerModulesByKey = new Map(); for (const source of sources) { if (claimed.has(source)) continue; const relativePath = toPosixPath(relative(projectRoot, source)); @@ -436,6 +442,29 @@ export const compileRouteGraph = async ( )); continue; } + if (module.surface === 'provider') { + const key = providerKeyFromName(module.name); + if (key === 'processLifetime') { + diagnostics.push(routeError( + 'AB4942', + `Provider module ${relativePath} derives the reserved framework provider key "processLifetime".`, + 'Rename the provider file so its camel-cased key is not processLifetime.', + source, + )); + continue; + } + const existingProvider = providerModulesByKey.get(key); + if (existingProvider !== undefined) { + diagnostics.push(routeError( + 'AB4941', + `Provider key ${JSON.stringify(key)} is declared by both ${existingProvider.relativePath} and ${relativePath}.`, + 'Rename one provider file so every camel-cased provider key is unique.', + source, + )); + continue; + } + providerModulesByKey.set(key, module); + } const existing = modulesById.get(module.id); if (existing !== undefined) { diagnostics.push(routeError( @@ -463,6 +492,15 @@ export const compileRouteGraph = async ( provenance: { kind: 'conventional', relativePath: module.relativePath }, source: module.source, }); + try { + diagnostics.push(...validateProviderModuleContract( + await readFile(module.source, 'utf8'), + module.relativePath, + module.source, + )); + } catch { + // Racing deletion is handled by the next source snapshot. + } continue; } const metadata = await extractedModuleMetadata(module, diagnostics); diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index fcbdb238b..5a9c52931 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -24,7 +24,12 @@ export type { RouteProvenance, } from './types.ts'; export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts'; -export { scanRouteModuleExports, validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; +export { + scanRouteModuleExports, + validateEventRouteModuleContract, + validateProviderModuleContract, + validateRouteModuleContract, +} from './contract.ts'; export type { RouteModuleExports } from './contract.ts'; export { canonicalAgentEvents } from './public.ts'; export type { @@ -36,6 +41,8 @@ export type { AgentEventRouteConfig, AgentEventRouteProps, AgentEventRuntimeMode, + AgentProviderContext, + AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, CliRouteConfig, diff --git a/packages/agent-bundle/src/routes/providers.ts b/packages/agent-bundle/src/routes/providers.ts new file mode 100644 index 000000000..7d6e59294 --- /dev/null +++ b/packages/agent-bundle/src/routes/providers.ts @@ -0,0 +1,9 @@ +/** Derives the request-context key for one conventional provider file stem. */ +export const providerKeyFromName = (name: string): string => { + const words = name.split(/[._-]+/u); + return words.map((word, index) => { + const normalized = word === word.toUpperCase() ? word.toLowerCase() : word; + if (index === 0) return `${normalized.charAt(0).toLowerCase()}${normalized.slice(1)}`; + return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`; + }).join(''); +}; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 8fec25e68..2e9a4ec1e 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -1,3 +1,5 @@ +import type { AgentRenderInvocation } from '@agent-bundle/runtime'; + /** The structural schema surface route props infer without coupling to one schema library. */ export interface RouteSchema { readonly _output: Output; @@ -44,6 +46,15 @@ export interface AgentEventRouteProps { readonly signal: AbortSignal; } +/** Request-scoped inputs supplied to a conventional context provider factory. */ +export interface AgentProviderContext { + readonly invocation: AgentRenderInvocation; + readonly signal: AbortSignal; +} + +/** Default export contract for one `src/providers/.{ts,tsx}` module. */ +export type AgentProviderFactory = (context: AgentProviderContext) => unknown | Promise; + export type AgentEventDelivery = 'immediate'; export type AgentEventRuntimeMode = 'shared' | 'standalone'; export type AgentEventFallbackMode = 'none' | 'standalone'; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index ad71d9716..8bebf29de 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -1,4 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { access, readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; @@ -303,6 +304,69 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain('/project/src/mcp/curator/tools/inspect.tsx'); expect(source).toContain('/project/src/events/tool/after.tsx'); expect(source).toContain("message.invocation.kind === 'event'"); + expect(createHash('sha256').update(source).digest('hex')).toBe( + '2b9feba295b3a77cd14bdee6527379837a9a21712e649c545d35d1fed107245d', + ); + expect(generate({ + artifactEpoch: 'route-fixture@1.2.3', + eventRoutes: [{ + event: 'afterTool', + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:tool-after', + name: 'event-route-tool-after', + provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/after.tsx' }, + source: '/project/src/events/tool/after.tsx', + targets: ['claude'], + tools: [], + }], + providers: [], + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + serverName: 'curator', + })).toBe(source); +}); + +it('generates deterministic per-request provider execution in the shared Flight worker', () => { + const source = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + providers: [ + { + id: 'provider:zeta', + name: 'zeta', + provenance: { kind: 'conventional', relativePath: 'src/providers/zeta.ts' }, + source: '/project/src/providers/zeta.ts', + }, + { + id: 'provider:alpha-value', + name: 'alpha-value', + provenance: { kind: 'conventional', relativePath: 'src/providers/alpha-value.ts' }, + source: '/project/src/providers/alpha-value.ts', + }, + ], + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + serverName: 'curator', + }); + + expect(source).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(source).toContain('import * as provider1 from "/project/src/providers/zeta.ts"'); + expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( + source.indexOf('/project/src/providers/zeta.ts'), + ); + expect(source).toContain('key: "alphaValue"'); + expect(source).toContain('await provider.module.default({ invocation: message.invocation, signal: controller.signal })'); + expect(source).toContain('Context provider "'); + expect(source).toContain('provider.source'); + expect(source).toContain('providers: providerValues'); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index b560c675c..65bc818ae 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -448,24 +448,56 @@ it('renders one tool/after event route through two native thin clients', { retry '', ].join('\n')), writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [ - "import { Agent } from '@agent-bundle/runtime';", + "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", "import { z } from 'zod';", 'export const inputSchema = z.object({}).strict();', - 'export const resultSchema = z.object({ ready: z.literal(true) }).strict();', + "export const resultSchema = z.object({ providerKind: z.literal('tool'), providersFrozen: z.literal(true) }).strict();", 'export default async function Status() {', - " return createElement(Agent.Result, { value: { ready: true } }, createElement(Agent.Text, null, 'ready'));", + ' const context = await agent();', + ' const requestValue = context.providers.requestValue as { kind: string };', + ' const value = { providerKind: requestValue.kind, providersFrozen: Object.isFrozen(context.providers) };', + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, `provider:${requestValue.kind}`));", '}', '', ].join('\n')), + writeProjectFile(root, 'src/mcp/runtime/tools/explode.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.literal(true) }).strict();', + 'export default async function Explode() {', + " return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'unreachable'));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/providers/request-value.ts', [ + "import type { AgentProviderFactory } from 'agent-bundle';", + 'const provideRequest: AgentProviderFactory = ({ invocation }) => Object.freeze({ kind: invocation.kind });', + 'export default provideRequest;', + '', + ].join('\n')), + writeProjectFile(root, 'src/providers/throwing.ts', [ + "import type { AgentProviderFactory } from 'agent-bundle';", + 'const provideFailure: AgentProviderFactory = ({ invocation }) => {', + " if (invocation.kind === 'tool' && invocation.props.operationId.endsWith('/explode')) {", + " throw new Error('provider exploded');", + ' }', + " return 'ready';", + '};', + 'export default provideFailure;', + '', + ].join('\n')), writeProjectFile(root, 'src/events/tool/after.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", "export const config = { targets: ['claude', 'cursor'], tools: ['file.write'], timeoutMs: 5000 };", 'export default async function AfterTool({ canonical, native }) {', ' const context = await agent();', + ' const requestValue = context.providers.requestValue as { kind: string };', ' const tool = typeof native.tool_name === "string" ? native.tool_name : "unknown";', - ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${context.invocation.kind}`));', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${tool}:${requestValue.kind}:${String(Object.isFrozen(context.providers))}`));', '}', '', ].join('\n')), @@ -484,6 +516,13 @@ it('renders one tool/after event route through two native thin clients', { retry const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); await client.connect(transport); try { + await expect(client.callTool({ arguments: {}, name: 'status' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ + content: [{ text: 'provider:tool', type: 'text' }], + structuredContent: { providerKind: 'tool', providersFrozen: true }, + }); + const exploded = await callGeneratedTool(client, 'explode'); + expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); + const endpointId = `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(mcp.output)))}`; const expectedEndpoint = eventRuntimeEndpoint(endpointId); await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); @@ -510,10 +549,10 @@ it('renders one tool/after event route through two native thin clients', { retry }; const response = await runHook(hook.output, native); expect(response).toEqual(target === 'cursor' - ? { additional_context: 'cursor:Write:event' } + ? { additional_context: 'cursor:Write:event:true' } : { hookSpecificOutput: { - additionalContext: 'claude:Write:event', + additionalContext: 'claude:Write:event:true', hookEventName: 'PostToolUse', }, }); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index ac6a38b34..dc9bafa36 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -673,6 +673,49 @@ it('validates the single async route-module authoring contract statically', asyn ]); }); +it('validates provider default factories with AB4940', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/providers/missing.ts': 'export const value = 1;\n', + 'src/providers/not-a-function.ts': 'export default { value: 1 };\n', + 'src/providers/valid.ts': 'export default ({ invocation }) => invocation.kind;\n', + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics.map(({ code, sourcePath }) => ({ + code, + source: sourcePath?.slice(root.length + 1).replaceAll('\\', '/'), + }))).toEqual([ + { code: 'AB4940', source: 'src/providers/missing.ts' }, + { code: 'AB4940', source: 'src/providers/not-a-function.ts' }, + ]); +}); + +it('rejects provider key collisions and the reserved processLifetime key', async () => { + const root = await createRoot(); + const provider = 'export default () => undefined;\n'; + await writeTree(root, { + 'src/providers/foo-bar.ts': provider, + 'src/providers/foo_bar.ts': provider, + 'src/providers/process-lifetime.ts': provider, + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics.map(({ code }) => code)).toEqual(['AB4941', 'AB4942']); + expect(graph.diagnostics[0]).toMatchObject({ + message: expect.stringMatching(/fooBar/u), + sourcePath: expect.stringMatching(/src[/\\]providers[/\\]foo[-_]bar\.ts$/u), + }); + expect(graph.diagnostics[0]!.message).toContain('foo-bar.ts'); + expect(graph.diagnostics[0]!.message).toContain('foo_bar.ts'); + expect(graph.diagnostics[1]).toMatchObject({ + message: expect.stringMatching(/processLifetime/u), + sourcePath: join(root, 'src/providers/process-lifetime.ts'), + }); +}); + it('discovers only the seven v1 event families and validates their component contract', async () => { const root = await createRoot(); const eventSource = 'export default async function EventRoute() { return undefined; }\n'; diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index 0e645a035..1c9b6b68f 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -85,6 +85,7 @@ export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; export type { JsonObject, JsonValue } from './lower-mcp.js'; export { createRscRequestContext } from './request-context.js'; +export type { AgentRenderInvocation } from './agent-request.js'; // Type-only: the state kernel itself ships behind the './state' subpath so // stateless artifacts include none of it (#98). export type { AgentStateHandle, AgentStateLifetime } from './state/contract.js'; diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index d0ad7a9a0..6d0c7fd67 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -19,6 +19,11 @@ const distFile = async (...segments: string[]): Promise => readFile(join(packageRoot, 'dist', ...segments), 'utf8'); describe.sequential('state kernel packaging boundaries', () => { + it('publishes the provider invocation type from the root declaration entry', async () => { + const declaration = await distFile('index.d.ts'); + expect(declaration).toContain('AgentRenderInvocation'); + }); + it('keeps every kernel and storage identifier out of the root and plugin entries', async () => { const kernel = [ 'node:sqlite',