/** * `ToolClient` — surfaces inbound `tool.request` envelopes * (parallel-batched) and accepts outbound `tool.response` envelopes * (same shape). * * The parallel-batched envelope shape: * * - `tool.request.payload` carries `calls: ToolCall[]` (always a list, even * for a single call — modern LLM `parallel_tool_use`) and a required * `node_name`. The handler receives the full envelope; `req.payload.calls` * is the call list, `req.payload.node_name` is the originating node. * - `tool.response.payload` carries `responses: ToolResponseItem[]` (same * length / membership as the request's `calls`) and the same `node_name`. * * Handler return shapes: * * - returns `ToolResponseResult` (single SDK union) → auto-batched * into `responses: [resp]` against `calls[0]`. Requires `calls.length === 1`; * otherwise a `QodoWireValidationError` surfaces. The most common shape * after a 1.x → 2.0 migration (where every request was single-call). * - returns `readonly ToolResponseResult[]` (one per call) → auto-batched * positionally; length must equal `calls.length`. * - returns `undefined` → fall through * to the next handler in the stack; if every handler returns `undefined` * the consumer is expected to call `respond()` later (approval flow / async * tool). With zero handlers, the SDK emits a `denied` envelope so QAR's * agent loop doesn't hang. * * `respond(nodeName, response | responses)` is the manual escape hatch. It * builds a `tool.response` envelope and writes it through the connection. No * ack: QAR's `task.delta` / `task.done` chain advances on its own once the * response lands. * * Concurrency: each `tool.request` envelope carries N calls but is dispatched * as a single handler invocation. QAR may emit multiple `tool.request` * envelopes per task (separate dispatch rounds); each runs through the stack * independently. The wire is keyed on `tool_call_id` for each individual call. * * Error mapping (per-call): * - handler throws `ToolDeniedError` → `status: 'error'`, `error.code: 'denied'` * - handler throws anything else → `status: 'error'`, `error.code: 'error'` * - returns malformed result → `status: 'error'`, generic message * (handler internals never leak to QAR) * * Error/reason strings are truncated so a stack trace can't blow up the wire * payload. */ import type { ToolRequestEnvelope } from '../qar/envelopes.js'; import type { ToolResponseFor, ToolResponseResult } from '../qar/payloads.js'; import type { FunctionToolDef } from '../qar/specs.js'; import { Connection } from './connection.js'; import { QodoWireValidationError } from './errors.js'; import type { ToolRespondOptions } from './options.js'; import type { SpanRecorder } from '../observability/spans.js'; import { type ApprovalHandler } from './tools/approval.js'; /** * Handler signature for inbound `tool.request` envelopes. * * The request envelope carries `payload.calls: ToolCall[]` (always a list, * length ≥ 1) and `payload.node_name`. The handler may return: * * - a single `ToolResponseResult` — auto-batched into `responses: [resp]` * against `calls[0]` (single-call case, length must be 1); * - a `readonly ToolResponseResult[]` matching `calls` positionally — for * parallel reply (length must equal `calls.length`); * - `undefined` — "I'll respond later via `client.tools.respond(...)`" * (useful when the tool involves user approval or a long-running external * service), falls through to the next handler in the stack. * * Throws bubble back to the runtime as per-call `status: 'error'` items, * except `ToolDeniedError` which sets `error.code: 'denied'`. */ export type ToolRequestHandler = (request: ToolRequestEnvelope) => ToolResponseResult | readonly ToolResponseResult[] | undefined | Promise; /** * Handler signature for inbound `tool.request` envelopes that the SDK * dropped because they violated the wire shape (empty `node_name` or * empty `calls`). Fires AFTER the `[@qodo/sdk]` console.error * breadcrumb but BEFORE the early-return — the consumer gets a typed * `QodoWireValidationError` they can route into structured logging, * metrics, or test fixtures, instead of grep-on-stderr being the only * observable signal. * * The handler runs inside a defensive try/catch — a throwing handler must * never crash the dispatch hot path, so any exception it raises is itself * swallowed (and logged via the same `[@qodo/sdk]` breadcrumb channel). * * Handlers are advisory only. The SDK has no `tool_call_id` to respond * against, so it CANNOT emit a `tool.response` for these envelopes — QAR's * per-task tool-response timeout is the wire-side safety net. */ export type WireValidationErrorHandler = (err: QodoWireValidationError) => void | Promise; /** * Subscription token returned by `ToolClient.onRequest` / * `ToolClient.onWireValidationError`. * * Calling `unsubscribe()` removes the handler. Multiple subscriptions stack * in registration order; for `onRequest`, the first non-`undefined` result * wins; for `onWireValidationError`, every handler runs (it's an observer * surface, not a winner-takes-all dispatch). */ export interface Subscription { unsubscribe(): void; } export declare class ToolClient { private readonly resolveConnection; private readonly spanRecorder; private readonly handlers; /** * SDK-attached auto-bridge handlers (qodo-skills, remote-MCP). Distinct * from {@link handlers} (consumer-registered via `onRequest`) so the * dispatcher's deterministic-deny path can fire when only SDK fallbacks * are in the stack and no consumer-side handler exists. * * Without this split, the bridge-being-attached defeats the * zero-primary-handler deny fallback. Walking primaries first, * fallbacks second, with deny keyed on `primaries.length === 0` * (regardless of fallback count) gives consumers the * deterministic-deny ergonomic they expect from "I didn't register * a handler" + lets the SDK auto-bridges still route MCP / skills * calls when the consumer DOES register handlers later. */ private readonly fallbackHandlers; private readonly wireValidationHandlers; private dispatcher; private dispatcherConnection; /** * `defineFunctionTool` router — owns the per-tool-name handler map * and a single `onRequest` subscription that dispatches inbound * `tool.request` calls by name. Lazily attached the first time a * function-tool handler is registered. */ private readonly functionToolRouter; /** * S4 — consumer-overridable approval resolver for * `requireApproval`-gated function tools. Defaults to the * stdin-prompt-when-TTY-else-deny handler from * `src/client/tools/approval.ts`. Production apps register a custom * handler (Slack, web UI, pager) via {@link setApprovalHandler}. * * Mutable so a `setApprovalHandler` call AFTER the router attaches * still applies to subsequent batches — the router captures a * getter, not the value, at attach time. */ private approvalHandler; /** * @param resolveConnection Returns the live `Connection` or throws if the * client isn't connected yet. * @param spanRecorder Recorder for `qar.client.tool.*` spans. No-op * when OTel isn't configured. */ constructor(resolveConnection: () => Connection, spanRecorder: SpanRecorder); /** * Register a handler for inbound `tool.request` events. * * Stacking semantics: the first handler that returns a non-`undefined` * value (single result or array) claims the entire request; its responses * are auto-sent. If every handler returns `undefined`, the consumer must * call `respond()` explicitly within QAR's per-task tool-response timeout. * * `QodoClient.connect()` already attaches the fan-out dispatcher on the * live connection (so a `tool.request` arriving with zero handlers * deterministically emits `status: 'error'` / `error.code: 'denied'` * instead of leaving QAR paused on the call set). `onRequest` just adds to * the stack. * * If `attachDispatcher()` throws — e.g. the caller registered a handler * before `connect()` and we have no connection to attach to — we roll * back the handler push so consumers don't end up with an orphaned * registration they can't `unsubscribe()`. */ onRequest(handler: ToolRequestHandler): Subscription; /** * Register `defineFunctionTool`-produced entries against the * client's internal router. Any tool in `tools` carrying the * `defineFunctionTool` handler symbol gets added to the router's * name → handler table; tools without the symbol (e.g. hand-authored * `FunctionToolDef` literals) are skipped. * * Idempotent — re-registering the same tool name replaces the * existing handler in-place. The router's `onRequest` subscription * is attached lazily on first registration; it stays attached for * the lifetime of the client. * * `tasks.startWithAgent` / `tasks.startWithGraph` auto-call this * with `agent.tools` so the common path "drop helpers into * `tools[]`" needs no manual binding. The explicit method is * available for consumers using `tasks.start` (server-defined * agent_id) who still want type-safe handler organization by tool * name, or for any pre-task registration flow. * * @returns Number of newly registered handlers from this call. */ bindFunctionTools(tools: readonly FunctionToolDef[] | null | undefined): number; /** * Register the resolver invoked when a `requireApproval`-gated * function tool is about to dispatch (S4). The handler decides * approve / deny; the SDK runs the function-tool handler on * approve and returns `outcome: 'denied'` to QAR on deny. * * The default handler prompts on stdin when `process.stdin.isTTY` * is true; in non-interactive contexts it deterministic-denies * with a typed error message pointing at the missing registration. * Production apps that route approvals to Slack / a web UI / a * pager should register a custom handler at startup. * * Late binding is fine — a handler registered AFTER the router * has attached still applies to subsequent inbound batches. * * @see {@link ApprovalHandler} for the resolver contract. * @see `src/client/tools/approval.ts` for the default behavior. */ setApprovalHandler(handler: ApprovalHandler): void; /** * Register a handler for inbound wire-validation failures — `tool.request` * envelopes the SDK dropped because they violated the wire shape * (missing `node_name`, empty `calls`). The handler receives a typed * `QodoWireValidationError` that carries the offending `message_id` and * the field that failed validation, so consumer telemetry can branch on * the exact protocol drift without parsing the `[@qodo/sdk]` log line. * * Observer-style: every registered handler fires on every drop (no * winner-takes-all dispatch). Handlers run inside a defensive try/catch * — a throwing handler logs to `console.error` and is otherwise swallowed * so the dispatch hot path stays alive. * * The SDK has no `tool_call_id` to respond against for these envelopes, * so registering a handler does NOT make the runtime see a response — * QAR's per-task tool-response timeout remains the wire-side safety net. * This surface exists purely so consumer code can observe the failure. * * Returning `Subscription` mirrors `onRequest` — `unsubscribe()` removes * the handler; the dispatcher itself stays attached (it's the path that * fires the breadcrumb log even when no handler is registered). */ onWireValidationError(handler: WireValidationErrorHandler): Subscription; /** * Send a `tool.response` envelope. Every envelope carries `node_name` * (echoed from the inbound `tool.request`) and a `responses` list. * Two overloads cover the common shapes: * * - `respond(nodeName, single)` — auto-batches into `responses: [single]`. * The typical approval-flow / async-tool path. * - `respond(nodeName, batch)` — parallel reply matching the originating * request's `calls`. Length must be ≥ 1. * * The wire write is fire-and-forget — QAR doesn't ack `tool.response`. * * Throws if `nodeName` is empty (typed `QodoWireValidationError`) or if any * response doesn't match the `ToolResponseResult` discriminated union * (`TypeError`), so consumer bugs surface synchronously rather than as a * malformed envelope on the wire. * * Cross-process recovery: pass `opts.sessionId` to respond against a * `tool_call_id` whose originating `tool.request` was never observed * in this process (HITL approval handed off to a different SDK * instance). Without the override the SDK throws * {@link QodoColdAddressError} before the wire write. */ respond(nodeName: string, response: ToolResponseFor, opts?: ToolRespondOptions): Promise; respond(nodeName: string, responses: readonly ToolResponseFor[], opts?: ToolRespondOptions): Promise; private attachDispatcher; private sendResponse; /** * Snapshot whether the underlying connection is still open. Used by the * dispatcher's send-fallback to distinguish "connection died" (give up) from * "the payload itself was poisoned" (try a safe `status: 'error'`). */ private isConnectionLive; /** * Heuristic: a send failure is an internal serialization error (rather than * a connection-level one) when the connection is live AND the batch we just * tried to send wasn't already the safe internal-error fallback. Filtering * on the fallback's own message stops a serialization-failed loop from * looping. */ private isInternalSerializationError; } //# sourceMappingURL=ToolClient.d.ts.map