@anthropic-ai/sdk
Version:
The official TypeScript library for the Anthropic API
280 lines (249 loc) • 11.3 kB
text/typescript
/**
* Turns what a browser or computer member returned into `tool_result` content, and a `BetaBrowserState` into the
* `browser_state` block.
*
* The registry row gives each member's result kind, and the renderer reads the fields it needs off
* that result. A pure action renders as its fixed acknowledgment, followed by the line it
* returned, if any. The tab-management members render no content of their own: the API accepts the
* `browser_state` block as their whole result and produces the text the model reads from it.
*/
import type {
BetaBrowserStateBlockParam,
BetaBrowserStateChange,
BetaBrowserStateTabEntry,
BetaImageBlockParam,
BetaTextBlockParam,
} from '../../../resources/beta';
import type { BetaToolsetContent } from '../../tools/BetaRunnableToolset';
import { ToolsetUsageError } from './errors';
import type { ToolsetMember } from './core';
import type { BetaFilePolicy } from './hooks';
import type { ComputerTypedResult } from './computer-members';
import type { TypedResult } from './registry';
import type { BetaDialogDismissed, BetaNavigationRefused } from './results';
import { boundedTabURL, cutPoints, oneLine, wellFormed } from './sanitize';
import { checkNever } from '../../../internal/utils/values';
import type { Report } from './pipeline';
/**
* The line the model reads, once, for any number of `BetaNavigationRefused` entries in a report. Nothing else in an
* entry is echoed: it is shaped by the page that was refused.
*/
export const NAVIGATION_REFUSED_LINE = 'A navigation was refused.';
function inputField(input: unknown, path: string): string {
const value = path
.split('.')
.reduce<unknown>((parent, part) => (parent as Record<string, unknown> | undefined)?.[part], input);
return oneLine(String(value ?? '')); // a number or boolean as JSON spells it, so the model reads `Waited 2s.` / `true`
}
const TEMPLATE_FIELDS: Record<string, string> = {
scroll_direction: 'scroll_direction',
text: 'text',
duration: 'duration',
ref: 'target.ref',
};
const TEMPLATE_TOKEN_RE = /* @__PURE__ */ new RegExp(
/* @__PURE__ */ Object.keys(TEMPLATE_FIELDS)
.map((key) => `\\{${key}\\}`)
.join('|'),
'g',
);
/**
* Fill a pure action's template from its input in a single pass, so a model-supplied value such as
* `"{ref}"` stays data and is never re-read as a placeholder.
*/
function confirmation(template: string, input: unknown): string {
return template.replace(TEMPLATE_TOKEN_RE, (token) =>
inputField(input, TEMPLATE_FIELDS[token.slice(1, -1)]!),
);
}
export function text(value: string): BetaTextBlockParam {
return { type: 'text', text: value };
}
/**
* The line a pure action returned, as written (not yet checked, folded or cut). `undefined` when it returned none (or
* anything but a non-empty string). The browser pipeline runs the error-text check on it first; `renderResult` then
* folds and bounds it.
*/
export function actionLine(member: ToolsetMember, result: RenderedResult | undefined): string | undefined {
if (member.text === undefined || result?.kind !== 'none' || typeof result.value !== 'string')
return undefined;
return result.value || undefined;
}
/** A result the renderer reads: either family's typed result, by its result kind. */
export type RenderedResult = TypedResult | ComputerTypedResult;
/** The content blocks for a member's result, by the registry's result kind. */
export function renderResult(
member: ToolsetMember,
input: unknown,
result: RenderedResult,
): BetaToolsetContent {
switch (result.kind) {
case 'none': {
if (member.text === undefined) return [];
// A line the action returned is its own block after the acknowledgment's, folded to one line and bounded here.
const lines = [confirmation(member.text, input)];
const line = actionLine(member, result);
const folded = line === undefined ? '' : oneLine(line);
if (folded) lines.push(folded); // a line that folds to nothing adds no block: the API may reject an empty text block
return lines.map(text);
}
case 'tab':
case 'tabs':
// new_tab / switch_tab / list_tabs: the browser_state block is the whole result.
return [];
case 'navigate': {
const r = result.value;
// the final address is page-chosen through redirects: folded to one line and held to the bound, as a tab's is
let line = `Navigated to ${boundedTabURL(r.url)}`;
if (r.title != null) line += ` — ${oneLine(r.title)}`;
if (r.status != null) line += ` (HTTP ${r.status})`;
return [text(line)];
}
case 'screenshot': {
const r = result.value;
const image: BetaImageBlockParam = {
type: 'image',
source: { type: 'base64', media_type: r.mediaType ?? 'image/png', data: r.data },
};
return [image];
}
case 'point':
// cursor_position: the cursor, in screenshot pixels
return [text(`X=${result.value.x},Y=${result.value.y}`)];
case 'text': {
const value = wellFormed(String(result.value)); // page text: a lone surrogate in it could not be sent
return [text(value === '' ? '(empty)' : value)];
}
default:
checkNever(result);
return [];
}
}
export function isNavigationRefused(
change: BetaBrowserStateChange | BetaNavigationRefused | BetaDialogDismissed,
): change is BetaNavigationRefused {
return change.type === 'navigation_refused';
}
export function isDialogDismissed(
change: BetaBrowserStateChange | BetaNavigationRefused | BetaDialogDismissed,
): change is BetaDialogDismissed {
return change.type === 'dialog_dismissed';
}
/** The wire change kinds that have a page-supplied `url` (and possibly a local `path` or an `error`). */
export type DownloadChange = Exclude<BetaBrowserStateChange, { type: 'tab_opened' }>;
const DOWNLOAD_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set(
/* @__PURE__ */ Object.keys({
download_started: true,
download_completed: true,
download_failed: true,
} satisfies Record<DownloadChange['type'], true>),
);
export function isDownloadChange(
change: BetaBrowserStateChange | BetaNavigationRefused | BetaDialogDismissed,
): change is DownloadChange {
return DOWNLOAD_TYPES.has(change.type);
}
const DIALOG_MESSAGE_MAX = 200;
const DIALOG_KIND_MAX = 20; // `kind` is driver-supplied and lands unquoted in the line; a real one is a word
const DIALOG_LINES_MAX = 3;
/**
* One line per dismissed dialog the driver reported (`A confirm dialog "…" was dismissed.`), the message capped and
* at most a few lines, then a count of the rest, because a page can open dialogs in a loop.
*/
export function dialogLines(dialogs: readonly BetaDialogDismissed[]): string[] {
const lines: string[] = [];
for (const dialog of dialogs.slice(0, DIALOG_LINES_MAX)) {
const kind = cutPoints(oneLine(dialog.kind), DIALOG_KIND_MAX) || 'dialog';
const article = 'aeiou'.includes(kind.slice(0, 1).toLowerCase()) ? 'An' : 'A';
const noun = kind === 'dialog' ? kind : `${kind} dialog`;
const message = oneLine(dialog.message ?? '');
const shown = cutPoints(message, DIALOG_MESSAGE_MAX);
const quoted = message ? ` ${JSON.stringify(shown.length < message.length ? shown + '…' : shown)}` : '';
lines.push(`${article} ${noun}${quoted} was dismissed.`);
}
if (dialogs.length > DIALOG_LINES_MAX)
lines.push(`${dialogs.length - DIALOG_LINES_MAX} more dialogs were dismissed.`);
return lines;
}
/**
* The state changes a block includes: those held back from an earlier call whose result never reached the model,
* then this report's own, coalesced so the list meets the API's rules however the driver filled it. Of the download
* changes the latest per `download_id` is kept, where it stands; of the `tab_opened` changes the first per tab, and
* only for a tab the report still lists. The SDK's own kinds never reach here (see `reportOf`).
*/
export function coalesceChanges(
pending: readonly BetaBrowserStateChange[],
report: Report,
): BetaBrowserStateChange[] {
const all = [...pending, ...report.changes];
const liveTabs = new Set(report.tabs.map((t) => t.tab_id));
const latest = new Map<string, number>();
all.forEach((change, index) => {
if ('download_id' in change) latest.set(change.download_id, index);
});
const opened = new Set<string>();
return all.filter((change, index) => {
if (change.type === 'tab_opened') {
if (!liveTabs.has(change.tab_id) || opened.has(change.tab_id)) return false;
opened.add(change.tab_id);
return true;
}
return !('download_id' in change) || latest.get(change.download_id) === index;
});
}
/**
* The file policy's verdict on one download path, hidden without a policy. The answer is awaited, so the predicate
* may be `async`. Anything but `true` keeps the path hidden. A `ToolsetUsageError` propagates. Any other exception
* or rejection from the predicate fails closed: the path stays hidden and the report, with its changes, still goes
* out.
*/
export async function pathVisible(filePolicy: BetaFilePolicy | undefined, path: string): Promise<boolean> {
if (!filePolicy) return false;
let verdict: unknown;
try {
verdict = await filePolicy.isPathVisible(path);
} catch (error) {
if (error instanceof ToolsetUsageError) throw error;
return false;
}
return verdict === true;
}
/**
* The `browser_state` block: the driver's tabs with each URL and title folded to one line and held to the API's length
* limit, and `changes` with a download's `url` and a failed download's `error` bounded the same way and its `path`
* removed from every download change, kept only on a `download_completed` whose path the file policy exposes to the
* model and folding would leave unchanged.
*/
export async function renderBrowserState(
tabs: readonly BetaBrowserStateTabEntry[],
changes: readonly BetaBrowserStateChange[],
filePolicy: BetaFilePolicy | undefined,
): Promise<BetaBrowserStateBlockParam> {
const wire: BetaBrowserStateChange[] = [];
for (let change of changes) {
if (isDownloadChange(change)) {
// removed from whichever kind has it, declared or not: a local path never goes out unchecked
const { path, ...rest } = change as typeof change & { path?: unknown };
change = { ...rest, url: boundedTabURL(change.url) };
if (change.type === 'download_completed' && typeof path === 'string') {
// page-shaped: a path that folding would change could name another file, so it is withheld
if (oneLine(path) === path && (await pathVisible(filePolicy, path))) change.path = path;
}
if (change.type === 'download_failed' && change.error != null)
change.error = oneLine(String(change.error));
} else {
// copied like the others: the block holds nothing the driver can still change after the call
change = { ...change };
}
wire.push(change);
}
const bounded = tabs.map((tab) => ({
...tab,
url: boundedTabURL(tab.url),
title: oneLine(tab.title),
}));
const block: BetaBrowserStateBlockParam = { type: 'browser_state', tabs: bounded };
// "Nothing to report" is an absent field, never an empty list.
if (wire.length) block.state_changes = wire;
return block;
}