Files
openclaw/src/agents/tools/computer-tool.ts
T
Peter Steinberger aa7a2fbe0d feat(cua-computer): browser action family over v2 (#123960)
* feat(cua-computer): add browser action family

* refactor(cua-computer): split browser action files

* refactor(cua-computer): move the shared act-params type to the leaf module

* fix(cua-computer): carry the contract params import with the moved type
2026-08-14 20:59:46 -07:00

1474 lines
54 KiB
TypeScript

/**
* computer built-in tool.
*
* Drives a paired desktop node with computer_20251124-style actions: reads
* reuse the screen.snapshot node command as the reference frame and input is
* routed through the dangerous computer.act node command. The tool cannot
* tell how a node fulfills computer.act; macOS nodes are the first fulfiller.
*/
import crypto from "node:crypto";
import { imageMimeFromFormat } from "@openclaw/media-core/mime";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { Type } from "typebox";
import { parseScreenSnapshotPayload } from "../../cli/nodes-screen.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type {
ComputerActParams,
ComputerActResult,
ComputerUseCapabilityDescriptor,
ComputerUseV2ActionName,
ScreenSnapshotParams,
} from "../../plugins/computer-use-contract.js";
import {
COMPUTER_ACT_V1_ACTION_NAMES,
COMPUTER_CONTRACT_MISMATCH,
COMPUTER_STALE_OBSERVATION,
COMPUTER_USE_CONTRACT_ONLY_ACTION_NAMES,
COMPUTER_USE_V1_ACTION_NAMES,
COMPUTER_USE_V2_ACTION_NAMES,
parseComputerActResult,
} from "../../plugins/computer-use-contract.js";
import { sleep } from "../../utils/sleep.js";
import {
DEFAULT_IMAGE_MAX_DIMENSION_PX,
resolveImageSanitizationLimits,
} from "../image-sanitization.js";
import type { AgentMessage, AgentToolResult } from "../runtime/index.js";
import {
optionalFiniteNumberSchema,
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
optionalStringEnum,
stringEnum,
} from "../schema/typebox.js";
import { sanitizeToolResultImages } from "../tool-images.js";
import {
type AnyAgentTool,
readFiniteNumberParam,
readPositiveIntegerParam,
readToolStringParam,
} from "./common.js";
import { buildComputerToolDescription } from "./computer-tool-guidance.js";
import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js";
import { callGatewayTool, type GatewayCallOptions, readGatewayCallOptions } from "./gateway.js";
import {
type EligibleNodeMessages,
listNodes,
type NodeListNode,
resolveEligibleNodeFromList,
} from "./nodes-utils.js";
const COMPUTER_ACT_COMMAND = "computer.act";
const SCREEN_SNAPSHOT_COMMAND = "screen.snapshot";
// Reference frame width cap in pixels. The effective reference width is the
// smaller of this cap and the model's image sanitization limit, so a persisted
// screenshot that is replay-sanitized in later turns keeps the same pixel
// dimensions the coordinates were issued against (see resolveReferenceWidth).
const COMPUTER_REF_WIDTH = 1280;
const SCREENSHOT_QUALITY = 0.85;
// UI settle delay before the after-action screenshot.
const AFTER_ACTION_SCREENSHOT_DELAY_MS = 500;
const MAX_WAIT_SECONDS = 100;
const MAX_HOLD_SECONDS = 10;
const COMPUTER_TOOL_ACTIONS = COMPUTER_USE_V1_ACTION_NAMES;
type ComputerToolAction = ComputerUseV2ActionName;
const LOCAL_ACTIONS = new Set<ComputerUseV2ActionName>(["screenshot", "wait"]);
const CONTRACT_ONLY_ACTIONS = new Set<ComputerUseV2ActionName>(
COMPUTER_USE_CONTRACT_ONLY_ACTION_NAMES,
);
const INPUT_ACTIONS = new Set<ComputerUseV2ActionName>(
COMPUTER_USE_V2_ACTION_NAMES.filter(
(action) => !LOCAL_ACTIONS.has(action) && !CONTRACT_ONLY_ACTIONS.has(action),
),
);
function isComputerActAction(action: ComputerToolAction): boolean {
return INPUT_ACTIONS.has(action);
}
const COORDINATE_REQUIRED_ACTIONS = new Set<ComputerToolAction>([
"left_click",
"right_click",
"middle_click",
"double_click",
"triple_click",
"mouse_move",
"left_click_drag",
]);
// Actions that accept an optional target coordinate (scroll at a point, press
// or release the button at a point). Keyboard actions never carry coordinates.
const COORDINATE_OPTIONAL_ACTIONS = new Set<ComputerToolAction>([
"scroll",
"left_mouse_down",
"left_mouse_up",
]);
// Modifier keys ride the text field on pointer actions, mirroring the
// Anthropic computer_20251124 contract.
const MODIFIER_TEXT_ACTIONS = new Set<ComputerToolAction>([
"left_click",
"right_click",
"middle_click",
"double_click",
"triple_click",
"left_mouse_down",
"left_mouse_up",
"scroll",
]);
const POINTER_OR_KEYBOARD_ACTIONS = new Set<ComputerToolAction>(COMPUTER_ACT_V1_ACTION_NAMES);
const ESCALATION_REASONS = new Set([
"ax_tree_pixel_mismatch",
"background_delivery_failed",
"foreground_ineffective",
"no_window_target",
"other",
]);
const SCROLL_DIRECTIONS = ["up", "down", "left", "right"] as const;
function isScrollDirection(value: string): value is (typeof SCROLL_DIRECTIONS)[number] {
return SCROLL_DIRECTIONS.some((direction) => direction === value);
}
function createComputerToolSchema(actions: readonly ComputerUseV2ActionName[]) {
return Type.Object({
action: stringEnum(actions),
...gatewayCallOptionSchemaProperties(),
node: Type.Optional(
Type.String({
description:
"Paired node id or display name. Omit when exactly one connected computer-capable node exists.",
}),
),
// Codex accepts a single schema in array `items`, not tuple item arrays.
// Fixed bounds preserve the coordinate-pair contract across runtimes.
coordinate: Type.Optional(
Type.Array(Type.Integer({ minimum: 0 }), {
minItems: 2,
maxItems: 2,
description: "[x, y] target in pixels of the most recent screenshot.",
}),
),
startCoordinate: Type.Optional(
Type.Array(Type.Integer({ minimum: 0 }), {
minItems: 2,
maxItems: 2,
description: "left_click_drag: [x, y] drag origin in screenshot pixels.",
}),
),
destinationCoordinate: Type.Optional(
Type.Array(Type.Number({ minimum: 0 }), {
minItems: 2,
maxItems: 2,
description: "browser_pointer drag destination [x, y] in viewport CSS pixels.",
}),
),
text: Type.Optional(
Type.String({
description:
'type: text to type; key/hold_key: key combo such as "cmd+shift+t" or "Return"; ' +
'click/scroll actions: modifier keys to hold ("shift", "ctrl", "alt", "cmd").',
}),
),
scrollDirection: optionalStringEnum(SCROLL_DIRECTIONS),
scrollAmount: optionalPositiveIntegerSchema({
maximum: 100,
description: "scroll: number of wheel ticks.",
}),
duration: optionalFiniteNumberSchema({
minimum: 0,
maximum: MAX_WAIT_SECONDS,
description: `Seconds. hold_key: >0 to ${MAX_HOLD_SECONDS}; wait: 0 to ${MAX_WAIT_SECONDS}.`,
}),
screenIndex: optionalNonNegativeIntegerSchema(),
frameId: Type.Optional(
Type.String({
description:
"Coordinate actions: exact frame id returned by the most recent screenshot result.",
}),
),
windowRef: Type.Optional(
Type.String({ description: "Opaque window reference from observation." }),
),
browserRef: Type.Optional(
Type.String({ description: "Opaque browser reference from get_browser_state." }),
),
pageRef: Type.Optional(
Type.String({ description: "Opaque browser page reference from get_browser_state." }),
),
elementRef: Type.Optional(
Type.String({ description: "Opaque accessibility element reference from observation." }),
),
observationId: Type.Optional(
Type.String({ description: "Observation id that issued window or element references." }),
),
deliveryMode: optionalStringEnum(["background", "foreground"] as const),
query: Type.Optional(Type.String()),
depth: Type.Optional(Type.Integer({ minimum: 0, maximum: 64 })),
maxElements: Type.Optional(Type.Integer({ minimum: 1, maximum: 2_000 })),
app: Type.Optional(Type.String()),
value: Type.Optional(Type.String()),
path: Type.Optional(
Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 1, maxItems: 16 }),
),
x1: Type.Optional(Type.Number({ minimum: 0 })),
y1: Type.Optional(Type.Number({ minimum: 0 })),
x2: Type.Optional(Type.Number({ minimum: 0 })),
y2: Type.Optional(Type.Number({ minimum: 0 })),
reason: optionalStringEnum([
"ax_tree_pixel_mismatch",
"background_delivery_failed",
"foreground_ineffective",
"no_window_target",
"other",
] as const),
snapshotFormat: optionalStringEnum(["dom_refs_v1", "semantic_v2"] as const),
continuation: Type.Optional(Type.String()),
includeScreenshot: Type.Optional(Type.Boolean()),
profile: optionalStringEnum(["isolated_new", "isolated_named"] as const),
profileName: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
url: Type.Optional(Type.String()),
inputRoute: optionalStringEnum(["trusted", "dom_event"] as const),
mode: optionalStringEnum(["insert_text", "keystrokes"] as const),
replace: Type.Optional(Type.Boolean()),
dialogAction: optionalStringEnum(["inspect", "accept", "dismiss"] as const),
dialogRef: Type.Optional(Type.String()),
promptText: Type.Optional(Type.String()),
files: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 32 })),
destinationRoot: Type.Optional(Type.String()),
pointerAction: optionalStringEnum([
"hover",
"right_click",
"double_click",
"scroll",
"drag",
] as const),
destinationElementRef: Type.Optional(Type.String()),
deltaX: Type.Optional(Type.Number()),
deltaY: Type.Optional(Type.Number()),
});
}
function readCoordinate(
params: Record<string, unknown>,
key: "coordinate" | "startCoordinate",
): [number, number] | undefined {
const raw = params[key];
if (raw === undefined) {
return undefined;
}
if (
!Array.isArray(raw) ||
raw.length !== 2 ||
raw.some(
(entry) =>
typeof entry !== "number" ||
!Number.isFinite(entry) ||
!Number.isInteger(entry) ||
entry < 0,
)
) {
throw new Error(`${key} must be a pair of non-negative integers`);
}
return [raw[0] as number, raw[1] as number];
}
function requireCoordinate(params: Record<string, unknown>, action: string): [number, number] {
const coordinate = readCoordinate(params, "coordinate");
if (!coordinate) {
throw new Error(`coordinate [x, y] required for ${action}`);
}
return [coordinate[0], coordinate[1]];
}
function readModifiers(params: Record<string, unknown>, action: ComputerToolAction) {
if (!MODIFIER_TEXT_ACTIONS.has(action)) {
return undefined;
}
const text = typeof params.text === "string" ? params.text.trim() : "";
return text ? text : undefined;
}
function copyOptionalStringParam(
target: Record<string, unknown>,
input: Record<string, unknown>,
key: string,
): void {
const value = readToolStringParam(input, key);
if (value !== undefined) {
target[key] = value;
}
}
function copyOptionalIntegerParam(
target: Record<string, unknown>,
input: Record<string, unknown>,
key: string,
bounds: { min: number; max: number },
): void {
const value = readFiniteNumberParam(input, key, bounds);
if (value === undefined) {
return;
}
if (!Number.isInteger(value)) {
throw new Error(`${key} must be an integer`);
}
target[key] = value;
}
function copyDeliveryMode(target: Record<string, unknown>, input: Record<string, unknown>): void {
const deliveryMode = normalizeOptionalLowercaseString(input.deliveryMode);
if (deliveryMode === undefined) {
return;
}
if (deliveryMode !== "background" && deliveryMode !== "foreground") {
throw new Error("deliveryMode must be background or foreground");
}
target.deliveryMode = deliveryMode;
}
function copyOptionalBooleanParam(
target: Record<string, unknown>,
input: Record<string, unknown>,
key: string,
): void {
const value = input[key];
if (value === undefined) {
return;
}
if (typeof value !== "boolean") {
throw new Error(`${key} must be a boolean`);
}
target[key] = value;
}
function copyBrowserRefs(target: Record<string, unknown>, input: Record<string, unknown>): void {
target.browserRef = readToolStringParam(input, "browserRef", { required: true });
target.pageRef = readToolStringParam(input, "pageRef", { required: true });
}
/** Builds the computer.act wire params for one tool input action. */
function buildComputerActParams(params: {
action: ComputerToolAction;
input: Record<string, unknown>;
screenIndex: number;
displayFrameId?: string;
refWidth?: number;
}): ComputerActParams {
const { action, input } = params;
const wire: Record<string, unknown> = { action };
if ((COMPUTER_ACT_V1_ACTION_NAMES as readonly string[]).includes(action)) {
wire.screenIndex = params.screenIndex;
wire.refWidth = params.refWidth ?? COMPUTER_REF_WIDTH;
}
if (COORDINATE_REQUIRED_ACTIONS.has(action)) {
const [x, y] = requireCoordinate(input, action);
wire.x = x;
wire.y = y;
} else if (COORDINATE_OPTIONAL_ACTIONS.has(action)) {
const coordinate = readCoordinate(input, "coordinate");
if (coordinate) {
wire.x = coordinate[0];
wire.y = coordinate[1];
}
}
if ((wire.x !== undefined || wire.fromX !== undefined) && params.displayFrameId) {
wire.displayFrameId = params.displayFrameId;
}
const modifiers = readModifiers(input, action);
if (modifiers) {
wire.modifiers = modifiers;
}
switch (action) {
case "left_click_drag": {
const start = readCoordinate(input, "startCoordinate");
if (!start) {
throw new Error("startCoordinate [x, y] required for left_click_drag");
}
wire.fromX = start[0];
wire.fromY = start[1];
break;
}
case "scroll": {
const direction = normalizeOptionalLowercaseString(input.scrollDirection);
if (!direction || !isScrollDirection(direction)) {
throw new Error("scrollDirection up|down|left|right required for scroll");
}
wire.scrollDirection = direction;
const amount = readPositiveIntegerParam(input, "scrollAmount") ?? 3;
wire.scrollAmount = Math.min(100, amount);
break;
}
case "type": {
const text = typeof input.text === "string" ? input.text : "";
if (!text) {
throw new Error("text required for type");
}
wire.text = text;
break;
}
case "key":
case "hold_key": {
const keys = readToolStringParam(input, "text", { required: true });
wire.keys = keys;
if (action === "hold_key") {
const seconds =
readFiniteNumberParam(input, "duration", {
min: 0,
minExclusive: true,
max: MAX_HOLD_SECONDS,
message: `duration must be >0 and <=${MAX_HOLD_SECONDS} seconds for hold_key`,
}) ?? 1;
wire.durationMs = Math.round(seconds * 1000);
}
break;
}
case "get_accessibility_tree": {
copyOptionalStringParam(wire, input, "windowRef");
copyOptionalStringParam(wire, input, "query");
copyOptionalIntegerParam(wire, input, "depth", { min: 0, max: 64 });
copyOptionalIntegerParam(wire, input, "maxElements", { min: 1, max: 2_000 });
break;
}
case "get_window_state": {
wire.windowRef = readToolStringParam(input, "windowRef", { required: true });
copyOptionalStringParam(wire, input, "query");
copyOptionalIntegerParam(wire, input, "depth", { min: 0, max: 64 });
copyOptionalIntegerParam(wire, input, "maxElements", { min: 1, max: 2_000 });
break;
}
case "launch_app":
case "kill_app": {
wire.app = readToolStringParam(input, "app", { required: true });
break;
}
case "bring_to_front": {
wire.windowRef = readToolStringParam(input, "windowRef", { required: true });
break;
}
case "set_value": {
for (const key of ["windowRef", "elementRef", "observationId", "value"] as const) {
wire[key] = readToolStringParam(input, key, {
required: true,
allowEmpty: key === "value",
});
}
copyDeliveryMode(wire, input);
break;
}
case "invoke_menu": {
wire.windowRef = readToolStringParam(input, "windowRef", { required: true });
const path = input.path;
if (
!Array.isArray(path) ||
path.length < 1 ||
path.length > 16 ||
path.some((segment) => typeof segment !== "string" || !segment.trim())
) {
throw new Error("path must contain 1-16 non-empty menu labels");
}
wire.path = path;
copyDeliveryMode(wire, input);
break;
}
case "zoom": {
wire.windowRef = readToolStringParam(input, "windowRef", { required: true });
wire.observationId = readToolStringParam(input, "observationId", { required: true });
for (const key of ["x1", "y1", "x2", "y2"] as const) {
const value = readFiniteNumberParam(input, key, { min: 0 });
if (value === undefined) {
throw new Error(`${key} required for zoom`);
}
wire[key] = value;
}
break;
}
case "get_browser_state": {
const windowRef = readToolStringParam(input, "windowRef");
if (windowRef) {
wire.windowRef = windowRef;
break;
}
copyBrowserRefs(wire, input);
for (const key of [
"snapshotFormat",
"elementRef",
"observationId",
"query",
"continuation",
] as const) {
copyOptionalStringParam(wire, input, key);
}
copyOptionalBooleanParam(wire, input, "includeScreenshot");
break;
}
case "browser_prepare": {
wire.windowRef = readToolStringParam(input, "windowRef", { required: true });
copyOptionalStringParam(wire, input, "profile");
copyOptionalStringParam(wire, input, "profileName");
break;
}
case "browser_navigate": {
copyBrowserRefs(wire, input);
wire.url = readToolStringParam(input, "url", { required: true });
break;
}
case "browser_click": {
copyBrowserRefs(wire, input);
wire.observationId = readToolStringParam(input, "observationId", { required: true });
copyOptionalStringParam(wire, input, "elementRef");
copyOptionalStringParam(wire, input, "inputRoute");
const coordinate = readCoordinate(input, "coordinate");
if (coordinate) {
wire.x = coordinate[0];
wire.y = coordinate[1];
}
break;
}
case "browser_type": {
copyBrowserRefs(wire, input);
for (const key of ["observationId", "elementRef"] as const) {
wire[key] = readToolStringParam(input, key, { required: true });
}
wire.text = readToolStringParam(input, "text", { required: true, allowEmpty: true });
copyOptionalStringParam(wire, input, "mode");
copyOptionalBooleanParam(wire, input, "replace");
break;
}
case "browser_dialog": {
copyBrowserRefs(wire, input);
wire.dialogAction = readToolStringParam(input, "dialogAction", { required: true });
copyOptionalStringParam(wire, input, "dialogRef");
copyOptionalStringParam(wire, input, "promptText");
copyDeliveryMode(wire, input);
break;
}
case "browser_set_input_files": {
copyBrowserRefs(wire, input);
for (const key of ["observationId", "elementRef"] as const) {
wire[key] = readToolStringParam(input, key, { required: true });
}
const files = input.files;
if (
!Array.isArray(files) ||
files.length < 1 ||
files.length > 32 ||
files.some((file) => typeof file !== "string" || !file)
) {
throw new Error("files must contain 1-32 non-empty paths");
}
wire.files = files;
break;
}
case "browser_download": {
copyBrowserRefs(wire, input);
for (const key of ["observationId", "elementRef", "destinationRoot"] as const) {
wire[key] = readToolStringParam(input, key, { required: true });
}
break;
}
case "browser_pointer": {
copyBrowserRefs(wire, input);
wire.observationId = readToolStringParam(input, "observationId", { required: true });
wire.pointerAction = readToolStringParam(input, "pointerAction", { required: true });
for (const key of ["inputRoute", "elementRef", "destinationElementRef"] as const) {
copyOptionalStringParam(wire, input, key);
}
const coordinate = readCoordinate(input, "coordinate");
if (coordinate) {
wire.x = coordinate[0];
wire.y = coordinate[1];
}
const destination = input.destinationCoordinate;
if (destination !== undefined) {
if (
!Array.isArray(destination) ||
destination.length !== 2 ||
destination.some((value) => typeof value !== "number" || !Number.isFinite(value))
) {
throw new Error("destinationCoordinate must be a pair of finite numbers");
}
wire.toX = destination[0];
wire.toY = destination[1];
}
for (const key of ["deltaX", "deltaY"] as const) {
const value = readFiniteNumberParam(input, key);
if (value !== undefined) {
wire[key] = value;
}
}
break;
}
case "escalate_scope": {
const reason = readToolStringParam(input, "reason", { required: true });
if (!ESCALATION_REASONS.has(reason)) {
throw new Error("reason must be a supported escalation reason");
}
wire.reason = reason;
break;
}
default:
break;
}
if (POINTER_OR_KEYBOARD_ACTIONS.has(action)) {
for (const key of ["windowRef", "elementRef", "observationId"] as const) {
copyOptionalStringParam(wire, input, key);
}
copyDeliveryMode(wire, input);
}
return wire as ComputerActParams;
}
function isEligibleComputerNode(node: NodeListNode): boolean {
const commands = Array.isArray(node.commands) ? node.commands : [];
// The tool loop authorizes coordinates against captured frames, so screenshot
// support is a functional requirement rather than gating by platform name.
return (
node.connected === true &&
commands.includes(COMPUTER_ACT_COMMAND) &&
commands.includes(SCREEN_SNAPSHOT_COMMAND)
);
}
const NOT_COMPUTER_CAPABLE_HINT =
"enable Computer Control in the OpenClaw app and approve the pairing update";
const COMPUTER_NODE_MESSAGES: EligibleNodeMessages = {
ineligibleExact: (query, eligibleIds) =>
`node "${query}" is not computer-capable (needs a connected node advertising ${COMPUTER_ACT_COMMAND} and ${SCREEN_SNAPSHOT_COMMAND}; ${NOT_COMPUTER_CAPABLE_HINT}; ` +
`eligible node ids: ${eligibleIds})`,
nameResolveFailed: (reason, eligibleIds) =>
`${reason} (eligible computer-capable node ids: ${eligibleIds})`,
noneEligible: () =>
`no connected computer-capable node (a node must advertise ${COMPUTER_ACT_COMMAND} and ${SCREEN_SNAPSHOT_COMMAND}; ${NOT_COMPUTER_CAPABLE_HINT})`,
multipleEligible: (eligible) =>
`multiple computer-capable nodes connected; pass node explicitly: ${eligible
.map((node) => node.nodeId)
.join(", ")}`,
};
async function resolveComputerNode(
gatewayOpts: GatewayCallOptions,
query?: string,
signal?: AbortSignal,
): Promise<NodeListNode> {
const nodes = await listNodes(gatewayOpts, signal);
return resolveEligibleNodeFromList(nodes, query, isEligibleComputerNode, COMPUTER_NODE_MESSAGES);
}
type ScreenshotCapture = {
base64: string;
displayFrameId: string;
mimeType: string;
width?: number;
height?: number;
};
const READ_ONLY_COMPUTER_ACT_ACTIONS = new Set<ComputerUseV2ActionName>([
"list_apps",
"list_windows",
"get_accessibility_tree",
"get_cursor_position",
"get_window_state",
"zoom",
"get_browser_state",
]);
function parseComputerActPayload(value: unknown): ComputerActResult {
if (typeof value !== "string") {
return parseComputerActResult(value);
}
try {
return parseComputerActResult(JSON.parse(value));
} catch (error) {
if (error instanceof Error && error.message.startsWith(COMPUTER_CONTRACT_MISMATCH)) {
throw error;
}
throw new Error(`${COMPUTER_CONTRACT_MISMATCH}: computer.act returned invalid JSON`, {
cause: error,
});
}
}
// Model-visible ceiling for semantic elements per result. The wire schema
// admits far more for node-side fidelity; projecting them all would blow the
// model context budget, so the tool truncates and says so.
const MODEL_OBSERVATION_MAX_ELEMENTS = 200;
type ModelObservationProjection = NonNullable<ComputerActResult["observation"]> & {
truncatedElements?: number;
};
function computerActResultText(action: ComputerUseV2ActionName, result: ComputerActResult): string {
let observation: ModelObservationProjection | undefined = result.observation
? { ...result.observation, ...(result.observation.base64 ? { base64: "[image]" } : {}) }
: undefined;
if (observation?.elements && observation.elements.length > MODEL_OBSERVATION_MAX_ELEMENTS) {
observation = {
...observation,
elements: observation.elements.slice(0, MODEL_OBSERVATION_MAX_ELEMENTS),
truncatedElements: observation.elements.length - MODEL_OBSERVATION_MAX_ELEMENTS,
};
}
const details = result.details ? { ...result.details } : undefined;
if (
details &&
Array.isArray(details.elements) &&
details.elements.length > MODEL_OBSERVATION_MAX_ELEMENTS
) {
const originalLength = details.elements.length;
details.elements = details.elements.slice(0, MODEL_OBSERVATION_MAX_ELEMENTS);
details.truncatedElements = originalLength - MODEL_OBSERVATION_MAX_ELEMENTS;
}
return JSON.stringify({
action,
...result,
...(observation ? { observation } : {}),
...(details ? { details } : {}),
});
}
async function invokeNodeCommand(params: {
gatewayOpts: GatewayCallOptions;
nodeId: string;
command: string;
commandParams: Record<string, unknown>;
timeoutMs?: number;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<unknown> {
const raw = await callGatewayTool<{ payload: unknown }>(
"node.invoke",
params.gatewayOpts,
{
nodeId: params.nodeId,
command: params.command,
params: params.commandParams,
timeoutMs: params.timeoutMs,
idempotencyKey: params.idempotencyKey ?? crypto.randomUUID(),
},
{ signal: params.signal },
);
return raw && typeof raw === "object" && Object.hasOwn(raw, "payload")
? (raw as { payload: unknown }).payload
: raw;
}
function computerActIdempotencyKey(params: { scope?: string; toolCallId: string }): string {
const stableScope = params.scope?.trim();
const stableCallId = params.toolCallId.trim();
if (!stableScope || !stableCallId) {
// A call id is only unique inside its model response. Without a stable run
// scope and provider/fallback id, avoid collapsing unrelated actions.
return crypto.randomUUID();
}
const digest = crypto
.createHash("sha256")
.update(JSON.stringify([stableScope, stableCallId, COMPUTER_ACT_COMMAND]))
.digest("hex");
return `computer.act:v1:${digest}`;
}
async function captureScreenshot(params: {
gatewayOpts: GatewayCallOptions;
nodeId: string;
screenIndex: number;
refWidth: number;
signal?: AbortSignal;
}): Promise<ScreenshotCapture> {
const commandParams: ScreenSnapshotParams = {
screenIndex: params.screenIndex,
maxWidth: params.refWidth,
quality: SCREENSHOT_QUALITY,
format: "jpeg",
};
const payload = await invokeNodeCommand({
gatewayOpts: params.gatewayOpts,
nodeId: params.nodeId,
command: SCREEN_SNAPSHOT_COMMAND,
commandParams,
signal: params.signal,
});
const parsed = parseScreenSnapshotPayload(payload);
if (!parsed.displayFrameId) {
throw new Error(
"screen.snapshot response missing displayFrameId; update the node app before computer use",
);
}
return {
base64: parsed.base64,
displayFrameId: parsed.displayFrameId,
mimeType: imageMimeFromFormat(parsed.format) ?? "image/jpeg",
width: parsed.width,
height: parsed.height,
};
}
/**
* The reference frame width both the screenshot and the coordinates use.
* Capped at the model's image sanitization limit so a persisted screenshot that
* is replay-sanitized in a later turn is not resized underneath the coordinate
* frame the model is still issuing `refWidth` against.
*/
function resolveReferenceWidth(limits: { maxDimensionPx?: number }): number {
const sanitizationLimit = limits.maxDimensionPx ?? DEFAULT_IMAGE_MAX_DIMENSION_PX;
return Math.max(1, Math.min(COMPUTER_REF_WIDTH, sanitizationLimit));
}
const DANGEROUS_DENY_HINT = "blocked by gateway.nodes.commands.deny";
const PLATFORM_ALLOWLIST_HINT = "is not in the allowlist for platform";
const BUTTON_NOT_HELD_HINT = "left button is not held by computer control";
const DEFINITIVE_NODE_COMMAND_REASONS = new Set([
"command required",
"command not allowlisted",
"command not declared by node",
"node did not declare commands",
]);
export type ComputerContextEpoch = {
value: number;
/** Tool result whose screenshot currently authorizes coordinates. */
frameToolCallId?: string;
/** Digest of the exact sanitized image the model received for that result. */
frameImageIdentity?: string;
};
function computerFrameImageIdentity(
content: AgentToolResult<unknown>["content"],
): string | undefined {
const images = content.filter(
(block): block is Extract<(typeof content)[number], { type: "image" }> =>
block.type === "image",
);
if (images.length !== 1) {
return undefined;
}
const image = images.at(0);
if (!image) {
return undefined;
}
return crypto
.createHash("sha256")
.update(JSON.stringify([image.mimeType, image.data]))
.digest("hex");
}
function invalidateComputerFrame(contextEpoch: ComputerContextEpoch): boolean {
if (contextEpoch.frameToolCallId === undefined && contextEpoch.frameImageIdentity === undefined) {
return false;
}
contextEpoch.value += 1;
delete contextEpoch.frameToolCallId;
delete contextEpoch.frameImageIdentity;
return true;
}
/**
* Invalidate screenshot coordinates when the final model context no longer
* contains the image produced by the tracked computer tool result.
*/
export function invalidateComputerFrameIfMissing(params: {
contextEpoch: ComputerContextEpoch;
messages: AgentMessage[];
imagesBlocked?: boolean;
}): boolean {
const frameToolCallId = params.contextEpoch.frameToolCallId;
if (frameToolCallId === undefined) {
return invalidateComputerFrame(params.contextEpoch);
}
let frameImageIdentity: string | undefined;
for (let index = params.messages.length - 1; index >= 0; index -= 1) {
const message = params.messages[index];
if (
message?.role !== "toolResult" ||
message.toolName !== "computer" ||
message.toolCallId !== frameToolCallId
) {
continue;
}
frameImageIdentity = computerFrameImageIdentity(message.content);
break;
}
if (
!params.imagesBlocked &&
frameImageIdentity !== undefined &&
frameImageIdentity === params.contextEpoch.frameImageIdentity
) {
return false;
}
return invalidateComputerFrame(params.contextEpoch);
}
function gatewayRequestDetails(err: unknown): Record<string, unknown> | undefined {
if (!(err instanceof Error) || err.name !== "GatewayClientRequestError") {
return undefined;
}
const details = (err as Error & { details?: unknown }).details;
return isRecord(details) ? details : undefined;
}
function withComputerEnablementHint(err: unknown): Error {
const message = formatErrorMessage(err);
const reason = gatewayRequestDetails(err)?.reason;
if (message.includes(DANGEROUS_DENY_HINT)) {
return new Error(
`${message} — remove ${COMPUTER_ACT_COMMAND} from gateway.nodes.commands.deny, then retry.`,
{ cause: err },
);
}
if (
reason === "command not allowlisted" ||
reason === "command not declared by node" ||
reason === "node did not declare commands" ||
message.includes(PLATFORM_ALLOWLIST_HINT)
) {
return new Error(`${message}${NOT_COMPUTER_CAPABLE_HINT}, then retry.`, { cause: err });
}
return err instanceof Error ? err : new Error(message);
}
function isDefinitiveComputerActRejection(err: unknown): boolean {
const details = gatewayRequestDetails(err);
return (
details?.nodeCommandDispatched === false ||
(typeof details?.reason === "string" && DEFINITIVE_NODE_COMMAND_REASONS.has(details.reason))
);
}
function isButtonAlreadyReleasedError(err: unknown): boolean {
return (
err instanceof Error &&
err.name === "GatewayClientRequestError" &&
err.message.includes(BUTTON_NOT_HELD_HINT)
);
}
function validateCapabilityBoundInput(params: {
action: ComputerUseV2ActionName;
input: Record<string, unknown>;
nodeId: string;
capabilities?: ComputerUseCapabilityDescriptor;
observationState?: {
nodeId: string;
providerGeneration: string;
observationId: string;
};
}): void {
const { capabilities, input } = params;
const windowRef = readToolStringParam(input, "windowRef");
const browserRef = readToolStringParam(input, "browserRef");
const pageRef = readToolStringParam(input, "pageRef");
const elementRef = readToolStringParam(input, "elementRef");
const observationId = readToolStringParam(input, "observationId");
const deliveryMode = normalizeOptionalLowercaseString(input.deliveryMode);
if (windowRef && !capabilities?.targets.includes("window")) {
throw new Error(`${COMPUTER_CONTRACT_MISMATCH}: selected node has no window target support`);
}
if (elementRef && !capabilities?.targets.includes("element")) {
throw new Error(`${COMPUTER_CONTRACT_MISMATCH}: selected node has no element target support`);
}
if ((browserRef || pageRef) && !capabilities?.targets.includes("browser")) {
throw new Error(`${COMPUTER_CONTRACT_MISMATCH}: selected node has no browser target support`);
}
if (deliveryMode && !capabilities?.deliveryModes.includes(deliveryMode as never)) {
throw new Error(
`${COMPUTER_CONTRACT_MISMATCH}: selected node does not advertise ${deliveryMode} delivery`,
);
}
if (elementRef && !observationId) {
throw new Error(`${COMPUTER_STALE_OBSERVATION}: elementRef requires observationId`);
}
if (!observationId) {
return;
}
if (
!params.observationState ||
params.observationState.nodeId !== params.nodeId ||
params.observationState.providerGeneration !== capabilities?.provider.generation ||
params.observationState.observationId !== observationId
) {
throw new Error(`${COMPUTER_STALE_OBSERVATION}: take a fresh observation and retry`);
}
}
export function createComputerTool(options?: {
config?: OpenClawConfig;
modelHasVision?: boolean;
/** Stable run scope used to deduplicate a replayed model tool call on the node. */
idempotencyScope?: string;
/** Tracks whether the current screenshot pixels still reach model context. */
contextEpoch?: ComputerContextEpoch;
/** Preselected node declaration, when tool preparation already resolved one. */
capabilityDescriptor?: ComputerUseCapabilityDescriptor;
}): AnyAgentTool {
const configuredLimits = resolveImageSanitizationLimits(options?.config);
const referenceWidth = resolveReferenceWidth(configuredLimits);
const parameterSchema = createComputerToolSchema(
options?.capabilityDescriptor?.actions ?? COMPUTER_TOOL_ACTIONS,
);
let selectedCapabilities = options?.capabilityDescriptor;
let selectedCapabilityNodeId: string | undefined;
let observationState:
| { nodeId: string; providerGeneration: string; observationId: string }
| undefined;
const replaceParameterSchema = (actions: readonly ComputerUseV2ActionName[]) => {
const next = createComputerToolSchema(actions) as unknown as Record<string, unknown>;
const target = parameterSchema as unknown as Record<string, unknown>;
for (const key of Object.keys(target)) {
delete target[key];
}
Object.assign(target, next);
};
const bindNodeCapabilities = (node: NodeListNode) => {
const next = node.computerUse;
const changed =
selectedCapabilityNodeId !== node.nodeId ||
selectedCapabilities?.provider.generation !== next?.provider.generation;
selectedCapabilityNodeId = node.nodeId;
selectedCapabilities = next;
replaceParameterSchema(next?.actions ?? COMPUTER_TOOL_ACTIONS);
tool.description = buildComputerToolDescription(next);
if (changed) {
observationState = undefined;
}
};
type ComputerTarget = { nodeId: string; screenIndex: number };
type ComputerState =
| { kind: "unbound" }
| { kind: "target"; target: ComputerTarget }
| {
kind: "frame";
target: ComputerTarget;
id: string;
displayFrameId: string;
contextEpoch: number;
};
// Keep target affinity after pixels expire so cleanup input such as
// left_mouse_up still reaches the machine/display that received the matching down.
// Only the frame state authorizes coordinates from model-visible pixels.
let computerState: ComputerState = { kind: "unbound" };
const setComputerState = (
next: ComputerState,
frameToolCallId?: string,
frameImageIdentity?: string,
) => {
computerState = next;
if (!options?.contextEpoch) {
return;
}
if (
next.kind === "frame" &&
frameToolCallId !== undefined &&
frameImageIdentity !== undefined
) {
options.contextEpoch.frameToolCallId = frameToolCallId;
options.contextEpoch.frameImageIdentity = frameImageIdentity;
} else {
delete options.contextEpoch.frameToolCallId;
delete options.contextEpoch.frameImageIdentity;
}
};
// A down timeout is ambiguous: input may have landed even when no response
// arrived. Pin subsequent actions to that target until an up is confirmed,
// so retargeting cannot strand a held button on another machine.
let heldButtonTarget: ComputerTarget | undefined;
// Serialize execute() per tool instance. This runtime can dispatch parallel
// tool calls (some providers enable it by default), but desktop input and the
// shared target/frame/button state must apply in model order, not completion
// order: a click racing a type could type into the wrong app, and split
// mouse down/move/up could interleave. Chaining preserves invocation order.
let opQueue: Promise<unknown> = Promise.resolve();
const serialize = <T>(fn: () => Promise<T>): Promise<T> => {
const result = opQueue.then(fn, fn);
opQueue = result.then(
() => undefined,
() => undefined,
);
return result;
};
const tool: AnyAgentTool = {
label: "Computer",
name: "computer",
// Catalog bridges serialize nested results as JSON, which strips the
// model-visible screenshot block that coordinate actions depend on.
catalogMode: "direct-only",
executionMode: "sequential",
description: buildComputerToolDescription(options?.capabilityDescriptor),
parameters: parameterSchema,
execute: (toolCallId, args, signal) =>
serialize(async () => {
signal?.throwIfAborted();
const params = args as Record<string, unknown>;
const action = readToolStringParam(params, "action", {
required: true,
}) as ComputerToolAction;
const gatewayOpts = readGatewayCallOptions(params);
const explicitNode = typeof params.node === "string" ? params.node : undefined;
const explicitScreenIndex = (() => {
if (params.screenIndex === undefined) {
return undefined;
}
if (
typeof params.screenIndex !== "number" ||
!Number.isInteger(params.screenIndex) ||
params.screenIndex < 0
) {
throw new Error("screenIndex must be a non-negative integer");
}
return params.screenIndex;
})();
// Coordinate actions apply pixels from a specific screenshot, so they must
// target the exact frame the model saw; keyboard actions and cursor-relative
// scroll do not.
const needsFrame =
!params.windowRef &&
!params.elementRef &&
(COORDINATE_REQUIRED_ACTIONS.has(action) ||
(COORDINATE_OPTIONAL_ACTIONS.has(action) && Array.isArray(params.coordinate)));
const priorTarget = computerState.kind === "unbound" ? undefined : computerState.target;
const implicitTarget = heldButtonTarget ?? priorTarget;
// Bind the node to the established target: reuse the last machine unless the
// caller names one, so cleanup input never drifts to a different desktop.
let nodeId: string;
if (explicitNode !== undefined) {
const node = await resolveComputerNode(gatewayOpts, explicitNode, signal);
nodeId = node.nodeId;
bindNodeCapabilities(node);
} else if (implicitTarget) {
nodeId = implicitTarget.nodeId;
} else {
const node = await resolveComputerNode(gatewayOpts, undefined, signal);
nodeId = node.nodeId;
bindNodeCapabilities(node);
}
const capabilitiesForNode =
selectedCapabilityNodeId === nodeId ? selectedCapabilities : undefined;
const advertisedActions = capabilitiesForNode?.actions ?? COMPUTER_TOOL_ACTIONS;
if (!advertisedActions.includes(action)) {
throw new Error(
`${COMPUTER_CONTRACT_MISMATCH}: node ${nodeId} does not advertise action ${action}`,
);
}
if (CONTRACT_ONLY_ACTIONS.has(action)) {
throw new Error(
`${COMPUTER_CONTRACT_MISMATCH}: action ${action} is contract-only until its adapter lands`,
);
}
validateCapabilityBoundInput({
action,
input: params,
nodeId,
capabilities: capabilitiesForNode,
observationState,
});
if (heldButtonTarget && nodeId !== heldButtonTarget.nodeId) {
throw new Error(
`computer: left button may still be held on node ${heldButtonTarget.nodeId}; ` +
"release it before targeting another node",
);
}
if (
heldButtonTarget &&
explicitScreenIndex !== undefined &&
explicitScreenIndex !== heldButtonTarget.screenIndex
) {
throw new Error(
`computer: left button may still be held on screen ${heldButtonTarget.screenIndex}; ` +
"release it before targeting another screen",
);
}
// The observed frame is only a valid coordinate reference for its own node,
// so switching to a different node drops the inherited display index and
// requires a fresh screenshot of that node.
const targetForNode = priorTarget?.nodeId === nodeId ? priorTarget : undefined;
const frameForNode =
computerState.kind === "frame" &&
computerState.target.nodeId === nodeId &&
computerState.contextEpoch === (options?.contextEpoch?.value ?? 0)
? computerState
: undefined;
// Fail closed rather than silently retargeting: a coordinate action with no
// frame observed for this node this run (a fresh run, or a node switch) must
// not fall back to display 0, nor apply another node's display index.
if (needsFrame && !frameForNode) {
throw new Error(
"computer: no screenshot of this node has been taken yet, so there is no display frame to " +
"target. Take a `screenshot` first (of this node) before issuing coordinate actions.",
);
}
if (
needsFrame &&
explicitScreenIndex !== undefined &&
explicitScreenIndex !== frameForNode?.target.screenIndex
) {
throw new Error("computer: screenIndex does not match the most recent screenshot frame");
}
if (needsFrame && params.frameId !== frameForNode?.id) {
throw new Error(
"computer: frameId does not match the most recent screenshot result; take a new screenshot",
);
}
const screenIndex =
explicitScreenIndex ??
frameForNode?.target.screenIndex ??
heldButtonTarget?.screenIndex ??
targetForNode?.screenIndex ??
0;
const target: ComputerTarget = { nodeId, screenIndex };
const screenshotResult = async (
capture: ScreenshotCapture,
noteLines: string[],
): Promise<AgentToolResult<unknown>> => {
const frameId = crypto.randomUUID();
// Report the delivered dimensions, not the pre-sanitization capture size:
// sanitizeToolResultImages caps the longest edge to referenceWidth, so a
// portrait capture is scaled down. Advertising the original size would let
// the model pick coordinates against a wider frame than it was shown.
const longestEdge = Math.max(capture.width ?? 0, capture.height ?? 0);
const frameScale = longestEdge > referenceWidth ? referenceWidth / longestEdge : 1;
const deliveredWidth =
capture.width != null ? Math.round(capture.width * frameScale) : undefined;
const deliveredHeight =
capture.height != null ? Math.round(capture.height * frameScale) : undefined;
const dims =
deliveredWidth && deliveredHeight
? `${deliveredWidth}x${deliveredHeight}`
: "unknown size";
const text = [
...noteLines,
`screenshot ${dims} (screen ${screenIndex}, frameId ${frameId})`,
].join("\n");
const content: AgentToolResult<unknown>["content"] = [{ type: "text", text }];
if (options?.modelHasVision !== false) {
content.push({ type: "image", data: capture.base64, mimeType: capture.mimeType });
} else {
content.push({
type: "text",
text: "[model has no vision; screenshot omitted — use a vision-capable model for computer use]",
});
}
// Cap the delivered screenshot's longest edge to the reference width so
// the coordinate frame is stable across turns. Replay-sanitization in
// later turns caps the longest edge to the configured limit, which is
// >= referenceWidth, so it is a no-op and the node maps coordinates
// against this same width for both portrait and landscape captures. A
// portrait frame (height > referenceWidth) is uniformly scaled down here,
// matching OpenClawComputerInputGeometry.capturedWidth on the node.
// media.outbound=false keeps desktop pixels model-only (#44759).
const result = await sanitizeToolResultImages(
{
content,
details: {
node: nodeId,
action,
width: deliveredWidth,
height: deliveredHeight,
screenIndex,
frameId,
refWidth: referenceWidth,
media: { outbound: false },
},
},
`computer:${action}`,
{
maxDimensionPx: referenceWidth,
},
);
const deliveredImageIdentity = computerFrameImageIdentity(result.content);
if (options?.modelHasVision !== false && deliveredImageIdentity) {
// Only a model-visible, successfully sanitized image may authorize
// coordinates. A token also prevents same-turn batched clicks from
// targeting a screenshot the model has not observed yet.
setComputerState(
{
kind: "frame",
target,
id: frameId,
displayFrameId: capture.displayFrameId,
contextEpoch: options?.contextEpoch?.value ?? 0,
},
toolCallId,
deliveredImageIdentity,
);
} else {
setComputerState({ kind: "target", target });
}
return result;
};
const actEnvelopeResult = async (
result: ComputerActResult,
): Promise<AgentToolResult<unknown>> => {
const observation = result.observation;
if (observation?.observationId && capabilitiesForNode) {
observationState = {
nodeId,
providerGeneration: capabilitiesForNode.provider.generation,
observationId: observation.observationId,
};
}
const content: AgentToolResult<unknown>["content"] = [
{ type: "text", text: computerActResultText(action, result) },
];
if (observation?.base64 && options?.modelHasVision !== false) {
content.push({
type: "image",
data: observation.base64,
mimeType: imageMimeFromFormat(observation.format ?? "png") ?? "image/png",
});
}
setComputerState({ kind: "target", target });
return await sanitizeToolResultImages(
{
content,
details: {
node: nodeId,
action,
screenIndex,
result,
media: { outbound: false },
},
},
`computer:${action}`,
{ maxDimensionPx: referenceWidth },
);
};
switch (action) {
case "screenshot": {
setComputerState({ kind: "target", target });
const capture = await captureScreenshot({
gatewayOpts,
nodeId,
screenIndex,
refWidth: referenceWidth,
signal,
});
return await screenshotResult(capture, []);
}
case "wait": {
const seconds =
readFiniteNumberParam(params, "duration", {
min: 0,
max: MAX_WAIT_SECONDS,
message: `duration must be 0-${MAX_WAIT_SECONDS} seconds for wait`,
}) ?? 1;
setComputerState({ kind: "target", target });
await sleep(Math.round(seconds * 1000), signal);
const capture = await captureScreenshot({
gatewayOpts,
nodeId,
screenIndex,
refWidth: referenceWidth,
signal,
});
return await screenshotResult(capture, [`waited ${seconds}s`]);
}
default:
break;
}
if (!isComputerActAction(action)) {
throw new Error(`Unknown action: ${action}`);
}
const wireParams = buildComputerActParams({
action,
input: params,
screenIndex,
displayFrameId: frameForNode?.displayFrameId,
refWidth: referenceWidth,
});
// hold_key blocks node-side for its duration; give the invoke headroom.
const durationMs =
"durationMs" in wireParams && typeof wireParams.durationMs === "number"
? wireParams.durationMs
: undefined;
const invokeTimeoutMs = durationMs ? durationMs + 10_000 : undefined;
// Node/display resolution is asynchronous. Recheck before claiming
// affinity so pre-dispatch cancellation cannot leave a phantom hold.
signal?.throwIfAborted();
// Any input attempt invalidates the pre-action pixels, including timeouts
// and failures where the gateway cannot prove whether input landed. Keep
// affinity so a later coordinate-free cleanup action reaches this target.
setComputerState({ kind: "target", target });
if (action === "left_mouse_down") {
heldButtonTarget = target;
}
let actResult: ComputerActResult;
try {
actResult = parseComputerActPayload(
await invokeNodeCommand({
gatewayOpts,
nodeId,
command: COMPUTER_ACT_COMMAND,
commandParams: wireParams as unknown as Record<string, unknown>,
timeoutMs: invokeTimeoutMs,
idempotencyKey: computerActIdempotencyKey({
scope: options?.idempotencyScope,
toolCallId,
}),
signal,
}),
);
} catch (err) {
if (action === "left_mouse_down" && isDefinitiveComputerActRejection(err)) {
// Request validation and gateway policy denials happen before
// dispatch. UNAVAILABLE may arrive after input landed, so it keeps
// affinity until a matching release is confirmed.
heldButtonTarget = undefined;
}
if (action === "left_mouse_up" && isButtonAlreadyReleasedError(err)) {
// Lifecycle cleanup or the node watchdog may have released it first.
// Treat cleanup as idempotent without posting an unmatched mouse-up.
heldButtonTarget = undefined;
actResult = { ok: true };
} else {
throw withComputerEnablementHint(err);
}
}
if (action === "left_mouse_up") {
heldButtonTarget = undefined;
}
if (actResult.observation || READ_ONLY_COMPUTER_ACT_ACTIONS.has(action)) {
return await actEnvelopeResult(actResult);
}
await sleep(AFTER_ACTION_SCREENSHOT_DELAY_MS, signal);
try {
const capture = await captureScreenshot({
gatewayOpts,
nodeId,
screenIndex,
refWidth: referenceWidth,
signal,
});
return await screenshotResult(capture, [computerActResultText(action, actResult)]);
} catch (err) {
signal?.throwIfAborted();
// Input landed; a failed follow-up screenshot should not fail the action.
return {
content: [
{
type: "text",
text: `${computerActResultText(action, actResult)}\nfollow-up screenshot failed: ${formatErrorMessage(err)}`,
},
],
details: { node: nodeId, action, screenIndex, result: actResult },
};
}
}),
};
return tool;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */