@loadmill/droid-cua 2.22.0 → 2.23.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/build/index.js +31 -1
- package/build/src/browser/playwright/actions.js +65 -1
- package/build/src/commands/help.js +1 -1
- package/build/src/core/cua-models.js +11 -4
- package/build/src/core/execution-engine.js +334 -5
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/render-html-report.js +8 -3
- package/build/src/core/report/run-log-model.js +42 -0
- package/build/src/core/runtime-config.js +24 -39
- package/build/src/core/session.js +6 -0
- package/build/src/core/tool-handlers/loadmill.js +3 -0
- package/build/src/core/tool-handlers/test-design-complete.js +1 -0
- package/build/src/core/tool-handlers/type-sensitive-text.js +1 -0
- package/build/src/device/android/actions.js +6 -2
- package/build/src/device/android/connection.js +58 -23
- package/build/src/device/assertions.js +15 -8
- package/build/src/device/ios/connection.js +36 -30
- package/build/src/device/openai.js +68 -2
- package/build/src/device/scroll-gesture.js +22 -9
- package/build/src/integrations/loadmill/client.js +20 -4
- package/build/src/modes/design-mode-ink.js +10 -1
- package/build/src/modes/design-mode.js +7 -1
- package/build/src/modes/execution-mode.js +84 -8
- package/build/src/prompts/design.js +21 -1
- package/build/src/utils/console-output.js +5 -0
- package/build/src/utils/cua-debug-tracer.js +50 -7
- package/package.json +1 -1
- package/build/src/core/cua-backends.js +0 -11
package/build/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { selectDevice } from "./src/cli/device-selector.js";
|
|
|
15
15
|
import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
16
16
|
import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
17
17
|
import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
|
|
18
|
-
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
18
|
+
import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
19
19
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
20
20
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
21
21
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
@@ -71,6 +71,33 @@ function createCliRuntimeContext(configOverrides = {}) {
|
|
|
71
71
|
loadmillClient,
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
async function assertCliRuntimeCredentials({ llmProviderMode }) {
|
|
75
|
+
const credentials = resolveRuntimeCredentialsFromEnv(process.env);
|
|
76
|
+
assertRuntimeCredentialsForProvider({
|
|
77
|
+
llmProviderMode,
|
|
78
|
+
credentials,
|
|
79
|
+
});
|
|
80
|
+
if (llmProviderMode !== "loadmill") {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const loadmillClient = createLoadmillApiClient({
|
|
84
|
+
token: credentials.loadmillApiToken,
|
|
85
|
+
baseUrl: credentials.loadmillBaseUrl,
|
|
86
|
+
});
|
|
87
|
+
let tokenValidation = null;
|
|
88
|
+
try {
|
|
89
|
+
tokenValidation = await loadmillClient.validateApiToken();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error("Could not validate LOADMILL_API_TOKEN.");
|
|
93
|
+
}
|
|
94
|
+
if (tokenValidation?.reason === "invalid") {
|
|
95
|
+
throw new Error("LOADMILL_API_TOKEN is invalid.");
|
|
96
|
+
}
|
|
97
|
+
if (!tokenValidation?.valid) {
|
|
98
|
+
throw new Error("Could not validate LOADMILL_API_TOKEN.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
function createLambdaTestCliBuildName() {
|
|
75
102
|
return `loadmill-droid-cua-cli-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
76
103
|
}
|
|
@@ -184,6 +211,9 @@ async function main() {
|
|
|
184
211
|
let preloadedSecrets = null;
|
|
185
212
|
if (isHeadlessInstructionsRun) {
|
|
186
213
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
214
|
+
await assertCliRuntimeCredentials({
|
|
215
|
+
llmProviderMode: preloadedHeadlessConfig.llmProviderMode,
|
|
216
|
+
});
|
|
187
217
|
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
188
218
|
preloadedSecrets = await resolveExecutionSecrets({
|
|
189
219
|
secretsPath: getDefaultSecretsPath(process.cwd()),
|
|
@@ -3,7 +3,8 @@ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js
|
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
4
|
import { buildActionEventMeta } from "../../device/action-event-meta.js";
|
|
5
5
|
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../../device/sensitive-action.js";
|
|
6
|
-
import {
|
|
6
|
+
import { normalizeAutomationUrl } from "./url.js";
|
|
7
|
+
import { getActiveBrowserSession, getActiveBrowserPage } from "./session-state.js";
|
|
7
8
|
const KEY_ALIASES = {
|
|
8
9
|
CTRL: "Control",
|
|
9
10
|
CONTROL: "Control",
|
|
@@ -58,8 +59,47 @@ function buildMeta(context, action, payload = {}) {
|
|
|
58
59
|
payload,
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
function handleNavigationTimeout(error, description) {
|
|
63
|
+
if (!(error instanceof Error) || error.name !== "TimeoutError") {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
console.warn(`Page load did not finish within the timeout for ${description}. Continuing with the current page state.`);
|
|
67
|
+
}
|
|
68
|
+
async function gotoWithTimeoutTolerance(page, url) {
|
|
69
|
+
try {
|
|
70
|
+
await page.goto(url, { waitUntil: "load" });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
handleNavigationTimeout(error, url);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function goBackWithTimeoutTolerance(page) {
|
|
77
|
+
try {
|
|
78
|
+
await page.goBack({ waitUntil: "load" });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
handleNavigationTimeout(error, "browser history back");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function goForwardWithTimeoutTolerance(page) {
|
|
85
|
+
try {
|
|
86
|
+
await page.goForward({ waitUntil: "load" });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
handleNavigationTimeout(error, "browser history forward");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function updateSessionUrlFromPage(session, page) {
|
|
93
|
+
const currentUrl = typeof page.url === "function" ? page.url() : null;
|
|
94
|
+
if (!currentUrl) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
session.pageUrls?.set?.(page, currentUrl);
|
|
98
|
+
session.url = currentUrl;
|
|
99
|
+
}
|
|
61
100
|
export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
|
|
62
101
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
102
|
+
const session = getActiveBrowserSession(browserSessionId);
|
|
63
103
|
const page = getActiveBrowserPage(browserSessionId);
|
|
64
104
|
try {
|
|
65
105
|
emitDesktopDebug("browser.action.execute", "device", {
|
|
@@ -166,6 +206,30 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
166
206
|
await page.waitForTimeout(timeoutMs);
|
|
167
207
|
break;
|
|
168
208
|
}
|
|
209
|
+
case "goto": {
|
|
210
|
+
const normalizedUrl = normalizeAutomationUrl(action.url);
|
|
211
|
+
addOutput({
|
|
212
|
+
type: "action",
|
|
213
|
+
text: `Navigating to ${normalizedUrl}`,
|
|
214
|
+
...buildMeta(context, action, { url: normalizedUrl }),
|
|
215
|
+
});
|
|
216
|
+
await gotoWithTimeoutTolerance(page, normalizedUrl);
|
|
217
|
+
session.pageUrls?.set?.(page, normalizedUrl);
|
|
218
|
+
session.url = normalizedUrl;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "back": {
|
|
222
|
+
addOutput({ type: "action", text: "Going back", ...buildMeta(context, action) });
|
|
223
|
+
await goBackWithTimeoutTolerance(page);
|
|
224
|
+
updateSessionUrlFromPage(session, page);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "forward": {
|
|
228
|
+
addOutput({ type: "action", text: "Going forward", ...buildMeta(context, action) });
|
|
229
|
+
await goForwardWithTimeoutTolerance(page);
|
|
230
|
+
updateSessionUrlFromPage(session, page);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
169
233
|
default:
|
|
170
234
|
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
171
235
|
}
|
|
@@ -32,7 +32,7 @@ export async function handleHelp(args, session, context) {
|
|
|
32
32
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
33
33
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
34
34
|
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
35
|
-
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
|
|
35
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, loadmill-beacon, or gpt-5.4-mini' });
|
|
36
36
|
addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
|
|
37
37
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
38
38
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
@@ -2,15 +2,22 @@ export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
|
2
2
|
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
3
|
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
4
|
export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
5
|
-
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
|
|
6
|
-
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
|
|
5
|
+
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL, "loadmill-beacon"];
|
|
6
|
+
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "loadmill-beacon", "gpt-5.4-mini"];
|
|
7
7
|
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
8
8
|
Object.freeze({
|
|
9
9
|
id: "loadmill-smart",
|
|
10
10
|
providerMode: "loadmill",
|
|
11
11
|
providerLabel: "Loadmill",
|
|
12
|
-
name: "Loadmill Smart",
|
|
13
|
-
label: "Loadmill Smart (recommended)",
|
|
12
|
+
name: "Loadmill - Smart",
|
|
13
|
+
label: "Loadmill - Smart (recommended)",
|
|
14
|
+
}),
|
|
15
|
+
Object.freeze({
|
|
16
|
+
id: "loadmill-beacon",
|
|
17
|
+
providerMode: "loadmill",
|
|
18
|
+
providerLabel: "Loadmill",
|
|
19
|
+
name: "Loadmill - Beacon",
|
|
20
|
+
label: "Loadmill - Beacon",
|
|
14
21
|
}),
|
|
15
22
|
Object.freeze({
|
|
16
23
|
id: "gpt-5.4",
|
|
@@ -19,6 +19,51 @@ import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
|
|
|
19
19
|
import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
|
|
20
20
|
import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
|
|
21
21
|
import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
|
|
22
|
+
const HOLO_RESPONSE_ID_PREFIX = "resp_holo_";
|
|
23
|
+
function isHoloResponseId(responseId) {
|
|
24
|
+
return typeof responseId === "string" && responseId.startsWith(HOLO_RESPONSE_ID_PREFIX);
|
|
25
|
+
}
|
|
26
|
+
function isImageOutputChunk(chunk) {
|
|
27
|
+
if (!chunk || typeof chunk !== "object") {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (chunk.type !== "input_image" && chunk.type !== "image_url") {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (typeof chunk.image_url === "string") {
|
|
34
|
+
return chunk.image_url.trim().length > 0;
|
|
35
|
+
}
|
|
36
|
+
return typeof chunk.image_url?.url === "string" && chunk.image_url.url.trim().length > 0;
|
|
37
|
+
}
|
|
38
|
+
function functionCallOutputHasImage(outputItem) {
|
|
39
|
+
if (outputItem?.type !== "function_call_output") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const chunks = [];
|
|
43
|
+
if (Array.isArray(outputItem.output)) {
|
|
44
|
+
chunks.push(...outputItem.output);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(outputItem.content)) {
|
|
47
|
+
chunks.push(...outputItem.content);
|
|
48
|
+
}
|
|
49
|
+
return chunks.some((chunk) => isImageOutputChunk(chunk));
|
|
50
|
+
}
|
|
51
|
+
function appendImageToFunctionCallOutput(outputItem, screenshotBase64) {
|
|
52
|
+
if (outputItem?.type !== "function_call_output" || typeof screenshotBase64 !== "string" || !screenshotBase64) {
|
|
53
|
+
return outputItem;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...outputItem,
|
|
57
|
+
output: [
|
|
58
|
+
...(Array.isArray(outputItem.output) ? outputItem.output : []),
|
|
59
|
+
{
|
|
60
|
+
type: "input_image",
|
|
61
|
+
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
62
|
+
detail: "high",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
22
67
|
function extractActionableOutputItems(items) {
|
|
23
68
|
const entries = [];
|
|
24
69
|
for (const item of items) {
|
|
@@ -58,6 +103,129 @@ function collectPendingSafetyChecks(items = []) {
|
|
|
58
103
|
.filter((item) => item.type === "pending_safety_check")
|
|
59
104
|
.map((item) => ({ id: item.id }));
|
|
60
105
|
}
|
|
106
|
+
const MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION = 10;
|
|
107
|
+
const OBSERVATION_ONLY_RECOVERY_TURN = 5;
|
|
108
|
+
const MAX_RECOVERY_ACTION_SUMMARIES = 8;
|
|
109
|
+
const OBSERVATION_ONLY_RECOVERY_PROMPT = [
|
|
110
|
+
"You have repeatedly requested screenshots/previews without taking a device action.",
|
|
111
|
+
"Perform the next real action, choose a meaningfully different action, or stop if this instruction is complete.",
|
|
112
|
+
"Do not request another screenshot or preview unless you need it after changing the UI.",
|
|
113
|
+
].join(" ");
|
|
114
|
+
function isObservationOnlyActionableItem(actionableItem) {
|
|
115
|
+
if (actionableItem?.type === "function_call") {
|
|
116
|
+
return actionableItem.name === "preview_click";
|
|
117
|
+
}
|
|
118
|
+
if (actionableItem?.type !== "computer_call") {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return actionableItem.actions.length > 0 &&
|
|
122
|
+
actionableItem.actions.every((action) => action?.type === "screenshot");
|
|
123
|
+
}
|
|
124
|
+
function parseObservationToolArguments(argumentsText) {
|
|
125
|
+
if (typeof argumentsText !== "string" || !argumentsText.trim()) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(argumentsText);
|
|
130
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function buildObservationOnlyPayload(actionableItem) {
|
|
137
|
+
if (actionableItem?.type === "function_call") {
|
|
138
|
+
const parsedArguments = parseObservationToolArguments(actionableItem.arguments);
|
|
139
|
+
return {
|
|
140
|
+
observationKind: "preview_click",
|
|
141
|
+
callId: actionableItem.call_id,
|
|
142
|
+
toolName: actionableItem.name,
|
|
143
|
+
...(parsedArguments ? { toolArguments: parsedArguments } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
observationKind: "screenshot_only",
|
|
148
|
+
callId: actionableItem?.call_id,
|
|
149
|
+
actionTypes: actionableItem?.actions?.map((action) => action?.type).filter(Boolean) ?? [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function truncateRecoveryText(text, maxLength = 80) {
|
|
153
|
+
if (typeof text !== "string")
|
|
154
|
+
return "";
|
|
155
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
|
|
156
|
+
}
|
|
157
|
+
function formatActionForRecovery(action) {
|
|
158
|
+
if (!action || action.type === "screenshot" || action.type === "wait") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
switch (action.type) {
|
|
162
|
+
case "click":
|
|
163
|
+
return "Clicked";
|
|
164
|
+
case "double_click":
|
|
165
|
+
return "Double-clicked";
|
|
166
|
+
case "type": {
|
|
167
|
+
if (action.sensitive) {
|
|
168
|
+
return `Typed secret${action.sensitiveKey ? `: ${action.sensitiveKey}` : ""}`;
|
|
169
|
+
}
|
|
170
|
+
const text = truncateRecoveryText(action.text);
|
|
171
|
+
return text ? `Typed ${JSON.stringify(text)}` : "Typed text";
|
|
172
|
+
}
|
|
173
|
+
case "keypress": {
|
|
174
|
+
const keys = Array.isArray(action.keys) ? action.keys.join("+") : action.key || action.keys;
|
|
175
|
+
return keys ? `Pressed ${keys}` : "Pressed a key";
|
|
176
|
+
}
|
|
177
|
+
case "scroll":
|
|
178
|
+
return "Scrolled";
|
|
179
|
+
default:
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function buildObservationOnlyRecoveryMessage({ instruction = null, recentRealActions = [] } = {}) {
|
|
184
|
+
const content = [OBSERVATION_ONLY_RECOVERY_PROMPT];
|
|
185
|
+
if (typeof instruction === "string" && instruction.trim()) {
|
|
186
|
+
content.push(`Current instruction: ${instruction.trim()}`);
|
|
187
|
+
}
|
|
188
|
+
if (recentRealActions.length > 0) {
|
|
189
|
+
content.push([
|
|
190
|
+
"Recent real interactions:",
|
|
191
|
+
...recentRealActions.map((actionSummary) => `- ${actionSummary}`),
|
|
192
|
+
].join("\n"));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
role: "user",
|
|
196
|
+
content: content.join("\n\n"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function getInstructionScrollDirection(instruction) {
|
|
200
|
+
if (typeof instruction !== "string") {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const normalizedInstruction = instruction.toLowerCase();
|
|
204
|
+
if (normalizedInstruction.includes("scroll down")) {
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
if (normalizedInstruction.includes("scroll up")) {
|
|
208
|
+
return -1;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function normalizeScrollActionForInstruction(action, instruction) {
|
|
213
|
+
if (action?.type !== "scroll") {
|
|
214
|
+
return action;
|
|
215
|
+
}
|
|
216
|
+
const expectedDirection = getInstructionScrollDirection(instruction);
|
|
217
|
+
const scrollY = Number(action.scroll_y);
|
|
218
|
+
if (!expectedDirection || !Number.isFinite(scrollY) || scrollY === 0) {
|
|
219
|
+
return action;
|
|
220
|
+
}
|
|
221
|
+
if (Math.sign(scrollY) === expectedDirection) {
|
|
222
|
+
return action;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
...action,
|
|
226
|
+
scroll_y: Math.abs(scrollY) * expectedDirection,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
61
229
|
export class ExecutionEngine {
|
|
62
230
|
constructor(session, options = {}) {
|
|
63
231
|
this.session = session;
|
|
@@ -169,9 +337,10 @@ export class ExecutionEngine {
|
|
|
169
337
|
}
|
|
170
338
|
}
|
|
171
339
|
async requestNextTurn(messages, previousResponseId, context = null, stepContext = null, options = {}) {
|
|
340
|
+
const continuationMode = options.continuationModeOverride === "fresh" ? "fresh" : "auto";
|
|
172
341
|
return await this.sendCUARequest({
|
|
173
342
|
messages,
|
|
174
|
-
previousResponseId,
|
|
343
|
+
previousResponseId: continuationMode === "fresh" ? null : previousResponseId,
|
|
175
344
|
deviceInfo: this.session.deviceInfo,
|
|
176
345
|
debugContext: options.debugContextOverride ?? buildDebugContext(context, stepContext),
|
|
177
346
|
toolCapabilities: options.toolCapabilitiesOverride ?? this.toolCapabilities,
|
|
@@ -180,8 +349,21 @@ export class ExecutionEngine {
|
|
|
180
349
|
}
|
|
181
350
|
async executeActionBatch(actions, currentScreenshot, shouldStop, context = null, stepContext = null, trackAction = null) {
|
|
182
351
|
let sawExplicitScreenshotAction = false;
|
|
183
|
-
for (const
|
|
352
|
+
for (const rawAction of actions) {
|
|
353
|
+
const action = normalizeScrollActionForInstruction(rawAction, context?.currentInstruction);
|
|
184
354
|
const actionEventId = randomUUID();
|
|
355
|
+
if (action !== rawAction) {
|
|
356
|
+
emitDesktopDebug("runner.action.normalized", "execution", {
|
|
357
|
+
runId: context?.runId,
|
|
358
|
+
stepId: stepContext?.stepId,
|
|
359
|
+
instructionIndex: stepContext?.instructionIndex
|
|
360
|
+
}, {
|
|
361
|
+
reason: "instruction_scroll_direction",
|
|
362
|
+
instruction: context?.currentInstruction,
|
|
363
|
+
originalAction: rawAction,
|
|
364
|
+
normalizedAction: action
|
|
365
|
+
});
|
|
366
|
+
}
|
|
185
367
|
if (shouldStop()) {
|
|
186
368
|
return { stopped: true, sawExplicitScreenshotAction };
|
|
187
369
|
}
|
|
@@ -219,6 +401,18 @@ export class ExecutionEngine {
|
|
|
219
401
|
shouldStop,
|
|
220
402
|
stepId: stepContext?.stepId,
|
|
221
403
|
instructionIndex: stepContext?.instructionIndex,
|
|
404
|
+
deviceWidth: Number.isFinite(this.session.deviceInfo?.device_width)
|
|
405
|
+
? Math.round(this.session.deviceInfo.device_width)
|
|
406
|
+
: undefined,
|
|
407
|
+
deviceHeight: Number.isFinite(this.session.deviceInfo?.device_height)
|
|
408
|
+
? Math.round(this.session.deviceInfo.device_height)
|
|
409
|
+
: undefined,
|
|
410
|
+
fallbackScrollStartX: Number.isFinite(this.session.deviceInfo?.device_width)
|
|
411
|
+
? Math.round(this.session.deviceInfo.device_width / 2)
|
|
412
|
+
: undefined,
|
|
413
|
+
fallbackScrollStartY: Number.isFinite(this.session.deviceInfo?.device_height)
|
|
414
|
+
? Math.round(this.session.deviceInfo.device_height / 2)
|
|
415
|
+
: undefined,
|
|
222
416
|
eventId: actionEventId,
|
|
223
417
|
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
224
418
|
...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
|
|
@@ -366,7 +560,8 @@ export class ExecutionEngine {
|
|
|
366
560
|
const addOutput = context?.addOutput || printCliOutput;
|
|
367
561
|
let newResponseId = response.id;
|
|
368
562
|
let currentScreenshot = options.currentScreenshot ?? null;
|
|
369
|
-
const
|
|
563
|
+
const originalTrackAction = trackAction;
|
|
564
|
+
const shouldStop = () => Boolean(originalTrackAction?.());
|
|
370
565
|
const summary = {
|
|
371
566
|
responseId: newResponseId,
|
|
372
567
|
invokedFunctionTools: [],
|
|
@@ -383,6 +578,93 @@ export class ExecutionEngine {
|
|
|
383
578
|
...extra
|
|
384
579
|
});
|
|
385
580
|
let refreshBeforeNextComputerAction = false;
|
|
581
|
+
let observationOnlyTurnsSinceLastAction = 0;
|
|
582
|
+
let observationOnlyRecoverySent = false;
|
|
583
|
+
let pendingObservationOnlyRecoveryMessage = null;
|
|
584
|
+
const recentRealActions = [];
|
|
585
|
+
const recordRecentRealAction = (action) => {
|
|
586
|
+
const summaryText = formatActionForRecovery(action);
|
|
587
|
+
if (!summaryText)
|
|
588
|
+
return;
|
|
589
|
+
recentRealActions.push(summaryText);
|
|
590
|
+
if (recentRealActions.length > MAX_RECOVERY_ACTION_SUMMARIES) {
|
|
591
|
+
recentRealActions.shift();
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const recordRecentRealActions = (actions = []) => {
|
|
595
|
+
for (const action of actions)
|
|
596
|
+
recordRecentRealAction(action);
|
|
597
|
+
};
|
|
598
|
+
const wrappedTrackAction = (action = null) => {
|
|
599
|
+
if (action)
|
|
600
|
+
recordRecentRealAction(action);
|
|
601
|
+
return originalTrackAction?.(action);
|
|
602
|
+
};
|
|
603
|
+
const resetObservationOnlyStreak = () => {
|
|
604
|
+
observationOnlyTurnsSinceLastAction = 0;
|
|
605
|
+
observationOnlyRecoverySent = false;
|
|
606
|
+
pendingObservationOnlyRecoveryMessage = null;
|
|
607
|
+
};
|
|
608
|
+
const buildObservationLoopPayload = (actionableItem) => ({
|
|
609
|
+
reason: "observation_only_loop",
|
|
610
|
+
responseId: newResponseId,
|
|
611
|
+
observationOnlyTurnsSinceLastAction,
|
|
612
|
+
maxObservationOnlyTurnsWithoutAction: MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION,
|
|
613
|
+
recoveryTurn: OBSERVATION_ONLY_RECOVERY_TURN,
|
|
614
|
+
recoverySent: observationOnlyRecoverySent,
|
|
615
|
+
...buildObservationOnlyPayload(actionableItem),
|
|
616
|
+
});
|
|
617
|
+
const queueObservationOnlyRecovery = (actionableItem) => {
|
|
618
|
+
if (observationOnlyRecoverySent ||
|
|
619
|
+
observationOnlyTurnsSinceLastAction < OBSERVATION_ONLY_RECOVERY_TURN) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
observationOnlyRecoverySent = true;
|
|
623
|
+
pendingObservationOnlyRecoveryMessage = buildObservationOnlyRecoveryMessage({
|
|
624
|
+
instruction: stepContext?.instruction,
|
|
625
|
+
recentRealActions,
|
|
626
|
+
});
|
|
627
|
+
addOutput({
|
|
628
|
+
type: "warning",
|
|
629
|
+
text: "The agent seems confused. Trying to get it back on track.",
|
|
630
|
+
eventType: "system_message",
|
|
631
|
+
...eventMeta({
|
|
632
|
+
payload: {
|
|
633
|
+
...buildObservationLoopPayload(actionableItem),
|
|
634
|
+
recoveryPrompt: pendingObservationOnlyRecoveryMessage.content,
|
|
635
|
+
...(typeof stepContext?.instruction === "string" && stepContext.instruction.trim()
|
|
636
|
+
? { instruction: stepContext.instruction.trim() }
|
|
637
|
+
: {}),
|
|
638
|
+
...(recentRealActions.length > 0 ? { recentRealActions: [...recentRealActions] } : {}),
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
});
|
|
642
|
+
return true;
|
|
643
|
+
};
|
|
644
|
+
const failObservationOnlyLoop = (actionableItem) => {
|
|
645
|
+
const error = "The agent got too confused. Stopping the test.";
|
|
646
|
+
summary.failed = true;
|
|
647
|
+
summary.error = error;
|
|
648
|
+
summary.observationLoopDetected = true;
|
|
649
|
+
addOutput({
|
|
650
|
+
type: "error",
|
|
651
|
+
text: error,
|
|
652
|
+
eventType: "error",
|
|
653
|
+
...eventMeta({
|
|
654
|
+
payload: buildObservationLoopPayload(actionableItem),
|
|
655
|
+
}),
|
|
656
|
+
});
|
|
657
|
+
newResponseId = null;
|
|
658
|
+
};
|
|
659
|
+
const updateObservationOnlyStreak = (actionableItem) => {
|
|
660
|
+
observationOnlyTurnsSinceLastAction += 1;
|
|
661
|
+
if (observationOnlyTurnsSinceLastAction >= MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION) {
|
|
662
|
+
failObservationOnlyLoop(actionableItem);
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
queueObservationOnlyRecovery(actionableItem);
|
|
666
|
+
return false;
|
|
667
|
+
};
|
|
386
668
|
while (true) {
|
|
387
669
|
// Check for interruption before processing next batch of actions
|
|
388
670
|
if (shouldStop()) {
|
|
@@ -409,6 +691,10 @@ export class ExecutionEngine {
|
|
|
409
691
|
if (!actionableItem?.call_id) {
|
|
410
692
|
break;
|
|
411
693
|
}
|
|
694
|
+
const observationOnlyTurn = isObservationOnlyActionableItem(actionableItem);
|
|
695
|
+
if (!observationOnlyTurn) {
|
|
696
|
+
resetObservationOnlyStreak();
|
|
697
|
+
}
|
|
412
698
|
if (actionableItem.type === "function_call") {
|
|
413
699
|
const toolResult = await this.handleFunctionToolCall(actionableItem.name, actionableItem.call_id, actionableItem.arguments, context, stepContext);
|
|
414
700
|
summary.invokedFunctionTools.push(actionableItem.name);
|
|
@@ -445,15 +731,51 @@ export class ExecutionEngine {
|
|
|
445
731
|
newResponseId = null;
|
|
446
732
|
break;
|
|
447
733
|
}
|
|
448
|
-
|
|
734
|
+
if (Array.isArray(toolResult.executedActions)) {
|
|
735
|
+
recordRecentRealActions(toolResult.executedActions);
|
|
736
|
+
}
|
|
737
|
+
if (observationOnlyTurn && updateObservationOnlyStreak(actionableItem)) {
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
449
740
|
const nextTurnConfig = toolResult.nextTurnConfig ?? {};
|
|
741
|
+
let nextTurnOutputItem = toolResult.outputItem;
|
|
742
|
+
if (nextTurnConfig.attachFreshScreenshotOnContinuation === true
|
|
743
|
+
&& isHoloResponseId(newResponseId)
|
|
744
|
+
&& nextTurnOutputItem?.type === "function_call_output"
|
|
745
|
+
&& !functionCallOutputHasImage(nextTurnOutputItem)) {
|
|
746
|
+
let continuationScreenshot = toolResult.currentScreenshot ?? null;
|
|
747
|
+
let screenshotBase64 = typeof continuationScreenshot?.base64 === "string" && continuationScreenshot.base64
|
|
748
|
+
? continuationScreenshot.base64
|
|
749
|
+
: null;
|
|
750
|
+
if (!screenshotBase64) {
|
|
751
|
+
screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
|
|
752
|
+
continuationScreenshot = await this.recordScreenshot(screenshotBase64, {
|
|
753
|
+
runId: context?.runId,
|
|
754
|
+
sessionId: context?.sessionId,
|
|
755
|
+
stepId: stepContext?.stepId,
|
|
756
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
757
|
+
captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
nextTurnOutputItem = appendImageToFunctionCallOutput(nextTurnOutputItem, screenshotBase64);
|
|
761
|
+
currentScreenshot = continuationScreenshot ?? currentScreenshot;
|
|
762
|
+
}
|
|
763
|
+
const startsFreshResponseChain = nextTurnConfig.continuationMode === "fresh";
|
|
764
|
+
// Fresh follow-ups start a new provider response chain. Tool outputs from
|
|
765
|
+
// the previous chain cannot be acknowledged without the matching tool call.
|
|
766
|
+
const nextTurnMessages = startsFreshResponseChain ? [] : [nextTurnOutputItem];
|
|
450
767
|
if (Array.isArray(nextTurnConfig.followUpMessages) && nextTurnConfig.followUpMessages.length > 0) {
|
|
451
768
|
nextTurnMessages.push(...nextTurnConfig.followUpMessages);
|
|
452
769
|
}
|
|
770
|
+
if (pendingObservationOnlyRecoveryMessage) {
|
|
771
|
+
nextTurnMessages.push(pendingObservationOnlyRecoveryMessage);
|
|
772
|
+
pendingObservationOnlyRecoveryMessage = null;
|
|
773
|
+
}
|
|
453
774
|
response = await this.requestNextTurn(nextTurnMessages, newResponseId, context, stepContext, {
|
|
454
775
|
debugContextOverride: options.debugContextOverride,
|
|
455
776
|
toolCapabilitiesOverride: nextTurnConfig.toolCapabilitiesOverride,
|
|
456
777
|
includeComputerToolOverride: nextTurnConfig.includeComputerToolOverride,
|
|
778
|
+
continuationModeOverride: nextTurnConfig.continuationMode,
|
|
457
779
|
});
|
|
458
780
|
newResponseId = response.id;
|
|
459
781
|
if (toolResult.kind === "close_turn") {
|
|
@@ -470,7 +792,7 @@ export class ExecutionEngine {
|
|
|
470
792
|
refreshBeforeNextComputerAction = false;
|
|
471
793
|
continue;
|
|
472
794
|
}
|
|
473
|
-
const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext,
|
|
795
|
+
const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, wrappedTrackAction);
|
|
474
796
|
if (actionBatchResult.stopped) {
|
|
475
797
|
summary.stopped = true;
|
|
476
798
|
return finalize();
|
|
@@ -496,6 +818,13 @@ export class ExecutionEngine {
|
|
|
496
818
|
refreshBeforeNextComputerAction = true;
|
|
497
819
|
}
|
|
498
820
|
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
821
|
+
if (observationOnlyTurn && updateObservationOnlyStreak(actionableItem)) {
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
if (pendingObservationOnlyRecoveryMessage) {
|
|
825
|
+
followUp.input.push(pendingObservationOnlyRecoveryMessage);
|
|
826
|
+
pendingObservationOnlyRecoveryMessage = null;
|
|
827
|
+
}
|
|
499
828
|
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
500
829
|
newResponseId = response.id;
|
|
501
830
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { buildBaseSystemPrompt } from "../prompts/base.js";
|
|
2
|
-
export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
|
|
2
|
+
export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptFormatRetryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
|
|
3
3
|
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildSecretKeysContextSection, buildTestDataContextSection, } from "../prompts/execution.js";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// HTML document that reproduces the Desktop App's run-details view. Pure: no I/O and
|
|
3
3
|
// no external references — screenshots must already be inlined as data URLs in
|
|
4
4
|
// `event.payload.screenshotDataUrl`. Use generate-html-report.js to inline them.
|
|
5
|
-
import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, } from "./run-log-model.js";
|
|
5
|
+
import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
|
|
6
6
|
// ---------- text / value formatting (mirrors the Desktop App) ----------
|
|
7
7
|
function escapeHtml(value) {
|
|
8
8
|
return String(value ?? "")
|
|
@@ -18,7 +18,7 @@ function formatRunTimestamp(iso) {
|
|
|
18
18
|
const parsed = Date.parse(iso);
|
|
19
19
|
if (!Number.isFinite(parsed))
|
|
20
20
|
return "—";
|
|
21
|
-
return new Date(parsed).toLocaleString();
|
|
21
|
+
return new Date(parsed).toLocaleString("en-US");
|
|
22
22
|
}
|
|
23
23
|
function formatRunDuration(durationSeconds) {
|
|
24
24
|
if (durationSeconds === null || durationSeconds === undefined || !Number.isFinite(durationSeconds)) {
|
|
@@ -121,6 +121,11 @@ function renderEvent(event, shots) {
|
|
|
121
121
|
}
|
|
122
122
|
const isAssistant = event.eventType === "assistant_message" || event.kind === "assistant";
|
|
123
123
|
if (isAssistant) {
|
|
124
|
+
const stepCompletion = stepCompletionData(event);
|
|
125
|
+
if (stepCompletion) {
|
|
126
|
+
const tooltip = stepCompletionReasonTooltip(stepCompletion);
|
|
127
|
+
return `<div class="dc-line"${tooltip ? ` title="${escapeHtml(tooltip)}"` : ""}><span aria-hidden="true">⚑</span><span>Step complete</span></div>`;
|
|
128
|
+
}
|
|
124
129
|
return `<div class="dc-assistant">${escapeHtml(event.text)}</div>`;
|
|
125
130
|
}
|
|
126
131
|
const assertion = assertionData(event);
|
|
@@ -239,7 +244,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
|
|
|
239
244
|
.dc-row-right { justify-content: flex-end; }
|
|
240
245
|
.dc-bubble { display: inline-block; max-width: 70%; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 16px; border-bottom-right-radius: 6px; padding: 8px 14px; color: #0f172a; }
|
|
241
246
|
.dc-assistant { max-width: 82%; color: #334155; }
|
|
242
|
-
.dc-line { display: flex; align-items: center; gap: 8px; color: #475569;
|
|
247
|
+
.dc-line { display: flex; align-items: center; gap: 8px; color: #475569; }
|
|
243
248
|
.dc-retry { color: #b45309; }
|
|
244
249
|
.dc-error { color: #b91c1c; }
|
|
245
250
|
.dc-warning { color: #b45309; }
|