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
13 changes: 13 additions & 0 deletions .changeset/generated-context-providers.md
Original file line number Diff line number Diff line change
@@ -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.<camelCaseKey>`. 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.
5 changes: 4 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -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`)

Expand Down
18 changes: 18 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `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 |
| `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 `_` |
| `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/<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 `_` |

Route and package entry conventions match `.ts` and `.tsx` files exactly;
the state convention is specifically `src/state.ts`.
Expand All @@ -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<value>`, 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
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type {
AgentEventRouteConfig,
AgentEventRouteProps,
AgentEventRuntimeMode,
AgentProviderContext,
AgentProviderFactory,
AppRouteConfig,
CanonicalAgentEvent,
PromptConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
.map((entry) => entry.hook),
outDir: target.root,
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
providers: options.model.providers ?? [],

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 Mount providers in standalone event execution

When an event route sets config.runtime: 'standalone', or a shared route takes its configured standalone fallback, the hook wrapper renders the component directly through renderStandaloneEventRoute; providers are forwarded only here to compileMcpEntries. Consequently provider factories never execute on those event requests, and a route using the documented (await agent()).providers context fails outside a request scope instead of receiving its provider values. Forward the providers into the standalone hook compilation and install the request context around that render as well.

Useful? React with 👍 / 👎.

...(options.model.state === undefined ? {} : { state: options.model.state }),
target: target.name,
...tools,
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
Expand Down
46 changes: 44 additions & 2 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';",
Expand All @@ -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({}) });',
Expand All @@ -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),
Expand All @@ -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,']),
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/inspect-bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-bundle/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-bundle/src/core/project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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, {
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type {
AgentEventRouteConfig,
AgentEventRouteProps,
AgentEventRuntimeMode,
AgentProviderContext,
AgentProviderFactory,
AppRouteConfig,
CanonicalAgentEvent,
CliRouteConfig,
Expand Down
Loading
Loading