@loadmill/droid-cua 2.9.0 → 2.10.1-rc.8

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.
Files changed (31) hide show
  1. package/README.md +0 -2
  2. package/build/index.js +24 -18
  3. package/build/src/brand/loadmill-mark.js +20 -0
  4. package/build/src/browser/playwright/connection.js +148 -11
  5. package/build/src/browser/playwright/welcome-page.js +161 -0
  6. package/build/src/cli/headless-execution-config.js +16 -24
  7. package/build/src/cli/headless-target.js +0 -9
  8. package/build/src/commands/help.js +3 -5
  9. package/build/src/commands/run.js +1 -1
  10. package/build/src/core/aim-assist.js +5 -0
  11. package/build/src/core/execution-engine.js +266 -505
  12. package/build/src/core/prompts.js +1 -1
  13. package/build/src/core/runtime-config.js +1 -2
  14. package/build/src/core/screenshot-store.js +153 -0
  15. package/build/src/core/tool-handlers/assert.js +155 -0
  16. package/build/src/core/tool-handlers/loadmill.js +29 -0
  17. package/build/src/core/tool-handlers/navigate.js +110 -0
  18. package/build/src/core/tool-handlers/preview-click.js +112 -0
  19. package/build/src/core/tool-handlers/test-design-complete.js +93 -0
  20. package/build/src/core/tool-handlers/utils.js +80 -0
  21. package/build/src/device/openai.js +103 -4
  22. package/build/src/modes/design-mode-ink.js +17 -5
  23. package/build/src/modes/design-mode.js +17 -5
  24. package/build/src/modes/execution-mode.js +42 -234
  25. package/build/src/prompts/base.js +44 -31
  26. package/build/src/prompts/design.js +55 -15
  27. package/build/src/prompts/editor.js +1 -1
  28. package/build/src/prompts/execution.js +15 -10
  29. package/build/src/utils/cua-debug-tracer.js +3 -1
  30. package/package.json +3 -4
  31. package/build/src/device/screenshot-resolution.js +0 -33
