@loadmill/droid-cua 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +11 -2
- package/build/index.js +7 -6
- package/build/src/cli/headless-execution-config.js +59 -3
- package/build/src/cli/headless-instructions.js +12 -0
- package/build/src/commands/help.js +3 -1
- package/build/src/core/context-optimization.js +36 -0
- package/build/src/core/execution-engine.js +383 -130
- package/build/src/device/android/actions.js +5 -1
- package/build/src/device/assertions.js +6 -4
- package/build/src/device/cloud/actions.js +5 -1
- package/build/src/device/ios/actions.js +5 -1
- package/build/src/device/ios/appium-server.js +4 -3
- package/build/src/device/ios/node-cli-env.js +28 -0
- package/build/src/device/openai.js +127 -48
- package/build/src/modes/execution-mode.js +36 -3
- package/build/src/prompts/base.js +6 -0
- package/build/src/utils/cua-debug-tracer.js +48 -5
- package/build/src/utils/run-screenshot-recorder.js +161 -0
- package/package.json +6 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import { writeFile } from "fs/promises";
|
|
3
4
|
import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
|
|
@@ -6,31 +7,100 @@ import { sendCUARequest } from "../device/openai.js";
|
|
|
6
7
|
import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
7
8
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
8
9
|
import { printCliOutput } from "../utils/console-output.js";
|
|
9
|
-
|
|
10
|
+
import { annotateScreenshotArtifact, createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
|
|
11
|
+
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
12
|
+
function extractActionableOutputItems(items) {
|
|
10
13
|
const entries = [];
|
|
11
14
|
for (const item of items) {
|
|
12
|
-
if (item?.type
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
if (item?.type === "function_call" && item.name === "preview_click") {
|
|
16
|
+
entries.push({
|
|
17
|
+
type: "preview_click",
|
|
18
|
+
call_id: item.call_id || item.id,
|
|
19
|
+
arguments: item.arguments,
|
|
20
|
+
});
|
|
16
21
|
continue;
|
|
17
|
-
const actions = [];
|
|
18
|
-
if (Array.isArray(item.actions)) {
|
|
19
|
-
for (const actionEntry of item.actions) {
|
|
20
|
-
if (!actionEntry)
|
|
21
|
-
continue;
|
|
22
|
-
const action = actionEntry.action || actionEntry;
|
|
23
|
-
if (action?.type)
|
|
24
|
-
actions.push(action);
|
|
25
|
-
}
|
|
26
22
|
}
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
if (item?.type === "computer_call") {
|
|
24
|
+
const baseCallId = item.call_id || item.id;
|
|
25
|
+
if (!baseCallId)
|
|
26
|
+
continue;
|
|
27
|
+
const actions = [];
|
|
28
|
+
if (Array.isArray(item.actions)) {
|
|
29
|
+
for (const actionEntry of item.actions) {
|
|
30
|
+
if (!actionEntry)
|
|
31
|
+
continue;
|
|
32
|
+
const action = actionEntry.action || actionEntry;
|
|
33
|
+
if (action?.type)
|
|
34
|
+
actions.push(action);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (item.action?.type) {
|
|
38
|
+
actions.push(item.action);
|
|
39
|
+
}
|
|
40
|
+
entries.push({ type: "computer_call", call_id: baseCallId, actions });
|
|
29
41
|
}
|
|
30
|
-
entries.push({ call_id: baseCallId, actions });
|
|
31
42
|
}
|
|
32
43
|
return entries;
|
|
33
44
|
}
|
|
45
|
+
function buildPreviewClickValidationError(argumentsText) {
|
|
46
|
+
if (!argumentsText || typeof argumentsText !== "string") {
|
|
47
|
+
return "Invalid preview_click arguments: expected a JSON object with numeric x and y.";
|
|
48
|
+
}
|
|
49
|
+
return `Invalid preview_click arguments: ${argumentsText}`;
|
|
50
|
+
}
|
|
51
|
+
function parsePreviewClickArguments(argumentsText) {
|
|
52
|
+
let parsedArguments;
|
|
53
|
+
try {
|
|
54
|
+
parsedArguments = JSON.parse(argumentsText);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
message: buildPreviewClickValidationError("arguments could not be parsed as JSON; expected numeric x and y."),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const x = parsedArguments?.x;
|
|
63
|
+
const y = parsedArguments?.y;
|
|
64
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
message: buildPreviewClickValidationError("x and y must both be numeric."),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
x,
|
|
73
|
+
y,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function buildFunctionCallOutputItem(callId, output) {
|
|
77
|
+
return {
|
|
78
|
+
type: "function_call_output",
|
|
79
|
+
call_id: callId,
|
|
80
|
+
output,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function buildPreviewOutputText({ requestedX, requestedY, renderedX, renderedY, wasClamped }) {
|
|
84
|
+
const baseText = `Marker placed at (${renderedX}, ${renderedY}) on the current screen. The click has not been executed.`;
|
|
85
|
+
if (!wasClamped) {
|
|
86
|
+
return baseText;
|
|
87
|
+
}
|
|
88
|
+
return `${baseText} Requested point (${requestedX}, ${requestedY}) was off-screen; marker rendered at (${renderedX}, ${renderedY}).`;
|
|
89
|
+
}
|
|
90
|
+
function buildPreviewOutputContent({ text, annotatedScreenshotBase64 = null }) {
|
|
91
|
+
const content = [{
|
|
92
|
+
type: "input_text",
|
|
93
|
+
text,
|
|
94
|
+
}];
|
|
95
|
+
if (annotatedScreenshotBase64) {
|
|
96
|
+
content.push({
|
|
97
|
+
type: "input_image",
|
|
98
|
+
image_url: `data:image/png;base64,${annotatedScreenshotBase64}`,
|
|
99
|
+
detail: "high",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return content;
|
|
103
|
+
}
|
|
34
104
|
function getScopeAndIds(context = null, stepContext = null) {
|
|
35
105
|
const scope = context?.sessionId ? "design" : "execution";
|
|
36
106
|
const ids = scope === "design"
|
|
@@ -80,6 +150,37 @@ function buildStrictModePlan(actions = [], strictMode = false) {
|
|
|
80
150
|
runtimeNote
|
|
81
151
|
};
|
|
82
152
|
}
|
|
153
|
+
function normalizeArtifactPaths(result) {
|
|
154
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
155
|
+
if (Array.isArray(result.annotationSourcePaths)) {
|
|
156
|
+
return normalizeArtifactPaths(result.annotationSourcePaths);
|
|
157
|
+
}
|
|
158
|
+
return normalizeArtifactPaths(result.artifactPaths);
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(result)) {
|
|
161
|
+
return result.filter((value) => typeof value === "string" && value.trim());
|
|
162
|
+
}
|
|
163
|
+
if (typeof result === "string" && result.trim()) {
|
|
164
|
+
return [result];
|
|
165
|
+
}
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
function screenshotPathFromRecorderResult(result) {
|
|
169
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
170
|
+
return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
|
|
171
|
+
? result.screenshotPath
|
|
172
|
+
: null;
|
|
173
|
+
}
|
|
174
|
+
return typeof result === "string" && result.trim() ? result : null;
|
|
175
|
+
}
|
|
176
|
+
function screenshotDataUrlFromRecorderResult(result) {
|
|
177
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
178
|
+
return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
|
|
179
|
+
? result.screenshotDataUrl
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
83
184
|
export class ExecutionEngine {
|
|
84
185
|
constructor(session, options = {}) {
|
|
85
186
|
this.session = session;
|
|
@@ -89,26 +190,45 @@ export class ExecutionEngine {
|
|
|
89
190
|
this.strictMode = options.strictMode === true;
|
|
90
191
|
this.stepDelayMs = getConfiguredStepDelayMs();
|
|
91
192
|
this.reportedScreenshotWriteError = false;
|
|
193
|
+
this.reportedScreenshotAnnotateError = false;
|
|
194
|
+
this.lastRecordedScreenshotPaths = [];
|
|
92
195
|
this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
|
|
93
196
|
this.handleModelAction = options.handleModelAction || handleModelAction;
|
|
94
197
|
this.sendCUARequest = options.sendCUARequest || sendCUARequest;
|
|
95
198
|
this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
|
|
96
199
|
}
|
|
97
|
-
async
|
|
200
|
+
async saveScreenshotArtifact(screenshotBase64, metadata = {}, options = {}) {
|
|
98
201
|
if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
|
|
99
202
|
return null;
|
|
100
203
|
}
|
|
204
|
+
const updateLastRecordedScreenshotPaths = options.updateLastRecordedScreenshotPaths !== false;
|
|
205
|
+
const previousRecordedScreenshotPaths = updateLastRecordedScreenshotPaths
|
|
206
|
+
? null
|
|
207
|
+
: [...this.lastRecordedScreenshotPaths];
|
|
101
208
|
try {
|
|
102
209
|
if (this.screenshotRecorder?.saveScreenshot) {
|
|
103
|
-
|
|
210
|
+
const result = await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
|
|
211
|
+
if (updateLastRecordedScreenshotPaths) {
|
|
212
|
+
this.lastRecordedScreenshotPaths = normalizeArtifactPaths(result);
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
104
215
|
}
|
|
105
216
|
if (this.recordScreenshots && this.screenshotDir) {
|
|
106
217
|
const framePath = path.join(this.screenshotDir, `frame_${String(Date.now())}.png`);
|
|
107
218
|
await writeFile(framePath, Buffer.from(screenshotBase64, "base64"));
|
|
219
|
+
if (updateLastRecordedScreenshotPaths) {
|
|
220
|
+
this.lastRecordedScreenshotPaths = [framePath];
|
|
221
|
+
}
|
|
108
222
|
return framePath;
|
|
109
223
|
}
|
|
110
224
|
}
|
|
111
225
|
catch (error) {
|
|
226
|
+
if (updateLastRecordedScreenshotPaths) {
|
|
227
|
+
this.lastRecordedScreenshotPaths = [];
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
this.lastRecordedScreenshotPaths = previousRecordedScreenshotPaths;
|
|
231
|
+
}
|
|
112
232
|
if (!this.reportedScreenshotWriteError) {
|
|
113
233
|
this.reportedScreenshotWriteError = true;
|
|
114
234
|
const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
|
|
@@ -131,6 +251,100 @@ export class ExecutionEngine {
|
|
|
131
251
|
}
|
|
132
252
|
return null;
|
|
133
253
|
}
|
|
254
|
+
async recordScreenshot(screenshotBase64, metadata = {}) {
|
|
255
|
+
return await this.saveScreenshotArtifact(screenshotBase64, metadata);
|
|
256
|
+
}
|
|
257
|
+
async recordPreviewScreenshot(screenshotBase64, metadata = {}) {
|
|
258
|
+
return await this.saveScreenshotArtifact(screenshotBase64, metadata, {
|
|
259
|
+
updateLastRecordedScreenshotPaths: false,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async annotateLatestScreenshot(action, metadata = {}) {
|
|
263
|
+
if (!(action?.type === "click" || action?.type === "double_click") || this.lastRecordedScreenshotPaths.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const marker = {
|
|
268
|
+
x: action.x,
|
|
269
|
+
y: action.y,
|
|
270
|
+
style: "click",
|
|
271
|
+
};
|
|
272
|
+
const sourcePath = this.lastRecordedScreenshotPaths.find((filePath) => typeof filePath === "string" && filePath.trim());
|
|
273
|
+
if (sourcePath && this.screenshotRecorder?.saveAnnotatedScreenshot) {
|
|
274
|
+
return await this.screenshotRecorder.saveAnnotatedScreenshot(sourcePath, marker, metadata);
|
|
275
|
+
}
|
|
276
|
+
await Promise.all(this.lastRecordedScreenshotPaths.map((filePath) => annotateScreenshotArtifact(filePath, marker)));
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (!this.reportedScreenshotAnnotateError) {
|
|
281
|
+
this.reportedScreenshotAnnotateError = true;
|
|
282
|
+
const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
|
|
283
|
+
const ids = scope === "design"
|
|
284
|
+
? {
|
|
285
|
+
sessionId: metadata.sessionId,
|
|
286
|
+
stepId: metadata.stepId,
|
|
287
|
+
instructionIndex: metadata.instructionIndex
|
|
288
|
+
}
|
|
289
|
+
: {
|
|
290
|
+
runId: metadata.runId,
|
|
291
|
+
stepId: metadata.stepId,
|
|
292
|
+
instructionIndex: metadata.instructionIndex
|
|
293
|
+
};
|
|
294
|
+
emitDesktopDebug("run.screenshot_annotate.error", scope, ids, {
|
|
295
|
+
actionType: action?.type ?? null,
|
|
296
|
+
x: action?.x ?? null,
|
|
297
|
+
y: action?.y ?? null,
|
|
298
|
+
message: error instanceof Error ? error.message : "Failed to annotate screenshot"
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
async handlePreviewClick(callId, argumentsText, context = null, stepContext = null) {
|
|
305
|
+
const parsedArguments = parsePreviewClickArguments(argumentsText);
|
|
306
|
+
if (!parsedArguments.ok) {
|
|
307
|
+
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({ text: parsedArguments.message }));
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
311
|
+
const previewResult = await createAnnotatedScreenshotFromBase64(screenshotBase64, {
|
|
312
|
+
x: parsedArguments.x,
|
|
313
|
+
y: parsedArguments.y,
|
|
314
|
+
style: "preview",
|
|
315
|
+
});
|
|
316
|
+
if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
|
|
317
|
+
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
318
|
+
text: "preview_click failed: could not annotate the current screenshot.",
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
const previewEventId = randomUUID();
|
|
322
|
+
await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
|
+
runId: context?.runId,
|
|
324
|
+
sessionId: context?.sessionId,
|
|
325
|
+
stepId: stepContext?.stepId,
|
|
326
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
327
|
+
callId,
|
|
328
|
+
eventId: previewEventId,
|
|
329
|
+
captureSource: "preview-click",
|
|
330
|
+
});
|
|
331
|
+
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
332
|
+
text: buildPreviewOutputText({
|
|
333
|
+
requestedX: previewResult.marker.requestedX,
|
|
334
|
+
requestedY: previewResult.marker.requestedY,
|
|
335
|
+
renderedX: previewResult.marker.x,
|
|
336
|
+
renderedY: previewResult.marker.y,
|
|
337
|
+
wasClamped: previewResult.marker.wasClamped,
|
|
338
|
+
}),
|
|
339
|
+
annotatedScreenshotBase64: previewResult.screenshotBase64,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
344
|
+
text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
134
348
|
/**
|
|
135
349
|
* Run a full turn with the CUA model
|
|
136
350
|
* Executes actions until the model stops requesting actions
|
|
@@ -156,7 +370,7 @@ export class ExecutionEngine {
|
|
|
156
370
|
return newResponseId;
|
|
157
371
|
}
|
|
158
372
|
const items = response.output || [];
|
|
159
|
-
const
|
|
373
|
+
const actionableItems = extractActionableOutputItems(items);
|
|
160
374
|
// ── Collect pending safety checks ──
|
|
161
375
|
const pendingSafetyChecks = items
|
|
162
376
|
.filter(item => item.type === "pending_safety_check")
|
|
@@ -203,117 +417,18 @@ export class ExecutionEngine {
|
|
|
203
417
|
});
|
|
204
418
|
}
|
|
205
419
|
}
|
|
206
|
-
if (
|
|
420
|
+
if (actionableItems.length === 0) {
|
|
207
421
|
// No actions = turn complete
|
|
208
422
|
break;
|
|
209
423
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return newResponseId;
|
|
219
|
-
}
|
|
220
|
-
if (action.type === "screenshot") {
|
|
221
|
-
sawExplicitScreenshotAction = true;
|
|
222
|
-
addOutput({
|
|
223
|
-
type: 'info',
|
|
224
|
-
text: 'Capturing screen',
|
|
225
|
-
eventType: 'screenshot_captured',
|
|
226
|
-
actionType: 'screenshot',
|
|
227
|
-
...eventMeta({
|
|
228
|
-
payload: {
|
|
229
|
-
callId: call_id,
|
|
230
|
-
source: 'explicit_action'
|
|
231
|
-
}
|
|
232
|
-
})
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
237
|
-
...context,
|
|
238
|
-
shouldStop,
|
|
239
|
-
stepId: stepContext?.stepId,
|
|
240
|
-
instructionIndex: stepContext?.instructionIndex
|
|
241
|
-
});
|
|
242
|
-
// Track action and check for interruption
|
|
243
|
-
if (trackAction) {
|
|
244
|
-
trackAction(action);
|
|
245
|
-
}
|
|
246
|
-
if (shouldStop()) {
|
|
247
|
-
// User interrupted - stop execution immediately
|
|
248
|
-
return newResponseId;
|
|
249
|
-
}
|
|
250
|
-
// Add delay after UI-changing actions to let the interface update
|
|
251
|
-
// before taking the screenshot (except for explicit wait actions which have their own delay)
|
|
252
|
-
if (action.type !== "wait") {
|
|
253
|
-
await new Promise(resolve => setTimeout(resolve, this.stepDelayMs));
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (shouldStop()) {
|
|
258
|
-
return newResponseId;
|
|
259
|
-
}
|
|
260
|
-
const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
261
|
-
emitDesktopDebug("device.screenshot", "device", {
|
|
262
|
-
runId: context?.runId,
|
|
263
|
-
stepId: stepContext?.stepId,
|
|
264
|
-
instructionIndex: stepContext?.instructionIndex
|
|
265
|
-
}, {
|
|
266
|
-
source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
|
|
267
|
-
callId: call_id,
|
|
268
|
-
width: this.session.deviceInfo?.scaled_width,
|
|
269
|
-
height: this.session.deviceInfo?.scaled_height,
|
|
270
|
-
base64Length: screenshotBase64.length
|
|
271
|
-
});
|
|
272
|
-
await this.recordScreenshot(screenshotBase64, {
|
|
273
|
-
runId: context?.runId,
|
|
274
|
-
sessionId: context?.sessionId,
|
|
275
|
-
stepId: stepContext?.stepId,
|
|
276
|
-
instructionIndex: stepContext?.instructionIndex,
|
|
277
|
-
callId: call_id,
|
|
278
|
-
captureSource: sawExplicitScreenshotAction ? "call-output-explicit-action" : "call-output-post-action"
|
|
279
|
-
});
|
|
280
|
-
if (runtimeNote) {
|
|
281
|
-
const executedActionTypes = actionsToExecute
|
|
282
|
-
.map((action) => action?.type)
|
|
283
|
-
.filter(Boolean);
|
|
284
|
-
const droppedActionTypes = droppedActions
|
|
285
|
-
.map((action) => action?.type)
|
|
286
|
-
.filter(Boolean);
|
|
287
|
-
emitDesktopDebug("cua.strict_mode.truncation", scope, ids, {
|
|
288
|
-
callId: call_id,
|
|
289
|
-
executedCount: actionsToExecute.length,
|
|
290
|
-
droppedCount: droppedActions.length,
|
|
291
|
-
executedActionTypes,
|
|
292
|
-
droppedActionTypes,
|
|
293
|
-
reason: truncationReason,
|
|
294
|
-
note: runtimeNote
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
// Build next input: screenshot + any carryover reasoning
|
|
298
|
-
const selectedCuaModel = process.env.OPENAI_CUA_MODEL === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
|
|
299
|
-
const input = [{
|
|
300
|
-
type: "computer_call_output",
|
|
301
|
-
call_id,
|
|
302
|
-
output: {
|
|
303
|
-
type: "computer_screenshot",
|
|
304
|
-
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
305
|
-
},
|
|
306
|
-
...(selectedCuaModel === "computer-use-preview"
|
|
307
|
-
? { current_url: this.getCurrentPlatform() === "ios" ? "ios://simulator" : "android://device" }
|
|
308
|
-
: {}),
|
|
309
|
-
...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
|
|
310
|
-
}];
|
|
311
|
-
if (runtimeNote) {
|
|
312
|
-
input.push({
|
|
313
|
-
role: "user",
|
|
314
|
-
content: runtimeNote
|
|
315
|
-
});
|
|
316
|
-
}
|
|
424
|
+
const actionableItem = actionableItems[0];
|
|
425
|
+
if (!actionableItem?.call_id) {
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (actionableItem.type === "preview_click") {
|
|
429
|
+
const input = [
|
|
430
|
+
await this.handlePreviewClick(actionableItem.call_id, actionableItem.arguments, context, stepContext),
|
|
431
|
+
];
|
|
317
432
|
if (shouldStop()) {
|
|
318
433
|
return newResponseId;
|
|
319
434
|
}
|
|
@@ -330,9 +445,147 @@ export class ExecutionEngine {
|
|
|
330
445
|
}
|
|
331
446
|
});
|
|
332
447
|
newResponseId = response.id;
|
|
333
|
-
|
|
334
|
-
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const { call_id, actions } = actionableItem;
|
|
451
|
+
const { actionsToExecute, droppedActions, truncationReason, runtimeNote } = buildStrictModePlan(actions, this.strictMode);
|
|
452
|
+
let sawExplicitScreenshotAction = false;
|
|
453
|
+
for (const action of actionsToExecute) {
|
|
454
|
+
const actionEventId = randomUUID();
|
|
455
|
+
if (shouldStop()) {
|
|
456
|
+
return newResponseId;
|
|
457
|
+
}
|
|
458
|
+
if (action.type === "screenshot") {
|
|
459
|
+
sawExplicitScreenshotAction = true;
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
const annotatedScreenshotResult = await this.annotateLatestScreenshot(action, {
|
|
463
|
+
runId: context?.runId,
|
|
464
|
+
sessionId: context?.sessionId,
|
|
465
|
+
stepId: stepContext?.stepId,
|
|
466
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
467
|
+
eventId: actionEventId,
|
|
468
|
+
captureSource: "action-click-annotation"
|
|
469
|
+
});
|
|
470
|
+
const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
|
|
471
|
+
const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
|
|
472
|
+
await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
473
|
+
...context,
|
|
474
|
+
shouldStop,
|
|
475
|
+
stepId: stepContext?.stepId,
|
|
476
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
477
|
+
eventId: actionEventId,
|
|
478
|
+
...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
|
|
479
|
+
...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
|
|
480
|
+
});
|
|
481
|
+
if (trackAction) {
|
|
482
|
+
trackAction(action);
|
|
483
|
+
}
|
|
484
|
+
if (shouldStop()) {
|
|
485
|
+
return newResponseId;
|
|
486
|
+
}
|
|
487
|
+
if (action.type !== "wait") {
|
|
488
|
+
await new Promise(resolve => setTimeout(resolve, this.stepDelayMs));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
335
491
|
}
|
|
492
|
+
if (shouldStop()) {
|
|
493
|
+
return newResponseId;
|
|
494
|
+
}
|
|
495
|
+
const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
496
|
+
emitDesktopDebug("device.screenshot", "device", {
|
|
497
|
+
runId: context?.runId,
|
|
498
|
+
stepId: stepContext?.stepId,
|
|
499
|
+
instructionIndex: stepContext?.instructionIndex
|
|
500
|
+
}, {
|
|
501
|
+
source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
|
|
502
|
+
callId: call_id,
|
|
503
|
+
width: this.session.deviceInfo?.scaled_width,
|
|
504
|
+
height: this.session.deviceInfo?.scaled_height,
|
|
505
|
+
base64Length: screenshotBase64.length
|
|
506
|
+
});
|
|
507
|
+
const screenshotEventId = randomUUID();
|
|
508
|
+
const captureSource = sawExplicitScreenshotAction ? "call-output-explicit-action" : "call-output-post-action";
|
|
509
|
+
const screenshotResult = await this.recordScreenshot(screenshotBase64, {
|
|
510
|
+
runId: context?.runId,
|
|
511
|
+
sessionId: context?.sessionId,
|
|
512
|
+
stepId: stepContext?.stepId,
|
|
513
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
514
|
+
eventId: screenshotEventId,
|
|
515
|
+
captureSource
|
|
516
|
+
});
|
|
517
|
+
const screenshotPath = screenshotPathFromRecorderResult(screenshotResult);
|
|
518
|
+
addOutput({
|
|
519
|
+
type: 'info',
|
|
520
|
+
text: 'Capturing screen',
|
|
521
|
+
eventType: 'screenshot_captured',
|
|
522
|
+
actionType: 'screenshot',
|
|
523
|
+
...eventMeta({
|
|
524
|
+
payload: {
|
|
525
|
+
eventId: screenshotEventId,
|
|
526
|
+
captureSource,
|
|
527
|
+
source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
|
|
528
|
+
...(typeof screenshotPath === "string" && screenshotPath ? { screenshotPath } : {}),
|
|
529
|
+
...(context?.includeScreenshotDataUrl
|
|
530
|
+
? { screenshotDataUrl: `data:image/png;base64,${screenshotBase64}` }
|
|
531
|
+
: {})
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
});
|
|
535
|
+
if (runtimeNote) {
|
|
536
|
+
const executedActionTypes = actionsToExecute
|
|
537
|
+
.map((action) => action?.type)
|
|
538
|
+
.filter(Boolean);
|
|
539
|
+
const droppedActionTypes = droppedActions
|
|
540
|
+
.map((action) => action?.type)
|
|
541
|
+
.filter(Boolean);
|
|
542
|
+
emitDesktopDebug("cua.strict_mode.truncation", scope, ids, {
|
|
543
|
+
callId: call_id,
|
|
544
|
+
executedCount: actionsToExecute.length,
|
|
545
|
+
droppedCount: droppedActions.length,
|
|
546
|
+
executedActionTypes,
|
|
547
|
+
droppedActionTypes,
|
|
548
|
+
reason: truncationReason,
|
|
549
|
+
note: runtimeNote
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const selectedCuaModel = VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL)
|
|
553
|
+
? process.env.OPENAI_CUA_MODEL
|
|
554
|
+
: "gpt-5.4";
|
|
555
|
+
const input = [{
|
|
556
|
+
type: "computer_call_output",
|
|
557
|
+
call_id,
|
|
558
|
+
output: {
|
|
559
|
+
type: "computer_screenshot",
|
|
560
|
+
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
561
|
+
},
|
|
562
|
+
...(selectedCuaModel === "computer-use-preview"
|
|
563
|
+
? { current_url: this.getCurrentPlatform() === "ios" ? "ios://simulator" : "android://device" }
|
|
564
|
+
: {}),
|
|
565
|
+
...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
|
|
566
|
+
}];
|
|
567
|
+
if (runtimeNote) {
|
|
568
|
+
input.push({
|
|
569
|
+
role: "user",
|
|
570
|
+
content: runtimeNote
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (shouldStop()) {
|
|
574
|
+
return newResponseId;
|
|
575
|
+
}
|
|
576
|
+
response = await this.sendCUARequest({
|
|
577
|
+
messages: input,
|
|
578
|
+
previousResponseId: newResponseId,
|
|
579
|
+
deviceInfo: this.session.deviceInfo,
|
|
580
|
+
debugContext: {
|
|
581
|
+
scope,
|
|
582
|
+
runId: context?.runId,
|
|
583
|
+
sessionId: context?.sessionId,
|
|
584
|
+
stepId: stepContext?.stepId,
|
|
585
|
+
instructionIndex: stepContext?.instructionIndex
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
newResponseId = response.id;
|
|
336
589
|
}
|
|
337
590
|
// ── At end, if last output was only reasoning ──
|
|
338
591
|
const finalItems = response.output || [];
|
|
@@ -40,6 +40,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
40
40
|
stepId: context?.stepId,
|
|
41
41
|
instructionIndex: context?.instructionIndex,
|
|
42
42
|
payload: {
|
|
43
|
+
eventId: context?.eventId,
|
|
44
|
+
screenshotPath: context?.screenshotPath,
|
|
45
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
43
46
|
platform: 'android',
|
|
44
47
|
...payload
|
|
45
48
|
}
|
|
@@ -71,7 +74,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
71
74
|
case "double_click":
|
|
72
75
|
const realX = Math.round(x / scale);
|
|
73
76
|
const realY = Math.round(y / scale);
|
|
74
|
-
addOutput({ type: 'action', text:
|
|
77
|
+
addOutput({ type: 'action', text: 'Click', ...meta({ x: realX, y: realY, unit: 'px' }) });
|
|
75
78
|
await adbShell(deviceId, `input tap ${realX} ${realY}`);
|
|
76
79
|
break;
|
|
77
80
|
case "scroll":
|
|
@@ -154,6 +157,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
154
157
|
stepId: context?.stepId,
|
|
155
158
|
instructionIndex: context?.instructionIndex,
|
|
156
159
|
payload: {
|
|
160
|
+
eventId: context?.eventId,
|
|
157
161
|
message: error.message,
|
|
158
162
|
platform: 'android'
|
|
159
163
|
}
|
|
@@ -35,7 +35,7 @@ export function extractFailureDetails(transcript) {
|
|
|
35
35
|
const parts = recentTranscript.split("ASSERTION RESULT: FAIL");
|
|
36
36
|
return parts[1]?.trim() || "Could not confidently validate the assertion.";
|
|
37
37
|
}
|
|
38
|
-
export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null) {
|
|
38
|
+
export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null, eventPayload = {}) {
|
|
39
39
|
const details = extractFailureDetails(transcript);
|
|
40
40
|
const addOutput = context?.addOutput || printCliOutput;
|
|
41
41
|
const meta = {
|
|
@@ -46,7 +46,8 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
|
|
|
46
46
|
payload: {
|
|
47
47
|
assertion: assertionPrompt,
|
|
48
48
|
passed: false,
|
|
49
|
-
details
|
|
49
|
+
details,
|
|
50
|
+
...eventPayload
|
|
50
51
|
}
|
|
51
52
|
};
|
|
52
53
|
addOutput({ type: 'error', text: '❌ ASSERTION FAILED', ...meta });
|
|
@@ -61,7 +62,7 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
|
|
|
61
62
|
}
|
|
62
63
|
// Interactive mode: caller should clear remaining instructions
|
|
63
64
|
}
|
|
64
|
-
export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null) {
|
|
65
|
+
export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null, eventPayload = {}) {
|
|
65
66
|
const addOutput = context?.addOutput || printCliOutput;
|
|
66
67
|
addOutput({
|
|
67
68
|
type: 'success',
|
|
@@ -72,7 +73,8 @@ export function handleAssertionSuccess(assertionPrompt, context = null, stepCont
|
|
|
72
73
|
instructionIndex: stepContext?.instructionIndex,
|
|
73
74
|
payload: {
|
|
74
75
|
assertion: assertionPrompt,
|
|
75
|
-
passed: true
|
|
76
|
+
passed: true,
|
|
77
|
+
...eventPayload
|
|
76
78
|
}
|
|
77
79
|
});
|
|
78
80
|
}
|