ADR 0054 — In-process tool calls use the canonical runner
- Status: Accepted — extends the shared execution path in ADR 0014 and the per-call isolation rule in ADR 0045
- Date: 2026-08-07
Context
An application tool may dispatch to a more specific contract tool. Mounting a
Vercel AI SDK ToolSet merely to call its execute callback preserves behavior
but makes a transport adapter the application API, recompiles the whole surface
and leaks an SDK presentation envelope into internal code.
The framework already owns one executable path: collectTools resolves the
surface, createToolRunner prepares shared options, and executeToolMethod
performs validation, extension resolution, lifecycle, hooks, isolation and
output validation.
Decision
Expose createToolInvoker. It collects an immutable name→operation map once
and invokes each operation through createToolRunner. It returns the canonical
discriminated ToolResult, with no MCP or AI SDK formatting.
The caller must choose transport: 'MCP' | 'AGENT' | 'CLI'. This is an exposure
policy, not the audit source: source defaults to internal. There is no mode
that collects every endpoint regardless of expose.
Unknown names throw a NOT_FOUND AppError before execution because no
operation identity exists for lifecycle or hooks. Duplicate or invalid names
fail during compilation using the same name ratchet as mounted surfaces.
Each invocation calls the existing runner, so extension context and request context are fresh per call. Recursive and parallel invocations inherit and fork the ambient observability context under ADR 0045; the invoker owns no mutable per-call global state.
Alternatives rejected
- Mount and call an AI SDK tool. It recompiles presentation schemas and couples internal code to SDK execution options and error formatting.
- Expose
executeToolMethodplus a name map recipe. Every consumer would rebuild exposure, collision and extension behavior differently. - Internal bypass surface. It would make
exposeadvisory and allow a nested call to escape a deliberate transport restriction. - Throw every tool failure. Mounted surfaces use
ToolResult; retaining it keeps validation and handler failures mechanically comparable.
Consequences
- Internal dispatch has no SDK dependency and no presentation-envelope parsing.
- The compiled invoker is intentionally untyped by runtime tool name; schemas still validate every boundary and the result is discriminated.
- A caller that wants thrown domain behavior translates a failed
ToolResultexplicitly at its own service boundary.