UNPKG

@anthropic-ai/sdk

Version:
954 lines (908 loc) • 41.3 kB
import { context, type Context } from '@opentelemetry/api'; import { AnthropicError } from '../../core/error'; import type { Anthropic } from '../../client'; import type { BetaManagedAgentsAgentCustomToolUseEvent, BetaManagedAgentsAgentToolUseEvent, BetaManagedAgentsSessionEvent, BetaManagedAgentsStreamSessionEvents, BetaManagedAgentsUserCustomToolResultEventParams, BetaManagedAgentsUserToolResultEventParams, } from '../../resources/beta/sessions/events'; import type { BetaToolResultContentBlockParam } from '../../resources/beta'; import { loggerFor, type Logger } from '../../internal/utils/log'; import { sleep } from '../../internal/utils/sleep'; import { applyJitter, backoff, isFatal4xx } from '../../internal/utils/backoff'; import { linkAbort } from '../../internal/utils/abort'; import { AsyncQueue } from '../../internal/utils/async-queue'; import { buildHeaders } from '../../internal/headers'; import { helperHeader } from '../../internal/stainless-helper-header'; import type { RequestOptions } from '../../internal/request-options'; import { runRunnableTool, toolName, type BetaRunnableTool } from './BetaRunnableTool'; import type { BetaToolRunnerRequestOptions } from './BetaToolRunner'; import { withToolUseSpan } from '../internal/tracing'; /** Beta header for the managed-agents API. */ export const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'; const STREAM_BACKOFF_START_MS = 500; const STREAM_BACKOFF_CAP_MS = 10_000; const TOOL_TIMEOUT_MS = 120_000; const DRAIN_TIMEOUT_MS = 30_000; const SEND_BACKOFF_START_MS = 1_000; const SEND_BACKOFF_CAP_MS = 30_000; /** * How long a transiently failing tool-result send keeps retrying when the * runner is used on its own: the server's default work-item lease TTL. * `EnvironmentWorker` overrides it with the live TTL from each lease heartbeat * (`_setSendRetryWindow`), so a send is only abandoned once the lease can no * longer be ours. */ const SEND_RETRY_WINDOW_MS = 5 * 60_000; /** Block type accepted in a `user.tool_result` event's content — codegen'd, stays in sync with the API. */ type SessionContentBlock = NonNullable<BetaManagedAgentsUserToolResultEventParams['content']>[number]; /** * A tool-call event the runner dispatches against the local registry: either a * builtin `agent.tool_use` (answered with `user.tool_result`) or a custom * `agent.custom_tool_use` (answered with `user.custom_tool_result`). Server-side * `agent.mcp_tool_use` calls are intentionally excluded — the runner does not * handle them. */ type DispatchedToolUseEvent = BetaManagedAgentsAgentToolUseEvent | BetaManagedAgentsAgentCustomToolUseEvent; /** * The result-event params paired with a {@link DispatchedToolUseEvent}: a * `user.tool_result` answers an `agent.tool_use`, a `user.custom_tool_result` * answers an `agent.custom_tool_use`. The two pairs must be matched exactly. */ type DispatchedToolResultParams = | BetaManagedAgentsUserToolResultEventParams | BetaManagedAgentsUserCustomToolResultEventParams; export interface SessionToolRunnerOptions { client: Anthropic; /** * Tools to expose to the session, in the same {@link BetaRunnableTool} shape * `client.beta.messages.toolRunner` accepts. Use * `betaAgentToolset20260401({ workdir })` from * `@anthropic-ai/sdk/tools/agent-toolset/node` for the standard * `agent_toolset_20260401` set; filter or extend the array to customise. */ tools: Array<BetaRunnableTool>; /** * Once the session goes idle with any `stop_reason.type` but * `"requires_action"`, the runner keeps running for this many milliseconds before * stopping; any new event resets the countdown and it re-arms on the next such * idle. The countdown is deferred while a confirmation-gated call is held or * still dispatching, and starts fresh once the last one resolves. * Defaults to {@link DEFAULT_MAX_IDLE_MS} (60s). `0` (or negative) disables * it — the runner then only stops on session termination or the consumer * breaking out / aborting. */ maxIdleMs?: number; /** External abort signal. Aborting it ends the iteration. */ signal?: AbortSignal; /** * Extra per-request options merged into every call this runner issues * (event stream / list / send). Mirrors what `client.beta.messages.toolRunner` * accepts: custom `headers` (e.g. a proxy's auth/routing headers) reach the * poll/heartbeat/stop/stream/list/send calls. The runner always owns the abort * signal, so a `signal` here is ignored — pass {@link SessionToolRunnerOptions.signal} * to abort externally. */ requestOptions?: BetaToolRunnerRequestOptions; } /** Default {@link SessionToolRunnerOptions.maxIdleMs}: 60 seconds. */ export const DEFAULT_MAX_IDLE_MS = 60_000; /** * Outcome of a single tool execution dispatched by {@link SessionToolRunner}. * * Yielded after the tool ran (or failed) and after the result was posted back * to the session as a `user.tool_result` event. Consumers can read either the * embedded {@link DispatchedToolCall.event} / {@link DispatchedToolCall.result} * blocks or the flat top-level convenience fields. */ export interface DispatchedToolCall { /** * The `agent.tool_use` or `agent.custom_tool_use` event that triggered this * dispatch. Read `event.input` for the raw tool input and `event.name` for the * tool name; `event.type` distinguishes a builtin tool call from a custom one. */ readonly event: DispatchedToolUseEvent; /** * The result event posted (or attempted) back to the session for this call: a * `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result` for * an `agent.custom_tool_use`. Read `result.content` for the tool's output * blocks and `result.is_error` for the error flag. * * `undefined` when no result event was ever built — i.e. the tool name is * not one this runner owns and, under the split-client behavior, it * deliberately posted nothing and left the id pending for its owner. */ readonly result?: DispatchedToolResultParams; /** * Flat convenience for `event.id` — the id of the tool-use event this result * answers (echoed back as `tool_use_id` / `custom_tool_use_id` on the result). */ readonly toolUseId: string; /** Flat convenience for `event.name` — the dispatched tool's name. */ readonly name: string; /** * Flat convenience for `result.is_error` — `true` when the tool threw, * `false` on success and for a skipped unowned call. */ readonly isError: boolean; /** * Whether a result event for this call reached the session. `false` when the * post itself failed (typically a permanent 4xx or send-retry exhaustion) * and also `false` — with no `result` event ever built — for a tool name * this runner does not own when it deliberately posts nothing and leaves the * id pending for its owner (the split-client behavior), or when the call * was denied and never executed (see `confirmation`). */ readonly posted: boolean; /** * The confirmation verdict that gated this call, if any. * * `'allow'` — the call required user confirmation (the server evaluated * its permission to `ask`, e.g. under an `always_ask` policy) and the * matching `user.tool_confirmation` approved it before the tool ran. * `'deny'` — the user denied it, or the server itself evaluated the * permission to `deny`; the tool was never executed and nothing was posted * (`result=undefined`, `posted=false`, `isError=false`). * `undefined` — the call needed no confirmation. */ readonly confirmation: 'allow' | 'deny' | undefined; } /** * Whether `ev` is a `session.status_idle` whose turn is over. Only * `requires_action` leaves the turn open: the session resumes once a client * resolves the events it names. Every other stop reason ends the turn, including * one newer than this SDK's types. */ function endsTurn(ev: { type?: string; stop_reason?: { type?: string } }): boolean { if (ev.type !== 'session.status_idle') return false; return ev.stop_reason?.type !== 'requires_action'; } /** * The `maxIdleMs` stop-countdown, including its deferral. {@link noteEvent} * arms on an idle that ends the turn and disarms on anything else. Gated tool * work registered via {@link block} — a call held for user confirmation, or a * user-approved call still dispatching — keeps * {@link arm} pending until {@link unblock} retires the last blocker, at which * point the countdown starts. Event-driven — there is no polling watchdog. */ class IdleClock { readonly #maxIdleMs: number; readonly #onExpire: () => void; readonly #blockers = new Set<string>(); // Set when arm() found blockers outstanding; the unblock that retires the // last blocker applies it. Cleared by any disarm. #armPending = false; #timer: ReturnType<typeof setTimeout> | undefined; constructor(maxIdleMs: number, onExpire: () => void) { this.#maxIdleMs = maxIdleMs; this.#onExpire = onExpire; } /** * Arm on an idle that ends the turn; disarm otherwise. `user.tool_confirmation` * is neutral: it signals neither agent activity nor an idle, and its effect * on the clock flows through {@link block} / {@link unblock} instead — * disarming here would discard the pending arm the verdict is about to * settle. */ noteEvent(ev: { type: string; stop_reason?: { type?: string } }): void { if (ev.type === 'user.tool_confirmation') return; if (endsTurn(ev)) this.arm(); else this.disarm(); } /** Register gated work that must resolve before an idle countdown starts. */ block(toolUseId: string): void { this.#blockers.add(toolUseId); if (this.#timer !== undefined) { // Defensive: every caller disarms first (any event that routes a tool // call is itself a disarming event), but a countdown running when gated // work appears is stale evidence — the session is not idly waiting to // stop. Convert it into a pending arm rather than let it fire over the // gated call. this.#armPending = true; clearTimeout(this.#timer); this.#timer = undefined; } } /** * Retire gated work (a no-op for ids never blocked); applies a pending arm — * with a fresh full `maxIdleMs` window — once the last blocker retires. */ unblock(toolUseId: string): void { this.#blockers.delete(toolUseId); if (this.#blockers.size === 0 && this.#armPending) this.arm(); } /** * (Re)start the idle countdown — or, while blockers are outstanding, hold * the arm pending instead. Stopping then would drop a held call when its * verdict later arrives, or cut the runner off before a released call's * result can drive the next turn. */ arm(): void { if (this.#maxIdleMs <= 0) return; if (this.#blockers.size > 0) { this.#armPending = true; return; } this.#armPending = false; if (this.#timer !== undefined) clearTimeout(this.#timer); this.#timer = setTimeout(this.#onExpire, this.#maxIdleMs); } /** * Cancel the idle countdown and any pending arm. Blockers persist — they * track real outstanding work, retired only by {@link unblock}. */ disarm(): void { this.#armPending = false; if (this.#timer !== undefined) { clearTimeout(this.#timer); this.#timer = undefined; } } } /** * The sessions-side counterpart to `client.beta.messages.toolRunner`: an * async-iterable that attaches to a managed-agents session, executes every * incoming `agent.tool_use` and `agent.custom_tool_use` event against a local * tool registry, posts the matching result back (`user.tool_result` for the * former, `user.custom_tool_result` for the latter), and yields one * {@link DispatchedToolCall} per completed call. Server-side `agent.mcp_tool_use` * calls are not dispatched. Internally drives event-stream reconnect and result * posting. * * A call the server gated with `evaluated_permission: "ask"` (the `always_ask` * policy — or any value this SDK doesn't recognize, which fails closed) is held * until its `user.tool_confirmation` arrives: only an explicit `allow` runs it; * `deny` — or any verdict this SDK doesn't recognize, failing closed — is never * executed and posts nothing (the denial resolves the call server-side), but is * still yielded (`confirmation="deny"`, `posted=false`, `result=undefined`) so * the consumer can observe it. A held call — and a user-approved one still * dispatching — defers the `maxIdleMs` countdown, so an idle observed in the * meantime cannot stop the runner: it waits until the verdict arrives, the * session terminates, or the abort signal fires — pass * `AbortSignal.timeout(...)` for a wall-clock bound. * * Iteration ends when the session terminates (`session.status_terminated` / * `session.deleted`), when the consumer `break`s out of the loop or aborts the * supplied signal, or — once the session has gone idle with any `stop_reason.type` * but `"requires_action"` — when `maxIdleMs` elapses with no new event (any * new event resets that countdown; it re-arms on the next such idle; * `maxIdleMs <= 0` disables it). The `finally` branch drains any in-flight * tool calls and runs each tool's `close()` cleanup hook. It does *not* touch * the work-item lease — wrap it in an `EnvironmentWorker` if you need * heartbeating / force-stop. * * @example * ```ts * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; * * for await (const call of client.beta.sessions.events.toolRunner(work.data.id, { * tools: [...betaAgentToolset20260401({ workdir }), myTool], * })) { * console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`); * } * ``` */ export class SessionToolRunner implements AsyncIterable<DispatchedToolCall> { readonly client: Anthropic; readonly sessionId: string; readonly tools: ReadonlyArray<BetaRunnableTool>; readonly maxIdleMs: number; #consumed = false; readonly #controller: AbortController; readonly #detachExternal: () => void; readonly #requestOpts: BetaToolRunnerRequestOptions | undefined; readonly #toolByName: Map<string, BetaRunnableTool>; readonly #logger: Logger; readonly #seen = new Set<string>(); readonly #answered = new Set<string>(); // Confirmation gating (`always_ask` tools): `#confirmationVerdicts` records // every `user.tool_confirmation` verdict by `tool_use_id`; // `#awaitingConfirmation` holds the tool-call events whose // `evaluated_permission` is `ask` and whose verdict has not arrived — // released (or resolved as denied) by `#noteConfirmation` / the next // reconcile pass. Like `#seen` and `#answered`, `#confirmationVerdicts` is // per-session O(tool calls): recorded verdicts persist for the life of the run. readonly #confirmationVerdicts = new Map<string, 'allow' | 'deny'>(); readonly #awaitingConfirmation = new Map<string, DispatchedToolUseEvent>(); readonly #results = new AsyncQueue<DispatchedToolCall>(); #inFlightCount = 0; #sendRetryWindowMs = SEND_RETRY_WINDOW_MS; #onIdle: (() => void) | null = null; readonly #idleClock: IdleClock; /** The OpenTelemetry `Context` that was active where the loop started, the parent of each tool span. */ #otelContext: Context | undefined; /** * The ID of the Session's primary thread, whose tool call events have no thread ID. It is the thread * of the Session's first thread status event: a child thread starts after the primary thread has. */ #primaryThreadId: string | undefined; /** Whether the last read of the history succeeded, so that the stream's events continue it. */ #historyRead = false; constructor(sessionId: string, opts: SessionToolRunnerOptions) { this.client = opts.client; this.sessionId = sessionId; this.tools = opts.tools; this.maxIdleMs = opts.maxIdleMs ?? DEFAULT_MAX_IDLE_MS; this.#logger = loggerFor(opts.client); this.#toolByName = new Map(opts.tools.map((t) => [toolName(t), t])); this.#controller = new AbortController(); this.#detachExternal = linkAbort(opts.signal, this.#controller); this.#requestOpts = opts.requestOptions; this.#idleClock = new IdleClock(this.maxIdleMs, () => { this.#logger.info('session idle after its turn ended; stopping', { component: 'session-tool-runner', session_id: this.sessionId, max_idle_ms: this.maxIdleMs, }); this.#controller.abort(); }); } /** Read-only view of this runner's abort signal. */ get signal(): AbortSignal { return this.#controller.signal; } /** Abort the runner. Background tasks will wind down and `for await` will exit cleanly. */ abort(): void { this.#controller.abort(); } /** * @internal * `EnvironmentWorker` keeps this equal to the lease TTL each heartbeat * reports; applies to a send already retrying. */ _setSendRetryWindow(ms: number): void { this.#sendRetryWindowMs = ms; } async *[Symbol.asyncIterator](): AsyncIterator<DispatchedToolCall> { if (this.#consumed) { throw new AnthropicError('Cannot iterate over a consumed SessionToolRunner'); } this.#consumed = true; this.#otelContext = this.client._tracer ? context.active() : undefined; this.#logger.info('session tool runner starting', { component: 'session-tool-runner', session_id: this.sessionId, }); // The one background promise: drives the event stream and dispatches tools. // Its `.catch` aborts the controller so the main loop unwinds. const streamPromise = this.#streamLoop().catch((e) => { if (!this.#controller.signal.aborted) { this.#logger.error('stream loop failed', { error: String(e) }); } this.#controller.abort(); }); try { // Phase 1: yield results as they arrive. `next(signal)` resolves // `done: true` when the controller aborts — cancellation is handled in // the queue read, no outer `Promise.race` needed. while (true) { const next = await this.#results.next(this.#controller.signal); if (next.done) break; yield next.value; } // Phase 2: let the stream loop settle (and push any final results), then // drain whatever is still queued before closing. await streamPromise; let pending: DispatchedToolCall | undefined; while ((pending = this.#results.tryShift()) !== undefined) { yield pending; } } finally { this.#controller.abort(); this.#idleClock.disarm(); // Re-await defensively in case the consumer broke out of phase 1 before // phase 2 ran — a no-op if it already settled. await streamPromise; try { await this.#drain(); } catch (e) { this.#logger.warn('drain failed', { error: String(e) }); } this.#results.close(); for (const t of this.tools) { try { // `close` is typed `() => Promisable<void>`, so a single `await` // covers both the sync and async return. await t.close?.(); } catch (e) { this.#logger.warn('tool.close failed', { tool: toolName(t), error: String(e) }); } } // Detach from the external signal so the consumer can drop their signal // reference without leaking this iterator instance. this.#detachExternal(); } } // ===== request options ===== /** * Request options for every helper-issued call: the caller's `requestOptions` * (custom proxy headers etc.) with the helper telemetry header stamped on and * the runner's own abort signal forced last so it always owns cancellation. */ #requestOptions(): RequestOptions { return { ...this.#requestOpts, headers: buildHeaders([helperHeader('session-tool-runner'), this.#requestOpts?.headers]), signal: this.#controller.signal, }; } // ===== event stream ===== async #streamLoop(): Promise<void> { const ctrl = this.#controller; let backoff = STREAM_BACKOFF_START_MS; while (!ctrl.signal.aborted) { try { // Establish the event stream *before* reconciling history, so an event // emitted in the gap between listing and attaching is buffered on the // stream rather than lost. `seen`/`answered` dedup any event that shows // up both in the reconcile pass and on the live stream. const stream = await this.client.beta.sessions.events.stream( this.sessionId, {}, this.#requestOptions(), ); await this.#reconcile(); for await (const ev of stream) { backoff = STREAM_BACKOFF_START_MS; if (await this.#handleStreamEvent(ev)) return; } } catch (e) { // An abort throws to unwind the caller (the iterator's `streamPromise` // `.catch`) rather than returning early and letting it carry on. ctrl.signal.throwIfAborted(); if (isFatal4xx(e)) { this.#logger.error('permanent stream failure, shutting down', { error: String(e) }); ctrl.abort(); throw e; } this.#logger.warn('stream disconnected, reconnecting', { error: String(e), backoff_ms: backoff, }); } ctrl.signal.throwIfAborted(); await sleep(backoff, ctrl.signal); backoff = Math.min(backoff * 2, STREAM_BACKOFF_CAP_MS); } } /** * Read full history before dispatching so a `tool_use` whose result appears * later in the same history is not re-executed. Runs after the live stream is * already attached (see {@link SessionToolRunner.#streamLoop}). */ async #reconcile(): Promise<void> { const ctrl = this.#controller; const pending: DispatchedToolUseEvent[] = []; let lastEndedTurn = false; this.#historyRead = false; try { for await (const ev of this.client.beta.sessions.events.list( this.sessionId, { limit: 1000 }, this.#requestOptions(), )) { this.#ingestHistory(ev, pending); // Neutral here as in `IdleClock.noteEvent`: a verdict is neither agent // activity nor an idle. if (ev.type !== 'user.tool_confirmation') lastEndedTurn = endsTurn(ev); } } catch (e) { // An abort throws to unwind the caller; a real list failure is // non-fatal — undo the speculative `seen` entries and let `#streamLoop` // carry on with the live stream. ctrl.signal.throwIfAborted(); this.#logger.warn('reconcile list failed', { error: String(e) }); // If list itself failed, undo the speculative `seen` entries so the next // reconcile pass (or the live stream) can pick them up. Leave the idle // timer untouched — the history we read may be incomplete. for (const ev of pending) this.#seen.delete(ev.id); return; } this.#historyRead = true; const unanswered = pending.filter((ev) => !this.#answered.has(ev.id)); // Disarm before routing: `#execute` runs inline here, so a timer left armed // from before the reconnect could fire over an in-flight tool. this.#idleClock.disarm(); for (const ev of unanswered) await this.#routeToolEvent(ev); // A held call's verdict is normally applied by the routing pass above; if // its tool_use fell outside the listed window the pass never saw it, so // apply the verdict to the held copy here. for (const held of [...this.#awaitingConfirmation.values()]) { const verdict = this.#confirmationVerdicts.get(held.id); if (verdict !== undefined) await this.#applyVerdict(held, verdict); } // Routing resolves denied calls in place (marking them answered) and holds // ask-gated calls for their `user.tool_confirmation`. If the most recent // event in history ends the turn and no tool work is outstanding, // the session is done — arm the idle clock so the runner stops even if that // idle arrived during a disconnect. A held call is not outstanding // here: it blocks the clock, so this arm stays pending until the verdict // (and, for an allow, the dispatch it releases) resolves it. const outstanding = unanswered.filter( (ev) => !this.#answered.has(ev.id) && !this.#awaitingConfirmation.has(ev.id), ); if (lastEndedTurn && outstanding.length === 0) this.#idleClock.arm(); else this.#idleClock.disarm(); } #ingestHistory(ev: BetaManagedAgentsSessionEvent, pending: DispatchedToolUseEvent[]): void { // The history starts with the Session's first event. this.#notePrimaryThread(ev); if (ev.type === 'agent.tool_use' || ev.type === 'agent.custom_tool_use') { // Mark the event seen so a replay on the live stream is not dispatched // twice, but decide whether it still needs executing from `answered`, not // `seen`: a call whose result post failed is seen-but-unanswered, and must // be retried on the next reconcile pass rather than silently dropped. this.#seen.add(ev.id); if (!this.#answered.has(ev.id)) pending.push(ev); } else if (ev.type === 'user.tool_result') { this.#answered.add(ev.tool_use_id); } else if (ev.type === 'user.custom_tool_result') { this.#answered.add(ev.custom_tool_use_id); } else if (ev.type === 'user.tool_confirmation') { // Record the verdict only, before the pending pass, so a call whose // confirmation appears later in the same history routes with its verdict // already known. Releasing a held call here as well would dispatch it a // second time when the routing pass reaches its tool_use event. if (!this.#answered.has(ev.tool_use_id)) this.#confirmationVerdicts.set(ev.tool_use_id, ev.result); } } /** Takes the primary thread's ID from `ev` when it is the Session's first thread status event. */ #notePrimaryThread(ev: BetaManagedAgentsSessionEvent | BetaManagedAgentsStreamSessionEvents): void { // Only the spans have a use for it. if (!this.client._tracer || this.#primaryThreadId !== undefined) return; switch (ev.type) { case 'session.thread_status_running': case 'session.thread_status_idle': case 'session.thread_status_rescheduled': case 'session.thread_status_terminated': this.#primaryThreadId = ev.session_thread_id; } } /** Returns true when the runner should exit. */ async #handleStreamEvent(ev: BetaManagedAgentsStreamSessionEvents): Promise<boolean> { this.#idleClock.noteEvent(ev); // Without the history, the stream may have begun after the Session's first thread status event. if (this.#historyRead) this.#notePrimaryThread(ev); switch (ev.type) { case 'agent.tool_use': case 'agent.custom_tool_use': if (!this.#seen.has(ev.id)) { this.#seen.add(ev.id); await this.#routeToolEvent(ev); } return false; case 'user.tool_confirmation': await this.#noteConfirmation(ev); return false; case 'user.tool_result': this.#answered.add(ev.tool_use_id); return false; case 'user.custom_tool_result': this.#answered.add(ev.custom_tool_use_id); return false; case 'session.status_terminated': case 'session.deleted': this.#logger.info('session terminated', { component: 'session-tool-runner', session_id: this.sessionId, }); this.#controller.abort(); return true; default: return false; } } // ===== confirmation gating (always_ask tools) ===== /** * Dispatch `ev`, honoring its evaluated permission. A call the server gated * (`evaluated_permission == "ask"`) is held until its `user.tool_confirmation` * arrives. Fails closed: only an explicit `allow` verdict releases a gated * call; a server-side `deny` overrides any recorded verdict; an unrecognized * permission is held like `ask` and an unrecognized verdict is denied. */ async #routeToolEvent(ev: DispatchedToolUseEvent): Promise<void> { // `getattr`-style read: today only `agent.tool_use` carries // `evaluated_permission`, but if it ever lands on `agent.custom_tool_use` // the gate must keep failing closed rather than dispatch by event type. const permission = (ev as { evaluated_permission?: 'allow' | 'ask' | 'deny' }).evaluated_permission; // A server-side `deny` overrides any (stray) recorded verdict. const verdict = permission === 'deny' ? 'deny' : this.#confirmationVerdicts.get(ev.id); if (verdict === undefined) { if (permission === undefined || permission === 'allow') { await this.#execute(ev, undefined); } else if (!this.#awaitingConfirmation.has(ev.id)) { // "ask" — or a permission this SDK does not recognize, which must not // dispatch unconfirmed — waits for the user's verdict. (Already-held: a // reconcile after reconnect re-routes the call; keep the existing hold.) this.#logger.info('tool call awaiting confirmation; holding', { component: 'session-tool-runner', session_id: this.sessionId, tool: ev.name, tool_use_id: ev.id, }); this.#awaitingConfirmation.set(ev.id, ev); this.#idleClock.block(ev.id); } return; } await this.#applyVerdict(ev, verdict); } /** Record an allow/deny verdict and release the held call it gates, if any. */ async #noteConfirmation(ev: { tool_use_id: string; result: 'allow' | 'deny' }): Promise<void> { this.#confirmationVerdicts.set(ev.tool_use_id, ev.result); const held = this.#awaitingConfirmation.get(ev.tool_use_id); // Nothing held: the verdict gates a call this runner has not seen yet (or // one it never gates, e.g. an `agent.mcp_tool_use`). Keeping it in // `#confirmationVerdicts` lets a later route of that call resolve instantly. if (held === undefined) return; await this.#applyVerdict(held, ev.result); } /** * Dispatch or resolve a gated call according to its verdict. * * The idle-clock blocker accounting lives here: a denial retires the held * call's blocker, while an allow keeps one on the call — taking it now if the * verdict was already known when the call was routed, so it was never held — * until `#execute` has finished with it. The countdown must not run over * gated work that is still in flight. */ async #applyVerdict(ev: DispatchedToolUseEvent, verdict: 'allow' | 'deny'): Promise<void> { const wasHeld = this.#awaitingConfirmation.delete(ev.id); if (verdict === 'allow') { this.#logger.info('tool call confirmed', { component: 'session-tool-runner', session_id: this.sessionId, tool: ev.name, tool_use_id: ev.id, }); if (!wasHeld) this.#idleClock.block(ev.id); try { await this.#execute(ev, 'allow'); } finally { // The approved call is fully disposed of (executed, or moot because it // was answered elsewhere) — the sole place an allow's blocker retires. this.#idleClock.unblock(ev.id); } return; } // "deny" — or any value other than an explicit "allow" (fail closed). The // denial resolves the call server-side, so mark it answered and yield it // (nothing ran, nothing posted). if (wasHeld) this.#idleClock.unblock(ev.id); this.#answered.add(ev.id); this.#logger.info('tool call denied; not executing', { component: 'session-tool-runner', session_id: this.sessionId, tool: ev.name, tool_use_id: ev.id, }); this.#surfaceCall({ event: ev, toolUseId: ev.id, name: ev.name, isError: false, posted: false, confirmation: 'deny', }); } /** * Yield `call` to the consumer. A closed queue — the consumer broke out of * the iterator — makes `push` a no-op returning false; the underlying work * already happened, so only the observability event is lost. */ #surfaceCall(call: DispatchedToolCall): void { this.#results.push(call); } // ===== tool execution ===== async #execute(ev: DispatchedToolUseEvent, confirmation: 'allow' | undefined): Promise<void> { if (this.#answered.has(ev.id)) return; this.#logger.info('executing tool', { component: 'session-tool-runner', session_id: this.sessionId, tool: ev.name, tool_use_id: ev.id, }); this.#inFlightCount++; try { const tool = this.#toolByName.get(ev.name); if (!tool) { // Skip (split-client partial fulfilment): a name this runner // is not registered for belongs to the other client servicing this // session (typically the customer's app backend handling custom tools). // Post NO result, do not mark it answered, and leave the tool_use_id // pending for its owner — claiming it would corrupt the conversation. // Still yield the call so the consumer can observe the unowned // dispatch; nothing was sent, so `posted`/`isError` stay false and no // `result` event is populated. The id stays unanswered, so reconcile // keeps it out of the idle/end-turn accounting and re-surfaces it after // a reconnect until its owner answers it. this.#logger.info('tool not owned by this runner; leaving the tool_use_id pending for its owner', { component: 'session-tool-runner', session_id: this.sessionId, tool: ev.name, tool_use_id: ev.id, }); // The approval kept the idle countdown pending on this call. Drop it // instead of starting it: the owner still has to answer. if (confirmation === 'allow') this.#idleClock.disarm(); this.#surfaceCall({ event: ev, toolUseId: ev.id, name: ev.name, isError: false, posted: false, confirmation, }); return; } let content: SessionContentBlock[]; let isError: boolean; // The default is the Session thread ID, which is the conversation ID of the platform's spans. // Only a child thread's call event has it, so a call without one is the primary thread's. const conversationId = this.#requestOpts?.openTelemetry?.conversationId ?? (ev.session_thread_id || this.#primaryThreadId); // Per-tool controller: aborts on the runner's own signal *or* the // per-tool timeout, so an in-flight tool stops promptly when the runner // is aborted instead of running until the timeout. const toolCtrl = new AbortController(); const detachTool = linkAbort(this.#controller.signal, toolCtrl); const timer = setTimeout(() => toolCtrl.abort(), TOOL_TIMEOUT_MS); try { // Pass the source `agent.tool_use` / `agent.custom_tool_use` event // straight through as the run context's `toolUse` — it is a union // member of `BetaToolUse`, no Messages-block adapter needed. const spanOptions = { signal: this.#controller.signal, conversationId, sessionID: this.sessionId, parentContext: this.#otelContext, }; const outcome = await withToolUseSpan(this.client, tool, ev, spanOptions, async () => { const toolOutcome = await runRunnableTool(tool, ev.input, { toolUse: ev, toolUseBlock: ev, signal: toolCtrl.signal, }); const sent = toSessionContent(toolOutcome.content); // A span keeps a text block whole, so it gets a block that is sent as its JSON as the block itself. const recorded = !this.client._tracer ? sent : sent.map((block, i) => { const original = typeof toolOutcome.content === 'string' ? undefined : toolOutcome.content[i]; return original && original.type !== 'text' && block.type === 'text' ? original : block; }); return { sent, content: recorded, isError: toolOutcome.isError }; }); content = outcome.sent; isError = outcome.isError; } finally { clearTimeout(timer); detachTool(); } // Answer with the result event that matches the call kind: a // `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result` // for an `agent.custom_tool_use`. Posting the wrong one leaves the call // unanswered and the session stuck. const result = buildResultEvent(ev, isError, content); const posted = await this.#sendResult(result, ev.id, conversationId); this.#surfaceCall({ event: ev, result, toolUseId: ev.id, name: ev.name, isError, posted, confirmation, }); } finally { this.#inFlightCount--; if (this.#inFlightCount === 0) this.#onIdle?.(); } } async #sendResult( result: DispatchedToolResultParams, toolUseId: string, conversationId: string | undefined, ): Promise<boolean> { const ctrl = this.#controller; const start = Date.now(); let lastErr: unknown; let attempt = 0; while (true) { attempt++; // An abort throws to unwind the caller rather than returning a // `posted: false` result the iterator would carry on past. ctrl.signal.throwIfAborted(); try { await this.client.beta.sessions.events.send( this.sessionId, { events: [result] }, { ...this.#requestOptions(), openTelemetry: { conversationId } }, ); this.#answered.add(toolUseId); return true; } catch (e) { lastErr = e; // Only short-circuit on a permanent 4xx; 408/409/429 deserve the // remaining retries (aligned with the core client's retry policy). if (isFatal4xx(e)) break; const remainingMs = this.#sendRetryWindowMs - (Date.now() - start); if (remainingMs <= 0) break; const waitMs = Math.min( applyJitter(backoff(attempt - 1, SEND_BACKOFF_START_MS, SEND_BACKOFF_CAP_MS)), remainingMs, ); this.#logger.warn('tool result send failed; retrying', { tool_use_id: toolUseId, attempt, backoff_ms: waitMs, error: String(e), }); await sleep(waitMs, ctrl.signal); } } this.#logger.error('failed to send tool result', { tool_use_id: toolUseId, attempts: attempt, error: String(lastErr), }); return false; } /** Wait (bounded) for in-flight tool executions to finish during teardown. */ async #drain(): Promise<void> { if (this.#inFlightCount === 0) return; await Promise.race([new Promise<void>((r) => (this.#onIdle = r)), sleep(DRAIN_TIMEOUT_MS)]); this.#onIdle = null; if (this.#inFlightCount > 0) { this.#logger.warn('drain timeout exceeded'); } } } /** * Build the result event that answers `ev`: a `user.tool_result` for a builtin * `agent.tool_use`, a `user.custom_tool_result` for a custom * `agent.custom_tool_use`. The two `(use, result)` pairs are distinct API event * types and must be matched exactly — a `user.tool_result` does not answer a * custom tool call. */ function buildResultEvent( ev: DispatchedToolUseEvent, isError: boolean, content: SessionContentBlock[], ): DispatchedToolResultParams { if (ev.type === 'agent.custom_tool_use') { return { type: 'user.custom_tool_result', custom_tool_use_id: ev.id, is_error: isError, content }; } return { type: 'user.tool_result', tool_use_id: ev.id, is_error: isError, content }; } // The Messages-API tool-result block union is wider than the Sessions-API // tool_result content union; pass through text/image/document and stringify // anything else so a BetaRunnableTool authored for toolRunner still works here. function toSessionContent(content: string | Array<BetaToolResultContentBlockParam>): SessionContentBlock[] { if (typeof content === 'string') return [{ type: 'text', text: content || '(no output)' }]; const out = content.map((b): SessionContentBlock => { if (b.type === 'text') return { type: 'text', text: b.text || '(no output)' }; if (b.type === 'image' || b.type === 'document') return b as SessionContentBlock; if (b.type === 'search_result') { // The Messages `search_result` block param maps field-for-field onto the // Sessions `BetaManagedAgentsSearchResultBlock`; map it explicitly rather // than letting it fall through to the JSON.stringify branch (which would // bury a structured result inside a text block). `citations` is required // on the Sessions side and optional on the Messages side — default the // flag to `false` when the producer left it unset. return { type: 'search_result', source: b.source, title: b.title, content: b.content.map((c) => ({ type: 'text', text: c.text })), citations: { enabled: b.citations?.enabled ?? false }, }; } return { type: 'text', text: JSON.stringify(b) }; }); return out.length > 0 ? out : [{ type: 'text', text: '(no output)' }]; }