UNPKG

@anthropic-ai/sdk

Version:
280 lines (249 loc) • 11.3 kB
/** * 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; }