Skip to content

Commit 02d2e37

Browse files
feat(routes): execute conventional context providers (#95 remainder) (#255)
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.
1 parent cba3dfd commit 02d2e37

23 files changed

Lines changed: 392 additions & 23 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"agent-bundle": minor
3+
"@agent-bundle/runtime": patch
4+
---
5+
6+
Execute conventional `src/providers/*.{ts,tsx}` factories once per generated
7+
MCP or event request and mount their values at
8+
`(await agent()).providers.<camelCaseKey>`. Provider execution is deterministic,
9+
sequential, abort-aware, and fail-closed; duplicate, reserved, and invalid
10+
provider exports report `AB4940`–`AB4942`.
11+
12+
Export `AgentRenderInvocation` as a type from the runtime package root so
13+
provider authoring types do not require an internal import.

‎docs/diagnostics.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ simply not been built yet is a validation **warning** that only
120120
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
121121
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |
122122

123-
## Route graph and state convention (`AB4800`–`AB4820`)
123+
## Route graph, state, and provider conventions (`AB4800`–`AB4820`, `AB4940`–`AB4942`)
124124

125125
The route-graph compiler discovers conventional route modules
126126
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
@@ -233,6 +233,9 @@ schema constants), unions, nested objects, transforms, coercions — raises
233233
| `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. |
234234
| `AB4819` | error | The state definition's `id` or `lifetime` is missing, non-literal, empty, duplicated, or outside the state lifetime vocabulary. |
235235
| `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. |
236+
| `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 }`. |
237+
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
238+
| `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. |
236239

237240
## Development package build (`AB7103`)
238241

‎docs/entry-conventions.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
6363
| `src/scripts/<name>.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/<name>.mjs` plus a `scripts/<name>-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 |
6464
| `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 `<bin> 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 `_` |
6565
| `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` |
66+
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes. | Prefix the file with `_` |
6667

6768
Route and package entry conventions match `.ts` and `.tsx` files exactly;
6869
the state convention is specifically `src/state.ts`.
@@ -86,6 +87,23 @@ directory. Routed CLI bins and rendered scripts use
8687
in generated mounting v1 (`authorized`); recipient/principal matching remains
8788
enforced by the ledger, while application authorization policy is deferred.
8889

90+
### Request context providers (power tier)
91+
92+
Each direct child of `src/providers/` derives its key by camel-casing the file
93+
stem: for example, `src/providers/project-auth.ts` mounts at
94+
`(await agent()).providers.projectAuth`. Every module default-exports a factory
95+
with the contract `(context: { invocation, signal }) => value |
96+
Promise<value>`, where `invocation` is the current route invocation and
97+
`signal` is its request abort signal.
98+
99+
The generated shared Flight worker executes providers once per request,
100+
sequentially in deterministic key order, before entering `runAgentRequest`.
101+
The returned values join the request's provider map. A thrown or rejected
102+
factory fails the request closed; expected degradation should return an honest
103+
unavailable-shaped value instead of throwing. `processLifetime` is reserved
104+
for the framework-owned process identity and hit counter, so provider filenames
105+
must not derive that key.
106+
89107
### Migration nudges
90108

