Skip to content

Decide and pin the thrown-route-error projection per surface instead of adopting error.tsx/loading.tsx file conventions (xref row 8) #492

Description

@ScriptedAlchemy

Problem

Cross-reference matrix row 8 / suggestion 8 asked whether agent-bundle should adopt Next-style error.tsx / loading.tsx / template.tsx file conventions beside src/layout.tsx and src/mcp/<server>/layout.tsx (#396). Assessed against the agent-host render target (MCP result, hook decision, CLI stdout) rather than the browser, the file conventions do not remove hand-rolled code in any real app, but the assessment surfaced one real gap the framework should decide explicitly: what a thrown (not represented) route error projects to on each surface, and whether the layout shell survives it.

Today the two error paths diverge:

Route outcome MCP tools/call MCP prompts/get / resources/read Layout _meta / shell Code
<Agent.Error code> (represented) isError: true + [code] message text, structuredContent from Agent.Result value, _meta from the layout same document projection kept author-chosen
route (or its Suspense child) throws SDK default: { content: [{ type: 'text', text: error.message }], isError: true } — no _meta, no structuredContent, no [code] JSON-RPC error lost (no document exists) none

The second row is not documented in docs/framework-mode.md ("What reaches the MCP wire" lists only Agent.Error), and no test pins it. A rejected nested Suspense boundary is a third case: the reconciler emits a render error event with boundaryId (packages/rsc-runtime/src/reconciler.ts settledBoundaryInputs), and the MCP projector discards it (packages/rsc-runtime/src/project-mcp.ts:255, case 'error': return;); what the final CallToolResult contains for that route is not covered by any test in packages/rsc-runtime/tests or packages/agent-bundle/tests.

Evidence

Surveyed at examples/* (main 2e59d6e), agent-plugins/cargo-conductor (3f70b60), and agent-plugins/movie-library feat/react-app-shell (f37a286), looking for hand-rolled error handling or fallback rendering inside tool, event, CLI, and script routes.

Error handling — errors are data, never boundaries. No route in any of the three codebases does try { … } catch → <Agent.Error>:

  • movie-library: 46 tool routes render through one ReceiptDocument (src/components/receipt.tsx) that switches on receipt.isError (a field of the legacy handler's CallToolResult) to emit <Agent.Error code> vs <Agent.Text>. The error is a value in the result, not a thrown exception; src/cli/shelf.tsx does the same over a { kind: 'failed' } union.
  • cargo-conductor: ErrorState / UnavailableState (src/components/states.tsx) render a DaemonHealth discriminated union (unreachable, open-failed, …) that lib/daemon-health.ts produces by catching at the client layer. Routes never see a throw.
  • examples: the only try/catch in route files are host-test/src/mcp/host-test/tools/reset.tsx:33 (folds the failure into the result value's stateReason) and rsc-agent-runtime/src/events/tool/after.tsx:23 (writes a probe, then rethrows — relies on fail-closed).

An error.tsx boundary would therefore replace zero lines in these apps; both real apps deliberately keep failures inside the document so the layout shell (shellMetadataCallToolResult._meta.movieLibrary; metadata.hauler) is preserved on every outcome. That preservation is exactly what a thrown error loses today.

Loading fallbacks — repetition exists, but the cause is #448, not a missing file. movie-library has 39 route files with the identical shape

export default async function Route({ input, signal }) {
  const progress = searchingMessage(String(input.query), []);
  return (
    <Suspense fallback={<Agent.Progress completed={0} message={progress} />}>
      <Page input={input} progress={progress} signal={signal} />
    </Suspense>
  );
}
// Page: await announce(progress);  // src/progress.ts, "exists because of agent-bundle#448"

cargo-conductor has two equivalent stream wrappers (src/components/streaming.tsx AwaitStream, LogStream). The duplicated part is the announce() + progress prop plumbing that mirrors the fallback message into progress.report() so MCP clients see a notifications/progress — the gap #448 describes. The fallback messages are per-route and input-dependent (searchingMessage(input.query, sources)), so a per-server src/mcp/<server>/loading.tsx could not express them without a switch over routes, which is more code than the current per-route <Suspense>. Fixing #448 (project a progress node in a streamed shell/replace document as a progress notification) deletes src/progress.ts and the progress prop from 39 routes; a loading.tsx convention deletes nothing.

Where the framework already stands (packages/rsc-runtime/src, packages/agent-bundle/src):

  • documentToCallToolResult sets isError for any status !== 'success'; Agent.Error[code] message text (project-mcp.ts).
  • registerGeneratedRoutes (mcp-server-runtime.ts) awaits renderGeneratedRoute; a rejection propagates to @modelcontextprotocol/server, whose tools/call path returns createToolError(message) and whose prompt/resource paths raise a JSON-RPC error.
  • Rendered CLI/scripts: an escaped throw is stderr + exit 1 (build/entry-shell.ts, test/script.ts docs); represented errors print **[code]** message.
  • Event routes: a throw fails the hook closed; hook projection (events/projection.ts) only ever sees a complete document.

Proposed shape

Not a file convention. Decide and document the thrown-error contract per surface, and give the layout a chance to see it, in this order of preference:

  1. Document the current behavior in docs/framework-mode.md "What reaches the MCP wire" (new row: "route throws → tool error text with no _meta/structuredContent; prompt/resource → JSON-RPC error; CLI → stderr + exit 1; hook → fail closed") and in website/docs/{en,zh}/guide/authoring/mcp.mdx. State that represented errors are the supported path and that the layout shell only wraps documents.
  2. Pin the rejected-Suspense-boundary outcome with a route-unit + mcp-in-memory test (a fixture route whose child boundary rejects), so the projector's case 'error': return; is a decision rather than an omission.
  3. Only if a consumer asks for it: an optional AgentLayoutProps.error?: { code: string; message: string } (or a layout-level errorFallback export) that lets the layout render a thrown error as <Agent.Error> inside its shell so _meta survives — the layout, not a new error.tsx, is the boundary the feat(routes): conventional shared layout module for rendered routes (#312) #396 scopes already define. This must be measured against the two apps above, which currently need nothing.

Explicitly out of scope: template.tsx (Next's remount-per-navigation semantics have no agent-host meaning) and loading.tsx (superseded by #448).

Acceptance

  • docs/framework-mode.md and the en/zh MCP authoring pages state what a thrown route error projects to on MCP tools, prompts, resources, rendered CLI/scripts, and hooks, and that the layout shell does not wrap it.
  • A test proves the CallToolResult (content, isError, absence of _meta/structuredContent) for (a) a route whose default export throws and (b) a route whose nested Suspense boundary rejects, at route-unit and mcp-in-memory proof levels.
  • No error.tsx / loading.tsx / template.tsx discovery is added to routes/graph.ts unless a real app's diff shows the code it removes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationmeta-frameworkAgent Bundle compiler-coupled meta-frameworkruntimeRuntime context, state, rendering, or execution

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions