From 227ee8f35c2a9bfda074ce183c1452cd9c3c143d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:34:01 +0000 Subject: [PATCH 1/3] feat(routes): let a route declare its render budget with config.render.maxElapsedMs (#454) --- .changeset/454-route-render-budget.md | 6 ++ docs/diagnostics.md | 5 +- docs/entry-conventions.md | 16 ++++ .../src/mcp/harness/tools/wait.tsx | 26 +++++- .../agent-bundle/src/build/entry-shell.ts | 8 +- packages/agent-bundle/src/index.ts | 3 +- .../agent-bundle/src/mcp-server-runtime.ts | 18 +++- .../agent-bundle/src/routes/cli-commands.ts | 21 +++++ packages/agent-bundle/src/routes/graph.ts | 4 + packages/agent-bundle/src/routes/index.ts | 5 +- packages/agent-bundle/src/routes/public.ts | 31 +++++++ .../agent-bundle/src/routes/render-budget.ts | 89 +++++++++++++++++++ packages/agent-bundle/src/routes/types.ts | 6 ++ packages/agent-bundle/src/test/mcp.ts | 10 +++ packages/agent-bundle/src/test/render.ts | 20 ++++- .../agent-bundle/tests/cli-routes.test.ts | 70 +++++++++++++++ .../tests/projection/mcp-in-memory.test.ts | 42 +++++++++ .../tests/route-unit/render-route.test.ts | 32 +++++++ .../tests/test-harness-manifest.test.ts | 5 +- packages/rsc-runtime/src/dispatcher.ts | 8 +- packages/rsc-runtime/tests/dispatcher.test.ts | 46 ++++++++++ website/docs/en/guide/authoring/mcp.mdx | 36 ++++++++ .../en/guide/authoring/package-entries.mdx | 7 ++ website/docs/zh/guide/authoring/mcp.mdx | 31 +++++++ .../zh/guide/authoring/package-entries.mdx | 5 ++ 25 files changed, 537 insertions(+), 13 deletions(-) create mode 100644 .changeset/454-route-render-budget.md create mode 100644 packages/agent-bundle/src/routes/render-budget.ts diff --git a/.changeset/454-route-render-budget.md b/.changeset/454-route-render-budget.md new file mode 100644 index 000000000..423b7f1a2 --- /dev/null +++ b/.changeset/454-route-render-budget.md @@ -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. (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a54d9c64f..aea010c45 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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/.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). | @@ -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//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -775,6 +775,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4832` | error | A server layout (`src/mcp//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.` 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: }` 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. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 938dcd1a8..389516efc 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -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 diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx index 515895dae..2f082f4ad 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/wait.tsx @@ -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() }); @@ -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'); } diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 976ca83aa..580c19508 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -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));", @@ -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,', ' });', '};', @@ -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,', @@ -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,', diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index edf5fa93f..88d362acd 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -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, @@ -47,6 +47,7 @@ export type { PromptConfig, ResourceConfig, RouteMeta, + RouteRenderConfig, RouteSchema, RouteSchemaOutput, RouteUiMeta, diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index bf102b989..1cecf2e7c 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -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, @@ -41,6 +42,7 @@ import type { AgentProgressReporter, AgentRenderDispatch, AgentRenderDispatcher, + AgentRenderLimits, AgentSessionIdentity, AgentTerminal, AgentWorkspaceIdentity, @@ -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 { @@ -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; readonly lineage?: AgentLineageRegistry; readonly notices?: GeneratedNoticeDeliveryBinding; readonly plugin: { readonly name: string; readonly version: string }; @@ -933,7 +946,10 @@ export const createGeneratedRouteMcpServer = async ( options: CreateGeneratedRouteMcpServerOptions, ): Promise => { 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. diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 6abc89b60..25a04007c 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -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, @@ -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 => @@ -130,12 +132,26 @@ 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); + if (render.render !== undefined && !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 }), }; }; @@ -310,6 +326,9 @@ 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 } : {}), @@ -317,6 +336,7 @@ export const compileMcpCliCommands = ( mcp: { confirm, server, tool }, options: confirm ? [toolOption, confirmationOption] : [toolOption], path: [server, tool], + ...(render === undefined ? {} : { render }), rendered: true, routeId: route.id, }; @@ -387,6 +407,7 @@ export const compileCliCommands = async ( exitCode: config.exitCode, options, path: cliCommandPath(route), + ...(config.render === undefined ? {} : { render: config.render }), rendered: isRenderedCliRoute(route), routeId: route.id, }); diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 4b1635274..6a7910dc4 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -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, @@ -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({ diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index c7aa9e43c..610b0016d 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -49,7 +49,9 @@ export { validateRouteModuleContract, } from './contract.ts'; export type { RouteModuleExports } from './contract.ts'; -export { appResourceUri, canonicalAgentEvents } from './public.ts'; +export { routeRenderLimits, validateRouteRenderConfig } from './render-budget.ts'; +export type { RouteRenderBudget, ValidatedRouteRenderConfig } from './render-budget.ts'; +export { appResourceUri, canonicalAgentEvents, MAX_ROUTE_RENDER_ELAPSED_MS } from './public.ts'; export type { AgentEventCanonicalIdentity, AgentEventDelivery, @@ -70,6 +72,7 @@ export type { PromptConfig, ResourceConfig, RouteMeta, + RouteRenderConfig, RouteSchema, RouteSchemaOutput, RouteUiMeta, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 9b8364a9f..f20bb8fc8 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -195,12 +195,37 @@ export type RouteMeta = Readonly> & { */ export const appResourceUri = (reference: string): string => reference; +/** + * The render budget a rendered route declares statically in `config.render`. + * Every rendered route runs inside one render session bounded by the runtime's + * default limits; a long-running route (a build await, a long poll) raises + * `maxElapsedMs` here instead of clamping its own waits. The compiler + * validates the value (`AB4835`: a positive integer of milliseconds up to + * {@link MAX_ROUTE_RENDER_ELAPSED_MS}), and the generated MCP server, the + * routed CLI, and the route-unit harness apply it to that route's render + * session — the host's own tool-call deadline still applies on top. + */ +export interface RouteRenderConfig { + /** Wall-clock budget of one render of this route, in milliseconds; the runtime default is 60 000. */ + readonly maxElapsedMs?: number; +} + +/** + * The ceiling `config.render.maxElapsedMs` may declare: 24 hours. The value + * stays inside Claude Code's default per-call wall clock (`MCP_TOOL_TIMEOUT`, + * about 28 hours); Codex (`tool_timeout_sec`, 60 s by default) and any + * per-server host setting must be raised separately by the operator. + */ +export const MAX_ROUTE_RENDER_ELAPSED_MS = 24 * 60 * 60 * 1000; + export interface ToolConfig { readonly _meta?: RouteMeta; readonly annotations?: Readonly>; readonly description?: string; /** Project a validated result's integer `exitCode` when this tool is exposed through the generated CLI. */ readonly exitCode?: 'result'; + /** The render budget of one call; also inherited by the tool's projected CLI command. */ + readonly render?: RouteRenderConfig; readonly title?: string; } @@ -208,6 +233,8 @@ export interface ResourceConfig { readonly _meta?: RouteMeta; readonly description?: string; readonly mimeType?: string; + /** The render budget of one read. */ + readonly render?: RouteRenderConfig; readonly title?: string; readonly uri: string; } @@ -215,6 +242,8 @@ export interface ResourceConfig { export interface PromptConfig { readonly _meta?: RouteMeta; readonly description?: string; + /** The render budget of one get. */ + readonly render?: RouteRenderConfig; readonly title?: string; } @@ -252,6 +281,8 @@ export interface CliRouteConfig { * named here become `--options`. */ readonly positionals?: readonly string[]; + /** The render budget of one rendered (`.tsx`) command run; plain commands ignore it. */ + readonly render?: RouteRenderConfig; } /** diff --git a/packages/agent-bundle/src/routes/render-budget.ts b/packages/agent-bundle/src/routes/render-budget.ts new file mode 100644 index 000000000..d3035ba37 --- /dev/null +++ b/packages/agent-bundle/src/routes/render-budget.ts @@ -0,0 +1,89 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import { MAX_ROUTE_RENDER_ELAPSED_MS } from './public.ts'; +import type { CompiledAgentRoute } from './types.ts'; + +/** The validated render budget of one route, as the compiled graph carries it. */ +export interface RouteRenderBudget { + readonly maxElapsedMs: number; +} + +const RENDER_KEYS: ReadonlySet = new Set(['maxElapsedMs']); + +const renderError = (message: string, sourcePath: string): Diagnostic => ({ + code: 'AB4835', + message, + recovery: `Declare config.render as { maxElapsedMs: }, or omit it to keep the runtime default of 60000.`, + severity: 'error', + sourcePath, +}); + +export interface ValidatedRouteRenderConfig { + readonly diagnostics: readonly Diagnostic[]; + /** Present only when `config.render` is declared and valid. */ + readonly render?: RouteRenderBudget; +} + +/** + * Interprets a route's statically extracted `config.render` (#454): absent + * means the runtime defaults; declared, it must be an object whose only key + * is `maxElapsedMs`, a positive safe integer of milliseconds no larger than + * {@link MAX_ROUTE_RENDER_ELAPSED_MS}. `describe` names the route kind in + * the message (`MCP route`, `CLI route`). + */ +export const validateRouteRenderConfig = ( + route: CompiledAgentRoute, + describe: string, +): ValidatedRouteRenderConfig => { + const declared = route.config['render']; + if (declared === undefined) return { diagnostics: [] }; + const relativePath = route.provenance.relativePath; + if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) { + return { + diagnostics: [renderError(`${describe} ${relativePath} config.render must be an object.`, route.source)], + }; + } + const unknown = Object.keys(declared).filter((key) => !RENDER_KEYS.has(key)); + if (unknown.length > 0) { + return { + diagnostics: [renderError( + `${describe} ${relativePath} config.render declares unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((key) => JSON.stringify(key)).join(', ')}; only maxElapsedMs is accepted.`, + route.source, + )], + }; + } + const maxElapsedMs = (declared as { readonly maxElapsedMs?: unknown }).maxElapsedMs; + if (maxElapsedMs === undefined) return { diagnostics: [] }; + if (typeof maxElapsedMs !== 'number' || !Number.isSafeInteger(maxElapsedMs) || maxElapsedMs <= 0) { + return { + diagnostics: [renderError( + `${describe} ${relativePath} config.render.maxElapsedMs must be a positive integer of milliseconds.`, + route.source, + )], + }; + } + if (maxElapsedMs > MAX_ROUTE_RENDER_ELAPSED_MS) { + return { + diagnostics: [renderError( + `${describe} ${relativePath} config.render.maxElapsedMs ${String(maxElapsedMs)} exceeds the framework ceiling of ${String(MAX_ROUTE_RENDER_ELAPSED_MS)} (24 hours).`, + route.source, + )], + }; + } + return { diagnostics: [], render: Object.freeze({ maxElapsedMs }) }; +}; + +/** + * The render limits a compiled route config asks for at run time: the + * generated MCP server and the test harness read the compiled `config`, which + * the build already validated, so this reader only picks the well-formed + * value and ignores anything else. + */ +export const routeRenderLimits = ( + config: Readonly>, +): RouteRenderBudget | undefined => { + const declared = config['render']; + if (typeof declared !== 'object' || declared === null) return undefined; + const maxElapsedMs = (declared as { readonly maxElapsedMs?: unknown }).maxElapsedMs; + if (typeof maxElapsedMs !== 'number' || !Number.isSafeInteger(maxElapsedMs) || maxElapsedMs <= 0) return undefined; + return Object.freeze({ maxElapsedMs }); +}; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 731b86005..77cc8f011 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -209,6 +209,12 @@ export interface CompiledCliCommand { readonly options: readonly CompiledCliOption[]; /** Command path segments below the CLI root (`['library', 'audit']`). */ readonly path: readonly string[]; + /** + * The render budget the route declared in `config.render` (#454); a + * projected MCP command inherits its tool's. Absent means the runtime + * default, so pre-#454 graphs digest unchanged. + */ + readonly render?: { readonly maxElapsedMs: number }; /** True for a `.tsx` route whose async default Server Component renders through the dispatcher (#102 stage 3). */ readonly rendered: boolean; readonly routeId: string; diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 00caaf67e..9a0d1e492 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -21,6 +21,7 @@ import type { AgentStateEventSchemas, } from '@agent-bundle/runtime/state'; import type { + AgentRenderLimits, LineageHost, RegisteredMcpRouteId, RegisteredMcpRouteKind, @@ -82,6 +83,14 @@ export interface InMemoryMcpSessionOptionsBase< * exactly as the artifact does; omitted sessions observe `request.lineage` * as `unavailable('not-provided')`. */ + /** + * The server dispatcher's base render limits, as the generated entry's + * dispatcher has them (the runtime defaults when omitted). A route's compiled + * `config.render` budget layers over them per call exactly as in the + * artifact, so a low `maxElapsedMs` here observes a route's raised budget + * without waiting out the 60-second default. + */ + readonly limits?: Partial; readonly lineage?: AgentLineageRegistry; /** The host vocabulary the registry applies when the in-memory client name maps to none. */ readonly lineageHost?: LineageHost; @@ -540,6 +549,7 @@ export const openInMemoryMcpServer = async < target: options.lineageHost, }, }), + ...(options.limits === undefined ? {} : { limits: options.limits }), plugin: manifest.plugin, routes: routes as never, }); diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 3caab0441..99d70169e 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -32,6 +32,7 @@ import type { GeneratedCliRenderSession, } from '../cli-entry.ts'; import { createProviderProcessLifetime, type ProviderProcessLifetime } from '../routes/provider-execution.ts'; +import { routeRenderLimits, type RouteRenderBudget } from '../routes/render-budget.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; import { AgentTestError, captured } from './errors.ts'; @@ -119,6 +120,12 @@ export interface RenderRouteOptionsBase { readonly input?: RouteTargetInput; /** Overrides the route kind when a module is rendered directly; ignored for manifest routes. */ readonly kind?: RenderableRouteKind; + /** + * The dispatcher's base render limits, as the generated executable's + * dispatcher has them. A manifest route's compiled `config.render` budget + * layers over them exactly as in the generated MCP server and routed CLI; + * a module rendered directly has no compiled config, so these apply alone. + */ readonly limits?: Partial; /** Renders against an explicit manifest instead of the one the generated configuration registered. */ readonly manifest?: AgentBundleTestManifest; @@ -344,6 +351,8 @@ interface ResolvedTarget { readonly manifest?: AgentBundleTestManifest; readonly module: AgentRouteModule; readonly provenance: RenderedRouteProvenance; + /** The route's compiled `config.render` budget; absent for a module rendered directly or a route without one. */ + readonly render?: RouteRenderBudget; } /** The protocol name a generated server registers, and the request surface it records. */ @@ -441,6 +450,7 @@ const resolveTarget = async ( const manifest = options.manifest ?? testManifest(); const loaded = await loadManifestRouteModule(manifest, target); const layouts = await loadLayoutChain(manifest, loaded.descriptor, loaded.provenance); + const render = routeRenderLimits(loaded.descriptor.config); return { component: componentOf(loaded.module, loaded.provenance), kind: loaded.kind, @@ -448,6 +458,7 @@ const resolveTarget = async ( manifest, module: loaded.module, provenance: loaded.provenance, + ...(render === undefined ? {} : { render }), }; }; @@ -1109,6 +1120,9 @@ export const prepareCliRenderHost = async ( contextProgress: context.progress, layoutRoute: descriptor ?? { id: command.routeId, kind: command.mcp === undefined ? 'cli' : 'tool' }, layouts: layoutsByRoute.get(command.routeId) ?? [], + // The compiled command carries its route's render budget (#454), as + // the generated executable's command table does. + ...(command.render === undefined ? {} : { limits: command.render }), renderer, requestInit: async (request) => { const root = process.cwd(); @@ -1422,7 +1436,11 @@ const prepareRender = async ( ...(resolved.provenance.serverId === undefined ? {} : { serverId: resolved.provenance.serverId }), }, layouts: resolved.layouts, - limits: options.limits, + // The route's compiled budget layers over the base limits, as it does on + // the generated dispatchers. + ...(options.limits === undefined && resolved.render === undefined + ? {} + : { limits: { ...options.limits, ...resolved.render } }), renderer, requestInit: async (request) => ({ // What the artifact's scope for this route kind mounts (#511): no diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 299f4f78e..892e5639d 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -419,6 +419,76 @@ describe('compiled command graph', () => { ]); }); + describe('the render budget a route declares in config.render (#454)', () => { + const renderedCommandModule = (config: string): string => [ + `export const config = ${config};`, + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'); + + it('carries a valid budget on the compiled command, and a projected MCP command inherits its tool budget', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/await.tsx': renderedCommandModule('{ render: { maxElapsedMs: 7_200_000 } }'), + 'src/cli/plain.ts': plainCommandModule(), + 'src/mcp/alpha/tools/poll.tsx': toolModule("{ annotations: { readOnlyHint: true }, render: { maxElapsedMs: 120000 } }"), + 'src/mcp/alpha/tools/quick.tsx': toolModule("{ annotations: { readOnlyHint: true } }"), + }); + + const graph = await compileRouteGraph(root, fixtureConfig({ routes: { mcpCommands: true } })); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.commands?.map((command) => [command.path.join(' '), command.render, command.rendered])).toEqual([ + ['alpha poll', { maxElapsedMs: 120_000 }, true], + ['alpha quick', undefined, true], + ['await', { maxElapsedMs: 7_200_000 }, true], + ['plain', undefined, false], + ]); + // Absent budgets are absent keys, so pre-#454 graphs digest unchanged. + expect(graph.cli?.commands?.map((command) => Object.hasOwn(command, 'render'))).toEqual([true, false, true, false]); + // The compiled tool config keeps the budget for the generated server to read. + expect(graph.servers[0]!.routes.find((route) => route.id === 'tool:alpha/poll')?.config).toEqual({ + annotations: { readOnlyHint: true }, + render: { maxElapsedMs: 120_000 }, + }); + }); + + it('errors with AB4835 on a malformed budget, one over the 24-hour ceiling, or one on a plain command', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/ceiling.tsx': renderedCommandModule('{ render: { maxElapsedMs: 86_400_001 } }'), + 'src/cli/fraction.tsx': renderedCommandModule('{ render: { maxElapsedMs: 1.5 } }'), + 'src/cli/negative.tsx': renderedCommandModule('{ render: { maxElapsedMs: -1 } }'), + 'src/cli/plain.ts': plainCommandModule({ config: '{ render: { maxElapsedMs: 1000 } }' }), + 'src/cli/shape.tsx': renderedCommandModule("{ render: 'long' }"), + 'src/cli/unknown.tsx': renderedCommandModule('{ render: { timeoutMs: 1000 } }'), + 'src/mcp/alpha/tools/text.tsx': toolModule("{ render: { maxElapsedMs: '60000' } }"), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835']); + const messages = graph.diagnostics.map((diagnostic) => diagnostic.message); + // Server routes are validated with their server, before the CLI surface compiles. + expect(messages).toEqual([ + expect.stringContaining('MCP route src/mcp/alpha/tools/text.tsx config.render.maxElapsedMs must be a positive integer of milliseconds'), + expect.stringContaining('CLI route src/cli/ceiling.tsx config.render.maxElapsedMs 86400001 exceeds the framework ceiling of 86400000 (24 hours)'), + expect.stringContaining('CLI route src/cli/fraction.tsx config.render.maxElapsedMs must be a positive integer of milliseconds'), + expect.stringContaining('CLI route src/cli/negative.tsx config.render.maxElapsedMs must be a positive integer of milliseconds'), + expect.stringContaining('CLI route src/cli/plain.ts declares config.render, but a plain .ts command executes without a render session'), + expect.stringContaining('CLI route src/cli/shape.tsx config.render must be an object'), + expect.stringContaining('CLI route src/cli/unknown.tsx config.render declares unknown key "timeoutMs"'), + ]); + expect(graph.diagnostics[0]!.recovery).toContain('maxElapsedMs'); + expect(graph.diagnostics[0]!.sourcePath).toBe(join(root, 'src/mcp/alpha/tools/text.tsx')); + // A route with a rejected config compiles no command; the route stays in the graph. + expect(graph.cli?.commands).toEqual([]); + expect(graph.cli?.routes.map((route) => route.id)).toContain('cli:plain'); + }); + }); + it('selects projected tools with literal-star patterns and reports every unmatched pattern', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index a830e0f84..486ff18f0 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -245,6 +245,48 @@ describe('the in-memory MCP projection level', () => { }); }); + describe('a tool that declares its own render budget (#454)', () => { + // The `wait` route declares `config.render.maxElapsedMs: 120_000`. With + // the server dispatcher's base lowered to 100ms, a 300ms hold completes + // only because the compiled budget reached the render session; a route + // without one (`catalog` here, held by its own Suspense boundary) is + // still bound by the base. The projector keeps forwarding progress for + // the whole render, which is what keeps a host's idle timer alive. + it('renders past the base limit under its declared budget and keeps progress notifications flowing', async () => { + await using session = await openInMemoryMcpServer({ limits: { maxElapsedMs: 100 } }); + const notifications: { readonly progress: number; readonly total?: number }[] = []; + session.client.setNotificationHandler('notifications/progress', (notification) => { + notifications.push({ progress: notification.params.progress, ...(notification.params.total === undefined ? {} : { total: notification.params.total }) }); + }); + + const result = await session.client.callTool({ + _meta: { progressToken: 'tok-454' }, + arguments: { holdMs: 300, tickMs: 100 }, + name: 'wait', + }); + + expect(result).toMatchObject({ structuredContent: { waitedMs: 300 } }); + expect(result).not.toHaveProperty('isError'); + expect(notifications).toEqual([ + { progress: 1, total: 3 }, + { progress: 2, total: 3 }, + { progress: 3, total: 3 }, + ]); + }); + + it('still bounds a route without a declared budget by the dispatcher base', async () => { + await using session = await openInMemoryMcpServer({ limits: { maxElapsedMs: 1 } }); + + // The route reaches emit time past a 1ms budget on any machine; the + // contract error is the SDK's default tool error on the wire. + const result = await session.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' }); + expect(result).toMatchObject({ + content: [{ text: expect.stringContaining('elapsed time exceeds 1ms'), type: 'text' }], + isError: true, + }); + }); + }); + it('reads a compiled resource route by its configured URI', async () => { const read = await readMcpResource('harness://notes'); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index 0a513377c..fd10e9418 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from '@rstest/core'; import { createElement } from 'react'; import Echo from '../../fixtures/route-harness/src/mcp/harness/tools/echo.tsx'; +import Wait from '../../fixtures/route-harness/src/mcp/harness/tools/wait.tsx'; import { AgentTestError } from '../../src/test/errors.ts'; import { expectDocument } from '../../src/test/matchers.ts'; import { renderRoute } from '../../src/test/render.ts'; @@ -288,6 +289,37 @@ describe('renderRoute through the real renderer', () => { expect(delegated).toEqual([...rendered.progress]); }); + describe('a route that declares its own render budget (#454)', () => { + // `wait` declares `config.render.maxElapsedMs: 120_000`. The harness + // `limits` are the dispatcher's base, so a base of 100ms shows whether the + // route's compiled budget reached its render session: a 300ms hold + // outlives the base and completes only because the route raised it. + const limits = { maxElapsedMs: 100 }; + + it('applies the compiled config.render budget over the base limits for a manifest route', async () => { + const rendered = await renderRoute('tool:harness/wait', { input: { holdMs: 300 }, limits }); + + expectDocument(rendered).toHaveStatus('success').toHaveValue({ waitedMs: 300 }); + }); + + it('leaves the base limits alone for a module rendered directly, which has no compiled config', async () => { + const error = await rejection(renderRoute({ default: Wait }, { input: { holdMs: 300 }, limits, routeId: 'tool:harness/wait' })); + + expect(error.code).toBe('render-failed'); + expect(error.message).toContain('elapsed time exceeds 100ms'); + }); + + it('keeps forwarding progress reports for the whole raised budget', async () => { + const rendered = await renderRoute('tool:harness/wait', { input: { holdMs: 300, tickMs: 100 }, limits }); + + expect(rendered.progress).toEqual([ + { completed: 1, message: 'waiting', total: 3 }, + { completed: 2, message: 'waiting', total: 3 }, + { completed: 3, message: 'waiting', total: 3 }, + ]); + }); + }); + it('reports a represented error as the document status the runtime decided', async () => { const rendered = await renderRoute('tool:harness/unavailable'); diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 7c2a7efc3..12661ae71 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -308,6 +308,7 @@ describe('the compiled test manifest', () => { tool: string, description: string | undefined, confirm: boolean, + render?: { readonly maxElapsedMs: number }, ) => ({ aliases: [], ...(description === undefined ? {} : { description }), @@ -315,6 +316,7 @@ describe('the compiled test manifest', () => { mcp: { confirm, server: 'harness', tool }, options: confirm ? [inputOption, confirmationOption] : [inputOption], path: ['harness', tool], + ...(render === undefined ? {} : { render }), rendered: true, routeId: `tool:harness/${tool}`, }); @@ -332,7 +334,8 @@ describe('the compiled test manifest', () => { projected('ticket', 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', true), projected('tooling', 'Reports the request providers an MCP tool observes.', false), projected('unavailable', 'Returns a typed unavailable result for projection checks.', true), - projected('wait', 'Waits until aborted or holdMs elapses, for cancellation contract proof.', true), + // The projected command inherits the tool's declared render budget (#454). + projected('wait', 'Waits until aborted or holdMs elapses, for cancellation contract proof.', true, { maxElapsedMs: 120_000 }), ]); }); diff --git a/packages/rsc-runtime/src/dispatcher.ts b/packages/rsc-runtime/src/dispatcher.ts index 6400e3e67..2b65bc40e 100644 --- a/packages/rsc-runtime/src/dispatcher.ts +++ b/packages/rsc-runtime/src/dispatcher.ts @@ -14,6 +14,12 @@ export { decodeAgentDocument } from './decode-document.js'; export interface AgentRenderDispatch { readonly artifactEpoch?: string; readonly invocation: AgentRenderInvocation; + /** + * Per-dispatch render limits layered over the dispatcher's own: the route's + * declared render budget (`config.render.maxElapsedMs`) travels here, so one + * long-lived dispatcher serves routes with different budgets. + */ + readonly limits?: Partial; readonly progress?: AgentProgressReporter; readonly signal: AbortSignal; } @@ -91,7 +97,7 @@ export const createAgentRenderDispatcher = ( } return current; }, - limits: options.limits, + limits: { ...options.limits, ...request.limits }, signal: request.signal, }); const rememberFlight = (flight: Promise>): Promise> => { diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts index e8e083714..07ff0563b 100644 --- a/packages/rsc-runtime/tests/dispatcher.test.ts +++ b/packages/rsc-runtime/tests/dispatcher.test.ts @@ -898,6 +898,52 @@ describe('AgentRenderDispatcher streaming', () => { } }); + it('layers per-dispatch render limits over the dispatcher base (#454)', { retry: 2, timeout: 5_000 }, async () => { + const finiteReader = ( + await createWorkerHost('ready').execute({ + invocation, + signal: new AbortController().signal, + }) + ).getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const next = await finiteReader.read(); + if (next.done) break; + chunks.push(next.value); + } + // The Flight bytes arrive at once; EOF only after 400ms of real time, so + // the render session's elapsed budget decides whether the document lands. + const host: AgentFlightExecutionHost = { + execute: async () => { + let eof: NodeJS.Timeout | undefined; + return new ReadableStream({ + cancel() { + clearTimeout(eof); + }, + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + eof = setTimeout(() => controller.close(), 400); + }, + }); + }, + }; + const dispatcher = createAgentRenderDispatcher(host, { limits: { maxElapsedMs: 100 } }); + const signal = new AbortController().signal; + + // The dispatcher's base limit alone: the 400ms EOF is past the budget. + await expect(dispatcher.dispatch({ invocation, signal })).rejects.toMatchObject({ + code: 'elapsed-time-exceeded', + }); + // The same dispatcher, one dispatch declaring its own budget: the route's + // `config.render.maxElapsedMs` rides here and the document completes. + const document = await dispatcher.dispatch({ invocation, limits: { maxElapsedMs: 5_000 }, signal }); + expect(document.status).toBe('success'); + // Other limits stay the dispatcher's own; only the declared key changes. + await expect(dispatcher.dispatch({ invocation, limits: { maxDocumentNodes: 5_000 }, signal })).rejects.toMatchObject({ + code: 'elapsed-time-exceeded', + }); + }); + it('converts a synchronous host throw into a stream failure', async () => { const host: AgentFlightExecutionHost = { execute: () => { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 5f46b20e1..9ce7a85d2 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -200,6 +200,42 @@ The route-unit and `mcp-in-memory` test levels prove this without a host: returns the notifications the fixture's token produced, and an `openInMemoryMcpServer()` client that sets `_meta.progressToken` on `callTool` receives them over the real protocol. +## Render budget + +Every call renders inside one render session bounded by the runtime's default limits, and the +wall-clock one is 60 seconds (`DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs`). A route whose +legitimate work outlives that — awaiting a build, long-polling a daemon — declares its own budget +in `config.render` instead of clamping its waits and asking the caller to poll: + +```ts twoslash +import type { ToolConfig } from 'agent-bundle'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Wait for a ticket to settle.', + render: { maxElapsedMs: 7_200_000 }, +} satisfies ToolConfig; +``` + +`maxElapsedMs` is a positive integer of milliseconds up to the framework ceiling of 24 hours +(`MAX_ROUTE_RENDER_ELAPSED_MS`, exported from `agent-bundle`); anything else is `AB4835` at build +time. The compiler reads the value statically with the rest of `config`, so the generated MCP +server applies it to that route's render session on every `tools/call`, `resources/read`, and +`prompts/get`; a `routes.mcpCommands` projection of the tool and a rendered `src/cli/**` command +carry the same key on the compiled command (`config.render` on `CliRouteConfig`; a plain `.ts` +command has no render session and rejects it). `renderRoute` and `openInMemoryMcpServer` apply it +too, layered over the `limits` a test passes as the dispatcher's base. Omitting `render` keeps +the default, and the other limits (document size, event count, event rate) are unchanged. + +The budget bounds the framework's session only; the host's own tool-call deadline still applies, +so keep progress flowing for the whole wait — every `progress.report()` and streamed +`Agent.Progress` fallback is forwarded as `notifications/progress` while the call runs. Claude +Code's default per-call wall clock is about 28 hours (`MCP_TOOL_TIMEOUT`, or a per-server +`timeout`), and its idle timer (30 minutes for stdio servers) is reset by exactly those +notifications; Codex bounds a call at `tool_timeout_sec`, 60 seconds unless the operator raises +it in `config.toml`. `agent-bundle inspect --routes` shows the budget with the rest of each +route's compiled `config`. + ## Shared layouts `src/layout.tsx` is the composition point around every rendered route — the `layout.tsx` idea from diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index c420d4d5e..1577f283a 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -141,6 +141,13 @@ same props and renders through the runtime dispatcher against a sibling [Scripts and assets](./scripts-assets.mdx). Routed CLI projects need `@agent-bundle/runtime` as a dependency, because the generated executable installs the request context through it. +A rendered command runs inside a render session whose wall clock defaults to 60 seconds. A command +that legitimately runs longer — ` await ` over a two-hour build — declares its budget +as `config.render: { maxElapsedMs }` (a positive integer of milliseconds up to 24 hours, `AB4835` +otherwise); the compiled command carries it into the generated executable, and a projected MCP +command (`routes.mcpCommands`) inherits its tool's. A plain `.ts` command executes without a render +session and rejects the key. See [Render budget](./mcp.mdx#render-budget). + Opt out with `bin: false`, `routes.cli: 'conventional'`, or by prefixing a path segment with `_`. ### The routed CLI inside host artifacts diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index f3ab2fcf4..a65ba20ef 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -181,6 +181,37 @@ route-unit 与 `mcp-in-memory` 两个测试层级无需宿主即可证明这一 返回 fixture 的 token 产生的通知;而在 `callTool` 上设置了 `_meta.progressToken` 的 `openInMemoryMcpServer()` 客户端会通过真实协议收到它们。 +## 渲染预算 + +每次调用都在一个受运行时默认上限约束的渲染会话中进行,其中挂钟上限是 60 秒 +(`DEFAULT_AGENT_RENDER_LIMITS.maxElapsedMs`)。一条合理工作时间超过这一上限的路由——等待一次构建、 +对守护进程长轮询——应在 `config.render` 中声明自己的预算,而不是截短自己的等待再让调用方反复轮询: + +```ts twoslash +import type { ToolConfig } from 'agent-bundle'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Wait for a ticket to settle.', + render: { maxElapsedMs: 7_200_000 }, +} satisfies ToolConfig; +``` + +`maxElapsedMs` 是一个以毫秒计的正整数,上限为框架天花板 24 小时(从 `agent-bundle` 导出的 +`MAX_ROUTE_RENDER_ELAPSED_MS`);其他任何值都会在构建时报 `AB4835`。编译器随 `config` 的其余部分静态读取 +该值,因此生成式 MCP 服务器会在该路由的每次 `tools/call`、`resources/read` 与 `prompts/get` 上把它应用到 +渲染会话;该工具的 `routes.mcpCommands` 投影以及渲染式 `src/cli/**` 命令在编译后的命令上携带同一个键 +(`CliRouteConfig` 上的 `config.render`;纯 `.ts` 命令没有渲染会话,会拒绝它)。`renderRoute` 与 +`openInMemoryMcpServer` 同样应用它,叠加在测试作为分发器基线传入的 `limits` 之上。省略 `render` 即保留 +默认值,其他上限(文档大小、事件数量、事件速率)保持不变。 + +预算只约束框架自身的会话;宿主自己的工具调用期限仍然生效,因此整个等待期间都要持续汇报进度——调用运行 +期间,每次 `progress.report()` 与流式传输的 `Agent.Progress` 回退都会作为 `notifications/progress` 转发。 +Claude Code 默认的单次调用挂钟约为 28 小时(`MCP_TOOL_TIMEOUT`,或按服务器配置的 `timeout`),其空闲 +计时器(stdio 服务器为 30 分钟)正是由这些通知重置;Codex 以 `tool_timeout_sec` 约束一次调用,除非操作者 +在 `config.toml` 中提高,否则为 60 秒。`agent-bundle inspect --routes` 会随每条路由编译后的 `config` +一并显示该预算。 + ## 共享布局 `src/layout.tsx` 是每个渲染式路由外层的组合点——页面框架中 `layout.tsx` 的思路应用到 Agent Document 上。 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 083ac7ddf..9cfd9ab0f 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -129,6 +129,11 @@ Run 'curator doctor --help' for usage. [脚本与资源](./scripts-assets.mdx)中描述的四种输出模式。路由式 CLI 项目需要把 `@agent-bundle/runtime` 作为依赖,因为生成的可执行文件通过它装配请求上下文。 +渲染式命令运行在一个挂钟默认为 60 秒的渲染会话中。合理运行时间更长的命令——例如在两小时的构建上执行 +` await `——以 `config.render: { maxElapsedMs }` 声明自己的预算(以毫秒计的正整数,最多 24 +小时,否则报 `AB4835`);编译后的命令把它带进生成的可执行文件,投影的 MCP 命令(`routes.mcpCommands`) +则继承其工具的预算。纯 `.ts` 命令没有渲染会话,会拒绝该键。参见[渲染预算](./mcp.mdx#渲染预算)。 + 退出方式:`bin: false`、`routes.cli: 'conventional'`,或给某一段路径加 `_` 前缀。 ### 宿主产物中的路由式 CLI From 3e430d3dfd33dcccebd860733f30387ef7f447c4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:34:14 +0000 Subject: [PATCH 2/3] chore: reference #526 in the changeset --- .changeset/454-route-render-budget.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/454-route-render-budget.md b/.changeset/454-route-render-budget.md index 423b7f1a2..c1bdcc3a5 100644 --- a/.changeset/454-route-render-budget.md +++ b/.changeset/454-route-render-budget.md @@ -3,4 +3,4 @@ '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. (#PR) +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) From 8813bdf582e0628636d5015df22970052bdaabae Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 05:57:35 +0000 Subject: [PATCH 3/3] fix(routes): reject any config.render declaration on a plain CLI command, including an empty one --- packages/agent-bundle/src/routes/cli-commands.ts | 4 +++- packages/agent-bundle/tests/cli-routes.test.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 25a04007c..62d44c72b 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -136,7 +136,9 @@ const routeCliConfig = (route: CompiledAgentRoute): RouteCliConfig => { // directly and has none, so declaring one there is a mistake to surface. const render = validateRouteRenderConfig(route, 'CLI route'); diagnostics.push(...render.diagnostics); - if (render.render !== undefined && !isRenderedCliRoute(route)) { + // 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.`, diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 892e5639d..11c929284 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -462,6 +462,8 @@ describe('compiled command graph', () => { 'src/cli/fraction.tsx': renderedCommandModule('{ render: { maxElapsedMs: 1.5 } }'), 'src/cli/negative.tsx': renderedCommandModule('{ render: { maxElapsedMs: -1 } }'), 'src/cli/plain.ts': plainCommandModule({ config: '{ render: { maxElapsedMs: 1000 } }' }), + // Type-valid but meaningless on a plain command: the declaration itself is the defect. + 'src/cli/plain-empty.ts': plainCommandModule({ config: '{ render: {} }' }), 'src/cli/shape.tsx': renderedCommandModule("{ render: 'long' }"), 'src/cli/unknown.tsx': renderedCommandModule('{ render: { timeoutMs: 1000 } }'), 'src/mcp/alpha/tools/text.tsx': toolModule("{ render: { maxElapsedMs: '60000' } }"), @@ -469,7 +471,7 @@ describe('compiled command graph', () => { const graph = await compileRouteGraph(root, fixtureConfig()); - expect(codesOf(graph.diagnostics)).toEqual(['AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835']); + expect(codesOf(graph.diagnostics)).toEqual(['AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835', 'AB4835']); const messages = graph.diagnostics.map((diagnostic) => diagnostic.message); // Server routes are validated with their server, before the CLI surface compiles. expect(messages).toEqual([ @@ -478,6 +480,7 @@ describe('compiled command graph', () => { expect.stringContaining('CLI route src/cli/fraction.tsx config.render.maxElapsedMs must be a positive integer of milliseconds'), expect.stringContaining('CLI route src/cli/negative.tsx config.render.maxElapsedMs must be a positive integer of milliseconds'), expect.stringContaining('CLI route src/cli/plain.ts declares config.render, but a plain .ts command executes without a render session'), + expect.stringContaining('CLI route src/cli/plain-empty.ts declares config.render, but a plain .ts command executes without a render session'), expect.stringContaining('CLI route src/cli/shape.tsx config.render must be an object'), expect.stringContaining('CLI route src/cli/unknown.tsx config.render declares unknown key "timeoutMs"'), ]);