@anthropic-ai/sdk
Version:
The official TypeScript library for the Anthropic API
429 lines (382 loc) • 18.1 kB
text/typescript
/**
* 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,
});
}