Files
oh-my-pi/packages/coding-agent/src/tools/computer.ts
T
2026-07-27 01:21:27 +00:00

517 lines
18 KiB
TypeScript

import type {
AgentTool,
AgentToolContext,
AgentToolResult,
AgentToolUpdateCallback,
ToolApprovalDecision,
} from "@oh-my-pi/pi-agent-core";
import type { ComputerAction, ComputerSafetyCheck, ComputerToolCallMetadata, Model } from "@oh-my-pi/pi-ai";
import { isClaudeModelId } from "@oh-my-pi/pi-catalog/identity";
import type {
DesktopAction,
DesktopCapabilities,
DesktopCapture,
DesktopDisplay,
DesktopSessionOptions,
} from "@oh-my-pi/pi-natives";
import { once, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
import { type Type, type } from "arktype";
import computerDescription from "../prompts/tools/computer.md" with { type: "text" };
import { truncateForPrompt } from "./approval";
import { type ComputerController, ComputerSupervisor, registerComputerController } from "./computer/supervisor";
import type { ToolSession } from "./index";
import { ToolError, throwIfAborted } from "./tool-errors";
// Image transports that cannot preserve native screenshot detail resize frames
// without returning transformed dimensions. Keep their native coordinate frames
// below the empirically verified threshold so pointer actions match what the
// model sees. Claude paths predate the resolved transport capability and retain
// their established model-family fallback.
const COORDINATE_SAFE_MAX_CAPTURE_WIDTH = 1280;
const COORDINATE_SAFE_MAX_CAPTURE_HEIGHT = 896;
function usesCoordinateSafeImageSizing(model: Model | undefined): boolean {
if (!model) return false;
const compat = model.compat;
return (
(!!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === false) ||
isClaudeModelId(model.id) ||
(model.requestModelId !== undefined && isClaudeModelId(model.requestModelId)) ||
(typeof model.name === "string" && /^claude(?:\s|$)/i.test(model.name))
);
}
function captureOptions(session: ToolSession, coordinateSafeImageSizing: boolean): DesktopSessionOptions {
const maxWidth = session.settings.get("computer.maxWidth");
const maxHeight = session.settings.get("computer.maxHeight");
return {
backend: session.settings.get("computer.backend"),
display: session.settings.get("computer.display"),
maxWidth: coordinateSafeImageSizing ? Math.min(maxWidth, COORDINATE_SAFE_MAX_CAPTURE_WIDTH) : maxWidth,
maxHeight: coordinateSafeImageSizing ? Math.min(maxHeight, COORDINATE_SAFE_MAX_CAPTURE_HEIGHT) : maxHeight,
};
}
// Desktop actions cross the N-API boundary as i32; out-of-range JS numbers
// must fail closed here instead of truncating in the napi conversion.
const INT32_MIN = -2_147_483_648;
const INT32_MAX = 2_147_483_647;
type ComputerSchemaPoint = {
x: number;
y: number;
};
type ComputerSchemaAction = {
type: ComputerAction["type"];
x?: number;
y?: number;
button?: "left" | "right" | "wheel" | "back" | "forward";
path?: ComputerSchemaPoint[];
keys?: string[] | null;
scroll_x?: number;
scroll_y?: number;
text?: string;
};
export type ComputerParams = {
actions?: ComputerSchemaAction[];
};
type IsSameType<Left, Right> = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false;
type ComputerSchema<Schema extends Type = Type<ComputerParams>> =
IsSameType<ComputerParams, Schema["infer"]> extends true ? Schema : never;
const getComputerSchema: () => ComputerSchema = once(() => {
const coordinateSchema = type("0 <= number.integer <= 2147483647");
const scrollDeltaSchema = type("-2147483648 <= number.integer <= 2147483647");
const pointSchema = type({
x: coordinateSchema.describe("x pixel coordinate"),
y: coordinateSchema.describe("y pixel coordinate"),
"+": "reject",
});
const computerActionSchema = type({
type: type(
"'click' | 'double_click' | 'drag' | 'keypress' | 'move' | 'screenshot' | 'scroll' | 'type' | 'wait'",
).describe("action kind"),
"x?": coordinateSchema.describe(
"x pixel coordinate in the most recent screenshot (click, double_click, move, scroll)",
),
"y?": coordinateSchema.describe(
"y pixel coordinate in the most recent screenshot (click, double_click, move, scroll)",
),
"button?": type("'left' | 'right' | 'wheel' | 'back' | 'forward'").describe("mouse button; required for click"),
"path?": pointSchema.array().atLeastLength(2).describe("waypoints from press to release; required for drag"),
"keys?": type("string[] | null").describe(
"key names (e.g. CTRL, SHIFT, ENTER, A); required chord for keypress, optional held modifiers for pointer actions",
),
"scroll_x?": scrollDeltaSchema.describe("horizontal scroll delta in pixels; required for scroll"),
"scroll_y?": scrollDeltaSchema.describe(
"vertical scroll delta in pixels, positive scrolls content down; required for scroll",
),
"text?": type("string").describe("literal text to type; required for type"),
"+": "reject",
});
const computerSchema = type({
"actions?": computerActionSchema
.array()
.describe("ordered actions executed as one batch; omit or pass [] to just capture a screenshot"),
"+": "reject",
});
return computerSchema satisfies ComputerSchema<typeof computerSchema>;
});
export interface ComputerToolDetails {
width: number;
height: number;
backend: DesktopCapture["backend"];
displayServer?: string;
capturePermission: string;
inputPermission: string;
displays: DesktopDisplay[];
capabilities?: DesktopCapabilities;
actions: ComputerAction["type"][];
}
export type ComputerControllerFactory = (options: DesktopSessionOptions) => ComputerController;
function isInt32(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= INT32_MIN && value <= INT32_MAX;
}
function isCoordinate(value: unknown): value is number {
return isInt32(value) && value >= 0;
}
type AllowedFields = Record<string, true>;
const POINT_FIELDS: AllowedFields = { x: true, y: true };
const MOUSE_BUTTONS: AllowedFields = { left: true, right: true, wheel: true, back: true, forward: true };
const ACTION_FIELDS: Record<ComputerAction["type"], AllowedFields> = {
click: { type: true, button: true, x: true, y: true, keys: true },
double_click: { type: true, x: true, y: true, keys: true },
drag: { type: true, path: true, keys: true },
keypress: { type: true, keys: true },
move: { type: true, x: true, y: true, keys: true },
screenshot: { type: true },
scroll: { type: true, x: true, y: true, scroll_x: true, scroll_y: true, keys: true },
type: { type: true, text: true },
wait: { type: true },
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function fieldsForAction(actionType: string): AllowedFields | undefined {
switch (actionType) {
case "click":
case "double_click":
case "drag":
case "keypress":
case "move":
case "screenshot":
case "scroll":
case "type":
case "wait":
return ACTION_FIELDS[actionType];
default:
return undefined;
}
}
function hasOnlyFields(value: Record<string, unknown>, allowed: AllowedFields): boolean {
return Object.keys(value).every(key => allowed[key] === true);
}
function isPoint(value: unknown): value is { x: number; y: number } {
return isRecord(value) && hasOnlyFields(value, POINT_FIELDS) && isCoordinate(value.x) && isCoordinate(value.y);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
function modifierBit(value: string): number {
switch (value.trim().toUpperCase()) {
case "CTRL":
case "CONTROL":
return 1;
case "SHIFT":
return 2;
case "ALT":
case "OPTION":
return 4;
case "META":
case "CMD":
case "COMMAND":
case "SUPER":
case "WINDOWS":
return 8;
default:
return 0;
}
}
function isModifierArray(value: unknown): value is string[] | null | undefined {
if (value == null) return true;
if (!isStringArray(value)) return false;
let seen = 0;
for (const entry of value) {
for (const component of entry.split("+")) {
const bit = modifierBit(component);
if (bit === 0 || (seen & bit) !== 0) return false;
seen |= bit;
}
}
return true;
}
function isKeypressArray(value: unknown): value is string[] {
return (
isStringArray(value) &&
value.length > 0 &&
value.every(key => key.split("+").every(component => component.trim().length > 0))
);
}
function isComputerAction(value: unknown): value is ComputerAction {
if (!isRecord(value) || typeof value.type !== "string") return false;
const action = value;
const actionType = value.type;
const allowed = fieldsForAction(actionType);
if (!allowed || !hasOnlyFields(action, allowed)) return false;
switch (actionType) {
case "click":
return (
isCoordinate(action.x) &&
isCoordinate(action.y) &&
typeof action.button === "string" &&
MOUSE_BUTTONS[action.button] === true &&
isModifierArray(action.keys)
);
case "double_click":
return isCoordinate(action.x) && isCoordinate(action.y) && isModifierArray(action.keys);
case "drag":
return (
Array.isArray(action.path) &&
action.path.length >= 2 &&
action.path.every(isPoint) &&
isModifierArray(action.keys)
);
case "keypress":
return isKeypressArray(action.keys);
case "move":
return isCoordinate(action.x) && isCoordinate(action.y) && isModifierArray(action.keys);
case "screenshot":
case "wait":
return true;
case "scroll":
return (
isCoordinate(action.x) &&
isCoordinate(action.y) &&
isInt32(action.scroll_x) &&
isInt32(action.scroll_y) &&
isModifierArray(action.keys)
);
case "type":
return typeof action.text === "string";
default:
return false;
}
}
function parseActions(value: unknown): ComputerAction[] {
// Missing or empty action batches degrade to a plain screenshot so a
// function-calling model can observe the screen before acting.
if (value == null) return [{ type: "screenshot" }];
if (!Array.isArray(value)) throw new ToolError("Computer call requires an array of actions");
if (value.length === 0) return [{ type: "screenshot" }];
if (!value.every(isComputerAction)) throw new ToolError("Computer call contains an invalid action");
return value;
}
function toDesktopAction(action: ComputerAction): DesktopAction {
switch (action.type) {
case "click":
return {
type: "click",
x: action.x,
y: action.y,
button: action.button,
...(action.keys ? { keys: action.keys } : {}),
};
case "double_click":
return {
type: "double_click",
x: action.x,
y: action.y,
...(action.keys ? { keys: action.keys } : {}),
};
case "drag":
return { type: "drag", path: action.path, ...(action.keys ? { keys: action.keys } : {}) };
case "keypress":
return { type: "keypress", keys: action.keys };
case "move":
return { type: "move", x: action.x, y: action.y, ...(action.keys ? { keys: action.keys } : {}) };
case "screenshot":
return { type: "screenshot" };
case "scroll":
return {
type: "scroll",
x: action.x,
y: action.y,
scroll_x: action.scroll_x,
scroll_y: action.scroll_y,
...(action.keys ? { keys: action.keys } : {}),
};
case "type":
return { type: "type", text: action.text };
case "wait":
return { type: "wait" };
}
}
function callMetadata(context: AgentToolContext | undefined): ComputerToolCallMetadata | undefined {
const metadata = context?.toolCall?.providerMetadata;
return metadata?.type === "computer" ? metadata : undefined;
}
export function computerApproval(args: unknown): ToolApprovalDecision {
const actions =
args && typeof args === "object" && "actions" in args ? (args as { actions?: unknown }).actions : undefined;
if (actions == null) return "read";
if (!Array.isArray(actions)) return "exec";
return actions.every(action => {
if (!action || typeof action !== "object") return false;
const actionType = (action as { type?: unknown }).type;
return actionType === "screenshot" || actionType === "wait";
})
? "read"
: "exec";
}
function modifierSummary(keys: unknown): string {
return isStringArray(keys) && keys.length > 0 ? ` keys=${JSON.stringify(keys)}` : "";
}
function approvalActionSummary(actions: unknown): string[] {
if (!Array.isArray(actions)) return ["Actions: unavailable"];
const lines = actions.slice(0, 12).map((value, index) => {
if (!value || typeof value !== "object") return `${index + 1}. invalid`;
const action = value as Record<string, unknown>;
const type = typeof action.type === "string" ? action.type : "invalid";
let detail: string;
switch (type) {
case "click":
detail = `click button=${String(action.button)} at (${String(action.x)}, ${String(action.y)})${modifierSummary(action.keys)}`;
break;
case "double_click":
detail = `double_click at (${String(action.x)}, ${String(action.y)})${modifierSummary(action.keys)}`;
break;
case "drag":
detail = `drag path=${Array.isArray(action.path) ? action.path.map(point => (isPoint(point) ? `(${point.x}, ${point.y})` : "invalid")).join(" -> ") : "invalid"}${modifierSummary(action.keys)}`;
break;
case "keypress":
detail = `keypress keys=${JSON.stringify(action.keys)}`;
break;
case "move":
detail = `move to (${String(action.x)}, ${String(action.y)})${modifierSummary(action.keys)}`;
break;
case "scroll":
detail = `scroll at (${String(action.x)}, ${String(action.y)}) delta=(${String(action.scroll_x)}, ${String(action.scroll_y)})${modifierSummary(action.keys)}`;
break;
case "type":
detail = `type text=${JSON.stringify(action.text)}`;
break;
case "screenshot":
case "wait":
detail = type;
break;
default:
detail = type;
}
return truncateForPrompt(sanitizeText(`${index + 1}. ${detail}`).replace(/[\r\n\t]+/g, " "), 240);
});
if (actions.length > 12) lines.push(`+${actions.length - 12} more actions`);
return truncateForPrompt(lines.join("\n"), 2_000).split("\n");
}
export class ComputerTool implements AgentTool<ComputerSchema, ComputerToolDetails> {
readonly name = "computer";
readonly native = { type: "computer" } as const;
readonly label = "Computer";
readonly loadMode = "essential" as const;
readonly concurrency = "exclusive" as const;
readonly summary = "Capture and control the host desktop through native OS APIs";
get parameters(): ComputerSchema {
return getComputerSchema();
}
readonly strict = false;
readonly approval = computerApproval;
readonly formatApprovalDetails = (args: unknown): string[] => {
const actions = args && typeof args === "object" ? (args as { actions?: unknown }).actions : undefined;
return approvalActionSummary(actions);
};
/**
* Settings snapshot used to create the tool's current controller; refreshed
* when a model switch crosses the coordinate-safe sizing boundary. Surfaced
* by `/computer status`.
*/
effectiveConfiguration: Readonly<DesktopSessionOptions>;
readonly #createController: ComputerControllerFactory;
#controller: ComputerController;
#unregisterOwner: () => void;
#usesCoordinateSafeImageSizing: boolean;
#closed = false;
#description?: string;
constructor(
readonly session: ToolSession,
createController: ComputerControllerFactory = options => new ComputerSupervisor(options),
) {
this.#createController = createController;
this.#usesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(session.getActiveModel?.());
this.effectiveConfiguration = Object.freeze(captureOptions(session, this.#usesCoordinateSafeImageSizing));
this.#controller = createController(this.effectiveConfiguration);
this.#unregisterOwner = registerComputerController(
session.getEvalKernelOwnerId?.() ?? undefined,
this.#controller,
);
}
get description(): string {
this.#description ??= prompt.render(computerDescription);
return this.#description;
}
async #refreshControllerForModel(): Promise<void> {
const nextUsesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(this.session.getActiveModel?.());
if (nextUsesCoordinateSafeImageSizing === this.#usesCoordinateSafeImageSizing) return;
const previous = this.#controller;
const nextOptions = Object.freeze(captureOptions(this.session, nextUsesCoordinateSafeImageSizing));
const next = this.#createController(nextOptions);
this.#unregisterOwner();
this.#controller = next;
this.#unregisterOwner = registerComputerController(this.session.getEvalKernelOwnerId?.() ?? undefined, next);
this.#usesCoordinateSafeImageSizing = nextUsesCoordinateSafeImageSizing;
this.effectiveConfiguration = nextOptions;
await previous.close();
}
async execute(
_toolCallId: string,
params: ComputerParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<ComputerToolDetails>,
context?: AgentToolContext,
): Promise<AgentToolResult<ComputerToolDetails>> {
throwIfAborted(signal);
if (this.#closed) throw new ToolError("Computer session is closed");
const metadata = callMetadata(context);
const actions = parseActions(metadata?.actions ?? params.actions);
const pendingSafetyChecks: ComputerSafetyCheck[] = metadata?.pendingSafetyChecks ?? [];
if (pendingSafetyChecks.length > 0 && context?.providerSafetyApproved !== true) {
throw new ToolError("Provider safety checks require interactive approval before computer input");
}
await this.#refreshControllerForModel();
throwIfAborted(signal);
const capture = await this.#controller.execute(actions.map(toDesktopAction), signal);
throwIfAborted(signal);
const data = Buffer.from(capture.data).toBase64();
return {
content: [{ type: "image", data, mimeType: "image/png", detail: "original" }],
details: {
width: capture.width,
height: capture.height,
backend: capture.backend,
displayServer: capture.displayServer,
capturePermission: capture.capturePermission,
inputPermission: capture.inputPermission,
displays: capture.displays,
capabilities: this.#controller.capabilities,
actions: actions.map(action => action.type),
},
...(metadata
? {
providerMetadata: {
type: "computer" as const,
screenshot: { type: "computer_screenshot" as const, image_url: `data:image/png;base64,${data}` },
acknowledgedSafetyChecks: pendingSafetyChecks,
},
}
: {}),
};
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#unregisterOwner();
await this.#controller.close();
}
}