blob: 3f861d30ee0c57e4e375bf0172df677b54feea8f [file]
import type { CuAction } from '@maka/core';
import {
convertOpenAIComputerAction,
isOpenAIComputerActionSafeByDefault,
type OpenAIComputerAction,
type OpenAIComputerActionConversion,
} from './openai-computer-actions.js';
import {
createOpenAIComputerContinuationRequest,
createOpenAIComputerInitialRequest,
decodeOpenAIComputerResponse,
type OpenAIComputerCall,
type OpenAIComputerDialect,
type OpenAIComputerRequest,
type OpenAIComputerResponse,
type OpenAIComputerSafetyCheck,
type OpenAIComputerScreenshot,
} from './openai-computer-codec.js';
import type { CuDispatchOutcome, CuRunResult } from './computer-use-types.js';
export const DEFAULT_OPENAI_COMPUTER_MAX_TURNS = 12;
export const DEFAULT_OPENAI_COMPUTER_MAX_ACTIONS_PER_CALL = 8;
export const DEFAULT_OPENAI_COMPUTER_DEADLINE_MS = 120_000;
export interface OpenAIComputerTransport {
create(request: OpenAIComputerRequest, signal: AbortSignal): Promise<unknown>;
}
export interface OpenAIComputerExecutor {
execute(action: CuAction, signal: AbortSignal): Promise<CuRunResult>;
}
export interface OpenAIComputerScreenshotProvider {
capture(signal: AbortSignal): Promise<OpenAIComputerScreenshot>;
}
export type OpenAIComputerLoopResult =
| { status: 'completed'; response: OpenAIComputerResponse; turns: number }
| {
status: 'safety_blocked';
response: OpenAIComputerResponse;
call: OpenAIComputerCall;
checks: OpenAIComputerSafetyCheck[];
turns: number;
}
| {
status: 'unsupported_action';
response: OpenAIComputerResponse;
call: OpenAIComputerCall;
actionIndex: number;
failure: Extract<OpenAIComputerActionConversion, { ok: false }>;
turns: number;
}
| {
status: 'execution_failed';
response: OpenAIComputerResponse;
call: OpenAIComputerCall;
actionIndex: number;
outcome: Extract<CuDispatchOutcome, { ok: false }>;
completedActions: number;
turns: number;
}
| {
status: 'outcome_unknown';
response: OpenAIComputerResponse;
call: OpenAIComputerCall;
actionIndex: number;
outcome: Extract<CuDispatchOutcome, { ok: false }>;
completedActions: number;
screenshot?: OpenAIComputerScreenshot;
turns: number;
}
| {
status: 'max_turns_reached';
response: OpenAIComputerResponse;
call: OpenAIComputerCall;
screenshot: OpenAIComputerScreenshot;
continuationRequest: OpenAIComputerRequest;
turns: number;
};
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) throw new Error('openai_computer_loop_aborted');
}
function positiveInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid_openai_computer_${label}: expected positive integer`);
}
return value;
}
function positiveFinite(value: number, label: string): number {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`invalid_openai_computer_${label}: expected positive finite number`);
}
return value;
}
function outcomeIsUnknown(
outcome: Extract<CuDispatchOutcome, { ok: false }>,
completedActions: number,
): boolean {
return (
outcome.error === 'outcome_unknown' ||
completedActions > 0 ||
(outcome.completedSubSteps ?? 0) > 0
);
}
export async function runOpenAIComputerLoop(input: {
dialect: OpenAIComputerDialect;
model: string;
prompt: string;
transport: OpenAIComputerTransport;
executor: OpenAIComputerExecutor;
screenshot: OpenAIComputerScreenshotProvider;
signal?: AbortSignal;
maxTurns?: number;
maxActionsPerCall?: number;
deadlineMs?: number;
display?: {
widthPx: number;
heightPx: number;
environment: 'browser' | 'mac' | 'windows' | 'linux';
};
acknowledgeSafetyChecks?: (
checks: OpenAIComputerSafetyCheck[],
call: OpenAIComputerCall,
signal: AbortSignal,
) => Promise<boolean>;
allowAction?: (
action: OpenAIComputerAction,
context: { turn: number; actionIndex: number; call: OpenAIComputerCall },
) => boolean | Promise<boolean>;
}): Promise<OpenAIComputerLoopResult> {
const maxTurns = positiveInteger(
input.maxTurns ?? DEFAULT_OPENAI_COMPUTER_MAX_TURNS,
'max_turns',
);
const maxActionsPerCall = positiveInteger(
input.maxActionsPerCall ?? DEFAULT_OPENAI_COMPUTER_MAX_ACTIONS_PER_CALL,
'max_actions_per_call',
);
const deadlineMs = positiveFinite(
input.deadlineMs ?? DEFAULT_OPENAI_COMPUTER_DEADLINE_MS,
'deadline_ms',
);
const controller = new AbortController();
let deadlineExceeded = false;
const onAbort = () => controller.abort(input.signal?.reason);
input.signal?.addEventListener('abort', onAbort, { once: true });
const deadline = setTimeout(() => {
deadlineExceeded = true;
controller.abort(new Error('openai_computer_loop_deadline_exceeded'));
}, deadlineMs);
const signal = controller.signal;
let request = createOpenAIComputerInitialRequest(input);
try {
for (let turns = 1; turns <= maxTurns; turns += 1) {
throwIfAborted(signal);
const response = decodeOpenAIComputerResponse(
await input.transport.create(request, signal),
input.dialect,
);
if (response.status === 'failed' || response.error) {
throw new Error(
`openai_computer_response_failed: ${
response.error?.code ?? response.error?.type ?? response.status
}: ${response.error?.message ?? 'request failed'}`,
);
}
if (response.status !== 'completed') {
throw new Error(`openai_computer_response_not_completed: ${response.status}`);
}
if (response.calls.length === 0) {
return { status: 'completed', response, turns };
}
if (response.calls.length !== 1) {
throw new Error(
`unsupported_openai_computer_parallel_calls: received ${response.calls.length}`,
);
}
const call = response.calls[0];
if (call.status !== 'completed') {
throw new Error(`openai_computer_call_not_completed: ${call.status}`);
}
if (call.actions.length > maxActionsPerCall) {
throw new Error(
`openai_computer_action_batch_limit_exceeded: ` +
`${call.actions.length} > ${maxActionsPerCall}`,
);
}
let acknowledgedSafetyChecks: OpenAIComputerSafetyCheck[] | undefined;
if (call.pendingSafetyChecks.length > 0) {
const acknowledged =
(await input.acknowledgeSafetyChecks?.(call.pendingSafetyChecks, call, signal)) ?? false;
if (!acknowledged) {
return {
status: 'safety_blocked',
response,
call,
checks: call.pendingSafetyChecks,
turns,
};
}
acknowledgedSafetyChecks = call.pendingSafetyChecks;
}
const converted: CuAction[][] = [];
for (let actionIndex = 0; actionIndex < call.actions.length; actionIndex += 1) {
const action = call.actions[actionIndex];
const allowed = input.allowAction
? await input.allowAction(action, { turn: turns, actionIndex, call })
: isOpenAIComputerActionSafeByDefault(action);
if (!allowed) {
return {
status: 'unsupported_action',
response,
call,
actionIndex,
failure: {
ok: false,
code: 'unsupported_action_policy',
message:
`OpenAI computer action '${action.type}' is disabled by the current ` +
'physical-input safety policy',
},
turns,
};
}
const conversion = convertOpenAIComputerAction(action);
if (!conversion.ok) {
return {
status: 'unsupported_action',
response,
call,
actionIndex,
failure: conversion,
turns,
};
}
converted.push(conversion.actions);
}
let completedActions = 0;
for (let actionIndex = 0; actionIndex < converted.length; actionIndex += 1) {
for (const action of converted[actionIndex]) {
throwIfAborted(signal);
const execution = await input.executor.execute(action, signal);
if (!execution.outcome.ok) {
const unknown = outcomeIsUnknown(execution.outcome, completedActions);
let screenshot: OpenAIComputerScreenshot | undefined;
if (unknown) {
screenshot = execution.screenshot
? {
base64: execution.screenshot.base64,
mimeType: execution.screenshot.mimeType,
}
: await input.screenshot.capture(signal);
}
return {
status: unknown ? 'outcome_unknown' : 'execution_failed',
response,
call,
actionIndex,
outcome: execution.outcome,
completedActions,
...(screenshot ? { screenshot } : {}),
turns,
};
}
completedActions += 1;
}
}
const screenshot = await input.screenshot.capture(signal);
const continuationRequest = createOpenAIComputerContinuationRequest({
dialect: input.dialect,
model: input.model,
previousResponseId: response.id,
callId: call.callId,
screenshot,
acknowledgedSafetyChecks,
display: input.display,
});
if (turns === maxTurns) {
return {
status: 'max_turns_reached',
response,
call,
screenshot,
continuationRequest,
turns,
};
}
request = continuationRequest;
}
throw new Error('openai_computer_loop_unreachable');
} catch (error) {
if (deadlineExceeded) {
throw new Error('openai_computer_loop_deadline_exceeded', { cause: error });
}
throw error;
} finally {
clearTimeout(deadline);
input.signal?.removeEventListener('abort', onAbort);
}
}