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

Type `renderRoute` and `renderRouteEvents` (`agent-bundle/test`) against the project's own routes: the generated `.agent-bundle/routes.d.ts` registers each route's harness contract on the new `Register` interface of `@agent-bundle/runtime`, so a string-literal route id is checked against the compiled ids, `input` is typed from the route's `inputSchema` (an event route's `{ canonical, native }` payload), and `result` from its `resultSchema` (`undefined` for event routes). Add `Register`, `RegisteredRoutes`, `RegisteredRouteContract`, `RegisteredRouteId`, `RegisteredRouteInput`, and `RegisteredRouteResult` to `@agent-bundle/runtime`, and `RouteTargetConstraint`, `RouteTargetInput`, and `RouteTargetResult` to `agent-bundle/test`; a program without the generated file keeps the previous `string` / `unknown` types. (#456)
16 changes: 16 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,22 @@ else supplies providers) requires `providers` outright: a handler typed against
[harness section](../packages/agent-bundle/README.md#testing-routes) for the
module-evaluation caveat that applies to provider-level state.

The same file registers the route contracts themselves. One generated
`AgentBundleRouteContracts` — `{ input, result }` per route id, inferred from
each module's own `inputSchema`/`resultSchema`; an event route registers the
`{ canonical, native }` payload the harness accepts (it supplies `signal`
itself) and an `undefined` result — is registered on `@agent-bundle/runtime`'s
`Register` interface in that one `declare module` block, so no per-route
declaration file is emitted.
`renderRoute('tool:library/summarize', { input })` then checks its id against
the compiled ids, types `input` from the route's input schema, and types
`result` from its result schema; `RegisteredRouteId`, `RegisteredRouteInput`,
and `RegisteredRouteResult` name that surface for a wrapper of your own. The
seam is inert without the file: an id typed `string` stays legal for dynamic
lookups, a directly imported module target is unaffected, and a project that
excludes the generated declarations (or has not built yet) sees the
unregistered types — any string, `unknown` input, `unknown` result.

### What reaches the MCP wire

The final Agent Document of a tool route lowers to one `CallToolResult`:
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,18 @@ than paying for a build per route. Every failure — an unknown route, a refused
route kind, a rejected input, a render error — names the route id, the target
kind, and the module provenance.

The same generated `.agent-bundle/routes.d.ts` registers the route contracts on
`@agent-bundle/runtime`'s `Register` interface. With that file in the project's
TypeScript program (add it to `tsconfig.json` `include`), a string-literal route
id is checked against the compiled ids, `input` is typed from the route's
`inputSchema`, and `result` from its `resultSchema` (an event route's `input`
is its `{ canonical, native }` payload and its `result` `undefined`);
`RegisteredRouteId`,
`RegisteredRouteInput`, and `RegisteredRouteResult` from `@agent-bundle/runtime`
name that surface for wrappers. A value typed `string`, a module target, or a
program without the generated file sees the previous types — any id, `unknown`
input and result.

Conventional request context providers (`src/providers/*`, see
[entry conventions](../../docs/entry-conventions.md#request-context-providers-power-tier))
are mounted automatically for every manifest-backed helper — `renderRoute`,
Expand Down
60 changes: 53 additions & 7 deletions packages/agent-bundle/src/routes/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@ const providerMember = (provider: CompiledProvider, index: number): string =>

/**
* 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.
* maps each camel-cased key to its factory's awaited return type. Omitted for
* provider-free graphs.
*/
const providerDeclarations = (providers: readonly CompiledProvider[]): readonly string[] =>
providers.length === 0
Expand All @@ -58,10 +56,42 @@ const providerDeclarations = (providers: readonly CompiledProvider[]): readonly
'export type ProviderKey = keyof AgentBundleProviders;',
'export type ProviderValue<Key extends ProviderKey> = AgentBundleProviders[Key];',
'',
];

/**
* The single `@agent-bundle/runtime` augmentation. Its `Register.routes`
* member registers the thin `{ input, result }` contract map (TanStack
* Router's `Register` pattern), so `agent-bundle/test`'s `renderRoute` narrows
* its route-id parameter, `input`, and `result` from the project's own route
* modules — a schema route's `inputSchema`/`resultSchema` output, an event
* route's `{ canonical, native }` payload with no result; its
* `AgentProviderValues` members make
* `(await agent()).providers.<key>` observe each factory's resolved type.
* Omitted for graphs with neither, so the augmentation never references a
* module the project has no reason to depend on.
*/
const runtimeAugmentation = (
routes: readonly CompiledAgentRoute[],
providers: readonly CompiledProvider[],
): readonly string[] =>
routes.length === 0 && providers.length === 0
? []
: [
"declare module '@agent-bundle/runtime' {",
' interface AgentProviderValues {',
...providers.map(providerMember).map((line) => ` ${line}`),
' }',
...(routes.length === 0
? []
: [
' interface Register {',
' readonly routes: AgentBundleRouteContracts;',
' }',
]),
...(providers.length === 0
? []
: [
' interface AgentProviderValues {',
...providers.map(providerMember).map((line) => ` ${line}`),
' }',
]),
'}',
'',
];
Expand Down Expand Up @@ -94,6 +124,17 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
' Contract extends { readonly result: infer Result } ? Result',
' : Contract extends { readonly component: infer Component } ? ComponentResult<Component>',
' : never;',
'// What the `agent-bundle/test` harness accepts and returns for one route: a schema route\'s own',
'// input and result; for an event route the `{ canonical, native }` payload (the harness supplies',
'// `signal` itself) and no result, since event modules export no `resultSchema`.',
'type HarnessInput<Contract> =',
' Contract extends { readonly input: infer Input } ? Input',
" : Contract extends { readonly component: infer Component } ? Omit<ComponentInput<Component>, 'signal'>",
' : never;',
'type HarnessResult<Contract> =',
' Contract extends { readonly result: infer Result } ? Result',
' : Contract extends { readonly component: unknown } ? undefined',
' : never;',
'',
'export interface AgentBundleRoutes {',
...routes.map((route, index) =>
Expand All @@ -105,8 +146,13 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => {
'export type RouteId = keyof AgentBundleRoutes;',
'export type RouteInput<Id extends RouteId> = ContractInput<AgentBundleRoutes[Id]>;',
'export type RouteResult<Id extends RouteId> = ContractResult<AgentBundleRoutes[Id]>;',
'/** The registered harness contract map: one `{ input, result }` per route id, for `@agent-bundle/runtime`\'s `Register`. */',
'export type AgentBundleRouteContracts = {',
' readonly [Id in RouteId]: Readonly<{ input: HarnessInput<AgentBundleRoutes[Id]>; result: HarnessResult<AgentBundleRoutes[Id]> }>;',
'};',
'',
...providerDeclarations(providers),
...runtimeAugmentation(routes, providers),
].join('\n');
};

Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export type {
RenderRouteTarget,
RenderedRoute,
RenderedRouteEvents,
RouteTargetInput,
RouteTargetConstraint,
RouteTargetResult,
} from './render.ts';
export { expectDocument } from './matchers.ts';
export type {
Expand Down
70 changes: 50 additions & 20 deletions packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import type {
AgentRenderInvocation,
AgentRenderLimits,
AgentRequestInit,
RegisteredRouteId,
RegisteredRouteInput,
RegisteredRouteResult,
} from '@agent-bundle/runtime';
import type * as React from 'react';

Expand Down Expand Up @@ -76,11 +79,39 @@ export type RenderRouteContext = Omit<AgentRequestInit, 'invocation' | 'progress
*/
export type RenderRouteContextInit = { readonly context?: RenderRouteContext };

export interface RenderRouteOptionsBase {
/** What `renderRoute` accepts: a route module rendered directly, or a route id. */
export type RenderRouteTarget = AgentRouteModule | string;

/**
* The constraint one target must satisfy. Once the generated
* `.agent-bundle/routes.d.ts` registers the project's routes on
* `@agent-bundle/runtime`'s `Register`, a string literal must be one of the
* registered ids (the editor completes them and a typo is rejected naming the
* alternatives), while a value typed `string` stays legal for dynamic lookups
* and a module target is unaffected. Without a registration `RegisteredRouteId`
* is `string`, so every string is legal, exactly as before.
*
* `renderRoute` infers `Target` from a literal while checking it against this
* constraint through TanStack Router's `ConstrainLiteral` shape,
* `(Target & Constraint) | Constraint`, spelled inline in each signature so the
* rejection message lists the registered ids rather than an alias name.
*/
export type RouteTargetConstraint<Target> = Target extends AgentRouteModule
? AgentRouteModule
: string extends Target ? string : RegisteredRouteId;


/** The registered input type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */
export type RouteTargetInput<Target> = Target extends RegisteredRouteId ? RegisteredRouteInput<Target> : unknown;

/** The registered result type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */
export type RouteTargetResult<Target> = Target extends RegisteredRouteId ? RegisteredRouteResult<Target> : unknown;

export interface RenderRouteOptionsBase<Target = RenderRouteTarget> {
/** CLI route arguments; `cli` routes only. */
readonly args?: readonly string[];
/** The route's input: tool input, event payload, or script input. */
readonly input?: unknown;
/** The route's input: tool input, event payload, or script input — typed from the route's own schema once the id is registered. */
readonly input?: RouteTargetInput<Target>;
/** Overrides the route kind when a module is rendered directly; ignored for manifest routes. */
readonly kind?: RenderableRouteKind;
readonly limits?: Partial<AgentRenderLimits>;
Expand All @@ -91,7 +122,7 @@ export interface RenderRouteOptionsBase {
readonly signal?: AbortSignal;
}

export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit;
export type RenderRouteOptions<Target = RenderRouteTarget> = RenderRouteOptionsBase<Target> & RenderRouteContextInit;

/**
* The trailing options parameter of every harness entry point. It is always
Expand All @@ -100,19 +131,17 @@ export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit
*/
export type HarnessOptionsArguments<Options> = readonly [options?: Options];

export interface RenderedRoute {
export interface RenderedRoute<Target = RenderRouteTarget> {
/** The final Agent Document the real renderer produced. */
readonly document: AgentDocument;
readonly invocation: AgentRenderInvocation;
/** The document value parsed by the route's own `resultSchema`; absent when the module exports none. */
readonly result?: unknown;
/** The document value parsed by the route's own `resultSchema`, typed from that schema once the id is registered; absent when the module exports none. */
readonly result?: RouteTargetResult<Target>;
/** Request-scoped progress the route reported. These are not render events; the final-only dispatcher emits no event stream. */
readonly progress: readonly AgentProgressUpdate[];
readonly provenance: RenderedRouteProvenance;
}

export type RenderRouteTarget = AgentRouteModule | string;

interface Renderer {
readonly available: typeof AgentRuntime.available;
readonly createAgentRenderDispatcher: typeof AgentRuntime.createAgentRenderDispatcher;
Expand Down Expand Up @@ -1128,10 +1157,10 @@ const renderFailure = (
* This is the route-unit proof level: no transport is opened, no browser
* surface is compiled, and no host artifact is built.
*/
export const renderRoute = async (
target: RenderRouteTarget,
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions>
): Promise<RenderedRoute> => {
export const renderRoute = async <Target extends RenderRouteTarget>(
target: (Target & RouteTargetConstraint<Target>) | RouteTargetConstraint<Target>,
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions<Target>>
): Promise<RenderedRoute<Target>> => {
const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options);
try {
const document = await dispatcher.dispatch({ invocation, signal });
Expand All @@ -1142,7 +1171,8 @@ export const renderRoute = async (
provenance: resolved.provenance,
...(resolved.module.resultSchema === undefined
? {}
: { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) }),
// The value was parsed by the same `resultSchema` the registration types it from.
: { result: parsedResult(resolved.module.resultSchema, document, resolved.provenance) as RouteTargetResult<Target> }),
});
} catch (error) {
throw renderFailure(error, invocation, resolved.provenance);
Expand All @@ -1151,7 +1181,7 @@ export const renderRoute = async (
}
};

export interface RenderedRouteEvents extends RenderedRoute {
export interface RenderedRouteEvents<Target = RenderRouteTarget> extends RenderedRoute<Target> {
/** Every render event the runtime emitted, in the order it emitted them. */
readonly events: readonly AgentRenderEvent[];
}
Expand All @@ -1163,10 +1193,10 @@ export interface RenderedRouteEvents extends RenderedRoute {
* {@link renderRoute}: this drains the dispatcher's public event stream
* rather than its final-only entry point.
*/
export const renderRouteEvents = async (
target: RenderRouteTarget,
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions>
): Promise<RenderedRouteEvents> => {
export const renderRouteEvents = async <Target extends RenderRouteTarget>(
target: (Target & RouteTargetConstraint<Target>) | RouteTargetConstraint<Target>,
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions<Target>>
): Promise<RenderedRouteEvents<Target>> => {
const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options);
const events: AgentRenderEvent[] = [];
const reader = dispatcher.stream({ invocation, signal }).getReader();
Expand Down Expand Up @@ -1199,6 +1229,6 @@ export const renderRouteEvents = async (
provenance: resolved.provenance,
...(resolved.module.resultSchema === undefined
? {}
: { result: parsedResult(resolved.module.resultSchema, complete.document, resolved.provenance) }),
: { result: parsedResult(resolved.module.resultSchema, complete.document, resolved.provenance) as RouteTargetResult<Target> }),
});
};
Loading
Loading