@loadmill/droid-cua 2.10.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.
@@ -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,311 +49,81 @@ 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 buildDebugContext(context = null, stepContext = null) {
105
- return {
106
- scope: context?.sessionId ? "design" : "execution",
107
- runId: context?.runId,
108
- sessionId: context?.sessionId,
109
- stepId: stepContext?.stepId,
110
- instructionIndex: stepContext?.instructionIndex
111
- };
112
- }
113
52
  function collectPendingSafetyChecks(items = []) {
114
53
  return items
115
54
  .filter((item) => item.type === "pending_safety_check")
116
55
  .map((item) => ({ id: item.id }));
117
56
  }
118
- function normalizeArtifactPaths(result) {
119
- if (result && typeof result === "object" && !Array.isArray(result)) {
120
- if (Array.isArray(result.annotationSourcePaths)) {
121
- return normalizeArtifactPaths(result.annotationSourcePaths);
122
- }
123
- return normalizeArtifactPaths(result.artifactPaths);
124
- }
125
- if (Array.isArray(result)) {
126
- return result.filter((value) => typeof value === "string" && value.trim());
127
- }
128
- if (typeof result === "string" && result.trim()) {
129
- return [result];
130
- }
131
- return [];
132
- }
133
- function screenshotPathFromRecorderResult(result) {
134
- if (result && typeof result === "object" && !Array.isArray(result)) {
135
- return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
136
- ? result.screenshotPath
137
- : null;
138
- }
139
- return typeof result === "string" && result.trim() ? result : null;
140
- }
141
- function screenshotDataUrlFromRecorderResult(result) {
142
- if (result && typeof result === "object" && !Array.isArray(result)) {
143
- return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
144
- ? result.screenshotDataUrl
145
- : null;
146
- }
147
- return null;
148
- }
149
57
  export class ExecutionEngine {
150
58
  constructor(session, options = {}) {
151
59
  this.session = session;
152
60
  this.recordScreenshots = options.recordScreenshots || false;
153
61
  this.screenshotDir = options.screenshotDir || null;
154
62
  this.screenshotRecorder = options.screenshotRecorder || null;
63
+ this.screenshotStore = options.screenshotStore || new ScreenshotStore({
64
+ recordScreenshots: this.recordScreenshots,
65
+ screenshotDir: this.screenshotDir,
66
+ screenshotRecorder: this.screenshotRecorder,
67
+ });
155
68
  this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
156
- this.reportedScreenshotWriteError = false;
157
- this.reportedScreenshotAnnotateError = false;
158
- this.lastRecordedScreenshotPaths = [];
159
69
  this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
160
70
  this.handleModelAction = options.handleModelAction || handleModelAction;
161
71
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
162
72
  this.reviseTestScript = options.reviseTestScript || null;
163
73
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
164
- }
165
- async saveScreenshotArtifact(screenshotBase64, metadata = {}, options = {}) {
166
- if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
167
- return null;
168
- }
169
- const updateLastRecordedScreenshotPaths = options.updateLastRecordedScreenshotPaths !== false;
170
- const previousRecordedScreenshotPaths = updateLastRecordedScreenshotPaths
171
- ? null
172
- : [...this.lastRecordedScreenshotPaths];
173
- try {
174
- if (this.screenshotRecorder?.saveScreenshot) {
175
- const result = await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
176
- if (updateLastRecordedScreenshotPaths) {
177
- this.lastRecordedScreenshotPaths = normalizeArtifactPaths(result);
178
- }
179
- return result;
180
- }
181
- if (this.recordScreenshots && this.screenshotDir) {
182
- const framePath = path.join(this.screenshotDir, `frame_${String(Date.now())}.png`);
183
- await writeFile(framePath, Buffer.from(screenshotBase64, "base64"));
184
- if (updateLastRecordedScreenshotPaths) {
185
- this.lastRecordedScreenshotPaths = [framePath];
186
- }
187
- return framePath;
188
- }
189
- }
190
- catch (error) {
191
- if (updateLastRecordedScreenshotPaths) {
192
- this.lastRecordedScreenshotPaths = [];
193
- }
194
- else {
195
- this.lastRecordedScreenshotPaths = previousRecordedScreenshotPaths;
196
- }
197
- if (!this.reportedScreenshotWriteError) {
198
- this.reportedScreenshotWriteError = true;
199
- const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
200
- const ids = scope === "design"
201
- ? {
202
- sessionId: metadata.sessionId,
203
- stepId: metadata.stepId,
204
- instructionIndex: metadata.instructionIndex
205
- }
206
- : {
207
- runId: metadata.runId,
208
- stepId: metadata.stepId,
209
- instructionIndex: metadata.instructionIndex
210
- };
211
- emitDesktopDebug("run.screenshot_write.error", scope, ids, {
212
- captureSource: metadata.captureSource ?? null,
213
- message: error instanceof Error ? error.message : "Failed to persist screenshot"
214
- });
215
- }
216
- }
217
- return null;
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;
218
80
  }
