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/454-route-render-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@agent-bundle/runtime': patch
'agent-bundle': patch
---

Let a rendered route declare its own render budget: `config.render: { maxElapsedMs }` on `ToolConfig`, `ResourceConfig`, `PromptConfig`, and `CliRouteConfig` raises (or lowers) the 60-second `maxElapsedMs` of that route's render session, validated at build time as a positive integer of milliseconds up to `MAX_ROUTE_RENDER_ELAPSED_MS` (24 hours, exported from `agent-bundle`) — `AB4835` otherwise, including on a plain `.ts` command, which has no render session. The generated MCP server applies it per `tools/call`, `resources/read`, and `prompts/get` while still forwarding every progress report as `notifications/progress`; the compiled command carries it into the generated CLI executable (`CompiledCliCommand.render`, inherited by `routes.mcpCommands` projections); `renderRoute` and `openInMemoryMcpServer` apply it over the `limits` a test passes as the dispatcher's base. `AgentRenderDispatch.limits` layers per-dispatch limits over `createAgentRenderDispatcher`'s. Defaults are unchanged. Fixes #454. (#526)
5 changes: 3 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ even when no error diagnostic was reported.
| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). |
| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). |
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), and provider conventions (see below). |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), and provider conventions (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). |
| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). |
Expand Down Expand Up @@ -562,7 +562,7 @@ framework-owned plugin twice by accident.
| `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. |
| `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. |

## Route graph, state, layout, and provider conventions (`AB4800`–`AB4834`, `AB4940`–`AB4942`)
## Route graph, state, layout, and provider conventions (`AB4800`–`AB4835`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -775,6 +775,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4832` | error | A server layout (`src/mcp/<server>/layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.<server>` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. |
| `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). |
| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. |
| `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: <positive integer ≤ 86400000> }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. |
| `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. |
Expand Down
16 changes: 16 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,22 @@ A tool whose validated result carries an integer `exitCode` can declare
domain failures exit nonzero. Other projected tools retain the success-status
policy and exit zero only for a successful rendered document.

Every rendered route — generated MCP tool, resource, or prompt, projected MCP
command, rendered `src/cli/**` command — runs inside one render session whose
wall clock defaults to `DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs` (60 s). A
route whose legitimate work runs longer declares `config.render:
{ maxElapsedMs }` (#454): a positive integer of milliseconds up to
`MAX_ROUTE_RENDER_ELAPSED_MS` (24 hours), `AB4835` otherwise. The value is read
statically with the rest of `config`; the generated MCP server passes it to
the dispatcher per call (`AgentRenderDispatch.limits`), the compiled command
carries it into the generated CLI executable (a projected command inherits
its tool's), and the route-unit and `mcp-in-memory` harnesses apply it over
the `limits` a test passes as the dispatcher's base. A plain `.ts` command
has no render session and rejects the key. The budget bounds only the
framework's session: the host's tool-call deadline still applies, so a long
route keeps reporting progress, which the projector forwards as
`notifications/progress` for the whole call.

This is an in-house projection over the compiled route graph, per gate G7; it
does not depend on MCPorter or introduce a second command model. MCPorter can
still be pointed independently at the generated MCP server when a live-server
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { Agent } from '@agent-bundle/runtime';
import { Agent, agent } from '@agent-bundle/runtime';
import { z } from 'zod';

const maxHoldMs = 5000;

export const config = {
description: 'Waits until aborted or holdMs elapses, for cancellation contract proof.',
// The long-poll shape of #454: a route whose legitimate wait outlives the
// runtime's default render session declares its own budget.
render: { maxElapsedMs: 120_000 },
title: 'Wait',
};

export const inputSchema = z.object({ holdMs: z.number().int().positive().optional() });
export const inputSchema = z.object({
holdMs: z.number().int().positive().optional(),
/** Report progress every `tickMs` while holding; omitted means no progress reports. */
tickMs: z.number().int().positive().optional(),
});

export const resultSchema = z.object({ waitedMs: z.number().int().nonnegative() });

