UNPKG

@anthropic-ai/sdk

Version:
429 lines (382 loc) • 18.1 kB
/** * Shared building blocks for each toolset family's class. */ import { APIUserAbortError } from '../../../core/error'; import type { Promisable } from '../../tools/BetaRunnableTool'; import type { ToolsetFamily } from './family'; import { throwIfPropagates } from './run'; import { ToolError } from '../../tools/ToolError'; import { ConfirmDeclinedError, ConfirmFailedError, DisabledMemberError, ToolsetConfigError, UnavailableMemberError, UnknownMemberError, } from './errors'; import type { BetaToolConfigs, BetaToolsetCallContext } from './hooks'; import { FIELD_MAX, cutPoints, errorTextContent } from './sanitize'; import type { BetaBase64ImageSource, BetaTextBlockParam } from '../../../resources/beta'; // --- results --------------------------------------------------------------------------------------------- /** A screenshot or zoom image from either toolset, rendered as one image block. */ export interface BetaScreenshotResult { /** Base64 image bytes, without a `data:` prefix. */ data: string; /** Defaults to `'image/png'`. */ mediaType?: BetaBase64ImageSource['media_type'] | undefined; } // --- the member registry --------------------------------------------------------------------------------- /** * The kinds of value a member hands back, each rendered by `render.ts`. `point` is a cursor position. `none` is a * pure action, rendered from its confirmation template and then the line it returned, if any. */ export type ResultKind = 'navigate' | 'screenshot' | 'text' | 'tab' | 'tabs' | 'point' | 'none'; /** * One member of a toolset: what the generated types do not carry about it, kept by hand in the family's registry. * `Kind` is the result kinds the family's rows use. */ export interface ToolsetMember<Name extends string = string, Kind extends ResultKind = ResultKind> { readonly name: Name; readonly result: Kind; /** * The confirmation the model reads after a pure action, with the placeholders `render.ts` knows (`{text}`, * `{duration}`, …; `{ref}` reads `target.ref`) filled from the input. A line the action returned follows it. */ readonly text?: string; readonly enabledByDefault: boolean; } /** * A registry row as a family writes it: result kind, confirmation template, default-enabled flag. A family writes its * rows `as const`, so each member's result kind is a literal the result types below can read. */ export type MemberRow = readonly [ResultKind, string | null, boolean]; /** The declared result type (`Results[K]`) of every member whose row in `Rows` gives result kind `R`. */ export type KindResultOf< Name extends string, Rows extends { readonly [K in Name]: MemberRow }, Results extends { readonly [K in Name]: unknown }, R extends ResultKind, > = { [K in Name]: Rows[K][0] extends R ? Results[K] : never }[Name]; /** * A member's result with its result kind, which says how to read it: for each kind `R` the family uses, `value` is * what that family's members of that kind declare. A family exports its own instantiation and a `typedResult` that * builds one, the one place the driver's declared result type is taken at its word. */ export type TypedResultOf< Name extends string, Rows extends { readonly [K in Name]: MemberRow }, Results extends { readonly [K in Name]: unknown }, R extends ResultKind = Rows[Name][0], > = { [P in R]: { readonly kind: P; readonly value: KindResultOf<Name, Rows, Results, P> } }[R]; /** The member table from a family's rows, in the order of `names`. */ export function buildMembers<Name extends string, Rows extends { readonly [K in Name]: MemberRow }>( names: readonly Name[], rows: Rows, ): ReadonlyMap<Name, ToolsetMember<Name, Rows[Name][0]>> { return new Map( names.map((name) => { const [result, text, enabledByDefault] = rows[name]; return [name, { name, result, ...(text !== null ? { text } : {}), enabledByDefault }]; }), ); } /** A family's member registry, as the parts of a toolset class shared by every family read it. */ export interface Registry<Name extends string, M extends ToolsetMember<Name> = ToolsetMember<Name>> { /** The family stamped on member blocks, matched against `tool_use.toolset_name`. */ readonly family: ToolsetFamily; /** Every member name, in canonical order. */ readonly names: readonly Name[]; readonly members: ReadonlyMap<Name, M>; /** Members the API withholds unless `configs` enables them. */ readonly defaultDisabled: ReadonlySet<string>; } /** The registry row for a model-supplied name, or undefined when it names no member. */ export function memberOf<Name extends string, M extends ToolsetMember<Name>>( registry: Registry<Name, M>, name: string, ): M | undefined { return registry.members.get(name as Name); } /** A wire entry's `configs` (any generated `*ToolsetConfigs`), read as `{ [member]: { enabled? } }`. */ export type MemberConfigs = object | undefined; /** * Whether `configs` leave `name` enabled, failing closed when the answer is unclear. * * Only a real boolean `enabled` turns a member on or off. Any other value fails closed, so a malformed * entry (a `"false"` string that came through JSON or an environment variable) can never turn on a * member that is off by default. */ export function isEnabled( name: string, configs: MemberConfigs, defaultDisabled: ReadonlySet<string>, ): boolean { // Own properties only: `in` would walk the prototype chain, so a member named `constructor` would find an entry. const entry = configs != null && Object.prototype.hasOwnProperty.call(configs, name) ? (configs as Record<string, { enabled?: boolean | null } | null | undefined>)[name] : undefined; // an entry that is neither an object nor null (`{ navigate: false }`) reads as off, not as the member's default if (entry != null && typeof entry !== 'object') return false; const enabled = entry?.enabled; if (enabled === true) return true; if (enabled !== undefined && enabled !== null) return false; return !defaultDisabled.has(name); } // --- constructor options ----------------------------------------------------------------------------------- /** The constructor options every family takes; a family's own options type adds to them. */ export interface ToolsetOptions<Configs extends object, Confirm> { configs?: Configs | undefined; confirm?: Confirm | undefined; toolConfigs?: BetaToolConfigs | undefined; } /** What the dispatch gate reads: the wire configs, and which members the class serves and the caller configured. */ export interface ServedMembers { /** What `toJSON` sends: the caller's `configs` plus `enabled: false` for every member the subclass does not serve. */ readonly wireConfigs: MemberConfigs; /** The members a sampled call may reach: the implemented ones, or all of them for an execute-only subclass. */ readonly served: ReadonlySet<string>; /** Members the caller's own `configs` mention. */ readonly configured: ReadonlySet<string>; } /** The shared constructor options, validated and resolved once at construction. */ export interface ResolvedToolsetOptions<Configs extends object, Confirm> extends ServedMembers { readonly wireConfigs: Configs | undefined; readonly confirm: Confirm | undefined; /** Spread onto the `tools[]` entry `toJSON` returns: your `toolConfigs`, copied and frozen at construction. */ readonly toolConfigs: BetaToolConfigs | undefined; } function deepFreeze<T>(value: T): T { if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { for (const v of Object.values(value)) deepFreeze(v); Object.freeze(value); } return value; } /** The prototype property a member's method is defined under (`type` is `type_`). */ function methodKey(name: string): string { return name === 'type' ? 'type_' : name; } /** Whether `instance`'s class chain overrides `key` before reaching `base`, by own-property lookup. Nothing is called. */ export function overrides(instance: object, base: object, key: string): boolean { let proto: object | null = Object.getPrototypeOf(instance); while (proto !== null && proto !== base) { if (Object.prototype.hasOwnProperty.call(proto, key)) return true; proto = Object.getPrototypeOf(proto); } return false; } function wireConfigs<Configs extends object>( names: readonly string[], configs: Configs | undefined, served: ReadonlySet<string>, ): Configs | undefined { // The copy is what is both checked and sent, so whatever `configs` holds that JSON drops (an entry on a // prototype, a non-enumerable one) can neither pass the check nor reach the wire: the member stays at its default. const wire: Record<string, Record<string, unknown>> = JSON.parse(JSON.stringify(configs ?? {})); const unserved = names.filter((name) => !served.has(name) && wire[name]?.['enabled'] === true); if (unserved.length) { // Offering the model a member that can only ever answer "not available" is a configuration mistake: // implement the method (a subclass that overrides execute alone serves every member). throw new ToolsetConfigError( `configs enables member(s) this toolset does not implement: ${JSON.stringify(unserved.sort())} ` + '(override the member method; a subclass that overrides only execute() serves every member)', ); } for (const name of names) { if (served.has(name)) continue; wire[name] = { ...wire[name], enabled: false }; } // Frozen: this object is what the dispatch gate reads and what toJSON() copies, and the runner snapshots the wire // entry when it is created. A later mutation would let the gate accept a member the API was told is off. return Object.keys(wire).length ? (deepFreeze(wire) as Configs) : undefined; } /** * Check and resolve the options every family shares, once, at construction. A family checks its own options in its * own class. */ export function resolveToolsetOptions< Configs extends object, Confirm extends (ctx: never) => Promisable<boolean>, >( registry: Registry<string>, instance: object, base: object, options: ToolsetOptions<Configs, Confirm>, ): ResolvedToolsetOptions<Configs, Confirm> { // a class that overrides execute serves every member, whether or not it also implements member methods const served = new Set<string>( overrides(instance, base, 'execute') ? registry.names : registry.names.filter((name) => overrides(instance, base, methodKey(name))), ); const wire = wireConfigs(registry.names, options.configs, served); const confirm = options.confirm; // Copied once and frozen, like the wire configs: a later change to the object you passed never reaches a request. const toolConfigs: BetaToolConfigs | undefined = options.toolConfigs === undefined ? undefined : deepFreeze(JSON.parse(JSON.stringify(options.toolConfigs))); if (toolConfigs != null && 'configs' in toolConfigs) { // refused rather than left to silently replace, or be replaced by, what the `configs` option writes there throw new ToolsetConfigError( 'toolConfigs sets "configs", which the toolset writes itself: pass member settings as the configs option', ); } return { wireConfigs: wire, served, configured: new Set(Object.keys(options.configs ?? {})), confirm, toolConfigs, }; } // --- a call's stages --------------------------------------------------------------------------------------- /** One member call in flight: what each stage produced, for the stages after it. `Result` is the family's typed result. */ export class MemberCall<M extends ToolsetMember = ToolsetMember, Input = unknown, Result = unknown> { member: M | undefined = undefined; input: Input | undefined = undefined; result: Result | undefined = undefined; error: ToolError | undefined = undefined; constructor( readonly ctx: BetaToolsetCallContext, readonly name: string, readonly rawInput: unknown, ) {} get toolUseId(): string | undefined { return this.ctx.toolUse?.id; } } /** * Stage one of a call: the registry row for `name`, or the refusal the model reads (an unknown name, a * member this application disabled, or one the driver does not implement). */ export function resolveMember<Name extends string, M extends ToolsetMember<Name>>( registry: Registry<Name, M>, options: ServedMembers, name: string, ): M { const member = memberOf(registry, name); if (member === undefined) throw new UnknownMemberError(name, registry.family); if (!isEnabled(member.name, options.wireConfigs, registry.defaultDisabled)) { if (!options.served.has(member.name) && !options.configured.has(member.name)) { throw new UnavailableMemberError(member.name, registry.family); } throw new DisabledMemberError(member.name); } return member; } /** * `execute`'s default body: the member's method on `instance`, resolved through the registry rather than by property * name, since `instance[name]` on raw model output would reach `constructor`, `toString` or any other property. */ export async function dispatchMember<Name extends string>( registry: Registry<Name>, instance: object, ctx: BetaToolsetCallContext, name: Name, input: unknown, ): Promise<unknown> { if (registry.members.get(name) === undefined) throw new UnknownMemberError(name, registry.family); const method = (instance as unknown as Record<string, (c: BetaToolsetCallContext, i: unknown) => unknown>)[ methodKey(name) ]!; return await method.call(instance, ctx, input); } /** * The text blocks of a `ToolError`'s content: an is_error result may hold text only (`errorTextContent`). An empty one * still signals that the call failed. */ export function errorContent(error: ToolError): BetaTextBlockParam[] { const content = errorTextContent(error.content); if (typeof content === 'string') return content === '' ? [] : [{ type: 'text', text: content }]; return content; } /** * `error` with each text block run through `check` and then bounded to the field limit. The same object when nothing * changed. The bound comes after the check: a driver's exception can embed a page-sized payload, and cutting first * would let a page place a path across the cut where the check reads a shorter one. */ export async function checkedError( error: ToolError, check: (text: string) => Promisable<string> = (text) => text, ): Promise<ToolError> { const content = errorContent(error); const checked = await Promise.all( content.map(async (block) => ({ ...block, text: cutPoints(await check(block.text), FIELD_MAX) })), ); if (checked.every((block, i) => block.text === content[i]!.text)) return error; return new ToolError(checked); } /** * Ask `confirm` about the call awaiting approval: returns when it approved, throws the refusal the model reads * otherwise. A `ToolError` it throws is relayed as-is. Any other exception means the prompt failed, and refuses the * call rather than ending the run. Anything but `true` declines the call. */ export async function confirmOrThrow<Context>( confirm: (ctx: Context) => Promisable<boolean>, ctx: Context, call: MemberCall, ): Promise<void> { const name = call.member!.name; let returned: unknown; try { returned = await confirm(ctx); } catch (error) { throwIfPropagates(error, call.ctx); if (error instanceof ToolError) throw error; throw new ConfirmFailedError(name); } if (returned !== true) throw new ConfirmDeclinedError(name); } // --- one call at a time ------------------------------------------------------------------------------------ /** The queue every top-level call of one toolset chains behind: calls run one at a time, in arrival order. */ export class CallQueue { #chain: Promise<void> = Promise.resolve(); /** Run `turn` once the calls ahead of it have settled; a call whose `signal` aborts meanwhile leaves the queue at once. */ enqueue<T>(signal: AbortSignal | null | undefined, turn: () => Promise<T>): Promise<T> { const previous = this.#chain; let ready: Promise<unknown> = previous; let onAbort = (): void => undefined; if (signal) { // A queued call whose run is aborted leaves the queue at once rather than when its turn comes. const aborted = new Promise<never>((_, reject) => { onAbort = () => reject(new APIUserAbortError()); if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); }); aborted.catch(() => undefined); ready = Promise.race([previous, aborted]); } const run = ready.then(turn); if (signal) { // The listener goes when the call settles, so a long-lived signal shared by many calls does not collect one each. run.finally(() => signal.removeEventListener('abort', onAbort)).catch(() => undefined); } // The queue still waits for the call ahead even when this one gave up waiting for it, and a failed call does not // break it. this.#chain = Promise.allSettled([previous, run]).then(() => undefined); return run; } /** Settles once every call accepted so far has settled, failed ones included. */ drain(): Promise<void> { return this.#chain; } } /** * Wire `Symbol.asyncDispose` on a toolset class's prototype to its `close()`, where the engine provides the symbol. * The tsconfig targets ES2020, so there is no typed `AsyncDisposable` member: `await using` works from JavaScript on * those engines, and TypeScript callers use `close()`. */ export function wireAsyncDispose(prototype: { close(): Promise<void> }): void { const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose; if (!asyncDispose || Object.prototype.hasOwnProperty.call(prototype, asyncDispose)) return; Object.defineProperty(prototype, asyncDispose, { // Through `this`, so a subclass's `close()` override is what runs. value: function (this: { close(): Promise<void> }) { return this.close(); }, configurable: true, writable: true, }); }