219
81
  async recordScreenshot(screenshotBase64, metadata = {}) {
220
- return await this.saveScreenshotArtifact(screenshotBase64, metadata);
82
+ return await this.screenshotStore.saveScreenshot(screenshotBase64, metadata);
221
83
  }
222
- async recordPreviewScreenshot(screenshotBase64, metadata = {}) {
223
- return await this.saveScreenshotArtifact(screenshotBase64, metadata, {
224
- updateLastRecordedScreenshotPaths: false,
225
- });
226
- }
227
- async annotateLatestScreenshot(action, metadata = {}) {
228
- if (!(action?.type === "click" || action?.type === "double_click") || this.lastRecordedScreenshotPaths.length === 0) {
229
- return null;
230
- }
231
- try {
232
- const marker = {
233
- x: action.x,
234
- y: action.y,
235
- style: "click",
236
- };
237
- const sourcePath = this.lastRecordedScreenshotPaths.find((filePath) => typeof filePath === "string" && filePath.trim());
238
- if (sourcePath && this.screenshotRecorder?.saveAnnotatedScreenshot) {
239
- return await this.screenshotRecorder.saveAnnotatedScreenshot(sourcePath, marker, metadata);
240
- }
241
- await Promise.all(this.lastRecordedScreenshotPaths.map((filePath) => annotateScreenshotArtifact(filePath, marker)));
84
+ async annotateScreenshot(currentScreenshot, action, metadata = {}) {
85
+ if (!(action?.type === "click" || action?.type === "double_click")) {
242
86
  return null;
243
87
  }
244
- catch (error) {
245
- if (!this.reportedScreenshotAnnotateError) {
246
- this.reportedScreenshotAnnotateError = true;
247
- const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
248
- const ids = scope === "design"
249
- ? {
250
- sessionId: metadata.sessionId,
251
- stepId: metadata.stepId,
252
- instructionIndex: metadata.instructionIndex
253
- }
254
- : {
255
- runId: metadata.runId,
256
- stepId: metadata.stepId,
257
- instructionIndex: metadata.instructionIndex
258
- };
259
- emitDesktopDebug("run.screenshot_annotate.error", scope, ids, {
260
- actionType: action?.type ?? null,
261
- x: action?.x ?? null,
262
- y: action?.y ?? null,
263
- message: error instanceof Error ? error.message : "Failed to annotate screenshot"
264
- });
265
- }
266
- }
267
- return null;
88
+ return await this.screenshotStore.annotateScreenshot(currentScreenshot, { x: action.x, y: action.y, style: "click" }, { ...metadata, actionType: action.type });
268
89
  }
269
- async handlePreviewClick(callId, argumentsText, context = null, stepContext = null) {
270
- const parsedArguments = parsePreviewClickArguments(argumentsText);
271
- if (!parsedArguments.ok) {
272
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({ text: parsedArguments.message }));
273
- }
274
- try {
275
- const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
276
- const previewResult = await createAnnotatedScreenshotFromBase64(screenshotBase64, {
277
- x: parsedArguments.x,
278
- y: parsedArguments.y,
279
- style: "preview",
280
- });
281
- if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
282
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
283
- text: "preview_click failed: could not annotate the current screenshot.",
284
- }));
285
- }
286
- const previewEventId = randomUUID();
287
- const previewScreenshotResult = await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
288
- runId: context?.runId,
289
- sessionId: context?.sessionId,
290
- stepId: stepContext?.stepId,
291
- instructionIndex: stepContext?.instructionIndex,
292
- callId,
293
- eventId: previewEventId,
294
- captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
295
- });
296
- const previewText = buildPreviewOutputText({
297
- requestedX: previewResult.marker.requestedX,
298
- requestedY: previewResult.marker.requestedY,
299
- renderedX: previewResult.marker.x,
300
- renderedY: previewResult.marker.y,
301
- wasClamped: previewResult.marker.wasClamped,
302
- });
303
- const addOutput = context?.addOutput || null;
304
- if (addOutput) {
305
- const previewScreenshotPath = screenshotPathFromRecorderResult(previewScreenshotResult);
306
- const previewScreenshotDataUrl = context?.includeScreenshotDataUrl
307
- ? `data:image/png;base64,${previewResult.screenshotBase64}`
308
- : null;
309
- addOutput({
310
- type: "action",
311
- text: "Click",
312
- eventType: "tool_call",
313
- runId: context?.runId,
314
- stepId: stepContext?.stepId,
315
- instructionIndex: stepContext?.instructionIndex,
316
- payload: {
317
- platform: this.session.deviceInfo?.platform,
318
- toolName: "preview_click",
319
- captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
320
- requestedX: previewResult.marker.requestedX,
321
- requestedY: previewResult.marker.requestedY,
322
- renderedX: previewResult.marker.x,
323
- renderedY: previewResult.marker.y,
324
- wasClamped: previewResult.marker.wasClamped,
325
- ...(previewScreenshotPath ? { screenshotPath: previewScreenshotPath } : {}),
326
- ...(previewScreenshotDataUrl ? { screenshotDataUrl: previewScreenshotDataUrl } : {})
327
- }
328
- });
329
- }
330
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
331
- text: previewText,
332
- annotatedScreenshotBase64: previewResult.screenshotBase64,
333
- }));
334
- }
335
- catch (error) {
336
- return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
337
- text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
338
- }));
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
+ };
339
114
  }