Expand All @@ -35,7 +42,20 @@ export default async function Wait({
readonly signal: AbortSignal;
}) {
const holdMs = Math.min(input.holdMs ?? maxHoldMs, maxHoldMs);
const outcome = await waitForAbortOrTimeout(signal, holdMs);
let outcome: 'aborted' | 'elapsed';
if (input.tickMs === undefined) {
outcome = await waitForAbortOrTimeout(signal, holdMs);
} else {
const { progress } = await agent();
const ticks = Math.ceil(holdMs / input.tickMs);
outcome = 'elapsed';
for (let tick = 0; tick < ticks; tick += 1) {
const slice = Math.min(input.tickMs, holdMs - tick * input.tickMs);
outcome = await waitForAbortOrTimeout(signal, slice);
if (outcome === 'aborted') break;
await progress.report({ completed: tick + 1, message: 'waiting', total: ticks });
}
}
if (outcome === 'aborted') {
throw new DOMException('Wait was aborted', 'AbortError');
}
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ const generatedStateOwner = (
* worker stdout guarded onto stderr (machine output owns stdout).
*/
const renderedSessionSource = (workerFile: string): readonly string[] => [
'const openRenderedSession = ({ invocation, props, request, routeId, signal, terminal, validate }) => {',
// `limits` is the route's compiled render budget (#454), absent for the
// runtime default; it rides the dispatch so one dispatcher serves every budget.
'const openRenderedSession = ({ invocation, limits, props, request, routeId, signal, terminal, validate }) => {',
` const worker = new Worker(new URL(${JSON.stringify(`./${workerFile}`)}, import.meta.url), { stderr: true, stdout: true });`,
" worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));",
" worker.stderr?.on('data', (chunk) => process.stderr.write(chunk));",
Expand Down Expand Up @@ -291,7 +293,7 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [
' const dispatcher = createAgentRenderDispatcher(host);',
' return Object.freeze({',
' close: async () => { await worker.terminate(); },',
' events: () => dispatcher.stream({ invocation, signal }),',
' events: () => dispatcher.stream({ invocation, ...(limits === undefined ? {} : { limits }), signal }),',
' validate,',
' });',
'};',
Expand Down Expand Up @@ -397,6 +399,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
' if (command.mcp !== undefined) {',
' return openRenderedSession({',
" invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } },",
' limits: command.render,',
' props: { input: parsed },',
` request: { artifactEpoch: ${JSON.stringify(generatedRouteArtifactEpoch(options.plugin))}, kind: 'tool', operationId: command.routeId, surface: command.mcp.tool },`,
' routeId: command.routeId,',
Expand All @@ -407,6 +410,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
' }',
' return openRenderedSession({',
" invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },",
' limits: command.render,',
' props: { input: parsed },',
" request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },",
' routeId: command.routeId,',
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type {
SkillTokenId,
SkillTreeLayoutDecision,
} from './skills/index.ts';
export { canonicalAgentEvents } from './routes/public.ts';
export { canonicalAgentEvents, MAX_ROUTE_RENDER_ELAPSED_MS } from './routes/public.ts';
export type {
AgentEventCanonicalIdentity,
AgentEventDelivery,
Expand All @@ -47,6 +47,7 @@ export type {
PromptConfig,
ResourceConfig,
RouteMeta,
RouteRenderConfig,
RouteSchema,
RouteSchemaOutput,
RouteUiMeta,
Expand Down
18 changes: 17 additions & 1 deletion packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import type { createEventRuntimeServer } from './events/ipc.ts';
import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts';
import { canonicalAgentEvents, type CanonicalAgentEvent } from './routes/public.ts';
import { routeRenderLimits } from './routes/render-budget.ts';
import { noTerminal } from './terminal-capability.ts';
import type {
AgentActorIdentity,
Expand All @@ -41,6 +42,7 @@ import type {
AgentProgressReporter,
AgentRenderDispatch,
AgentRenderDispatcher,
AgentRenderLimits,
AgentSessionIdentity,
AgentTerminal,
AgentWorkspaceIdentity,
Expand Down Expand Up @@ -259,9 +261,13 @@ export const renderGeneratedRoute = async (
}, async () => {
// State and notice admission live only in the render scope. This host scope
// establishes identity and forwards it so one invocation is admitted once.
// The route's compiled `config.render` budget (#454) bounds this render
// session; the projector keeps forwarding progress for as long as it runs.
const render = routeRenderLimits(route.config);
const projected = await projectMcpRenderStream(dispatcher.stream({
artifactEpoch,
invocation: { kind: 'tool', props: { input: input as never, operationId: route.id } },
...(render === undefined ? {} : { limits: render }),
signal: context.mcpReq.signal,
}), projectorOptions(context));
return {
Expand Down Expand Up @@ -670,6 +676,13 @@ export interface CreateGeneratedRouteMcpServerOptions {
* call reads `request.lineage` from it. Absent registries leave the axis
* `unavailable('not-provided')`.
*/
/**
* The dispatcher's base render limits; a route's compiled `config.render`
* budget layers over them per call. Generated entries leave the runtime
* defaults in place; the in-memory proof level lowers them to observe a
* route's budget without waiting out the default.
*/
readonly limits?: Partial<AgentRenderLimits>;
readonly lineage?: AgentLineageRegistry;
readonly notices?: GeneratedNoticeDeliveryBinding;
readonly plugin: { readonly name: string; readonly version: string };
Expand Down Expand Up @@ -933,7 +946,10 @@ export const createGeneratedRouteMcpServer = async (
options: CreateGeneratedRouteMcpServerOptions,
): Promise<McpServer> => {
const server = new McpServer(options.plugin);
const dispatcher = createAgentRenderDispatcher(options.host);
const dispatcher = createAgentRenderDispatcher(
options.host,
options.limits === undefined ? {} : { limits: options.limits },
);
// The subscribe bit registers before any transport connects; the SDK merges
// its own resources.listChanged into the same capability object when the
// inbox resource route registers below.
Expand Down
23 changes: 23 additions & 0 deletions packages/agent-bundle/src/routes/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { scanRouteModuleExports } from './contract.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { deepFreeze } from '../core/freeze.ts';
import { isRecord } from '../core/strict-json.ts';
import { routeRenderLimits, validateRouteRenderConfig, type RouteRenderBudget } from './render-budget.ts';
import type {
CompiledAgentRoute,
CompiledCliCommand,
Expand Down Expand Up @@ -85,6 +86,7 @@ interface RouteCliConfig {
readonly diagnostics: readonly Diagnostic[];
readonly exitCode: 'result' | 'zero';
readonly positionals?: readonly string[];
readonly render?: RouteRenderBudget;
}

const stringArray = (value: unknown): readonly string[] | undefined =>
Expand Down Expand Up @@ -130,12 +132,28 @@ const routeCliConfig = (route: CompiledAgentRoute): RouteCliConfig => {
route.source,
));
}
// A render budget bounds a render session; a plain `.ts` command executes
// directly and has none, so declaring one there is a mistake to surface.
const render = validateRouteRenderConfig(route, 'CLI route');
diagnostics.push(...render.diagnostics);
// Any declaration counts, including the type-valid `render: {}`: the key has
// no meaning on a plain command whatever it holds.
if (route.config['render'] !== undefined && render.diagnostics.length === 0 && !isRenderedCliRoute(route)) {
diagnostics.push({
code: 'AB4835',
message: `CLI route ${relativePath} declares config.render, but a plain .ts command executes without a render session; only rendered .tsx commands take a render budget.`,
recovery: 'Rename the module to .tsx and render Agent.* elements, or remove config.render.',
severity: 'error',
sourcePath: route.source,
});
}
return {
aliases,
...(typeof description === 'string' ? { description } : {}),
diagnostics,
exitCode,
...(positionals === undefined ? {} : { positionals }),
...(render.render === undefined ? {} : { render: render.render }),
};
};

Expand Down Expand Up @@ -310,13 +328,17 @@ export const compileMcpCliCommands = (
const annotations = route.config['annotations'];
const confirm = !(isRecord(annotations) && annotations.readOnlyHint === true);
const description = route.config['description'];
// The tool's own render budget was validated with its server (AB4835 is
// reported once, there); the projected command inherits the value.
const render = routeRenderLimits(route.config);
return {
aliases: [],
...(typeof description === 'string' ? { description } : {}),
exitCode: route.config['exitCode'] === 'result' ? 'result' as const : 'zero' as const,
mcp: { confirm, server, tool },
options: confirm ? [toolOption, confirmationOption] : [toolOption],
path: [server, tool],
...(render === undefined ? {} : { render }),
rendered: true,
routeId: route.id,
};
Expand Down Expand Up @@ -387,6 +409,7 @@ export const compileCliCommands = async (
exitCode: config.exitCode,
options,
path: cliCommandPath(route),
...(config.render === undefined ? {} : { render: config.render }),
rendered: isRenderedCliRoute(route),
routeId: route.id,
});
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { deepFreeze } from '../core/freeze.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleConfig } from '../core/types.ts';
import { canonicalAgentEvents, type CanonicalAgentEvent } from './public.ts';
import { validateRouteRenderConfig } from './render-budget.ts';
import {
emptyRouteConfig,
type CompiledAgentRoute,
Expand Down Expand Up @@ -888,6 +889,9 @@ export const compileRouteGraph = async (
route.source,
));
}
// The route's render budget (#454) is read by the generated server
// from this compiled config, so it is validated here, once.
diagnostics.push(...validateRouteRenderConfig(route, 'MCP route').diagnostics);
}
}
servers.push({
Expand Down
Loading
Loading