@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
|
@@ -54,6 +54,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
54
54
|
stepId: context?.stepId,
|
|
55
55
|
instructionIndex: context?.instructionIndex,
|
|
56
56
|
payload: {
|
|
57
|
+
eventId: context?.eventId,
|
|
58
|
+
screenshotPath: context?.screenshotPath,
|
|
59
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
57
60
|
platform,
|
|
58
61
|
source: session?.providerId,
|
|
59
62
|
...payload
|
|
@@ -84,7 +87,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
84
87
|
case "double_click": {
|
|
85
88
|
const targetX = Math.round((action.x / scale) / dpr);
|
|
86
89
|
const targetY = Math.round((action.y / scale) / dpr);
|
|
87
|
-
addOutput({ type: "action", text:
|
|
90
|
+
addOutput({ type: "action", text: "Click", ...meta({ x: targetX, y: targetY }) });
|
|
88
91
|
await session.client.tap(session.sessionId, targetX, targetY);
|
|
89
92
|
break;
|
|
90
93
|
}
|
|
@@ -181,6 +184,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
181
184
|
stepId: context?.stepId,
|
|
182
185
|
instructionIndex: context?.instructionIndex,
|
|
183
186
|
payload: {
|
|
187
|
+
eventId: context?.eventId,
|
|
184
188
|
message: error.message,
|
|
185
189
|
platform,
|
|
186
190
|
source: session.providerId
|
|
@@ -51,6 +51,9 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
51
51
|
stepId: context?.stepId,
|
|
52
52
|
instructionIndex: context?.instructionIndex,
|
|
53
53
|
payload: {
|
|
54
|
+
eventId: context?.eventId,
|
|
55
|
+
screenshotPath: context?.screenshotPath,
|
|
56
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
54
57
|
platform: "ios",
|
|
55
58
|
...payload
|
|
56
59
|
}
|
|
@@ -82,7 +85,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
82
85
|
const pixelY = Math.round(action.y / scale);
|
|
83
86
|
const pointX = Math.round(pixelX / dpr);
|
|
84
87
|
const pointY = Math.round(pixelY / dpr);
|
|
85
|
-
addOutput({ type: "action", text:
|
|
88
|
+
addOutput({ type: "action", text: "Click", ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
|
|
86
89
|
await appium.tap(session.sessionId, pointX, pointY);
|
|
87
90
|
break;
|
|
88
91
|
}
|
|
@@ -180,6 +183,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
180
183
|
stepId: context?.stepId,
|
|
181
184
|
instructionIndex: context?.instructionIndex,
|
|
182
185
|
payload: {
|
|
186
|
+
eventId: context?.eventId,
|
|
183
187
|
message: error.message,
|
|
184
188
|
platform: "ios"
|
|
185
189
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
8
|
import { existsSync, readdirSync } from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
+
import { COMMON_NODE_BIN_DIRS, makeNodeCliCommandEnv } from "./node-cli-env.js";
|
|
10
11
|
let appiumProcess = null;
|
|
11
12
|
let cleanupRegistered = false;
|
|
12
13
|
function getAppiumBaseUrl() {
|
|
@@ -35,8 +36,7 @@ export function resolveAppiumCommand() {
|
|
|
35
36
|
return explicit;
|
|
36
37
|
}
|
|
37
38
|
const commonCandidates = [
|
|
38
|
-
"
|
|
39
|
-
"/usr/local/bin/appium",
|
|
39
|
+
...COMMON_NODE_BIN_DIRS.map((dir) => path.join(dir, "appium")),
|
|
40
40
|
...collectNvmAppiumCandidates()
|
|
41
41
|
];
|
|
42
42
|
for (const candidate of commonCandidates) {
|
|
@@ -50,7 +50,8 @@ async function spawnAppiumProcess(command) {
|
|
|
50
50
|
return await new Promise((resolve, reject) => {
|
|
51
51
|
const child = spawn(command, [], {
|
|
52
52
|
detached: true,
|
|
53
|
-
stdio: "ignore"
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
env: makeNodeCliCommandEnv(command)
|
|
54
55
|
});
|
|
55
56
|
child.once("error", reject);
|
|
56
57
|
child.once("spawn", () => resolve(child));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
export const COMMON_NODE_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"];
|
|
3
|
+
function prependPathEntries(entries, originalPath) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const next = [];
|
|
6
|
+
const allEntries = [
|
|
7
|
+
...entries,
|
|
8
|
+
...(originalPath
|
|
9
|
+
?.split(path.delimiter)
|
|
10
|
+
.map((entry) => entry.trim())
|
|
11
|
+
.filter(Boolean) ?? [])
|
|
12
|
+
];
|
|
13
|
+
for (const entry of allEntries) {
|
|
14
|
+
if (seen.has(entry))
|
|
15
|
+
continue;
|
|
16
|
+
seen.add(entry);
|
|
17
|
+
next.push(entry);
|
|
18
|
+
}
|
|
19
|
+
return next.join(path.delimiter);
|
|
20
|
+
}
|
|
21
|
+
export function makeNodeCliCommandEnv(command) {
|
|
22
|
+
const binDirs = path.isAbsolute(command) ? [path.dirname(command), ...COMMON_NODE_BIN_DIRS] : COMMON_NODE_BIN_DIRS;
|
|
23
|
+
return {
|
|
24
|
+
...process.env,
|
|
25
|
+
// Finder-launched apps may miss Homebrew paths; npm/Appium shims use "#!/usr/bin/env node".
|
|
26
|
+
PATH: prependPathEntries(binDirs, process.env.PATH)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -1,54 +1,107 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { readContextOptimizationEnv } from "../core/context-optimization.js";
|
|
2
3
|
import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
|
|
3
4
|
import { buildAppContextCompactionInput } from "../prompts/execution.js";
|
|
4
5
|
import { logger } from "../utils/logger.js";
|
|
5
6
|
import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
|
|
6
7
|
let openai = null;
|
|
7
8
|
const cuaDebugTracer = new CuaDebugTracer(logger);
|
|
9
|
+
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
8
10
|
function getSelectedCuaModel() {
|
|
9
|
-
return process.env.OPENAI_CUA_MODEL
|
|
11
|
+
return VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL) ? process.env.OPENAI_CUA_MODEL : "gpt-5.4";
|
|
10
12
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return { type: "input_text", text: item };
|
|
20
|
-
}
|
|
21
|
-
return item;
|
|
22
|
-
})
|
|
23
|
-
: [{ type: "input_text", text: String(message.content ?? "") }];
|
|
24
|
-
return {
|
|
25
|
-
role: message.role,
|
|
26
|
-
content: contentItems,
|
|
27
|
-
};
|
|
28
|
-
});
|
|
29
|
-
if (!includeInitialScreenshot || !screenshotBase64) {
|
|
30
|
-
return normalized;
|
|
13
|
+
function normalizeMessageContent(content) {
|
|
14
|
+
if (Array.isArray(content)) {
|
|
15
|
+
return content.map((item) => {
|
|
16
|
+
if (typeof item === "string") {
|
|
17
|
+
return { type: "input_text", text: item };
|
|
18
|
+
}
|
|
19
|
+
return item;
|
|
20
|
+
});
|
|
31
21
|
}
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
return [{ type: "input_text", text: String(content ?? "") }];
|
|
23
|
+
}
|
|
24
|
+
function buildScreenshotDataUrl(screenshotBase64) {
|
|
25
|
+
return `data:image/png;base64,${screenshotBase64}`;
|
|
26
|
+
}
|
|
27
|
+
const previewClickTool = {
|
|
28
|
+
type: "function",
|
|
29
|
+
name: "preview_click",
|
|
30
|
+
description: "Preview where a proposed click would land on the current device screen. Returns an annotated screenshot with a marker at the given coordinates; the device state is unchanged and the click is not executed. Use this before issuing a click action to verify your aim.",
|
|
31
|
+
strict: true,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
x: { type: "number" },
|
|
36
|
+
y: { type: "number" },
|
|
37
|
+
},
|
|
38
|
+
required: ["x", "y"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
function appendInitialScreenshot(input, screenshotBase64) {
|
|
43
|
+
// Most callers end with the active user message, but some recovery flows can
|
|
44
|
+
// reset the conversation to system-only state before the next screenshot send.
|
|
45
|
+
// Attach to the last user message when present, otherwise synthesize one below.
|
|
46
|
+
for (let index = input.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
const item = input[index];
|
|
34
48
|
if (item?.role !== "user" || !Array.isArray(item.content))
|
|
35
49
|
continue;
|
|
36
50
|
item.content.push({
|
|
37
51
|
type: "input_image",
|
|
38
|
-
image_url:
|
|
52
|
+
image_url: buildScreenshotDataUrl(screenshotBase64),
|
|
39
53
|
});
|
|
40
|
-
return
|
|
54
|
+
return;
|
|
41
55
|
}
|
|
42
|
-
|
|
56
|
+
input.push({
|
|
43
57
|
role: "user",
|
|
44
58
|
content: [{
|
|
45
59
|
type: "input_image",
|
|
46
|
-
image_url:
|
|
60
|
+
image_url: buildScreenshotDataUrl(screenshotBase64),
|
|
47
61
|
}],
|
|
48
62
|
});
|
|
49
|
-
return normalized;
|
|
50
63
|
}
|
|
51
|
-
function
|
|
64
|
+
export function buildCuaInput({ messages, screenshotBase64, previousResponseId, callId }) {
|
|
65
|
+
const input = messages.map((message) => {
|
|
66
|
+
if (!message?.role) {
|
|
67
|
+
return message;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
role: message.role,
|
|
71
|
+
content: normalizeMessageContent(message.content),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
if (!screenshotBase64) {
|
|
75
|
+
return input;
|
|
76
|
+
}
|
|
77
|
+
if (callId) {
|
|
78
|
+
input.push({
|
|
79
|
+
type: "computer_call_output",
|
|
80
|
+
call_id: callId,
|
|
81
|
+
output: {
|
|
82
|
+
type: "computer_screenshot",
|
|
83
|
+
image_url: buildScreenshotDataUrl(screenshotBase64),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return input;
|
|
87
|
+
}
|
|
88
|
+
if (!previousResponseId) {
|
|
89
|
+
appendInitialScreenshot(input, screenshotBase64);
|
|
90
|
+
}
|
|
91
|
+
return input;
|
|
92
|
+
}
|
|
93
|
+
function buildContextManagement(cuaModel) {
|
|
94
|
+
if (cuaModel === "computer-use-preview") {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const { enabled, threshold } = readContextOptimizationEnv();
|
|
98
|
+
if (!enabled) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return [{ type: "compaction", compact_threshold: threshold }];
|
|
102
|
+
}
|
|
103
|
+
export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
|
|
104
|
+
const contextManagement = buildContextManagement(cuaModel);
|
|
52
105
|
if (cuaModel === "computer-use-preview") {
|
|
53
106
|
return {
|
|
54
107
|
model: cuaModel,
|
|
@@ -56,24 +109,32 @@ function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input
|
|
|
56
109
|
input,
|
|
57
110
|
store: true,
|
|
58
111
|
truncation: "auto",
|
|
59
|
-
tools: [
|
|
112
|
+
tools: [
|
|
113
|
+
{
|
|
60
114
|
type: "computer_use_preview",
|
|
61
115
|
display_width: deviceInfo.scaled_width,
|
|
62
116
|
display_height: deviceInfo.scaled_height,
|
|
63
117
|
environment: "browser",
|
|
64
|
-
}
|
|
118
|
+
},
|
|
119
|
+
previewClickTool,
|
|
120
|
+
],
|
|
65
121
|
reasoning: { summary: "concise" },
|
|
66
122
|
};
|
|
67
123
|
}
|
|
68
124
|
return {
|
|
69
125
|
model: cuaModel,
|
|
126
|
+
reasoning: { effort: "medium" },
|
|
127
|
+
...(contextManagement ? { context_management: contextManagement } : {}),
|
|
70
128
|
previous_response_id: previousResponseId || undefined,
|
|
71
129
|
input,
|
|
72
130
|
store: true,
|
|
73
131
|
truncation: "auto",
|
|
74
|
-
tools: [
|
|
132
|
+
tools: [
|
|
133
|
+
{
|
|
75
134
|
type: "computer",
|
|
76
|
-
}
|
|
135
|
+
},
|
|
136
|
+
previewClickTool,
|
|
137
|
+
],
|
|
77
138
|
};
|
|
78
139
|
}
|
|
79
140
|
function shouldMapPreviewAccessError(err) {
|
|
@@ -90,12 +151,41 @@ function shouldMapPreviewAccessError(err) {
|
|
|
90
151
|
return (message.includes("computer-use-preview") &&
|
|
91
152
|
(message.includes("access") || message.includes("permission") || message.includes("not found") || message.includes("unsupported")));
|
|
92
153
|
}
|
|
154
|
+
export function shouldMapMiniCompatibilityError(err) {
|
|
155
|
+
const status = Number(err?.status);
|
|
156
|
+
const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
|
|
157
|
+
const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
|
|
158
|
+
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
159
|
+
const mentionsMiniModel = message.includes("gpt-5.4-mini");
|
|
160
|
+
const mentionsComputerTool = message.includes("computer") || message.includes("tool");
|
|
161
|
+
const mentionsUnsupported = (message.includes("unsupported") ||
|
|
162
|
+
message.includes("not supported") ||
|
|
163
|
+
message.includes("does not support"));
|
|
164
|
+
if (code.includes("model_not_found")) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (status === 404) {
|
|
168
|
+
return mentionsMiniModel && (message.includes("not found") || mentionsUnsupported);
|
|
169
|
+
}
|
|
170
|
+
if (code.includes("unsupported")) {
|
|
171
|
+
return mentionsMiniModel || mentionsComputerTool;
|
|
172
|
+
}
|
|
173
|
+
if (!type.includes("invalid")) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return mentionsUnsupported && (mentionsMiniModel || mentionsComputerTool);
|
|
177
|
+
}
|
|
93
178
|
function mapCuaError(err, cuaModel) {
|
|
94
179
|
if (cuaModel === "computer-use-preview" && shouldMapPreviewAccessError(err)) {
|
|
95
180
|
const mapped = new Error("OpenAI API key does not have access to computer-use-preview. Switch to gpt-5.4 in Settings > CUA Model.");
|
|
96
181
|
mapped.cause = err;
|
|
97
182
|
return mapped;
|
|
98
183
|
}
|
|
184
|
+
if (cuaModel === "gpt-5.4-mini" && shouldMapMiniCompatibilityError(err)) {
|
|
185
|
+
const mapped = new Error("OpenAI rejected gpt-5.4-mini for computer-use execution. Switch to gpt-5.4 or computer-use-preview in Settings > CUA Model.");
|
|
186
|
+
mapped.cause = err;
|
|
187
|
+
return mapped;
|
|
188
|
+
}
|
|
99
189
|
return err;
|
|
100
190
|
}
|
|
101
191
|
export function isNonRetryableCuaError(err) {
|
|
@@ -110,7 +200,9 @@ export function isNonRetryableCuaError(err) {
|
|
|
110
200
|
code.includes("permission") ||
|
|
111
201
|
type.includes("permission") ||
|
|
112
202
|
message.includes("does not have access to computer-use-preview") ||
|
|
113
|
-
message.includes("switch to gpt-5.4 in settings > cua model")
|
|
203
|
+
message.includes("switch to gpt-5.4 in settings > cua model") ||
|
|
204
|
+
message.includes("openai rejected gpt-5.4-mini for computer-use execution") ||
|
|
205
|
+
message.includes("switch to gpt-5.4 or computer-use-preview in settings > cua model"));
|
|
114
206
|
}
|
|
115
207
|
function getOpenAI() {
|
|
116
208
|
if (!openai) {
|
|
@@ -153,20 +245,7 @@ export async function compactAppContext({ contextDocument, taskDescription, toke
|
|
|
153
245
|
}
|
|
154
246
|
export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
|
|
155
247
|
const cuaModel = getSelectedCuaModel();
|
|
156
|
-
const
|
|
157
|
-
const input = cuaModel === "computer-use-preview"
|
|
158
|
-
? normalizePreviewMessages(messages, screenshotBase64, includeInitialScreenshot)
|
|
159
|
-
: [...messages];
|
|
160
|
-
if (callId && screenshotBase64) {
|
|
161
|
-
input.push({
|
|
162
|
-
type: "computer_call_output",
|
|
163
|
-
call_id: callId,
|
|
164
|
-
output: {
|
|
165
|
-
type: "computer_screenshot",
|
|
166
|
-
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
}
|
|
248
|
+
const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
|
|
170
249
|
const requestParams = buildCuaRequestParams({
|
|
171
250
|
cuaModel,
|
|
172
251
|
previousResponseId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { getScreenshotAsBase64, connectToDevice, getDeviceInfo, getCurrentPlatform } from "../device/connection.js";
|
|
2
3
|
import { sendCUARequest } from "../device/openai.js";
|
|
3
4
|
import { buildExecutionRecoveryPrompt } from "../core/prompts.js";
|
|
@@ -14,6 +15,22 @@ export function buildExecutionRequestPayload({ instruction, isAssertionStep, mes
|
|
|
14
15
|
previousResponseIdToSend: previousResponseId,
|
|
15
16
|
};
|
|
16
17
|
}
|
|
18
|
+
function screenshotPathFromRecorderResult(result) {
|
|
19
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
20
|
+
return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
|
|
21
|
+
? result.screenshotPath
|
|
22
|
+
: null;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function screenshotDataUrlFromRecorderResult(result) {
|
|
27
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
28
|
+
return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
|
|
29
|
+
? result.screenshotDataUrl
|
|
30
|
+
: null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
17
34
|
/**
|
|
18
35
|
* Execution Mode - Run test scripts line-by-line
|
|
19
36
|
* Each instruction is executed in isolation (messages cleared after each turn)
|
|
@@ -197,6 +214,7 @@ export class ExecutionMode {
|
|
|
197
214
|
}
|
|
198
215
|
// ── Check for assertion ──
|
|
199
216
|
const isAssertionStep = isAssertion(instruction);
|
|
217
|
+
const assertionEventId = isAssertionStep ? randomUUID() : null;
|
|
200
218
|
let assertionPrompt = null;
|
|
201
219
|
const transcriptStartIndex = this.session.transcript.length;
|
|
202
220
|
if (isAssertionStep) {
|
|
@@ -213,12 +231,27 @@ export class ExecutionMode {
|
|
|
213
231
|
}
|
|
214
232
|
try {
|
|
215
233
|
const screenshotBase64 = await getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
216
|
-
await this.engine.recordScreenshot?.(screenshotBase64, {
|
|
234
|
+
const screenshotResult = await this.engine.recordScreenshot?.(screenshotBase64, {
|
|
217
235
|
runId: context?.runId,
|
|
218
236
|
stepId: stepContext?.stepId,
|
|
219
237
|
instructionIndex: stepContext?.instructionIndex,
|
|
238
|
+
...(assertionEventId ? { eventId: assertionEventId } : {}),
|
|
220
239
|
captureSource: isAssertionStep ? "instruction-input-assertion" : "instruction-input"
|
|
221
240
|
});
|
|
241
|
+
const assertionScreenshotPath = isAssertionStep ? screenshotPathFromRecorderResult(screenshotResult) : null;
|
|
242
|
+
const assertionScreenshotDataUrl = isAssertionStep
|
|
243
|
+
? screenshotDataUrlFromRecorderResult(screenshotResult) ||
|
|
244
|
+
(context?.includeScreenshotDataUrl && assertionScreenshotPath
|
|
245
|
+
? `data:image/png;base64,${screenshotBase64}`
|
|
246
|
+
: null)
|
|
247
|
+
: null;
|
|
248
|
+
const assertionEventPayload = isAssertionStep
|
|
249
|
+
? {
|
|
250
|
+
eventId: assertionEventId,
|
|
251
|
+
...(assertionScreenshotPath ? { screenshotPath: assertionScreenshotPath } : {}),
|
|
252
|
+
...(assertionScreenshotDataUrl ? { screenshotDataUrl: assertionScreenshotDataUrl } : {})
|
|
253
|
+
}
|
|
254
|
+
: {};
|
|
222
255
|
// Assertions rely on the prior response chain for earlier execution context.
|
|
223
256
|
const { messagesToSend, previousResponseIdToSend } = buildExecutionRequestPayload({
|
|
224
257
|
instruction,
|
|
@@ -253,7 +286,7 @@ export class ExecutionMode {
|
|
|
253
286
|
const result = checkAssertionResult(assertionTranscript);
|
|
254
287
|
if (result.failed) {
|
|
255
288
|
handleAssertionFailure(assertionPrompt, assertionTranscript, false, // Never exit process - we'll always prompt the user in interactive mode
|
|
256
|
-
context, stepContext);
|
|
289
|
+
context, stepContext, assertionEventPayload);
|
|
257
290
|
// In headless mode, exit immediately on assertion failure
|
|
258
291
|
if (this.isHeadlessMode) {
|
|
259
292
|
this.stats.assertionsFailed++;
|
|
@@ -297,7 +330,7 @@ export class ExecutionMode {
|
|
|
297
330
|
}
|
|
298
331
|
else if (result.passed) {
|
|
299
332
|
this.stats.assertionsPassed++;
|
|
300
|
-
handleAssertionSuccess(assertionPrompt, context, stepContext);
|
|
333
|
+
handleAssertionSuccess(assertionPrompt, context, stepContext, assertionEventPayload);
|
|
301
334
|
}
|
|
302
335
|
}
|
|
303
336
|
// Clear messages after each turn but KEEP the response chain for context
|
|
@@ -62,6 +62,12 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
|
|
|
62
62
|
|
|
63
63
|
When aiming for visual targets:
|
|
64
64
|
- Reason carefully about the approximate pixel position.
|
|
65
|
+
- Before every click action, call \`preview_click\` with the exact coordinates you intend to click.
|
|
66
|
+
- Review the returned annotated screenshot before clicking.
|
|
67
|
+
- Click if the marker is clearly inside the intended tappable control. Text inside a button, field, row, or bottom bar is okay.
|
|
68
|
+
- Adjust and preview again only if the marker is outside the target, on the target border/edge, or closer to a neighboring target than the intended one.
|
|
69
|
+
- If you preview more than once, the next click should use the most recent accepted preview coordinates unless the screen changed.
|
|
70
|
+
- If the click does not cause the expected UI change, treat it as a likely misclick and try a meaningfully different coordinate.
|
|
65
71
|
- Click precisely based on your visual estimate.
|
|
66
72
|
|
|
67
73
|
Available actions: click, scroll, type, keypress, wait, screenshot.
|
|
@@ -63,6 +63,19 @@ function summarizeRequestInput(input) {
|
|
|
63
63
|
hasSafetyChecks: Array.isArray(item.acknowledged_safety_checks) && item.acknowledged_safety_checks.length > 0
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
+
if (item?.type === "function_call_output") {
|
|
67
|
+
const content = Array.isArray(item.output) ? item.output : [];
|
|
68
|
+
const imageBytes = content
|
|
69
|
+
.filter((entry) => entry?.type === "input_image" && typeof entry.image_url === "string")
|
|
70
|
+
.map((entry) => entry.image_url.replace("data:image/png;base64,", "").length);
|
|
71
|
+
return {
|
|
72
|
+
type: "function_call_output",
|
|
73
|
+
callId: item.call_id,
|
|
74
|
+
contentTypes: content.map((entry) => entry?.type).filter(Boolean),
|
|
75
|
+
imageBytes,
|
|
76
|
+
text: summarizeContent(content),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
66
79
|
return {
|
|
67
80
|
type: item?.type || "unknown"
|
|
68
81
|
};
|
|
@@ -85,6 +98,21 @@ function redactRequestForFileLog(requestParams) {
|
|
|
85
98
|
acknowledged_safety_checks: item.acknowledged_safety_checks
|
|
86
99
|
};
|
|
87
100
|
}
|
|
101
|
+
if (item.type === "function_call_output" && Array.isArray(item.output)) {
|
|
102
|
+
return {
|
|
103
|
+
...item,
|
|
104
|
+
output: item.output.map((entry) => {
|
|
105
|
+
if (entry?.type === "input_image" && typeof entry.image_url === "string") {
|
|
106
|
+
const base64Data = entry.image_url.replace("data:image/png;base64,", "");
|
|
107
|
+
return {
|
|
108
|
+
...entry,
|
|
109
|
+
image_url: `data:image/png;base64,[${base64Data.length} chars]`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return entry;
|
|
113
|
+
})
|
|
114
|
+
};
|
|
115
|
+
}
|
|
88
116
|
return item;
|
|
89
117
|
})
|
|
90
118
|
};
|
|
@@ -117,6 +145,15 @@ function summarizeResponseOutput(response) {
|
|
|
117
145
|
actionType: item.action?.type
|
|
118
146
|
};
|
|
119
147
|
}
|
|
148
|
+
if (item.type === "function_call") {
|
|
149
|
+
return {
|
|
150
|
+
type: "function_call",
|
|
151
|
+
id: item.id,
|
|
152
|
+
callId: item.call_id,
|
|
153
|
+
name: item.name,
|
|
154
|
+
arguments: truncateForDebug(item.arguments, 300)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
120
157
|
if (item.type === "pending_safety_check") {
|
|
121
158
|
return { type: "pending_safety_check", id: item.id, code: item.code };
|
|
122
159
|
}
|
|
@@ -175,11 +212,11 @@ export class CuaDebugTracer {
|
|
|
175
212
|
}));
|
|
176
213
|
});
|
|
177
214
|
const inputCallIds = input
|
|
178
|
-
.filter((item) => item?.type === "computer_call_output")
|
|
215
|
+
.filter((item) => item?.type === "computer_call_output" || item?.type === "function_call_output")
|
|
179
216
|
.map((item) => item.call_id)
|
|
180
217
|
.filter(Boolean);
|
|
181
218
|
const inputItemTypes = input.map((item) => item?.type || (item?.role ? `message:${item.role}` : "unknown"));
|
|
182
|
-
const lastResponseCallIds = Array.isArray(lastResponseMeta?.
|
|
219
|
+
const lastResponseCallIds = Array.isArray(lastResponseMeta?.toolCallIds) ? lastResponseMeta.toolCallIds : [];
|
|
183
220
|
const missingCallIds = inputCallIds.filter((callId) => !lastResponseCallIds.includes(callId));
|
|
184
221
|
const previousResponseIdMatchesLastResponseId = !previousResponseId || !lastResponseMeta?.id ? null : previousResponseId === lastResponseMeta.id;
|
|
185
222
|
const allInputCallIdsFoundInLastResponse = inputCallIds.length === 0 ? true : missingCallIds.length === 0;
|
|
@@ -252,10 +289,12 @@ export class CuaDebugTracer {
|
|
|
252
289
|
onResponse(trace, response) {
|
|
253
290
|
const outputTypes = (response.output || []).map((item) => item.type);
|
|
254
291
|
const toolCalls = (response.output || [])
|
|
255
|
-
.filter((item) => item.type === "computer_call")
|
|
292
|
+
.filter((item) => item.type === "computer_call" || item.type === "function_call")
|
|
256
293
|
.map((item) => ({
|
|
257
294
|
call_id: item.call_id,
|
|
258
|
-
action_type: item.action?.type
|
|
295
|
+
action_type: item.action?.type,
|
|
296
|
+
name: item.name,
|
|
297
|
+
type: item.type
|
|
259
298
|
}));
|
|
260
299
|
const safetyChecks = (response.output || [])
|
|
261
300
|
.filter((item) => item.type === "pending_safety_check")
|
|
@@ -268,7 +307,11 @@ export class CuaDebugTracer {
|
|
|
268
307
|
this.lastResponseMetaByChain.set(trace.chainKey, {
|
|
269
308
|
id: response.id,
|
|
270
309
|
outputTypes,
|
|
271
|
-
|
|
310
|
+
toolCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
|
|
311
|
+
computerCallIds: toolCalls
|
|
312
|
+
.filter((item) => item.type === "computer_call")
|
|
313
|
+
.map((item) => item.call_id)
|
|
314
|
+
.filter(Boolean),
|
|
272
315
|
pendingSafetyCheckIds: safetyChecks.map((item) => item.id).filter(Boolean)
|
|
273
316
|
});
|
|
274
317
|
const accountedItems = toolCalls.length + safetyChecks.length;
|