340
115
  }
341
- async requestNextTurn(messages, previousResponseId, context = null, stepContext = null) {
116
+ async requestNextTurn(messages, previousResponseId, context = null, stepContext = null, options = {}) {
342
117
  return await this.sendCUARequest({
343
118
  messages,
344
119
  previousResponseId,
345
120
  deviceInfo: this.session.deviceInfo,
346
- debugContext: buildDebugContext(context, stepContext)
121
+ debugContext: options.debugContextOverride ?? buildDebugContext(context, stepContext),
122
+ toolCapabilities: options.toolCapabilitiesOverride ?? this.toolCapabilities,
123
+ includeComputerTool: options.includeComputerToolOverride ?? true,
347
124
  });
348
125
  }
349
- async executeActionBatch(actions, shouldStop, context = null, stepContext = null, trackAction = null) {
126
+ async executeActionBatch(actions, currentScreenshot, shouldStop, context = null, stepContext = null, trackAction = null) {
350
127
  let sawExplicitScreenshotAction = false;
351
128
  for (const action of actions) {
352
129
  const actionEventId = randomUUID();
@@ -357,7 +134,7 @@ export class ExecutionEngine {
357
134
  sawExplicitScreenshotAction = true;
358
135
  continue;
359
136
  }
360
- const annotatedScreenshotResult = await this.annotateLatestScreenshot(action, {
137
+ const annotatedScreenshot = await this.annotateScreenshot(currentScreenshot, action, {
361
138
  runId: context?.runId,
362
139
  sessionId: context?.sessionId,
363
140
  stepId: stepContext?.stepId,
@@ -365,8 +142,6 @@ export class ExecutionEngine {
365
142
  eventId: actionEventId,
366
143
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
367
144
  });
368
- const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
369
- const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
370
145
  await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
371
146
  ...context,
372
147
  shouldStop,
@@ -374,8 +149,8 @@ export class ExecutionEngine {
374
149
  instructionIndex: stepContext?.instructionIndex,
375
150
  eventId: actionEventId,
376
151
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
377
- ...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
378
- ...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
152
+ ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
153
+ ...(context?.includeScreenshotDataUrl && annotatedScreenshot?.screenshotDataUrl ? { screenshotDataUrl: annotatedScreenshot.screenshotDataUrl } : {})
379
154
  });
380
155
  if (trackAction) {
381
156
  trackAction(action);
@@ -404,7 +179,7 @@ export class ExecutionEngine {
404
179
  });
405
180
  const screenshotEventId = randomUUID();
406
181
  const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
407
- const screenshotResult = await this.recordScreenshot(screenshotBase64, {
182
+ const currentScreenshot = await this.recordScreenshot(screenshotBase64, {
408
183
  runId: context?.runId,
409
184
  sessionId: context?.sessionId,
410
185
  stepId: stepContext?.stepId,
@@ -412,7 +187,6 @@ export class ExecutionEngine {
412
187
  eventId: screenshotEventId,
413
188
  captureSource
414
189
  });
415
- const screenshotPath = screenshotPathFromRecorderResult(screenshotResult);
416
190
  addOutput({
417
191
  type: "info",
418
192
  text: "Capturing screen",
@@ -423,35 +197,102 @@ export class ExecutionEngine {
423
197
  eventId: screenshotEventId,
424
198
  captureSource,
425
199
  source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
426
- ...(typeof screenshotPath === "string" && screenshotPath ? { screenshotPath } : {}),
200
+ ...(currentScreenshot?.screenshotPath ? { screenshotPath: currentScreenshot.screenshotPath } : {}),
427
201
  ...(context?.includeScreenshotDataUrl
428
202
  ? { screenshotDataUrl: `data:image/png;base64,${screenshotBase64}` }
429
203
  : {})
430
204
  }
431
205
  })
432
206
  });
433
- return [{
434
- type: "computer_call_output",
435
- call_id: callId,
436
- output: {
437
- type: "computer_screenshot",
438
- image_url: `data:image/png;base64,${screenshotBase64}`,
439
- },
440
- ...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
441
- }];
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") {
252
+ addOutput({
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
+ }),
263
+ });
264
+ }
265
+ }
442
266
  }
443
267
  /**
444
- * Run a full turn with the CUA model
445
- * Executes actions until the model stops requesting actions
446
- * Returns the new response ID for chaining
447
- * @param {Object} response - The CUA response
448
- * @param {Function} trackAction - Optional callback to track actions for stuck detection
449
- * @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.
450
281
  */
451
- async runFullTurn(response, trackAction = null, context = null, stepContext = null) {
282
+ async runFullTurn(response, trackAction = null, context = null, stepContext = null, options = {}) {
452
283
  const addOutput = context?.addOutput || printCliOutput;
453
284
  let newResponseId = response.id;
285
+ let currentScreenshot = options.currentScreenshot ?? null;
454
286
  const shouldStop = () => Boolean(trackAction?.());
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
+ };
455
296
  const eventMeta = (extra = {}) => ({
456
297
  runId: context?.runId,
457
298
  stepId: stepContext?.stepId,
@@ -461,53 +302,12 @@ export class ExecutionEngine {
461
302
  while (true) {
462
303
  // Check for interruption before processing next batch of actions
463
304
  if (shouldStop()) {
464
- return newResponseId;
305
+ return finalize();
465
306
  }
466
307
  const items = response.output || [];
467
308
  const actionableItems = extractActionableOutputItems(items);
468
309
  const pendingSafetyChecks = collectPendingSafetyChecks(items);
469
- // ── Print assistant output ──
470
- for (const item of items) {
471
- if (item.type === "reasoning") {
472
- for (const entry of item.summary) {
473
- if (entry.type === "summary_text") {
474
- addOutput({
475
- type: 'reasoning',
476
- text: entry.text,
477
- eventType: 'reasoning',
478
- ...eventMeta()
479
- });
480
- this.session.addToTranscript(`[Reasoning] ${entry.text}`);
481
- }
482
- }
483
- }
484
- else if (item.type === "message") {
485
- const textPart = item.content.find(c => c.type === "output_text");
486
- if (textPart) {
487
- addOutput({
488
- type: 'assistant',
489
- text: textPart.text,
490
- eventType: 'assistant_message',
491
- ...eventMeta()
492
- });
493
- this.session.addToTranscript(`[Assistant] ${textPart.text}`);
494
- }
495
- }
496
- else if (item.type === "pending_safety_check") {
497
- addOutput({
498
- type: 'warning',
499
- text: `⚠️ Safety check: ${item.code} - ${item.message}`,
500
- eventType: 'system_message',
501
- ...eventMeta({
502
- payload: {
503
- id: item.id,
504
- code: item.code,
505
- message: item.message
506
- }
507
- })
508
- });
509
- }
510
- }
310
+ this.emitResponseOutputItems(items, addOutput, eventMeta, options);
511
311
  if (actionableItems.length === 0) {
512
312
  // No actions = turn complete
513
313
  break;
@@ -516,27 +316,60 @@ export class ExecutionEngine {
516
316
  if (!actionableItem?.call_id) {
517
317
  break;
518
318
  }
519
- if (actionableItem.type === "preview_click") {
520
- const input = [
521
- await this.handlePreviewClick(actionableItem.call_id, actionableItem.arguments, context, stepContext),
522
- ];
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;
327
+ }
523
328
  if (shouldStop()) {
524
- return newResponseId;
329
+ return finalize();
525
330
  }
526
- response = await this.requestNextTurn(input, newResponseId, context, stepContext);
331
+ if (toolResult.kind === "fail") {
332
+ summary.failed = true;
333
+ summary.error = toolResult.error;
334
+ break;
335
+ }
336
+ if (toolResult.kind === "retry_instruction") {
337
+ summary.retryInstruction = true;
338
+ newResponseId = null;
339
+ break;
340
+ }
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,
355
+ });
527
356
  newResponseId = response.id;
357
+ if (toolResult.kind === "close_turn") {
358
+ break;
359
+ }
528
360
  continue;
529
361
  }
530
362
  const { call_id, actions } = actionableItem;
531
- const actionBatchResult = await this.executeActionBatch(actions, shouldStop, context, stepContext, trackAction);
363
+ const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
532
364
  if (actionBatchResult.stopped) {
533
- return newResponseId;
365
+ return finalize();
534
366
  }
535
- const input = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
367
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
536
368
  if (shouldStop()) {
537
- return newResponseId;
369
+ return finalize();
538
370
  }
539
- response = await this.requestNextTurn(input, newResponseId, context, stepContext);
371
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
372
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
540
373
  newResponseId = response.id;
541
374
  }
542
375
  // ── At end, if last output was only reasoning ──
@@ -549,6 +382,6 @@ export class ExecutionEngine {
549
382
  ...eventMeta()
550
383
  });
551
384
  }
552
- return newResponseId;
385
+ return finalize();
553
386
  }
554
387
  }