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

Type project-defined context providers without a compiler change per
provider. `AgentProviderValues` is now an augmentable interface (string index
of `unknown` plus the optional framework-owned `processLifetime`, exported as
`AgentProcessLifetime`), and the generated `.agent-bundle/routes.d.ts`
declares `AgentBundleProviders` / `ProviderKey` / `ProviderValue<Key>` from
each conventional `src/providers/*` factory's awaited return type and augments
`@agent-bundle/runtime` so `(await agent()).providers.<key>` observes that
type. Provider-free graphs emit no augmentation; a graph with providers but no
executable routes keeps the declaration file.
10 changes: 9 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,15 @@ compiles silently with an empty config.
Generated route declarations are published at `.agent-bundle/routes.d.ts` from
the same graph. Development writes a sibling temporary file and renames it over
the prior complete declaration atomically; invalid source retains the prior
last-good file, while a successful route-free preparation removes it.
last-good file, while a successful route-free, provider-free preparation
removes it. Beside `AgentBundleRoutes`, a graph with conventional providers
declares `AgentBundleProviders` (`ProviderKey`, `ProviderValue<Key>`) — each
camel-cased key mapped to its factory's awaited return type, in execution
order — and augments `@agent-bundle/runtime`'s `AgentProviderValues` so
`(await agent()).providers.<key>` observes that type in projects whose
TypeScript program includes the file. Provider-free graphs emit no
augmentation, so the declaration never references a module the project has no
reason to depend on.

Conventional `src/scripts/` routes ship through the same pipeline as
explicit `scripts` entries (#102 stage 1): a plain module directly under
Expand Down
46 changes: 46 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,52 @@ Flight dispatcher and lowers the final Agent Document to legal MCP output.
Flight is an implementation transport inside the generated runtime, never a
public host wire protocol.

## Request context and providers

Every generated request scope — MCP tools, resources, and prompts, event
routes, plain and rendered routed CLI commands, rendered scripts, and Workbench
replay — installs the same typed `AgentRequestContext`. `await agent()`
returns the invocation plus `Observed` `host`, `session`, `actor`, and
`workspace` axes (an `available` value with its provenance, or a typed
`unavailable` reason — never a fabricated string), request capabilities,
progress, the request signal, and the `state`, `notices`, and `providers`
slots. The handle is request-scoped: it survives `await`, two concurrent
requests never observe each other, and reading a captured handle after the
request closes throws a typed `AgentRequestError`.

A **context provider** contributes one request-scoped value without touching
the compiler. Each `src/providers/<name>.{ts,tsx}` module default-exports a
factory receiving the public `AgentProviderContext` (`{ invocation, signal }`
from `agent-bundle`) and its value mounts at
`(await agent()).providers.<camelCaseName>`:

```ts
// src/providers/library.ts
import type { AgentProviderContext } from 'agent-bundle';

export interface LibraryContext { readonly stages: readonly string[]; readonly surface: string }

export default async function library({ invocation }: AgentProviderContext): Promise<LibraryContext> {
return { stages: ['discover', 'curate'], surface: invocation.kind };
}
```

Providers run once per request in deterministic key order before the request
scope opens; a thrown factory fails that request closed, so return an honest
unavailable-shaped value for expected degradation. The compiler validates the
default export (`AB4940`), unique keys (`AB4941`), and the reserved
framework-owned `processLifetime` key (`AB4942`).

The generated `.agent-bundle/routes.d.ts` declares `AgentBundleProviders`
(`ProviderKey`, `ProviderValue<Key>`) from each factory's resolved return type
and augments `@agent-bundle/runtime`'s `AgentProviderValues`, so
`(await agent()).providers.library` is a `LibraryContext` with no cast once
the file is part of the project's TypeScript program (add
`".agent-bundle/routes.d.ts"` to `tsconfig.json` `include`). Undeclared keys
stay `unknown`. Route-unit and CLI-dispatch tests inject fixture values through
`renderRoute(id, { context: { providers: { library } } })`; the harness never
executes provider modules on a test's behalf.

Everything else is power-tier reference: custom/remote server modes and
collision recovery are in [Entry conventions](entry-conventions.md); accepted
static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in
Expand Down
9 changes: 3 additions & 6 deletions examples/audiobook-curator/src/providers/library.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import type { AgentProviderContext } from 'agent-bundle';