@@ -1,21 +1,28 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import path from "path";
3
- import { writeFile } from "fs/promises";
4
2
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
3
+ import { getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
5
4
  import { handleModelAction } from "../device/actions.js";
6
5
  import { sendCUARequest } from "../device/openai.js";
6
+ import { executeLoadmillInstruction } from "../device/loadmill.js";
7
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
8
8
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
9
9
  import { printCliOutput } from "../utils/console-output.js";
10
- import { annotateScreenshotArtifact, createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
11
10
  import { CAPTURE_SOURCES } from "./capture-sources.js";
11
+ import { ScreenshotStore } from "./screenshot-store.js";
12
+ import { handlePreviewClick } from "./tool-handlers/preview-click.js";
13
+ import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
14
+ import { handleAssertionToolCall } from "./tool-handlers/assert.js";
15
+ import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
16
+ import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
17
+ import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
12
18
  function extractActionableOutputItems(items) {
13
19
  const entries = [];
14
20
  for (const item of items) {
15
- if (item?.type === "function_call" && item.name === "preview_click") {
21
+ if (item?.type === "function_call") {
16
22
  entries.push({
17
- type: "preview_click",
23
+ type: "function_call",
18
24
  call_id: item.call_id || item.id,
25
+ name: item.name,
19
26
  arguments: item.arguments,
20
27
  });
21
28
  continue;
@@ -42,144 +49,10 @@ function extractActionableOutputItems(items) {
42
49
  }
43
50
  return entries;
44
51
  }
45
- function buildPreviewClickValidationError(argumentsText) {
46
- if (!argumentsText || typeof argumentsText !== "string") {
47
- return "Invalid preview_click arguments: expected a JSON object with numeric x and y.";
48
- }
49
- return `Invalid preview_click arguments: ${argumentsText}`;
50
- }
51
- function parsePreviewClickArguments(argumentsText) {
52
- let parsedArguments;
53
- try {
54
- parsedArguments = JSON.parse(argumentsText);
55
- }
56
- catch {
57
- return {
58
- ok: false,
59
- message: buildPreviewClickValidationError("arguments could not be parsed as JSON; expected numeric x and y."),
60
- };
61
- }
62
- const x = parsedArguments?.x;
63
- const y = parsedArguments?.y;
64
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
65
- return {
66
- ok: false,
67
- message: buildPreviewClickValidationError("x and y must both be numeric."),
68
- };
69
- }
70
- return {
71
- ok: true,
72
- x,
73
- y,
74
- };
75
- }
76
- function buildFunctionCallOutputItem(callId, output) {
77
- return {
78
- type: "function_call_output",
79
- call_id: callId,
80
- output,
81
- };
82
- }
83
- function buildPreviewOutputText({ requestedX, requestedY, renderedX, renderedY, wasClamped }) {
84
- const baseText = `Marker placed at (${renderedX}, ${renderedY}) on the current screen. The click has not been executed.`;
85
- if (!wasClamped) {
86
- return baseText;
87
- }
88
- return `${baseText} Requested point (${requestedX}, ${requestedY}) was off-screen; marker rendered at (${renderedX}, ${renderedY}).`;
89
- }
90
- function buildPreviewOutputContent({ text, annotatedScreenshotBase64 = null }) {
91
- const content = [{
92
- type: "input_text",
93
- text,
94
- }];
95
- if (annotatedScreenshotBase64) {
96
- content.push({
97
- type: "input_image",
98
- image_url: `data:image/png;base64,${annotatedScreenshotBase64}`,
99
- detail: "high",
100
- });
101
- }
102
- return content;
103
- }
104
- function getScopeAndIds(context = null, stepContext = null) {
105
- const scope = context?.sessionId ? "design" : "execution";
106
- const ids = scope === "design"
107
- ? {
108
- sessionId: context?.sessionId,
109
- stepId: stepContext?.stepId,
110
- instructionIndex: stepContext?.instructionIndex
111
- }
112
- : {
113
- runId: context?.runId,
114
- stepId: stepContext?.stepId,
115
- instructionIndex: stepContext?.instructionIndex
116
- };
117
- return { scope, ids };
118
- }
119
- function buildStrictModePlan(actions = [], strictMode = false) {
120
- if (!strictMode || actions.length === 0) {
121
- return {
122
- actionsToExecute: actions,
123
- droppedActions: [],
124
- truncationReason: null,
125
- runtimeNote: null
126
- };
127
- }
128
- const actionsToExecute = [actions[0]];
129
- const droppedActions = actions.slice(1);
130
- if (droppedActions.length === 0) {
131
- return {
132
- actionsToExecute,
133
- droppedActions,
134
- truncationReason: null,
135
- runtimeNote: null
136
- };
137
- }
138
- const leadingActionType = actionsToExecute[0]?.type;
139
- const truncationReason = leadingActionType === "screenshot"
140
- ? "leading_screenshot_reobserve"
141
- : "post_first_action_reobserve";
142
- const droppedActionTypes = droppedActions
143
- .map((action) => action?.type)
144
- .filter(Boolean);
145
- const runtimeNote = `Strict Mode: I executed only the first ${leadingActionType === "screenshot" ? "screenshot request" : "action"} from your previous chain and intentionally skipped the remaining ${droppedActions.length} action${droppedActions.length === 1 ? "" : "s"} (${droppedActionTypes.join(", ")}) so I could re-observe the device before continuing. Base your next step only on what is visible now.`;
146
- return {
147
- actionsToExecute,
148
- droppedActions,
149
- truncationReason,
150
- runtimeNote
151
- };
152
- }
153
- function normalizeArtifactPaths(result) {
154
- if (result && typeof result === "object" && !Array.isArray(result)) {
155
- if (Array.isArray(result.annotationSourcePaths)) {
156
- return normalizeArtifactPaths(result.annotationSourcePaths);
157
- }
158
- return normalizeArtifactPaths(result.artifactPaths);
159
- }
160
- if (Array.isArray(result)) {
161
- return result.filter((value) => typeof value === "string" && value.trim());
162
- }
163
- if (typeof result === "string" && result.trim()) {
164
- return [result];
165
- }
166
- return [];
167
- }
168
- function screenshotPathFromRecorderResult(result) {
169
- if (result && typeof result === "object" && !Array.isArray(result)) {
170
- return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
171
- ? result.screenshotPath
172
- : null;
173
- }
174
- return typeof result === "string" && result.trim() ? result : null;
175
- }
176
- function screenshotDataUrlFromRecorderResult(result) {
177
- if (result && typeof result === "object" && !Array.isArray(result)) {
178
- return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
179
- ? result.screenshotDataUrl
180
- : null;
181
- }
182
- return null;
52
+ function collectPendingSafetyChecks(items = []) {
53
+ return items
54
+ .filter((item) => item.type === "pending_safety_check")
55
+ .map((item) => ({ id: item.id }));
183
56
  }
184
57
  export class ExecutionEngine {
185
58
  constructor(session, options = {}) {
@@ -187,206 +60,239 @@ export class ExecutionEngine {
187
60
  this.recordScreenshots = options.recordScreenshots || false;
188
61
  this.screenshotDir = options.screenshotDir || null;
189
62
  this.screenshotRecorder = options.screenshotRecorder || null;
190
- this.strictMode = options.strictMode === true;
63
+ this.screenshotStore = options.screenshotStore || new ScreenshotStore({
64
+ recordScreenshots: this.recordScreenshots,
65
+ screenshotDir: this.screenshotDir,
66
+ screenshotRecorder: this.screenshotRecorder,
67
+ });
191
68
  this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
192
- this.reportedScreenshotWriteError = false;
193
- this.reportedScreenshotAnnotateError = false;
194
- this.lastRecordedScreenshotPaths = [];
195
69
  this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
196
70
  this.handleModelAction = options.handleModelAction || handleModelAction;
197
71
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
198
72
  this.reviseTestScript = options.reviseTestScript || null;
199
73
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
74
+ this.getBrowserInfo = options.getBrowserInfo || getBrowserInfo;
75
+ this.navigateToUrl = options.navigateToUrl || navigateBrowserToUrl;
76
+ this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
77
+ this.toolCapabilities = options.toolCapabilities || {};
78
+ this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
79
+ this.isHeadlessMode = options.isHeadlessMode === true;
200
80
  }
201
- async saveScreenshotArtifact(screenshotBase64, metadata = {}, options = {}) {
202
- if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
81
+ async recordScreenshot(screenshotBase64, metadata = {}) {
82
+ return await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
83
+ }
84
+ async annotateScreenshot(currentScreenshot, action, metadata = {}) {
85
+ if (!(action?.type === "click" || action?.type === "double_click")) {
203
86
  return null;
204
87
  }
205
- const updateLastRecordedScreenshotPaths = options.updateLastRecordedScreenshotPaths !== false;
206
- const previousRecordedScreenshotPaths = updateLastRecordedScreenshotPaths
207
- ? null
208
- : [...this.lastRecordedScreenshotPaths];
209
- try {
210
- if (this.screenshotRecorder?.saveScreenshot) {
211
- const result = await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
212
- if (updateLastRecordedScreenshotPaths) {
213
- this.lastRecordedScreenshotPaths = normalizeArtifactPaths(result);
214
- }
215
- return result;
216
- }
217
- if (this.recordScreenshots && this.screenshotDir) {
218
- const framePath = path.join(this.screenshotDir, `frame_${String(Date.now())}.png`);
219
- await writeFile(framePath, Buffer.from(screenshotBase64, "base64"));
220
- if (updateLastRecordedScreenshotPaths) {
221
- this.lastRecordedScreenshotPaths = [framePath];
222
- }
223
- return framePath;
224
- }
225
- }
226
- catch (error) {
227
- if (updateLastRecordedScreenshotPaths) {
228
- this.lastRecordedScreenshotPaths = [];
229
- }
230
- else {
231
- this.lastRecordedScreenshotPaths = previousRecordedScreenshotPaths;
232
- }
233
- if (!this.reportedScreenshotWriteError) {
234
- this.reportedScreenshotWriteError = true;
235
- const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
236
- const ids = scope === "design"
237
- ? {
238
- sessionId: metadata.sessionId,
239
- stepId: metadata.stepId,
240
- instructionIndex: metadata.instructionIndex
241
- }
242
- : {
243
- runId: metadata.runId,
244
- stepId: metadata.stepId,
245
- instructionIndex: metadata.instructionIndex
246
- };
247
- emitDesktopDebug("run.screenshot_write.error", scope, ids, {
248
- captureSource: metadata.captureSource ?? null,
249
- message: error instanceof Error ? error.message : "Failed to persist screenshot"
250
- });
251
- }
252
- }
253
- return null;
88
+ return await this.screenshotStore.annotateScreenshot(currentScreenshot, { x: action.x, y: action.y, style: "click" }, { ...metadata, actionType: action.type });
254
89
  }
255
- async recordScreenshot(screenshotBase64, metadata = {}) {
256
- return await this.saveScreenshotArtifact(screenshotBase64, metadata);
90
+ /**
91
+ * Dispatches a function-call tool to its handler. Each handler returns a
92
+ * discriminated outcome that runFullTurn interprets below. Continue and
93
+ * close_turn outcomes may also return currentScreenshot to update the
94
+ * parent turn's explicit screenshot state.
95
+ */
96
+ async handleFunctionToolCall(toolName, callId, argumentsText, context = null, stepContext = null) {
97
+ const handlerArgs = { engine: this, callId, argumentsText, context, stepContext };
98
+ switch (toolName) {
99
+ case "preview_click":
100
+ return await handlePreviewClick(handlerArgs);
101
+ case "assert":
102
+ return await handleAssertionToolCall(handlerArgs);
103
+ case "navigate":
104
+ return await handleNavigateToolCall(handlerArgs);
105
+ case "loadmill":
106
+ return await handleLoadmillToolCall(handlerArgs);
107
+ case "test_design_complete":
108
+ return await handleTestDesignCompleteToolCall(handlerArgs);
109
+ default:
110
+ return {
111
+ kind: "continue",
112
+ outputItem: buildTextToolOutputItem(callId, `Unsupported tool call: ${toolName}`),
113
+ };
114
+ }
257
115
  }
258
- async recordPreviewScreenshot(screenshotBase64, metadata = {}) {
259
- return await this.saveScreenshotArtifact(screenshotBase64, metadata, {
260
- updateLastRecordedScreenshotPaths: false,
116
+ async requestNextTurn(messages, previousResponseId, context = null, stepContext = null, options = {}) {
117
+ return await this.sendCUARequest({
118
+ messages,
119
+ previousResponseId,
120
+ deviceInfo: this.session.deviceInfo,
121
+ debugContext: options.debugContextOverride ?? buildDebugContext(context, stepContext),
122
+ toolCapabilities: options.toolCapabilitiesOverride ?? this.toolCapabilities,
123
+ includeComputerTool: options.includeComputerToolOverride ?? true,
261
124
  });
262
125
  }
263
- async annotateLatestScreenshot(action, metadata = {}) {
264
- if (!(action?.type === "click" || action?.type === "double_click") || this.lastRecordedScreenshotPaths.length === 0) {
265
- return null;
266
- }
267
- try {
268
- const marker = {
269
- x: action.x,
270
- y: action.y,
271
- style: "click",
272
- };
273
- const sourcePath = this.lastRecordedScreenshotPaths.find((filePath) => typeof filePath === "string" && filePath.trim());
274
- if (sourcePath && this.screenshotRecorder?.saveAnnotatedScreenshot) {
275
- return await this.screenshotRecorder.saveAnnotatedScreenshot(sourcePath, marker, metadata);
276
- }
277
- await Promise.all(this.lastRecordedScreenshotPaths.map((filePath) => annotateScreenshotArtifact(filePath, marker)));
278
- return null;
279
- }
280
- catch (error) {
281
- if (!this.reportedScreenshotAnnotateError) {
282
- this.reportedScreenshotAnnotateError = true;
283
- const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
284
- const ids = scope === "design"
285
- ? {
286
- sessionId: metadata.sessionId,
287
- stepId: metadata.stepId,
288
- instructionIndex: metadata.instructionIndex
289
- }
290
- : {
291
- runId: metadata.runId,
292
- stepId: metadata.stepId,
293
- instructionIndex: metadata.instructionIndex
294
- };
295
- emitDesktopDebug("run.screenshot_annotate.error", scope, ids, {
296
- actionType: action?.type ?? null,
297
- x: action?.x ?? null,
298
- y: action?.y ?? null,
299
- message: error instanceof Error ? error.message : "Failed to annotate screenshot"
300
- });
126
+ async executeActionBatch(actions, currentScreenshot, shouldStop, context = null, stepContext = null, trackAction = null) {
127
+ let sawExplicitScreenshotAction = false;
128
+ for (const action of actions) {
129
+ const actionEventId = randomUUID();
130
+ if (shouldStop()) {
131
+ return { stopped: true, sawExplicitScreenshotAction };
301
132
  }
302
- }
303
- return null;
304
- }
305
- async handlePreviewClick(callId, argumentsText, context = null, stepContext = null) {
306
- const parsedArguments = parsePreviewClickArguments(argumentsText);
307
- if (!parsedArguments.ok) {
308
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({ text: parsedArguments.message }));
309
- }
310
- try {
311
- const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
312
- const previewResult = await createAnnotatedScreenshotFromBase64(screenshotBase64, {
313
- x: parsedArguments.x,
314
- y: parsedArguments.y,
315
- style: "preview",
316
- });
317
- if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
318
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
319
- text: "preview_click failed: could not annotate the current screenshot.",
320
- }));
133
+ if (action.type === "screenshot") {
134
+ sawExplicitScreenshotAction = true;
135
+ continue;
321
136
  }
322
- const previewEventId = randomUUID();
323
- const previewScreenshotResult = await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
137
+ const annotatedScreenshot = await this.annotateScreenshot(currentScreenshot, action, {
324
138
  runId: context?.runId,
325
139
  sessionId: context?.sessionId,
326
140
  stepId: stepContext?.stepId,
327
141
  instructionIndex: stepContext?.instructionIndex,
328
- callId,
329
- eventId: previewEventId,
330
- captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
142
+ eventId: actionEventId,
143
+ captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
331
144
  });
332
- const previewText = buildPreviewOutputText({
333
- requestedX: previewResult.marker.requestedX,
334
- requestedY: previewResult.marker.requestedY,
335
- renderedX: previewResult.marker.x,
336
- renderedY: previewResult.marker.y,
337
- wasClamped: previewResult.marker.wasClamped,
145
+ await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
146
+ ...context,
147
+ shouldStop,
148
+ stepId: stepContext?.stepId,
149
+ instructionIndex: stepContext?.instructionIndex,
150
+ eventId: actionEventId,
151
+ captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
152
+ ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
153
+ ...(context?.includeScreenshotDataUrl && annotatedScreenshot?.screenshotDataUrl ? { screenshotDataUrl: annotatedScreenshot.screenshotDataUrl } : {})
338
154
  });
339
- const addOutput = context?.addOutput || null;
340
- if (addOutput) {
341
- const previewScreenshotPath = screenshotPathFromRecorderResult(previewScreenshotResult);
342
- const previewScreenshotDataUrl = context?.includeScreenshotDataUrl
343
- ? `data:image/png;base64,${previewResult.screenshotBase64}`
344
- : null;
155
+ if (trackAction) {
156
+ trackAction(action);
157
+ }
158
+ if (shouldStop()) {
159
+ return { stopped: true, sawExplicitScreenshotAction };
160
+ }
161
+ if (action.type !== "wait") {
162
+ await new Promise((resolve) => setTimeout(resolve, this.stepDelayMs));
163
+ }
164
+ }
165
+ return { stopped: false, sawExplicitScreenshotAction };
166
+ }
167
+ async buildActionFollowUp(callId, pendingSafetyChecks, sawExplicitScreenshotAction, addOutput, eventMeta, context = null, stepContext = null) {
168
+ const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
169
+ emitDesktopDebug("device.screenshot", "device", {
170
+ runId: context?.runId,
171
+ stepId: stepContext?.stepId,
172
+ instructionIndex: stepContext?.instructionIndex
173
+ }, {
174
+ source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
175
+ callId,
176
+ width: this.session.deviceInfo?.scaled_width,
177
+ height: this.session.deviceInfo?.scaled_height,
178
+ base64Length: screenshotBase64.length
179
+ });
180
+ const screenshotEventId = randomUUID();
181
+ const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
182
+ const currentScreenshot = await this.recordScreenshot(screenshotBase64, {
183
+ runId: context?.runId,
184
+ sessionId: context?.sessionId,
185
+ stepId: stepContext?.stepId,
186
+ instructionIndex: stepContext?.instructionIndex,
187
+ eventId: screenshotEventId,
188
+ captureSource
189
+ });
190
+ addOutput({
191
+ type: "info",
192
+ text: "Capturing screen",
193
+ eventType: "screenshot_captured",
194
+ actionType: "screenshot",
195
+ ...eventMeta({
196
+ payload: {
197
+ eventId: screenshotEventId,
198
+ captureSource,
199
+ source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
200
+ ...(currentScreenshot?.screenshotPath ? { screenshotPath: currentScreenshot.screenshotPath } : {}),
201
+ ...(context?.includeScreenshotDataUrl
202
+ ? { screenshotDataUrl: `data:image/png;base64,${screenshotBase64}` }
203
+ : {})
204
+ }
205
+ })
206
+ });
207
+ return {
208
+ input: [{
209
+ type: "computer_call_output",
210
+ call_id: callId,
211
+ output: {
212
+ type: "computer_screenshot",
213
+ image_url: `data:image/png;base64,${screenshotBase64}`,
214
+ },
215
+ ...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
216
+ }],
217
+ currentScreenshot,
218
+ };
219
+ }
220
+ emitResponseOutputItems(items, addOutput, eventMeta, options = {}) {
221
+ for (const item of items) {
222
+ if (item.type === "reasoning") {
223
+ for (const entry of item.summary) {
224
+ if (entry.type !== "summary_text")
225
+ continue;
226
+ addOutput({
227
+ type: "reasoning",
228
+ text: entry.text,
229
+ eventType: "reasoning",
230
+ ...eventMeta(),
231
+ });
232
+ this.session.addToTranscript(`[Reasoning] ${entry.text}`);
233
+ }
234
+ continue;
235
+ }
236
+ if (item.type === "message") {
237
+ const textPart = item.content.find((contentItem) => contentItem.type === "output_text");
238
+ if (!textPart)
239
+ continue;
240
+ if (!options.suppressAssistantOutput) {
241
+ addOutput({
242
+ type: "assistant",
243
+ text: textPart.text,
244
+ eventType: "assistant_message",
245
+ ...eventMeta(),
246
+ });
247
+ }
248
+ this.session.addToTranscript(`[Assistant] ${textPart.text}`);
249
+ continue;
250
+ }
251
+ if (item.type === "pending_safety_check") {
345
252
  addOutput({
346
- type: "action",
347
- text: "Click",
348
- eventType: "tool_call",
349
- runId: context?.runId,
350
- stepId: stepContext?.stepId,
351
- instructionIndex: stepContext?.instructionIndex,
352
- payload: {
353
- platform: this.session.deviceInfo?.platform,
354
- toolName: "preview_click",
355
- captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
356
- requestedX: previewResult.marker.requestedX,
357
- requestedY: previewResult.marker.requestedY,
358
- renderedX: previewResult.marker.x,
359
- renderedY: previewResult.marker.y,
360
- wasClamped: previewResult.marker.wasClamped,
361
- ...(previewScreenshotPath ? { screenshotPath: previewScreenshotPath } : {}),
362
- ...(previewScreenshotDataUrl ? { screenshotDataUrl: previewScreenshotDataUrl } : {})
363
- }
253
+ type: "warning",
254
+ text: `⚠️ Safety check: ${item.code} - ${item.message}`,
255
+ eventType: "system_message",
256
+ ...eventMeta({
257
+ payload: {
258
+ id: item.id,
259
+ code: item.code,
260
+ message: item.message,
261
+ },
262
+ }),
364
263
  });
365
264
  }
366
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
367
- text: previewText,
368
- annotatedScreenshotBase64: previewResult.screenshotBase64,
369
- }));
370
- }
371
- catch (error) {
372
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
373
- text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
374
- }));
375
265
  }
376
266
  }
377
267
  /**
378
- * Run a full turn with the CUA model
379
- * Executes actions until the model stops requesting actions
380
- * Returns the new response ID for chaining
381
- * @param {Object} response - The CUA response
382
- * @param {Function} trackAction - Optional callback to track actions for stuck detection
383
- * @param {Object} context - Optional Ink context for output
268
+ * Run a full turn with the CUA model. Executes actions until the model
269
+ * stops requesting actions, then returns { responseId, summary, currentScreenshot }.
270
+ *
271
+ * The summary's shape depends on which (if any) tool handler ran:
272
+ * - assertions: summary.assertionStatus = "passed" | "failed" | "failed_skipped" | "retry"
273
+ * - failures: summary.failed = true, summary.error = string
274
+ * - retries: summary.retryInstruction = true (caller should rerun the instruction)
275
+ * - navigation: summary.preserveSessionMessages = true (skip the usual message reset)
276
+ *
277
+ * @param {Object} response - The CUA response that opens this turn.
278
+ * @param {Function} trackAction - Optional callback for tracking actions / interrupting.
279
+ * @param {Object} context - Optional Ink context for output.
280
+ * @param {Object} options.currentScreenshot - Screenshot object to annotate during this turn.
384
281
  */
385
- async runFullTurn(response, trackAction = null, context = null, stepContext = null) {
282
+ async runFullTurn(response, trackAction = null, context = null, stepContext = null, options = {}) {
386
283
  const addOutput = context?.addOutput || printCliOutput;
387
284
  let newResponseId = response.id;
285
+ let currentScreenshot = options.currentScreenshot ?? null;
388
286
  const shouldStop = () => Boolean(trackAction?.());
389
- const { scope, ids } = getScopeAndIds(context, stepContext);
287
+ const summary = {
288
+ responseId: newResponseId,
289
+ invokedFunctionTools: [],
290
+ preserveSessionMessages: false,
291
+ };
292
+ const finalize = () => {
293
+ summary.responseId = newResponseId;
294
+ return { responseId: newResponseId, summary, currentScreenshot };
295
+ };
390
296
  const eventMeta = (extra = {}) => ({
391
297
  runId: context?.runId,
392
298
  stepId: stepContext?.stepId,
@@ -396,56 +302,12 @@ export class ExecutionEngine {
396
302
  while (true) {
397
303
  // Check for interruption before processing next batch of actions
398
304
  if (shouldStop()) {
399
- return newResponseId;
305
+ return finalize();
400
306
  }
401
307
  const items = response.output || [];
402
308
  const actionableItems = extractActionableOutputItems(items);
403
- // ── Collect pending safety checks ──
404
- const pendingSafetyChecks = items
405
- .filter(item => item.type === "pending_safety_check")
406
- .map(item => ({ id: item.id }));
407
- // ── Print assistant output ──
408
- for (const item of items) {
409
- if (item.type === "reasoning") {
410
- for (const entry of item.summary) {
411
- if (entry.type === "summary_text") {
412
- addOutput({
413
- type: 'reasoning',
414
- text: entry.text,
415
- eventType: 'reasoning',
416
- ...eventMeta()
417
- });
418
- this.session.addToTranscript(`[Reasoning] ${entry.text}`);
419
- }
420
- }
421
- }
422
- else if (item.type === "message") {
423
- const textPart = item.content.find(c => c.type === "output_text");
424
- if (textPart) {
425
- addOutput({
426
- type: 'assistant',
427
- text: textPart.text,
428
- eventType: 'assistant_message',
429
- ...eventMeta()
430
- });
431
- this.session.addToTranscript(`[Assistant] ${textPart.text}`);
432
- }
433
- }
434
- else if (item.type === "pending_safety_check") {
435
- addOutput({
436
- type: 'warning',
437
- text: `⚠️ Safety check: ${item.code} - ${item.message}`,
438
- eventType: 'system_message',
439
- ...eventMeta({
440
- payload: {
441
- id: item.id,
442
- code: item.code,
443
- message: item.message
444
- }
445
- })
446
- });
447
- }
448
- }
309
+ const pendingSafetyChecks = collectPendingSafetyChecks(items);
310
+ this.emitResponseOutputItems(items, addOutput, eventMeta, options);
449
311
  if (actionableItems.length === 0) {
450
312
  // No actions = turn complete
451
313
  break;
@@ -454,161 +316,60 @@ export class ExecutionEngine {
454
316
  if (!actionableItem?.call_id) {
455
317
  break;
456
318
  }
457
- if (actionableItem.type === "preview_click") {
458
- const input = [
459
- await this.handlePreviewClick(actionableItem.call_id, actionableItem.arguments, context, stepContext),
460
- ];
461
- if (shouldStop()) {
462
- return newResponseId;
319
+ if (actionableItem.type === "function_call") {
320
+ const toolResult = await this.handleFunctionToolCall(actionableItem.name, actionableItem.call_id, actionableItem.arguments, context, stepContext);
321
+ summary.invokedFunctionTools.push(actionableItem.name);
322
+ if (toolResult.summaryUpdates) {
323
+ Object.assign(summary, toolResult.summaryUpdates);
324
+ }
325
+ if ((toolResult.kind === "continue" || toolResult.kind === "close_turn") && toolResult.currentScreenshot) {
326
+ currentScreenshot = toolResult.currentScreenshot;
463
327
  }
464
- response = await this.sendCUARequest({
465
- messages: input,
466
- previousResponseId: newResponseId,
467
- deviceInfo: this.session.deviceInfo,
468
- debugContext: {
469
- scope,
470
- runId: context?.runId,
471
- sessionId: context?.sessionId,
472
- stepId: stepContext?.stepId,
473
- instructionIndex: stepContext?.instructionIndex
474
- }
475
- });
476
- newResponseId = response.id;
477
- continue;
478
- }
479
- const { call_id, actions } = actionableItem;
480
- const { actionsToExecute, droppedActions, truncationReason, runtimeNote } = buildStrictModePlan(actions, this.strictMode);
481
- let sawExplicitScreenshotAction = false;
482
- for (const action of actionsToExecute) {
483
- const actionEventId = randomUUID();
484
328
  if (shouldStop()) {
485
- return newResponseId;
329
+ return finalize();
486
330
  }
487
- if (action.type === "screenshot") {
488
- sawExplicitScreenshotAction = true;
331
+ if (toolResult.kind === "fail") {
332
+ summary.failed = true;
333
+ summary.error = toolResult.error;
334
+ break;
489
335
  }
490
- else {
491
- const annotatedScreenshotResult = await this.annotateLatestScreenshot(action, {
492
- runId: context?.runId,
493
- sessionId: context?.sessionId,
494
- stepId: stepContext?.stepId,
495
- instructionIndex: stepContext?.instructionIndex,
496
- eventId: actionEventId,
497
- captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
498
- });
499
- const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
500
- const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
501
- await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
502
- ...context,
503
- shouldStop,
504
- stepId: stepContext?.stepId,
505
- instructionIndex: stepContext?.instructionIndex,
506
- eventId: actionEventId,
507
- captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
508
- ...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
509
- ...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
510
- });
511
- if (trackAction) {
512
- trackAction(action);
513
- }
514
- if (shouldStop()) {
515
- return newResponseId;
516
- }
517
- if (action.type !== "wait") {
518
- await new Promise(resolve => setTimeout(resolve, this.stepDelayMs));
519
- }
336
+ if (toolResult.kind === "retry_instruction") {
337
+ summary.retryInstruction = true;
338
+ newResponseId = null;
339
+ break;
520
340
  }
521
- }
522
- if (shouldStop()) {
523
- return newResponseId;
524
- }
525
- const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
526
- emitDesktopDebug("device.screenshot", "device", {
527
- runId: context?.runId,
528
- stepId: stepContext?.stepId,
529
- instructionIndex: stepContext?.instructionIndex
530
- }, {
531
- source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
532
- callId: call_id,
533
- width: this.session.deviceInfo?.scaled_width,
534
- height: this.session.deviceInfo?.scaled_height,
535
- base64Length: screenshotBase64.length
536
- });
537
- const screenshotEventId = randomUUID();
538
- const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
539
- const screenshotResult = await this.recordScreenshot(screenshotBase64, {
540
- runId: context?.runId,
541
- sessionId: context?.sessionId,
542
- stepId: stepContext?.stepId,
543
- instructionIndex: stepContext?.instructionIndex,
544
- eventId: screenshotEventId,
545
- captureSource
546
- });
547
- const screenshotPath = screenshotPathFromRecorderResult(screenshotResult);
548
- addOutput({
549
- type: 'info',
550
- text: 'Capturing screen',
551
- eventType: 'screenshot_captured',
552
- actionType: 'screenshot',
553
- ...eventMeta({
554
- payload: {
555
- eventId: screenshotEventId,
556
- captureSource,
557
- source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
558
- ...(typeof screenshotPath === "string" && screenshotPath ? { screenshotPath } : {}),
559
- ...(context?.includeScreenshotDataUrl
560
- ? { screenshotDataUrl: `data:image/png;base64,${screenshotBase64}` }
561
- : {})
562
- }
563
- })
564
- });
565
- if (runtimeNote) {
566
- const executedActionTypes = actionsToExecute
567
- .map((action) => action?.type)
568
- .filter(Boolean);
569
- const droppedActionTypes = droppedActions
570
- .map((action) => action?.type)
571
- .filter(Boolean);
572
- emitDesktopDebug("cua.strict_mode.truncation", scope, ids, {
573
- callId: call_id,
574
- executedCount: actionsToExecute.length,
575
- droppedCount: droppedActions.length,
576
- executedActionTypes,
577
- droppedActionTypes,
578
- reason: truncationReason,
579
- note: runtimeNote
341
+ if (toolResult.kind === "preserve_session_and_stop") {
342
+ summary.preserveSessionMessages = true;
343
+ newResponseId = null;
344
+ break;
345
+ }
346
+ const nextTurnMessages = [toolResult.outputItem];
347
+ const nextTurnConfig = toolResult.nextTurnConfig ?? {};
348
+ if (Array.isArray(nextTurnConfig.followUpMessages) && nextTurnConfig.followUpMessages.length > 0) {
349
+ nextTurnMessages.push(...nextTurnConfig.followUpMessages);
350
+ }
351
+ response = await this.requestNextTurn(nextTurnMessages, newResponseId, context, stepContext, {
352
+ debugContextOverride: options.debugContextOverride,
353
+ toolCapabilitiesOverride: nextTurnConfig.toolCapabilitiesOverride,
354
+ includeComputerToolOverride: nextTurnConfig.includeComputerToolOverride,
580
355
  });
356
+ newResponseId = response.id;
357
+ if (toolResult.kind === "close_turn") {
358
+ break;
359
+ }
360
+ continue;
581
361
  }
582
- const input = [{
583
- type: "computer_call_output",
584
- call_id,
585
- output: {
586
- type: "computer_screenshot",
587
- image_url: `data:image/png;base64,${screenshotBase64}`,
588
- },
589
- ...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
590
- }];
591
- if (runtimeNote) {
592
- input.push({
593
- role: "user",
594
- content: runtimeNote
595
- });
362
+ const { call_id, actions } = actionableItem;
363
+ const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
364
+ if (actionBatchResult.stopped) {
365
+ return finalize();
596
366
  }
367
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
597
368
  if (shouldStop()) {
598
- return newResponseId;
369
+ return finalize();
599
370
  }
600
- response = await this.sendCUARequest({
601
- messages: input,
602
- previousResponseId: newResponseId,
603
- deviceInfo: this.session.deviceInfo,
604
- debugContext: {
605
- scope,
606
- runId: context?.runId,
607
- sessionId: context?.sessionId,
608
- stepId: stepContext?.stepId,
609
- instructionIndex: stepContext?.instructionIndex
610
- }
611
- });
371
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
372
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
612
373
  newResponseId = response.id;
613
374
  }
614
375
  // ── At end, if last output was only reasoning ──
@@ -621,6 +382,6 @@ export class ExecutionEngine {
621
382
  ...eventMeta()
622
383
  });
623
384
  }
624
- return newResponseId;
385
+ return finalize();
625
386
  }
626
387
  }