@loadmill/droid-cua 2.21.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/cli/headless-runner.js +174 -12
- package/build/src/commands/help.js +1 -1
- package/build/src/core/cua-models.js +11 -4
- package/build/src/core/droid-cua-run-usage.js +106 -0
- package/build/src/core/droid-cua-segment-events.js +32 -0
- 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 +62 -0
- package/build/src/integrations/loadmill/run-reporter.js +113 -0
- package/build/src/integrations/loadmill/session-user.js +39 -0
- package/build/src/integrations/segment/droid-cua-segment.js +64 -0
- 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 +2 -1
- package/build/src/core/cua-backends.js +0 -11
|
@@ -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; }
|
|
@@ -98,6 +98,48 @@ export function runSummaryData(event) {
|
|
|
98
98
|
error,
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
export function stepCompletionData(eventOrText) {
|
|
102
|
+
const text = typeof eventOrText === "string"
|
|
103
|
+
? eventOrText
|
|
104
|
+
: typeof eventOrText?.text === "string"
|
|
105
|
+
? eventOrText.text
|
|
106
|
+
: null;
|
|
107
|
+
if (!text)
|
|
108
|
+
return null;
|
|
109
|
+
const normalized = text.replace(/\r/g, "").trim();
|
|
110
|
+
const prefix = "Step complete:";
|
|
111
|
+
if (!normalized.startsWith(prefix))
|
|
112
|
+
return null;
|
|
113
|
+
const rest = normalized.slice(prefix.length);
|
|
114
|
+
const reasonLabel = /\sReason:\s*/.exec(rest);
|
|
115
|
+
if (!reasonLabel)
|
|
116
|
+
return null;
|
|
117
|
+
const instruction = rest.slice(0, reasonLabel.index).trim();
|
|
118
|
+
const reasonStart = reasonLabel.index + reasonLabel[0].length;
|
|
119
|
+
const afterReason = rest.slice(reasonStart);
|
|
120
|
+
const visibleStateLabel = /\sVisible state:\s*/.exec(afterReason);
|
|
121
|
+
if (!visibleStateLabel)
|
|
122
|
+
return null;
|
|
123
|
+
const reason = afterReason.slice(0, visibleStateLabel.index).trim();
|
|
124
|
+
const visibleState = afterReason.slice(visibleStateLabel.index + visibleStateLabel[0].length).trim();
|
|
125
|
+
if (!instruction || !reason || !visibleState)
|
|
126
|
+
return null;
|
|
127
|
+
return { instruction, reason, visibleState };
|
|
128
|
+
}
|
|
129
|
+
export function stepCompletionReasonTooltip(stepCompletion, maxLength = 140) {
|
|
130
|
+
if (!stepCompletion?.reason)
|
|
131
|
+
return null;
|
|
132
|
+
const firstSentence = stepCompletion.reason.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
|
|
133
|
+
const text = (firstSentence || stepCompletion.reason).replace(/\s+/g, " ").trim();
|
|
134
|
+
if (!text)
|
|
135
|
+
return null;
|
|
136
|
+
if (text.length <= maxLength)
|
|
137
|
+
return `Reason: ${text}`;
|
|
138
|
+
const truncated = text.slice(0, Math.max(0, maxLength - 1)).trimEnd();
|
|
139
|
+
const wordBoundary = truncated.lastIndexOf(" ");
|
|
140
|
+
const cleanTruncated = wordBoundary > maxLength * 0.6 ? truncated.slice(0, wordBoundary).trimEnd() : truncated;
|
|
141
|
+
return `Reason: ${cleanTruncated}…`;
|
|
142
|
+
}
|
|
101
143
|
export function formatDuration(ms) {
|
|
102
144
|
if (ms === null || ms === undefined)
|
|
103
145
|
return "n/a";
|
|
@@ -2,13 +2,12 @@ import OpenAI from "openai";
|
|
|
2
2
|
import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
|
|
3
3
|
import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
|
|
4
4
|
import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
|
|
5
|
-
import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
|
|
6
5
|
import { normalizeStepDelayMs } from "../utils/step-delay.js";
|
|
7
6
|
export const LOADMILL_MODEL_ALIASES = Object.freeze({
|
|
8
7
|
DEFAULT_AGENT_MODEL: "default-agent-model",
|
|
9
8
|
FAST: "fast",
|
|
10
9
|
SMART: "smart",
|
|
11
|
-
|
|
10
|
+
BEACON: "beacon",
|
|
12
11
|
});
|
|
13
12
|
const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
|
|
14
13
|
export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
|
|
@@ -89,19 +88,19 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
|
|
|
89
88
|
}
|
|
90
89
|
return DEFAULT_LLM_PROVIDER_MODE;
|
|
91
90
|
}
|
|
92
|
-
export function resolveGatewayCuaModelAlias({
|
|
93
|
-
if (resolveCuaBackend(cuaBackend) === "fara") {
|
|
94
|
-
return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
|
|
95
|
-
}
|
|
91
|
+
export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
|
|
96
92
|
const normalizedCuaModel = resolveCuaModel(cuaModel);
|
|
93
|
+
if (normalizedCuaModel === "loadmill-beacon") {
|
|
94
|
+
return LOADMILL_MODEL_ALIASES.BEACON;
|
|
95
|
+
}
|
|
97
96
|
return normalizedCuaModel === "gpt-5.4-mini"
|
|
98
97
|
? LOADMILL_MODEL_ALIASES.FAST
|
|
99
98
|
: LOADMILL_MODEL_ALIASES.SMART;
|
|
100
99
|
}
|
|
101
|
-
export function resolveEffectiveCuaModel({ llmProviderMode = null,
|
|
100
|
+
export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
|
|
102
101
|
const providerMode = resolveLlmProviderMode(llmProviderMode);
|
|
103
102
|
if (providerMode === "loadmill") {
|
|
104
|
-
return resolveGatewayCuaModelAlias({
|
|
103
|
+
return resolveGatewayCuaModelAlias({ cuaModel });
|
|
105
104
|
}
|
|
106
105
|
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
107
106
|
? resolveCuaModel(cuaModel)
|
|
@@ -115,17 +114,14 @@ export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel =
|
|
|
115
114
|
? resolveCuaModel(cuaModel)
|
|
116
115
|
: DEFAULT_OPENAI_CUA_MODEL;
|
|
117
116
|
}
|
|
118
|
-
export function resolveRuntimeCredentials({ openAiApiKey = null,
|
|
117
|
+
export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
|
|
119
118
|
return {
|
|
120
119
|
openAiApiKey: trimNullableString(openAiApiKey),
|
|
121
|
-
faraApiKey: trimNullableString(faraApiKey),
|
|
122
|
-
faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
|
|
123
|
-
faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
|
|
124
120
|
loadmillApiToken: trimNullableString(loadmillApiToken),
|
|
125
121
|
loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
|
|
126
122
|
};
|
|
127
123
|
}
|
|
128
|
-
export function resolveRuntimeConfig({ llmProviderMode = null,
|
|
124
|
+
export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
129
125
|
const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
|
|
130
126
|
? cuaModel
|
|
131
127
|
: resolveCuaModel(cuaModel);
|
|
@@ -134,7 +130,6 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null
|
|
|
134
130
|
llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
|
|
135
131
|
? "loadmill"
|
|
136
132
|
: normalizedProviderMode,
|
|
137
|
-
cuaBackend: resolveCuaBackend(cuaBackend),
|
|
138
133
|
cuaModel: normalizedCuaModel,
|
|
139
134
|
stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
|
|
140
135
|
contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
|
|
@@ -161,11 +156,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
161
156
|
const contextOptimization = readContextOptimizationEnv(env);
|
|
162
157
|
return resolveRuntimeConfig({
|
|
163
158
|
llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
|
|
164
|
-
// Only the explicit backend flag should influence CUA model routing.
|
|
165
|
-
// Legacy screenshot-provider env vars must not silently remap Loadmill
|
|
166
|
-
// model aliases like "smart" to backend-specific aliases like
|
|
167
|
-
// "computer-use".
|
|
168
|
-
cuaBackend: env.DROID_CUA_BACKEND,
|
|
169
159
|
cuaModel: env.OPENAI_CUA_MODEL,
|
|
170
160
|
stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
|
|
171
161
|
contextOptimizationEnabled: contextOptimization.enabled,
|
|
@@ -176,9 +166,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
176
166
|
export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
|
|
177
167
|
return resolveRuntimeCredentials({
|
|
178
168
|
openAiApiKey: env.OPENAI_API_KEY,
|
|
179
|
-
faraApiKey: env.DROID_CUA_FARA_API_KEY,
|
|
180
|
-
faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
|
|
181
|
-
faraModel: env.DROID_CUA_FARA_MODEL,
|
|
182
169
|
loadmillApiToken: env.LOADMILL_API_TOKEN,
|
|
183
170
|
loadmillBaseUrl: env.LOADMILL_BASE_URL,
|
|
184
171
|
...overrides,
|
|
@@ -278,19 +265,27 @@ export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName
|
|
|
278
265
|
},
|
|
279
266
|
});
|
|
280
267
|
}
|
|
281
|
-
export function
|
|
282
|
-
return new OpenAI({
|
|
283
|
-
apiKey: trimNullableString(apiKey) || "",
|
|
284
|
-
baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
268
|
+
export function assertRuntimeCredentialsForProvider({ llmProviderMode = null, credentials = null, } = {}) {
|
|
288
269
|
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
289
270
|
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
290
271
|
if (resolvedProviderMode === "loadmill") {
|
|
291
272
|
if (!hasLoadmillLlmAccess(resolvedCredentials)) {
|
|
292
273
|
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
293
274
|
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
278
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
282
|
+
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
283
|
+
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
284
|
+
assertRuntimeCredentialsForProvider({
|
|
285
|
+
llmProviderMode: resolvedProviderMode,
|
|
286
|
+
credentials: resolvedCredentials,
|
|
287
|
+
});
|
|
288
|
+
if (resolvedProviderMode === "loadmill") {
|
|
294
289
|
return createLlmClient({
|
|
295
290
|
loadmillApiToken: resolvedCredentials.loadmillApiToken,
|
|
296
291
|
loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
|
|
@@ -298,9 +293,6 @@ export function createResolvedLlmClient({ llmProviderMode = null, credentials =
|
|
|
298
293
|
maxRetries,
|
|
299
294
|
});
|
|
300
295
|
}
|
|
301
|
-
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
302
|
-
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
303
|
-
}
|
|
304
296
|
return createOpenAiClient(resolvedCredentials.openAiApiKey);
|
|
305
297
|
}
|
|
306
298
|
export function createRunContext({ config, credentials }) {
|
|
@@ -312,7 +304,6 @@ export function createRunContext({ config, credentials }) {
|
|
|
312
304
|
llmProviderMode: resolvedProviderMode,
|
|
313
305
|
cuaModel: resolveEffectiveCuaModel({
|
|
314
306
|
llmProviderMode: resolvedProviderMode,
|
|
315
|
-
cuaBackend: resolvedConfig.cuaBackend,
|
|
316
307
|
cuaModel: resolvedConfig.cuaModel,
|
|
317
308
|
}),
|
|
318
309
|
};
|
|
@@ -328,12 +319,6 @@ export function createRunContext({ config, credentials }) {
|
|
|
328
319
|
credentials: resolvedCredentials,
|
|
329
320
|
clientName: "desktop",
|
|
330
321
|
}),
|
|
331
|
-
fara: resolvedConfig.cuaBackend === "fara"
|
|
332
|
-
? createFaraClient({
|
|
333
|
-
apiKey: resolvedCredentials.faraApiKey,
|
|
334
|
-
baseUrl: resolvedCredentials.faraBaseUrl,
|
|
335
|
-
})
|
|
336
|
-
: null,
|
|
337
322
|
},
|
|
338
323
|
};
|
|
339
324
|
}
|