export interface LibraryContext {
readonly tooling: {
readonly ffmpeg: {
Expand All @@ -16,11 +18,6 @@ export interface LibraryContext {
readonly probedAt: string;
}

interface ProviderContext {
readonly invocation: unknown;
readonly signal: AbortSignal;
}

interface ToolProbe {
readonly available: boolean;
readonly version?: string;
Expand All @@ -41,7 +38,7 @@ const probeTool = async (tool: 'ffmpeg' | 'ffprobe', signal: AbortSignal): Promi
};

export default async function libraryProvider(
{ signal }: ProviderContext,
{ signal }: AgentProviderContext,
): Promise<LibraryContext> {
const [ffmpeg, ffprobe] = await Promise.all([
probeTool('ffmpeg', signal),
Expand Down
19 changes: 8 additions & 11 deletions examples/worktree-proximity/src/providers/git-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,19 @@ import { execFile } from 'node:child_process';
import { resolve } from 'node:path';
import { promisify } from 'node:util';

import type { WorktreeProviderValue } from '../api.js';
import type { AgentProviderContext } from 'agent-bundle';

interface ProviderContext {
readonly invocation: {
readonly kind: string;
readonly props: Readonly<Record<string, unknown>>;
};
readonly signal: AbortSignal;
}
import type { WorktreeProviderValue } from '../api.js';

const execFileAsync = promisify(execFile);

const eventCwd = (context: ProviderContext): string | undefined => {
// Providers receive the surface-specific invocation, so an event-route
// request can anchor discovery at the host-reported cwd while tool, CLI, and
// script requests fall back to the process cwd.
const eventCwd = (context: AgentProviderContext): string | undefined => {
if (context.invocation.kind !== 'event') return undefined;
const payload = context.invocation.props.payload;
if (payload === null || typeof payload !== 'object') return undefined;
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
const native = (payload as { readonly native?: unknown }).native;
if (native === null || typeof native !== 'object') return undefined;
const cwd = (native as { readonly cwd?: unknown }).cwd;
Expand All @@ -28,7 +25,7 @@ const absoluteGitPath = (cwd: string, value: string): string =>
resolve(cwd, value);

export default async function gitWorktreeProvider(
context: ProviderContext,
context: AgentProviderContext,
): Promise<WorktreeProviderValue> {
const nativeCwd = eventCwd(context);
const cwd = nativeCwd ?? process.cwd();
Expand Down
59 changes: 53 additions & 6 deletions packages/agent-bundle/src/routes/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, extname, join, relative } from 'node:path';

import type { CompiledAgentRoute, CompiledRouteGraph } from './types.ts';
import { providerKeyFromName } from './providers.ts';
import type { CompiledAgentRoute, CompiledProvider, CompiledRouteGraph } from './types.ts';

export const routeTypesRelativePath = '.agent-bundle/routes.d.ts';

Expand All @@ -15,19 +16,64 @@ const executableRoutes = (graph: CompiledRouteGraph): readonly CompiledAgentRout
.sort((left, right) => left.id.localeCompare(right.id)),
);

const declarationImport = (route: CompiledAgentRoute, index: number): string => {
const relativePath = route.provenance.relativePath;
/** Providers in the exact order the generated request scopes execute them. */
const orderedProviders = (graph: CompiledRouteGraph): readonly CompiledProvider[] => Object.freeze(
[...graph.providers].sort((left, right) => {
const byKey = providerKeyFromName(left.name).localeCompare(providerKeyFromName(right.name));
return byKey === 0 ? left.source.localeCompare(right.source) : byKey;
}),
);

const declarationModulePath = (relativePath: string): string => {
const extension = extname(relativePath);
const modulePath = `../${relativePath.slice(0, -extension.length)}.js`;
return `import type * as route${String(index)} from ${JSON.stringify(modulePath)};`;
return `../${relativePath.slice(0, -extension.length)}.js`;
};

const declarationImport = (route: CompiledAgentRoute, index: number): string =>
`import type * as route${String(index)} from ${JSON.stringify(declarationModulePath(route.provenance.relativePath))};`;

const providerImport = (provider: CompiledProvider, index: number): string =>
`import type * as provider${String(index)} from ${JSON.stringify(declarationModulePath(provider.provenance.relativePath))};`;

const providerMember = (provider: CompiledProvider, index: number): string =>
` readonly ${JSON.stringify(providerKeyFromName(provider.name))}: ProviderValueOf<typeof provider${String(index)}.default>;`;

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 Do not require providers in contexts where they can be omitted

When the generated declaration is included, this required member merges globally into AgentProviderValues, but AgentRequestInit.providers remains optional and runAgentRequest converts an omission to {} (packages/rsc-runtime/src/agent-request.ts:184,415). Consequently, a custom invocation or renderRoute test can omit provider fixtures while (await agent()).providers.library is still typed as present, leading to an unchecked undefined dereference at runtime. Make generated keys optional for such contexts, or distinguish generated scopes from contexts that do not install providers.

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.

Agreed — addressed in follow-up #409 (2d1cd86). Rather than weakening handler types, the contexts that do not run providers now carry the obligation: once the generated augmentation adds required keys to AgentProviderValues, AgentRequestInit.providers (AgentRequestProvidersInit), the harness options argument (HarnessOptionsArguments) and context (RenderRouteContextInit) become required in that program. provider-typegen.test.ts proves omitting providers, a declared key, or the options argument is a compile error while a complete custom scope typechecks; provider-free projects and generated scopes are unchanged.

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 on main by #409 (62138ef, the #95/#100 follow-up lane): harness and test contexts that do not run providers now require the provider fixtures the augmentation declares, and docs/framework-mode.md/docs/entry-conventions.md document the contract; #408 had only clarified the docs and dropped that change in favour of #409 during rebase.


/**
* The provider half of the generated declarations: `AgentBundleProviders`
* maps each camel-cased key to its factory's awaited return type, and the
* `@agent-bundle/runtime` augmentation makes `(await agent()).providers.<key>`
* observe that type. Omitted for provider-free graphs so the augmentation
* never references a module the project has no reason to depend on.
*/
const providerDeclarations = (providers: readonly CompiledProvider[]): readonly string[] =>
providers.length === 0
? []
: [
'type ProviderValueOf<Factory> = Factory extends (...args: never[]) => infer Value ? Awaited<Value> : never;',
'',
'export interface AgentBundleProviders {',
...providers.map(providerMember),
'}',
'',
'export type ProviderKey = keyof AgentBundleProviders;',
'export type ProviderValue<Key extends ProviderKey> = AgentBundleProviders[Key];',
'',
"declare module '@agent-bundle/runtime' {",
' interface AgentProviderValues {',
...providers.map(providerMember).map((line) => ` ${line}`),
' }',
'}',
'',
];

/** Deterministic declarations derived from the exact immutable graph used by packaging. */
export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
const routes = executableRoutes(graph);
const providers = orderedProviders(graph);
return [
'// Generated by agent-bundle. Do not edit.',
...routes.map(declarationImport),
...providers.map(providerImport),
'',
'type SchemaOutput<Schema> = Schema extends { readonly _output: infer Output } ? Output : never;',
'export type RouteContract<InputSchema, ResultSchema> = Readonly<{',
Expand Down Expand Up @@ -60,6 +106,7 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
'export type RouteInput<Id extends RouteId> = ContractInput<AgentBundleRoutes[Id]>;',
'export type RouteResult<Id extends RouteId> = ContractResult<AgentBundleRoutes[Id]>;',
'',
...providerDeclarations(providers),
].join('\n');
};

Expand All @@ -69,7 +116,7 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
*/
export const writeRouteTypes = async (root: string, graph: CompiledRouteGraph): Promise<string> => {
const output = join(root, routeTypesRelativePath);
if (executableRoutes(graph).length === 0) {
if (executableRoutes(graph).length === 0 && graph.providers.length === 0) {
await rm(output, { force: true });
return relative(root, output).replaceAll('\\', '/');
}
Expand Down
129 changes: 129 additions & 0 deletions packages/agent-bundle/tests/provider-typegen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';

import { afterEach, expect, it } from '@rstest/core';
import ts from 'typescript-5';

import { inspect } from '../src/api.ts';

const roots: string[] = [];

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
});

const writeProjectFile = async (root: string, path: string, contents: string): Promise<void> => {
const output = join(root, path);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, contents);
};

const typecheck = (root: string, entry: string): readonly string[] => {
const program = ts.createProgram([join(root, entry), join(root, '.agent-bundle', 'routes.d.ts')], {
exactOptionalPropertyTypes: true,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ES2022,
});
return ts.getPreEmitDiagnostics(program)
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
};

/**
* #95 acceptance: a project-defined provider adds a typed context property
* without a compiler change. The compiler publishes `.agent-bundle/routes.d.ts`
* with `AgentBundleProviders` and a `@agent-bundle/runtime` augmentation, so
* `(await agent()).providers.<key>` observes the provider factory's resolved
* return type against the real published runtime declarations.
*/
it('types (await agent()).providers.<key> from the generated provider declarations', { timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-provider-typegen-'));
roots.push(root);
// The audiobook example's installed tree supplies the built @agent-bundle/runtime and zod.
await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir');
await Promise.all([
writeProjectFile(root, 'package.json', JSON.stringify({
dependencies: { '@agent-bundle/runtime': 'workspace:*', zod: '4.4.3' },
name: 'provider-typegen-fixture',
type: 'module',
version: '1.0.0',
})),
writeProjectFile(root, 'agent-bundle.config.ts', [
"import { defineConfig } from 'agent-bundle/config';",
'export default defineConfig({',
" plugin: { name: 'provider-typegen-fixture', version: '1.0.0' },",
" targets: ['portable'],",
'});',
'',
].join('\n')),
writeProjectFile(root, 'src/providers/library.ts', [
"import type { AgentProviderContext } from 'agent-bundle';",
'export interface LibraryContext { readonly stages: readonly string[]; readonly surface: string; }',
'export default async function library({ invocation }: AgentProviderContext): Promise<LibraryContext> {',
" return { stages: ['discover'], surface: invocation.kind };",
'}',
'',
].join('\n')),
writeProjectFile(root, 'src/providers/build-number.ts', [
'export default function buildNumber(): number {',
' return 7;',
'}',
'',
].join('\n')),
writeProjectFile(root, 'src/mcp/curator/tools/status.ts', [
"import { z } from 'zod';",
'export const inputSchema = z.object({}).strict();',
"export const resultSchema = z.object({ status: z.literal('ready') }).strict();",
"export default async function Status() { return { status: 'ready' as const }; }",
'',
].join('\n')),
writeProjectFile(root, 'assertions.ts', [
"import { agent } from '@agent-bundle/runtime';",
"import type { ProviderKey, ProviderValue } from './.agent-bundle/routes.js';",
"import type { LibraryContext } from './src/providers/library.js';",
'',
'type Equal<Left, Right> =',
' (<Value>() => Value extends Left ? 1 : 2) extends',
' (<Value>() => Value extends Right ? 1 : 2) ? true : false;',
'type Assert<Value extends true> = Value;',
'',
"export type Keys = Assert<Equal<ProviderKey, 'buildNumber' | 'library'>>;",
"export type Library = Assert<Equal<ProviderValue<'library'>, LibraryContext>>;",
"export type Sync = Assert<Equal<ProviderValue<'buildNumber'>, number>>;",
'',
'export const stages = async (): Promise<readonly string[]> => {',
' const context = await agent();',
' // Augmented: no cast, no runtime guard needed for declared providers.',
' const library: LibraryContext = context.providers.library;',
' const build: number = context.providers.buildNumber;',
' const lifetime: number | undefined = context.providers.processLifetime?.hits;',
' // Undeclared keys stay unknown.',
' const unknownValue: unknown = context.providers.somethingElse;',
' void build; void lifetime; void unknownValue;',
' return library.stages;',
'};',
'',
].join('\n')),
writeProjectFile(root, 'mismatch.ts', [
"import { agent } from '@agent-bundle/runtime';",
'export const wrong = async (): Promise<number> => (await agent()).providers.library;',
'',
].join('\n')),
]);

const result = await inspect({ root });
expect(result.state).toBe('ready');
const declarations = await readFile(join(root, '.agent-bundle', 'routes.d.ts'), 'utf8');
expect(declarations).toContain('readonly "buildNumber": ProviderValueOf<typeof provider0.default>;');
expect(declarations).toContain('readonly "library": ProviderValueOf<typeof provider1.default>;');
expect(declarations).toContain("declare module '@agent-bundle/runtime'");

expect(typecheck(root, 'assertions.ts')).toEqual([]);
const mismatch = typecheck(root, 'mismatch.ts');
expect(mismatch).toHaveLength(1);
expect(mismatch[0]).toContain("Type 'LibraryContext' is not assignable to type 'number'");
});
Loading
Loading