91109
Source validation reports **informational** nudges (never errors — migrations

‎packages/agent-bundle/src/api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type {
2424
AgentEventRouteConfig,
2525
AgentEventRouteProps,
2626
AgentEventRuntimeMode,
27+
AgentProviderContext,
28+
AgentProviderFactory,
2729
AppRouteConfig,
2830
CanonicalAgentEvent,
2931
PromptConfig,

‎packages/agent-bundle/src/build/build.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
383383
.map((entry) => entry.hook),
384384
outDir: target.root,
385385
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
386+
providers: options.model.providers ?? [],
386387
...(options.model.state === undefined ? {} : { state: options.model.state }),
387388
target: target.name,
388389
...tools,

‎packages/agent-bundle/src/build/entries.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
mcpServerRuntimePath,
3737
mcpServerRuntimeSpecifier,
3838
} from './entry-shell.ts';
39-
import { emptyRouteConfig } from '../routes/types.ts';
39+
import { emptyRouteConfig, type CompiledProvider } from '../routes/types.ts';
4040
import type { CompiledMcpApp } from './mcp-apps.ts';
4141
import type { ArtifactOutputKind } from './provenance.ts';
4242
import { buildWithRslib } from './rslib.ts';
@@ -309,6 +309,7 @@ export const compileMcpEntries = async (
309309
readonly eventHooks: readonly NormalizedHook[];
310310
readonly outDir: string;
311311
readonly plugin: { readonly name: string; readonly version: string };
312+
readonly providers?: readonly CompiledProvider[];
312313
readonly state?: NormalizedStateDefinition;
313314
readonly target: string;
314315
readonly tools?: AgentBundleToolsConfig;
@@ -357,6 +358,7 @@ export const compileMcpEntries = async (
357358
: generatedRouteFlightWorkerSource({
358359
artifactEpoch: generatedRouteArtifactEpoch(options.plugin),
359360
eventRoutes: entry.id === eventHostId ? options.eventHooks : [],
361+
providers: options.providers ?? [],
360362
routes: server.generatedRoutes,
361363
serverName: server.name,
362364
...(options.state === undefined ? {} : { state: options.state }),

‎packages/agent-bundle/src/build/entry-shell.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url';
44
import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts';
55
import { stableJson } from '../core/digest.ts';
66
import type { NormalizedHook, NormalizedStateDefinition } from '../core/types.ts';
7-
import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts';
7+
import { providerKeyFromName } from '../routes/providers.ts';
8+
import type { CompiledAgentRoute, CompiledCliCommand, CompiledProvider } from '../routes/types.ts';
89

910
/**
1011
* Generated-entry templates: the framework-provided entry files consumers
@@ -453,6 +454,7 @@ export interface GeneratedRouteMcpEntryOptions {
453454
export interface GeneratedRouteFlightWorkerOptions {
454455
readonly artifactEpoch: string;
455456
readonly eventRoutes?: readonly NormalizedHook[];
457+
readonly providers?: readonly CompiledProvider[];
456458
readonly routes: readonly CompiledAgentRoute[];
457459
readonly serverName: string;
458460
readonly state?: NormalizedStateDefinition;
@@ -488,10 +490,25 @@ const eventRouteRecords = (
488490
): readonly string[] => routes.map((route, index) =>
489491
` ${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)} }),`);
490492

493+
const orderedProviders = (providers: readonly CompiledProvider[]): readonly CompiledProvider[] =>
494+
[...providers].sort((left, right) => {
495+
const byKey = providerKeyFromName(left.name).localeCompare(providerKeyFromName(right.name));
496+
return byKey === 0 ? left.source.localeCompare(right.source) : byKey;
497+
});
498+
499+
const providerImports = (providers: readonly CompiledProvider[]): readonly string[] =>
500+
providers.map((provider, index) =>
501+
`import * as provider${String(index)} from ${JSON.stringify(provider.source)};`);
502+
503+
const providerRecords = (providers: readonly CompiledProvider[]): readonly string[] =>
504+
providers.map((provider, index) =>
505+
` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, module: provider${String(index)}, source: ${JSON.stringify(provider.provenance.relativePath)} }),`);
506+
491507
/** The long-lived react-server worker used by one generated MCP process. */
492508
export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => {
493509
const routes = executableMcpRoutes(options.routes);
494510
const eventRoutes = options.eventRoutes ?? [];
511+
const providers = orderedProviders(options.providers ?? []);
495512
return [
496513
"import { parentPort } from 'node:worker_threads';",
497514
"import { createElement } from 'react';",
@@ -500,6 +517,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
500517
...generatedStateImports(options.state, 'artifact'),
501518
...routeImports(routes),
502519
...eventRouteImports(eventRoutes, routes.length),
520+
...providerImports(providers),
503521
'',
504522
'// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.',
505523
'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });',
@@ -508,6 +526,13 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
508526
`const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`,
509527
'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };',
510528
...generatedStateOwner(options.state, 'artifact'),
529+
...(providers.length === 0
530+
? []
531+
: [
532+
'const providers = Object.freeze([',
533+
...providerRecords(providers),
534+
']);',
535+
]),
511536
'const routes = Object.freeze({',
512537
...routeRecords(routes),
513538
...eventRouteRecords(eventRoutes, routes.length),
@@ -529,13 +554,30 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
529554
...(options.state === undefined
530555
? []
531556
: [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']),
557+
...(providers.length === 0
558+
? []
559+
: [
560+
' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };',
561+
' for (const provider of providers) {',
562+
' if (typeof provider.module.default !== \'function\') {',
563+
' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);',
564+
' }',
565+
' try {',
566+
' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });',
567+
' } catch (error) {',
568+
' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });',
569+
' }',
570+
' }',
571+
]),
532572
' const bytes = await runAgentRequest({',
533573
' ...(message.actor === undefined ? {} : { actor: message.actor }),',
534574
' ...(message.host === undefined ? {} : { host: message.host }),',
535575
' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },',
536576
...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']),
537577
' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },',
538-
' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },',
578+
...(providers.length === 0
579+
? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },']
580+
: [' providers: providerValues,']),
539581
' ...(message.session === undefined ? {} : { session: message.session }),',
540582
' signal: controller.signal,',
541583
...(options.state === undefined ? [] : [' state: bindings.state,']),

‎packages/agent-bundle/src/build/inspect-bundler.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ const mcpEntryEntries = async (
222222
sourceInputs: [],
223223
virtualSource: generatedRouteFlightWorkerSource({
224224
artifactEpoch: generatedRouteArtifactEpoch({ name: model.metadata.name, version: model.metadata.version }),
225+
providers: model.providers ?? [],
225226
routes: generatedRoutes,
226227
serverName,
227228
...(model.state === undefined ? {} : { state: model.state }),

‎packages/agent-bundle/src/config/index.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ import type { AgentBundleConfig as CoreAgentBundleConfig } from '../core/types.t
55

66
export { discoverProject } from './discover.ts';
77
export { defineConfig } from '../core/types.ts';
8-
export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from '../routes/public.ts';
8+
export type {
9+
AgentProviderContext,
10+
AgentProviderFactory,
11+
AppRouteConfig,
12+
PromptConfig,
13+
ResourceConfig,
14+
RouteSchema,
15+
RouteSchemaOutput,
16+
ToolConfig,
17+
ToolRouteProps,
18+
} from '../routes/public.ts';
919
export type { AgentBundleRuntimeConfig, ConfigFactory, ConfigFactoryContext } from '../core/types.ts';
1020
export type { DiscoveredProject } from './discover.ts';
1121
export { loadConfig } from './load.ts';

‎packages/agent-bundle/src/config/normalize.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,7 @@ export const normalizeProject = async (
10101010
const scripts = normalizeScripts(loaded, discovered, targetNames);
10111011
const assets = normalizeAssets(loaded, discovered, targetNames);
10121012
const commands = normalizeCommands(discovered, targetNames);
1013+
const providers = discovered.routeGraph?.providers ?? [];
10131014
const rules = normalizeRules(discovered, targetNames);
10141015
const state: NormalizedStateDefinition | undefined = discovered.state?.definition === undefined
10151016
? undefined
@@ -1044,6 +1045,7 @@ export const normalizeProject = async (
10441045
...(nativeHooks.length === 0 ? {} : { nativeHooks }),
10451046
...(packageBuild === undefined ? {} : { packageBuild }),
10461047
...(payloads.length === 0 ? {} : { payloads }),
1048+
...(providers.length === 0 ? {} : { providers }),
10471049
...(rules.length === 0 ? {} : { rules }),
10481050
runtime: normalizeRuntime(loaded),
10491051
scripts,

0 commit comments

Comments
 (0)