/** * Agent tool-call dispatch engine (T1740 · epic T11456 · SG-TOOLS). * * The SINGLE chokepoint that turns a model-emitted tool-call `{ name, args }` * into an executed, LLM-safe result. It is the SDK-side dispatch path the T1738 * architecture (D11141) names: **core DISPATCHES, cleo-os DRIVES**. Given a tool * call from the agent loop, it: * * 1. **Looks up** the tool in the injected {@link AgentToolRegistry} (the * name→descriptor catalog). An unknown name is a typed * `tool-not-found` result — never a throw. * 2. **Validates** the model-supplied arguments against the tool's Zod schema * (defense in depth: the wire layer advertised the JSON-schema, but the * model's emitted args are re-validated here before any side effect). A * schema failure is a typed `invalid-args` result carrying the FLATTENED * issue list — not the raw Zod error (no internals leak). * 3. **Checks availability** for the current {@link ToolAvailabilityContext} * (network egress, binaries on PATH, capability flags). An unavailable tool * is a typed `guard-denied` result — the model is told the tool is not * offered right now, with no precondition internals. * 4. **Charges the budget** ({@link ToolCallBudget}) — per-call count / wall-time * ceilings. An exhausted budget short-circuits with a typed `guard-denied` * result BEFORE the tool runs; a per-call timeout races the execution and * yields a typed `timeout` result. * 5. **Executes** the bound {@link AgentToolExecutable} over the injected * {@link GuardedToolSurface} — the deny-first chokepoint * ({@link ./guard.js}). Every fs/shell side effect therefore still funnels * through the guard's path allowlist + command denylist + env scrub; there * is NO raw-primitive bypass. * 6. **Formats** the outcome for LLM consumption ({@link formatToolResultForLlm}) * — a `tool_result`-shaped payload the Pi loop feeds back to the model. On * ANY error the message is a redacted, classified summary; a raw secret, * stack trace, or guard internal NEVER reaches the model-facing string. * * ## Sync + async handlers (AC2) * * A registered {@link AgentToolExecutable} returns a `Promise`, but a * synchronous handler that returns a plain value is equally supported: the * engine `await`s the call so a non-thenable return is normalized to a resolved * value, and a synchronous throw is caught the same as a rejected promise. * * ## Why it lives in `core/src/tools` (Gate-11) * * The dispatch engine is a CORE-SDK concern, built entirely against the frozen * {@link AgentToolRegistry} + {@link GuardedToolSurface} contracts. It defines * NO new atomic tool primitive (Gate-11 Tools-vs-Skills boundary) and makes NO * LLM call (Gate-13): result-formatting is pure serialization. The harness * (`cleo-os`) and the in-process Pi adapter merely SUPPLY the registry, the * guard, and the workspace context, then read the {@link ToolDispatchResult}. * * @task T1740 * @epic T11456 * @see ./agent-registry.js — the name→descriptor catalog + availability checks * @see ./guard.js — the deny-first surface every executable routes side effects through * @see ../llm/pi/pi-tool-bridge.js — the AC6 consumer wiring this into the Pi loop */ import type { GuardedToolSurface } from '@cleocode/contracts/tools/skill-executor'; import type { z } from 'zod'; import type { AgentToolRegistry, ToolAvailabilityContext } from './agent-registry.js'; /** * The typed failure classes a dispatch can produce (AC3). Each maps a distinct * failure mode to a stable, model-safe category so the loop (and any UI) can * branch on the KIND without parsing a free-text message: * * - `tool-not-found` — the model named a tool not in the registry. * - `invalid-args` — arguments failed the tool's Zod schema. * - `guard-denied` — the tool is unavailable for this context, or the * per-call budget was exhausted before it ran. * - `execution-error`— the tool ran but threw (incl. a `GuardDeniedError` * from the deny-first surface during the side effect), OR * the call was cancelled via its abort signal before it * completed (the run was cancelled out from under it). * - `timeout` — the tool exceeded the per-call wall-time ceiling. */ export type ToolDispatchErrorKind = 'tool-not-found' | 'invalid-args' | 'guard-denied' | 'execution-error' | 'timeout'; /** * Stable machine-readable error codes, one per {@link ToolDispatchErrorKind}. * Surfaced on the failure envelope so a programmatic caller never string-matches * the human message. */ export declare const TOOL_DISPATCH_ERROR_CODE: Readonly>; /** * A single tool call as the model emits it — the dispatch engine's input. The * `name` is the model-visible tool name; `arguments` is the JSON-parsed argument * object (the engine never sees the raw JSON string — parsing is the loop's job, * the engine validates the parsed shape). */ export interface ToolCall { /** Stable id correlating the call to its `tool_result` (loop-supplied). */ readonly id: string; /** Model-visible tool name to dispatch. */ readonly name: string; /** JSON-parsed argument object the model supplied. */ readonly arguments: Readonly>; } /** * The successful outcome of a dispatch (AC4). `value` is the tool's raw return * (opaque to the engine); `display` is the LLM-facing string the loop feeds back * in the `tool_result` message. */ export interface ToolDispatchSuccess { /** Discriminant. */ readonly ok: true; /** The dispatched tool's name (echoed for correlation). */ readonly name: string; /** The raw tool return value (for structured logging / details). */ readonly value: unknown; /** The LLM-facing rendering of `value` (the `tool_result` body). */ readonly display: string; } /** * The failed outcome of a dispatch (AC3 + AC4). Carries the typed * {@link ToolDispatchErrorKind}, a stable code, and a REDACTED, model-safe * `message` — a raw secret / stack / guard internal is never placed here. */ export interface ToolDispatchFailure { /** Discriminant. */ readonly ok: false; /** The dispatched tool's name (echoed for correlation). */ readonly name: string; /** The typed failure class (AC3). */ readonly kind: ToolDispatchErrorKind; /** Stable machine-readable code ({@link TOOL_DISPATCH_ERROR_CODE}). */ readonly code: string; /** Redacted, model-safe failure summary (the `tool_result` body). */ readonly message: string; /** * For `invalid-args`, the flattened schema issues (path + reason) — safe to * show the model so it can correct its call. Absent for other kinds. */ readonly issues?: readonly ToolArgIssue[]; } /** A flattened Zod issue: the field path and the human-readable reason. */ export interface ToolArgIssue { /** Dotted field path, e.g. `path` or `args.0`. Empty for a root issue. */ readonly path: string; /** Human-readable validation reason. */ readonly message: string; } /** The terminal result of a single dispatch — success or a classified failure. */ export type ToolDispatchResult = ToolDispatchSuccess | ToolDispatchFailure; /** Caps for {@link ToolCallBudget}. A cap of `undefined` means "unbounded". */ export interface ToolBudgetLimits { /** Max number of tool calls this budget admits. */ readonly maxCalls?: number; /** Per-call wall-time ceiling in milliseconds (a slower call → `timeout`). */ readonly perCallTimeoutMs?: number; /** Cumulative wall-time ceiling across all calls in milliseconds. */ readonly totalTimeBudgetMs?: number; } /** * A snapshot of a {@link ToolCallBudget}'s consumption — surfaced so a consumer * (the Pi loop / a UI) can report remaining headroom (AC5). */ export interface ToolBudgetSnapshot { /** Calls charged so far. */ readonly callsUsed: number; /** Calls remaining (`Infinity` when uncapped). */ readonly callsRemaining: number; /** Cumulative wall-time charged so far, in ms. */ readonly timeUsedMs: number; /** Cumulative wall-time remaining, in ms (`Infinity` when uncapped). */ readonly timeRemainingMs: number; } /** * Per-run tool-call budget (AC5). Tracks call count + cumulative wall-time * against the configured {@link ToolBudgetLimits}. The engine consults * {@link admit} BEFORE running a tool (rejecting once a ceiling is hit) and * {@link charge}s the elapsed time AFTER. A single budget instance is threaded * across all calls of one run, so the count/time caps are run-scoped. */ export declare class ToolCallBudget { #private; /** * @param limits - The ceilings this budget enforces (each optional → uncapped). */ constructor(limits?: ToolBudgetLimits); /** The per-call timeout ceiling in ms, or `undefined` when uncapped. */ get perCallTimeoutMs(): number | undefined; /** * Whether another call is admitted right now. Returns a typed denial reason * (or `null` when admitted) so the engine can map it onto the precise * `guard-denied` message WITHOUT exposing the limit internals to the model. * * @returns `null` when a call is admitted; otherwise the human-readable reason. */ admit(): string | null; /** * Charge one completed call against the budget. * * @param elapsedMs - The call's measured wall-time in milliseconds. */ charge(elapsedMs: number): void; /** A read-only snapshot of current consumption (AC5). */ snapshot(): ToolBudgetSnapshot; } /** Maximum length of a formatted `tool_result` body before truncation (AC4). */ export declare const MAX_TOOL_RESULT_CHARS = 16000; /** * Construction dependencies for {@link ToolDispatchEngine}. */ export interface ToolDispatchEngineDeps { /** The frozen tool catalog the engine dispatches against (name→descriptor). */ readonly registry: AgentToolRegistry; /** * The deny-first guarded surface every executable performs side effects * through. Injected — the engine never constructs a guard (Gate-11). */ readonly tools: GuardedToolSurface; /** * Availability context (AC5 of the registry): a tool whose * {@link AgentToolDescriptor.available} predicate is false for this context is * `guard-denied` before it runs. Defaults to `{}` (everything available). */ readonly availability?: ToolAvailabilityContext; /** * Optional run-scoped budget (AC5). When omitted an unbounded budget is used, * so a call count / time ceiling is opt-in. Threaded across all * {@link ToolDispatchEngine.dispatch} calls of one run. */ readonly budget?: ToolCallBudget; } /** * The agent tool-call dispatch engine (AC1). * * One instance per agent run: it holds the frozen registry, the guarded surface, * the availability context, and the run-scoped budget, and dispatches each * model-emitted {@link ToolCall} to a classified {@link ToolDispatchResult}. * {@link dispatch} NEVER throws — every failure mode is encoded as a typed * {@link ToolDispatchFailure} (AC3), so the agent loop can always feed a * `tool_result` back and continue. * * @example * ```ts * const registry = await createAgentToolRegistry(); * const tools = createToolGuard({ allowedRoots: [root], mode: 'enforce' }); * const engine = new ToolDispatchEngine({ * registry, * tools, * budget: new ToolCallBudget({ maxCalls: 50, perCallTimeoutMs: 30_000 }), * }); * const result = await engine.dispatch({ id: 'c1', name: 'read_file_paged', arguments: { path } }); * // result.ok ? feed result.display : feed the classified result.message * ``` */ export declare class ToolDispatchEngine { #private; /** * @param deps - The registry, guarded surface, availability context, and * optional run-scoped budget. */ constructor(deps: ToolDispatchEngineDeps); /** A read-only snapshot of the run's budget consumption (AC5). */ budgetSnapshot(): ToolBudgetSnapshot; /** * Dispatch a single model-emitted tool call to a classified result (AC1–AC5). * * The full pipeline — lookup → validate → availability → budget admit → * (timed) execute → charge → format — runs here. It NEVER throws: a thrown * executable (incl. a deny-first `GuardDeniedError` during the side effect) * becomes an `execution-error`, a slow executable a `timeout`, a cancelled call * an `execution-error`, an unknown name a `tool-not-found`, bad args an * `invalid-args`, and an unavailable tool / exhausted budget a `guard-denied`. * * ## Abort handling (honest contract) * * A `signal` (when supplied) is honoured REGARDLESS of whether a per-call * timeout is configured — including the default no-budget production path: an * already-aborted signal short-circuits before the executable is awaited, and * an abort RACED in flight rejects with an `execution-error`. BUT this is * abandon-the-promise cancellation, NOT preemptive kill: the frozen * {@link AgentToolExecutable}/{@link GuardedToolSurface} contracts expose no * signal channel, so a tool that already spawned an OS process keeps that * process running until the tool's own internal bound fires. The engine * returns promptly; the side effect may still be settling. See * {@link ToolDispatchEngine.#runWithTimeout} for the full rationale. * * @param call - The model-emitted tool call (`{ id, name, arguments }`). * @param signal - Optional abort signal honoured on every path (timeout or * not); aborting yields an `execution-error` but cannot preemptively kill an * in-flight OS process (frozen-contract limitation). * @returns The terminal {@link ToolDispatchResult}. */ dispatch(call: ToolCall, signal?: AbortSignal): Promise; } /** Thrown internally when a tool call exceeds its per-call timeout (AC5). */ export declare class ToolTimeoutError extends Error { /** Machine-readable code. */ readonly code = "E_TOOL_TIMEOUT"; constructor(message: string); } /** * Thrown internally when a tool call is cancelled via its {@link AbortSignal} * (containment-managed run cancel / OOM-guard / budget kill). Distinct from * {@link ToolTimeoutError}: a timeout means "the call was too slow", an abort * means "the run was cancelled out from under the call". The engine maps it to a * typed `execution-error` result (the call did not complete). * * @remarks Winning the abort race only ABANDONS the executable promise — it does * NOT preemptively kill a tool that already spawned an OS process, because the * frozen {@link AgentToolExecutable}/{@link GuardedToolSurface} contracts carry * no signal channel (see {@link ToolDispatchEngine.dispatch}). */ export declare class ToolAbortError extends Error { /** Machine-readable code. */ readonly code = "E_TOOL_ABORTED"; constructor(message: string); } /** * Render a tool's raw return value into the LLM-facing `tool_result` body (AC4). * * A string passes through; everything else is JSON-serialized (pretty, stable). * A circular / non-serializable value degrades to its `String(...)` form rather * than throwing. The result is hard-capped at {@link MAX_TOOL_RESULT_CHARS} with * a truncation marker so one tool call cannot blow the model's context window. * * @param value - The raw tool return. * @returns The model-safe `tool_result` string. */ export declare function formatToolValueForLlm(value: unknown): string; /** * Project a terminal {@link ToolDispatchResult} onto the `tool_result` message * shape the agent loop feeds back to the model (AC4). * * The `content` is `result.display` on success or the redacted, classified * `result.message` on failure (with the flattened arg issues appended when * present). `isError` lets the loop / transport mark the turn so the model knows * the call did not succeed. * * @param result - The dispatch outcome. * @returns A `{ toolCallId, toolName, content, isError }` envelope. */ export declare function formatToolResultForLlm(call: Pick, result: ToolDispatchResult): ToolResultPayload; /** * The LLM-facing `tool_result` payload shape (AC4) — the minimal subset the loop * needs to build a provider `tool_result` / `toolResult` message. Structurally a * subset of `pi-ai`'s `ToolResultMessage`, so the Pi bridge maps it 1:1. */ export interface ToolResultPayload { /** Correlates back to the originating {@link ToolCall.id}. */ readonly toolCallId: string; /** The tool's name (echoed for the provider message). */ readonly toolName: string; /** The model-facing result body (display on success, redacted summary on error). */ readonly content: string; /** Whether the turn is an error result. */ readonly isError: boolean; } /** * Produce a REDACTED, model-safe message from a thrown executable error (AC3). * * Only the error's single-line `message` is kept — never the stack trace (which * can leak absolute paths / internal module structure) and never an attached * `cause`. The message itself is scrubbed for obvious secret-looking tokens so a * tool that interpolates a credential into its error string cannot leak it to * the model. A non-`Error` throw degrades to a fixed, generic summary. * * @param err - The thrown value. * @returns A single-line, secret-scrubbed message safe for the model. */ export declare function redactErrorMessage(err: unknown): string; /** * Flatten a {@link z.ZodError} into the minimal, model-safe * {@link ToolArgIssue}[] — path + reason only, no raw Zod internals. * * @param error - The Zod validation error. * @returns The flattened issues. */ export declare function flattenZodIssues(error: z.ZodError): readonly ToolArgIssue[]; //# sourceMappingURL=dispatch.d.ts.map