@browserbasehq/orca 3.7.1-preview.0 → 3.7.2-gemini-cua

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 (107) hide show
  1. package/dist/cjs/lib/v3/agent/GoogleCUAClient.d.ts +23 -0
  2. package/dist/cjs/lib/v3/agent/GoogleCUAClient.js +363 -206
  3. package/dist/cjs/lib/v3/agent/GoogleCUAClient.js.map +1 -1
  4. package/dist/cjs/lib/v3/agent/tools/click.js +16 -5
  5. package/dist/cjs/lib/v3/agent/tools/click.js.map +1 -1
  6. package/dist/cjs/lib/v3/agent/tools/clickAndHold.js +1 -1
  7. package/dist/cjs/lib/v3/agent/tools/clickAndHold.js.map +1 -1
  8. package/dist/cjs/lib/v3/agent/tools/dragAndDrop.js +2 -2
  9. package/dist/cjs/lib/v3/agent/tools/dragAndDrop.js.map +1 -1
  10. package/dist/cjs/lib/v3/agent/tools/fillFormVision.js +18 -7
  11. package/dist/cjs/lib/v3/agent/tools/fillFormVision.js.map +1 -1
  12. package/dist/cjs/lib/v3/agent/tools/scroll.js +1 -1
  13. package/dist/cjs/lib/v3/agent/tools/scroll.js.map +1 -1
  14. package/dist/cjs/lib/v3/agent/tools/type.js +13 -4
  15. package/dist/cjs/lib/v3/agent/tools/type.js.map +1 -1
  16. package/dist/cjs/lib/v3/agent/utils/coordinateNormalization.d.ts +5 -2
  17. package/dist/cjs/lib/v3/agent/utils/coordinateNormalization.js +5 -7
  18. package/dist/cjs/lib/v3/agent/utils/coordinateNormalization.js.map +1 -1
  19. package/dist/cjs/lib/v3/api.d.ts +1 -1
  20. package/dist/cjs/lib/v3/api.js +2 -1
  21. package/dist/cjs/lib/v3/api.js.map +1 -1
  22. package/dist/cjs/lib/v3/handlers/handlerUtils/actHandlerUtils.js +20 -0
  23. package/dist/cjs/lib/v3/handlers/handlerUtils/actHandlerUtils.js.map +1 -1
  24. package/dist/cjs/lib/v3/handlers/v3CuaAgentHandler.d.ts +4 -0
  25. package/dist/cjs/lib/v3/handlers/v3CuaAgentHandler.js +126 -20
  26. package/dist/cjs/lib/v3/handlers/v3CuaAgentHandler.js.map +1 -1
  27. package/dist/cjs/lib/v3/launch/local.js +1 -1
  28. package/dist/cjs/lib/v3/launch/local.js.map +1 -1
  29. package/dist/cjs/lib/v3/llm/aisdk.js +3 -3
  30. package/dist/cjs/lib/v3/llm/aisdk.js.map +1 -1
  31. package/dist/cjs/lib/v3/runtimePaths.js +0 -1
  32. package/dist/cjs/lib/v3/runtimePaths.js.map +1 -1
  33. package/dist/cjs/lib/v3/types/public/api.d.ts +53 -0
  34. package/dist/cjs/lib/v3/types/public/api.js +8 -0
  35. package/dist/cjs/lib/v3/types/public/api.js.map +1 -1
  36. package/dist/cjs/lib/v3/types/public/model.d.ts +5 -0
  37. package/dist/cjs/lib/v3/types/public/model.js.map +1 -1
  38. package/dist/cjs/lib/v3/types/public/options.d.ts +13 -0
  39. package/dist/cjs/lib/v3/types/public/options.js.map +1 -1
  40. package/dist/cjs/lib/v3/understudy/a11y/snapshot/coordinateResolver.js +39 -1
  41. package/dist/cjs/lib/v3/understudy/a11y/snapshot/coordinateResolver.js.map +1 -1
  42. package/dist/cjs/lib/v3/understudy/locator.d.ts +12 -0
  43. package/dist/cjs/lib/v3/understudy/locator.js +44 -0
  44. package/dist/cjs/lib/v3/understudy/locator.js.map +1 -1
  45. package/dist/cjs/lib/v3/understudy/page.d.ts +22 -3
  46. package/dist/cjs/lib/v3/understudy/page.js +55 -1
  47. package/dist/cjs/lib/v3/understudy/page.js.map +1 -1
  48. package/dist/cjs/lib/v3/v3.d.ts +14 -0
  49. package/dist/cjs/lib/v3/v3.js +42 -13
  50. package/dist/cjs/lib/v3/v3.js.map +1 -1
  51. package/dist/cjs/lib/version.d.ts +1 -1
  52. package/dist/cjs/lib/version.js +1 -1
  53. package/dist/cjs/lib/version.js.map +1 -1
  54. package/dist/esm/lib/v3/agent/GoogleCUAClient.d.ts +23 -0
  55. package/dist/esm/lib/v3/agent/GoogleCUAClient.js +363 -206
  56. package/dist/esm/lib/v3/agent/GoogleCUAClient.js.map +1 -1
  57. package/dist/esm/lib/v3/agent/tools/click.js +16 -5
  58. package/dist/esm/lib/v3/agent/tools/click.js.map +1 -1
  59. package/dist/esm/lib/v3/agent/tools/clickAndHold.js +1 -1
  60. package/dist/esm/lib/v3/agent/tools/clickAndHold.js.map +1 -1
  61. package/dist/esm/lib/v3/agent/tools/dragAndDrop.js +2 -2
  62. package/dist/esm/lib/v3/agent/tools/dragAndDrop.js.map +1 -1
  63. package/dist/esm/lib/v3/agent/tools/fillFormVision.js +19 -8
  64. package/dist/esm/lib/v3/agent/tools/fillFormVision.js.map +1 -1
  65. package/dist/esm/lib/v3/agent/tools/scroll.js +1 -1
  66. package/dist/esm/lib/v3/agent/tools/scroll.js.map +1 -1
  67. package/dist/esm/lib/v3/agent/tools/type.js +13 -4
  68. package/dist/esm/lib/v3/agent/tools/type.js.map +1 -1
  69. package/dist/esm/lib/v3/agent/utils/coordinateNormalization.d.ts +5 -2
  70. package/dist/esm/lib/v3/agent/utils/coordinateNormalization.js +5 -7
  71. package/dist/esm/lib/v3/agent/utils/coordinateNormalization.js.map +1 -1
  72. package/dist/esm/lib/v3/api.d.ts +1 -1
  73. package/dist/esm/lib/v3/api.js +2 -1
  74. package/dist/esm/lib/v3/api.js.map +1 -1
  75. package/dist/esm/lib/v3/handlers/handlerUtils/actHandlerUtils.js +20 -0
  76. package/dist/esm/lib/v3/handlers/handlerUtils/actHandlerUtils.js.map +1 -1
  77. package/dist/esm/lib/v3/handlers/v3CuaAgentHandler.d.ts +4 -0
  78. package/dist/esm/lib/v3/handlers/v3CuaAgentHandler.js +126 -20
  79. package/dist/esm/lib/v3/handlers/v3CuaAgentHandler.js.map +1 -1
  80. package/dist/esm/lib/v3/launch/local.js +1 -1
  81. package/dist/esm/lib/v3/launch/local.js.map +1 -1
  82. package/dist/esm/lib/v3/llm/aisdk.js +3 -3
  83. package/dist/esm/lib/v3/llm/aisdk.js.map +1 -1
  84. package/dist/esm/lib/v3/runtimePaths.js +0 -1
  85. package/dist/esm/lib/v3/runtimePaths.js.map +1 -1
  86. package/dist/esm/lib/v3/types/public/api.d.ts +53 -0
  87. package/dist/esm/lib/v3/types/public/api.js +8 -0
  88. package/dist/esm/lib/v3/types/public/api.js.map +1 -1
  89. package/dist/esm/lib/v3/types/public/model.d.ts +5 -0
  90. package/dist/esm/lib/v3/types/public/model.js.map +1 -1
  91. package/dist/esm/lib/v3/types/public/options.d.ts +13 -0
  92. package/dist/esm/lib/v3/types/public/options.js.map +1 -1
  93. package/dist/esm/lib/v3/understudy/a11y/snapshot/coordinateResolver.js +39 -1
  94. package/dist/esm/lib/v3/understudy/a11y/snapshot/coordinateResolver.js.map +1 -1
  95. package/dist/esm/lib/v3/understudy/locator.d.ts +12 -0
  96. package/dist/esm/lib/v3/understudy/locator.js +44 -0
  97. package/dist/esm/lib/v3/understudy/locator.js.map +1 -1
  98. package/dist/esm/lib/v3/understudy/page.d.ts +22 -3
  99. package/dist/esm/lib/v3/understudy/page.js +55 -1
  100. package/dist/esm/lib/v3/understudy/page.js.map +1 -1
  101. package/dist/esm/lib/v3/v3.d.ts +14 -0
  102. package/dist/esm/lib/v3/v3.js +42 -13
  103. package/dist/esm/lib/v3/v3.js.map +1 -1
  104. package/dist/esm/lib/version.d.ts +1 -1
  105. package/dist/esm/lib/version.js +1 -1
  106. package/dist/esm/lib/version.js.map +1 -1
  107. package/package.json +3 -3
@@ -7,6 +7,15 @@ import { mapKeyToPlaywright } from "./utils/cuaKeyMapping.js";
7
7
  import { executeGoogleCustomTool, isCustomTool, convertToolSetToFunctionDeclarations, } from "./utils/googleCustomToolHandler.js";
8
8
  import { FlowLogger, extractLlmCuaPromptSummary, extractLlmCuaResponseSummary, } from "../flowlogger/FlowLogger.js";
9
9
  import { v7 as uuidv7 } from "uuid";
10
+ /** Errors worth retrying: rate limits, server errors, network failures. */
11
+ function isTransientError(error) {
12
+ const status = error?.status;
13
+ if (typeof status === "number") {
14
+ return status === 429 || status >= 500;
15
+ }
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return /network|timeout|timed out|fetch failed|econn|socket hang up/i.test(message);
18
+ }
10
19
  /**
11
20
  * Client for Google's Computer Use Assistant API
12
21
  * This implementation uses the Google Generative AI SDK for Computer Use
@@ -24,6 +33,13 @@ export class GoogleCUAClient extends AgentClient {
24
33
  tools;
25
34
  baseURL;
26
35
  safetyConfirmationHandler;
36
+ /**
37
+ * Request API. "interactions" uses the stateful Interactions API (server-side
38
+ * history via previous_interaction_id) instead of generateContent.
39
+ */
40
+ api = "generateContent";
41
+ previousInteractionId;
42
+ pendingInteractionInput;
27
43
  constructor(type, modelName, userProvidedInstructions, clientOptions, tools) {
28
44
  super(type, modelName, userProvidedInstructions);
29
45
  this.tools = tools;
@@ -41,19 +57,23 @@ export class GoogleCUAClient extends AgentClient {
41
57
  ...(this.baseURL ? { httpOptions: { baseUrl: this.baseURL } } : {}),
42
58
  };
43
59
  this.client = new GoogleGenAI(genAIOptions);
60
+ if (clientOptions?.googleApi === "interactions") {
61
+ this.api = "interactions";
62
+ }
44
63
  // Get environment if specified
45
64
  if (clientOptions?.environment &&
46
65
  typeof clientOptions.environment === "string") {
47
66
  this.environment = clientOptions.environment;
48
67
  }
68
+ // No maxOutputTokens: thinking tokens count against it on gemini-3.x, so a
69
+ // fixed cap truncates turns (finishReason MAX_TOKENS). Use the model max.
49
70
  this.generateContentConfig = {
50
71
  temperature: 1,
51
72
  topP: 0.95,
52
73
  topK: 40,
53
- maxOutputTokens: 8192,
54
- // systemInstruction: this.userProvidedInstructions
55
- // ? { parts: [{ text: this.userProvidedInstructions }] }
56
- // : { parts: [{ text: buildGoogleCUASystemPrompt() }] },
74
+ systemInstruction: this.userProvidedInstructions
75
+ ? this.userProvidedInstructions
76
+ : buildGoogleCUASystemPrompt().content,
57
77
  tools: [
58
78
  {
59
79
  computerUse: {
@@ -163,6 +183,7 @@ export class GoogleCUAClient extends AgentClient {
163
183
  const maxSteps = options.maxSteps || 10;
164
184
  let currentStep = 0;
165
185
  let completed = false;
186
+ let declined = false;
166
187
  const actions = [];
167
188
  const messageList = [];
168
189
  let finalMessage = "";
@@ -193,6 +214,9 @@ export class GoogleCUAClient extends AgentClient {
193
214
  actions.push(...result.actions);
194
215
  // Update completion status
195
216
  completed = result.completed;
217
+ if (result.stopped) {
218
+ declined = true;
219
+ }
196
220
  // Record any message for this step
197
221
  if (result.message) {
198
222
  messageList.push(result.message);
@@ -203,7 +227,7 @@ export class GoogleCUAClient extends AgentClient {
203
227
  }
204
228
  // Return the final result
205
229
  return {
206
- success: completed,
230
+ success: completed && !declined,
207
231
  actions,
208
232
  message: finalMessage,
209
233
  completed,
@@ -243,20 +267,12 @@ export class GoogleCUAClient extends AgentClient {
243
267
  */
244
268
  async initializeHistory(instruction) {
245
269
  const parts = [{ text: instruction }];
270
+ this.previousInteractionId = undefined;
271
+ this.pendingInteractionInput = instruction;
246
272
  // Note: The Python implementation doesn't include the initial screenshot
247
- // Following the same pattern here
248
- const systemPromptContent = this.userProvidedInstructions
249
- ? this.userProvidedInstructions
250
- : buildGoogleCUASystemPrompt().content;
273
+ // Following the same pattern here. The system prompt travels in
274
+ // generateContentConfig.systemInstruction.
251
275
  this.history = [
252
- {
253
- role: "user",
254
- parts: [
255
- {
256
- text: "System prompt: " + systemPromptContent,
257
- },
258
- ],
259
- },
260
276
  {
261
277
  role: "user",
262
278
  parts,
@@ -272,11 +288,14 @@ export class GoogleCUAClient extends AgentClient {
272
288
  // Compress images in conversation history before sending to the model
273
289
  const compressedResult = compressGoogleConversationImages(this.history, 2);
274
290
  const compressedHistory = compressedResult.items;
275
- // Use the SDK's generateContent method with retry logic (matching Python's get_model_response)
291
+ // Use the SDK's generateContent method, retrying only transient failures
292
+ // (rate limits, 5xx, network, empty candidates). Anything else — a 400,
293
+ // or a candidate that stopped for a non-STOP reason — fails fast.
276
294
  const maxRetries = 5;
277
295
  const baseDelayS = 1;
278
296
  let lastError = null;
279
297
  let response = null;
298
+ let emptyResponse = false;
280
299
  // Log LLM request
281
300
  const llmRequestId = uuidv7();
282
301
  FlowLogger.logLlmRequest({
@@ -297,21 +316,20 @@ export class GoogleCUAClient extends AgentClient {
297
316
  await new Promise((resolve) => setTimeout(resolve, delay));
298
317
  }
299
318
  // Use the SDK's generateContent method - following Python SDK pattern
300
- response = await this.client.models.generateContent({
301
- model: this.modelName,
302
- contents: compressedHistory,
303
- config: this.generateContentConfig,
304
- });
305
- // Check if we have valid response content
319
+ response = await this.requestModel(compressedHistory);
320
+ // An empty response is treated as transient and retried; an early
321
+ // stop (non-STOP finish reason) is not — it is rejected below.
306
322
  if (!response.candidates || response.candidates.length === 0) {
323
+ emptyResponse = true;
307
324
  throw new LLMResponseError("agent", "Response has no candidates!");
308
325
  }
309
- const candidate = response.candidates[0];
310
- if (!candidate.content || !candidate.content.parts) {
311
- const reason = candidate.finishReason || "unknown";
312
- throw new LLMResponseError("agent", `Response has no content (finish reason: ${reason})`);
326
+ const first = response.candidates[0];
327
+ const stoppedEarly = first.finishReason && first.finishReason !== "STOP";
328
+ if (!stoppedEarly && !first.content?.parts?.length) {
329
+ emptyResponse = true;
330
+ throw new LLMResponseError("agent", `Response has no content (finish reason: ${first.finishReason || "unknown"})`);
313
331
  }
314
- // Success - we have a valid response
332
+ // Success - validated below, outside the retry loop
315
333
  break;
316
334
  }
317
335
  catch (error) {
@@ -321,6 +339,11 @@ export class GoogleCUAClient extends AgentClient {
321
339
  message: `API call error: ${lastError.message}`,
322
340
  level: 2,
323
341
  });
342
+ const retryable = emptyResponse || isTransientError(error);
343
+ emptyResponse = false;
344
+ if (!retryable) {
345
+ throw lastError;
346
+ }
324
347
  // If this was the last attempt, throw the error
325
348
  if (attempt === maxRetries - 1) {
326
349
  logger({
@@ -335,6 +358,13 @@ export class GoogleCUAClient extends AgentClient {
335
358
  if (!response) {
336
359
  throw (lastError || new Error("Failed to get response after all retries"));
337
360
  }
361
+ // A candidate that stopped for any reason other than STOP (MAX_TOKENS,
362
+ // SAFETY, MALFORMED_FUNCTION_CALL, ...) is incomplete: fail the run
363
+ // rather than executing its calls or reporting its text as the answer.
364
+ const candidate = response.candidates[0];
365
+ if (candidate.finishReason && candidate.finishReason !== "STOP") {
366
+ throw new LLMResponseError("agent", `Model response stopped early (finish reason: ${candidate.finishReason})`);
367
+ }
338
368
  const endTime = Date.now();
339
369
  const elapsedMs = endTime - startTime;
340
370
  const { usageMetadata } = response;
@@ -368,63 +398,58 @@ export class GoogleCUAClient extends AgentClient {
368
398
  }
369
399
  this.history.push(sanitizedContent);
370
400
  }
371
- // Execute actions and collect function responses
401
+ // Execute each function call and answer it with its own function
402
+ // response: the call's outcome (output or error text), the URL, and a
403
+ // fresh screenshot taken after that call. The model expects exactly one
404
+ // response per call, including calls with nothing to execute (e.g. a
405
+ // standalone take_screenshot) and calls that could not be converted.
372
406
  const functionResponses = [];
373
- // Always process the model's response, even when it produced no
374
- // executable action (e.g. a standalone take_screenshot). The model
375
- // expects exactly one function response — a fresh screenshot — per
376
- // computer-use call; skipping it when there are 0 actions leaves the
377
- // model blind and it gives up after one empty turn.
378
- {
379
- let hasError = false;
380
- // Execute all actions (a no-op when there are none).
381
- for (let i = 0; i < result.actions.length; i++) {
382
- const action = result.actions[i];
383
- logger({
384
- category: "agent",
385
- message: `Executing action ${i + 1}/${result.actions.length}: ${action.type}`,
386
- level: 2,
387
- });
388
- // Special handling for open_web_browser - don't execute it
389
- if (action.type === "open_web_browser") {
390
- // Set pageUrl for open_web_browser since it doesn't go through action handler
391
- action.pageUrl = this.currentUrl;
392
- logger({
393
- category: "agent",
394
- message: "Skipping open_web_browser action",
395
- level: 2,
396
- });
407
+ let stopped;
408
+ for (const call of result.calls) {
409
+ const { functionCall } = call;
410
+ if (call.actions[0]?.type === "custom_tool") {
411
+ const action = call.actions[0];
412
+ const toolName = action.name;
413
+ if (this.tools && toolName in this.tools) {
414
+ const executionResult = await executeGoogleCustomTool(toolName, action.arguments, this.tools, functionCall, logger);
415
+ functionResponses.push(executionResult.functionResponse);
416
+ }
417
+ continue;
418
+ }
419
+ // Ask for confirmation BEFORE acting. If the user declines, the
420
+ // action is not executed and the run stops.
421
+ let safetyAcknowledgement;
422
+ if (functionCall.args?.safety_decision) {
423
+ safetyAcknowledgement = await this.handleSafetyConfirmation(functionCall.args.safety_decision, logger);
424
+ if (safetyAcknowledgement === undefined) {
425
+ stopped = `Stopped: the user declined to confirm "${functionCall.name}".`;
426
+ break;
397
427
  }
398
- else if (action.type === "screenshot") {
399
- // No interaction to perform — the screenshot captured below is
400
- // returned to the model as this call's function response.
428
+ }
429
+ let error = call.error;
430
+ if (!error) {
431
+ for (let i = 0; i < call.actions.length; i++) {
432
+ const action = call.actions[i];
401
433
  logger({
402
434
  category: "agent",
403
- message: "take_screenshot: capturing current page",
435
+ message: `Executing action ${i + 1}/${call.actions.length} for ${functionCall.name}: ${action.type}`,
404
436
  level: 2,
405
437
  });
406
- }
407
- else if (action.type === "custom_tool") {
408
- const toolName = action.name;
409
- const toolArgs = action.arguments;
410
- if (this.tools && toolName in this.tools) {
411
- const correspondingFunctionCall = result.functionCalls.find((fc) => fc.name === toolName);
412
- if (correspondingFunctionCall) {
413
- const executionResult = await executeGoogleCustomTool(toolName, toolArgs, this.tools, correspondingFunctionCall, logger);
414
- functionResponses.push(executionResult.functionResponse);
415
- if (!executionResult.success) {
416
- hasError = true;
417
- }
418
- }
438
+ // open_web_browser and take_screenshot have nothing to execute;
439
+ // the screenshot captured below is the call's result.
440
+ if (action.type === "open_web_browser") {
441
+ action.pageUrl = this.currentUrl;
442
+ continue;
443
+ }
444
+ if (action.type === "screenshot" || !this.actionHandler) {
445
+ continue;
419
446
  }
420
- }
421
- else if (this.actionHandler) {
422
447
  try {
423
448
  await this.actionHandler(action);
424
449
  // Add a delay between actions to ensure they complete properly
425
450
  // Longer delay for typing actions to ensure fields are ready
426
- if (i < result.actions.length - 1) {
427
- const nextAction = result.actions[i + 1];
451
+ if (i < call.actions.length - 1) {
452
+ const nextAction = call.actions[i + 1];
428
453
  const isTypingAction = action.type === "type" || nextAction.type === "type";
429
454
  const delay = isTypingAction ? 500 : 200;
430
455
  await new Promise((resolve) => setTimeout(resolve, delay));
@@ -434,87 +459,88 @@ export class GoogleCUAClient extends AgentClient {
434
459
  if (actionError instanceof StagehandClosedError) {
435
460
  throw actionError;
436
461
  }
462
+ error =
463
+ actionError instanceof Error
464
+ ? actionError.message
465
+ : String(actionError);
437
466
  logger({
438
467
  category: "agent",
439
- message: `Error executing action ${action.type}: ${actionError}`,
468
+ message: `Error executing action ${action.type}: ${error}`,
440
469
  level: 0,
441
470
  });
442
- hasError = true;
443
- // Continue processing other actions even if one fails
471
+ // Skip the rest of this call; report the failure to the model.
472
+ break;
444
473
  }
445
474
  }
446
475
  }
447
- // Create function responses for computer use actions (non-custom tools)
448
- // We need exactly one response per function call, regardless of how many actions were generated
449
- if (result.functionCalls.length > 0 || hasError) {
450
- // Filter out custom tool function calls as they've already been handled
451
- const computerUseFunctionCalls = result.functionCalls.filter((fc) => !isCustomTool(fc, this.tools));
452
- if (computerUseFunctionCalls.length > 0) {
453
- try {
454
- logger({
455
- category: "agent",
456
- message: `Taking screenshot after executing ${result.actions.length} actions${hasError ? " (with errors)" : ""}`,
457
- level: 2,
458
- });
459
- const screenshot = await this.captureScreenshot();
460
- const base64Data = screenshot.base64;
461
- // Create one function response for each computer use function call
462
- // Following Python SDK pattern: FunctionResponse with parts containing inline_data
463
- for (const functionCall of computerUseFunctionCalls) {
464
- let safetyAcknowledgement;
465
- if (functionCall.args?.safety_decision) {
466
- safetyAcknowledgement = await this.handleSafetyConfirmation(functionCall.args.safety_decision, logger);
467
- }
468
- const functionResponsePart = {
469
- functionResponse: {
470
- name: functionCall.name,
471
- response: {
472
- url: this.currentUrl || "",
473
- ...(safetyAcknowledgement !== undefined
474
- ? {
475
- safety_acknowledgement: safetyAcknowledgement,
476
- }
477
- : {}),
478
- },
479
- parts: [
480
- {
481
- inlineData: {
482
- mimeType: screenshot.mediaType,
483
- data: base64Data,
484
- },
485
- },
486
- ],
487
- },
488
- };
489
- functionResponses.push(functionResponsePart);
490
- }
491
- }
492
- catch (error) {
493
- logger({
494
- category: "agent",
495
- message: `Error capturing screenshot: ${error}`,
496
- level: 0,
497
- });
498
- }
499
- }
476
+ let screenshot;
477
+ let screenshotError;
478
+ try {
479
+ screenshot = await this.captureScreenshot();
500
480
  }
501
- // Add all function responses to history in a single user message
502
- if (functionResponses.length > 0) {
481
+ catch (screenshotFailure) {
482
+ if (screenshotFailure instanceof StagehandClosedError) {
483
+ throw screenshotFailure;
484
+ }
485
+ screenshotError =
486
+ screenshotFailure instanceof Error
487
+ ? screenshotFailure.message
488
+ : String(screenshotFailure);
503
489
  logger({
504
490
  category: "agent",
505
- message: `Adding ${functionResponses.length} function responses to history`,
506
- level: 2,
507
- });
508
- this.history.push({
509
- role: "user",
510
- parts: functionResponses,
491
+ message: `Error capturing screenshot: ${screenshotError}`,
492
+ level: 0,
511
493
  });
512
494
  }
495
+ functionResponses.push({
496
+ functionResponse: {
497
+ ...(functionCall.id ? { id: functionCall.id } : {}),
498
+ name: functionCall.name,
499
+ response: {
500
+ url: this.currentUrl || "",
501
+ ...(error ? { error } : {}),
502
+ ...(screenshotError ? { screenshot_error: screenshotError } : {}),
503
+ ...(safetyAcknowledgement !== undefined
504
+ ? { safety_acknowledgement: safetyAcknowledgement }
505
+ : {}),
506
+ },
507
+ ...(screenshot
508
+ ? {
509
+ parts: [
510
+ {
511
+ inlineData: {
512
+ mimeType: screenshot.mediaType,
513
+ data: screenshot.base64,
514
+ },
515
+ },
516
+ ],
517
+ }
518
+ : {}),
519
+ },
520
+ });
521
+ }
522
+ // The Interactions API keeps history server-side: the next request sends
523
+ // only these results, chained by previous_interaction_id.
524
+ if (this.api === "interactions" && !stopped) {
525
+ this.pendingInteractionInput = functionResponses.map(GoogleCUAClient.toFunctionResult);
526
+ }
527
+ // Add all function responses to history in a single user message
528
+ if (!stopped && functionResponses.length > 0) {
529
+ logger({
530
+ category: "agent",
531
+ message: `Adding ${functionResponses.length} function responses to history`,
532
+ level: 2,
533
+ });
534
+ this.history.push({
535
+ role: "user",
536
+ parts: functionResponses,
537
+ });
513
538
  }
514
539
  return {
515
540
  actions: result.actions,
516
- message: result.message,
517
- completed: result.completed,
541
+ message: stopped ?? result.message,
542
+ completed: stopped ? true : result.completed,
543
+ stopped: stopped !== undefined,
518
544
  usage: {
519
545
  // promptTokenCount is the TOTAL input and already includes the
520
546
  // cached portion; cachedContentTokenCount is the cache-hit subset
@@ -538,19 +564,128 @@ export class GoogleCUAClient extends AgentClient {
538
564
  throw error;
539
565
  }
540
566
  }
567
+ /** Send one model request on the configured API. */
568
+ async requestModel(contents) {
569
+ if (this.api === "interactions") {
570
+ return this.createInteraction();
571
+ }
572
+ return this.client.models.generateContent({
573
+ model: this.modelName,
574
+ contents,
575
+ config: this.generateContentConfig,
576
+ });
577
+ }
578
+ /**
579
+ * Create an interaction (Interactions API, REST) continuing the previous one,
580
+ * and map it onto the generateContent response shape the step loop expects.
581
+ */
582
+ async createInteraction() {
583
+ const baseUrl = (this.baseURL ?? "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
584
+ const body = {
585
+ model: this.modelName.replace(/^models\//, ""),
586
+ input: this.pendingInteractionInput,
587
+ system_instruction: this.generateContentConfig.systemInstruction,
588
+ tools: [
589
+ {
590
+ type: "computer_use",
591
+ environment: this.environment === "ENVIRONMENT_DESKTOP" ? "desktop" : "browser",
592
+ },
593
+ ],
594
+ ...(this.previousInteractionId
595
+ ? { previous_interaction_id: this.previousInteractionId }
596
+ : {}),
597
+ };
598
+ const res = await fetch(`${baseUrl}/v1beta/interactions`, {
599
+ method: "POST",
600
+ headers: {
601
+ "content-type": "application/json",
602
+ "x-goog-api-key": this.apiKey,
603
+ },
604
+ body: JSON.stringify(body),
605
+ });
606
+ const json = (await res.json().catch(() => ({})));
607
+ if (!res.ok || !json.id) {
608
+ const { message = res.statusText, code } = json.error ?? {};
609
+ // code "safety": Google's input classifier rejected the turn. Not
610
+ // transient (status 400), so it fails the run instead of retrying.
611
+ throw Object.assign(new Error(code === "safety"
612
+ ? `Gemini blocked the request (safety): ${message}`
613
+ : `Interactions API error ${res.status}: ${message}`), { status: res.status });
614
+ }
615
+ const parts = [];
616
+ for (const step of json.steps ?? []) {
617
+ if (step.type === "function_call" && step.name) {
618
+ parts.push({
619
+ functionCall: { id: step.id, name: step.name, args: step.arguments },
620
+ });
621
+ }
622
+ else if (step.type === "model_output") {
623
+ for (const content of step.content ?? []) {
624
+ if (content.type === "text" && content.text) {
625
+ parts.push({ text: content.text });
626
+ }
627
+ }
628
+ }
629
+ }
630
+ if (parts.length === 0 && json.output_text) {
631
+ parts.push({ text: json.output_text });
632
+ }
633
+ this.previousInteractionId = json.id;
634
+ // requires_action (calls to answer) and completed are normal stops; any
635
+ // other status (incomplete, failed, budget_exceeded, ...) is an early stop.
636
+ const finishReason = json.status === "requires_action" || json.status === "completed"
637
+ ? "STOP"
638
+ : String(json.status).toUpperCase();
639
+ return {
640
+ candidates: [{ content: { role: "model", parts }, finishReason }],
641
+ usageMetadata: {
642
+ promptTokenCount: json.usage?.total_input_tokens,
643
+ candidatesTokenCount: json.usage?.total_output_tokens,
644
+ thoughtsTokenCount: json.usage?.total_thought_tokens,
645
+ cachedContentTokenCount: json.usage?.total_cached_tokens,
646
+ },
647
+ };
648
+ }
649
+ /** Convert a generateContent function response into an Interactions result. */
650
+ static toFunctionResult(part) {
651
+ const fr = part.functionResponse;
652
+ const response = { ...(fr.response ?? {}) };
653
+ // The Interactions API takes a boolean acknowledgement.
654
+ if (response.safety_acknowledgement !== undefined) {
655
+ response.safety_acknowledgement = true;
656
+ }
657
+ const image = fr.parts?.[0]?.inlineData;
658
+ return {
659
+ type: "function_result",
660
+ call_id: fr.id ?? "",
661
+ name: fr.name,
662
+ ...(response.error ? { is_error: true } : {}),
663
+ result: [
664
+ { type: "text", text: JSON.stringify(response) },
665
+ ...(image?.data
666
+ ? [
667
+ {
668
+ type: "image",
669
+ data: image.data,
670
+ mime_type: image.mimeType,
671
+ },
672
+ ]
673
+ : []),
674
+ ],
675
+ };
676
+ }
541
677
  /**
542
678
  * Process the response from Google's API
543
679
  */
544
680
  async processResponse(response, logger) {
545
- const actions = [];
681
+ const calls = [];
546
682
  let message = "";
547
- const functionCalls = [];
548
683
  if (!response.candidates || response.candidates.length === 0) {
549
684
  return {
550
685
  actions: [],
551
686
  message: "No candidates in response",
552
687
  completed: true,
553
- functionCalls: [],
688
+ calls: [],
554
689
  };
555
690
  }
556
691
  const candidate = response.candidates[0];
@@ -571,83 +706,71 @@ export class GoogleCUAClient extends AgentClient {
571
706
  });
572
707
  }
573
708
  if (part.functionCall) {
574
- functionCalls.push(part.functionCall);
709
+ const functionCall = part.functionCall;
575
710
  logger({
576
711
  category: "agent",
577
- message: `Found function call: ${part.functionCall.name} with args: ${JSON.stringify(part.functionCall.args)}`,
712
+ message: `Found function call: ${functionCall.name} with args: ${JSON.stringify(functionCall.args)}`,
578
713
  level: 2,
579
714
  });
580
- // Convert function call to action(s)
581
- const action = this.convertFunctionCallToAction(part.functionCall, logger);
582
- if (action) {
583
- // Special handling for type actions. gemini-2.5 type_text_at carries
584
- // coordinates (click the field first); gemini-3.x `type` has none and
585
- // types into the element the model already focused.
586
- if (action.type === "type") {
587
- logger({
588
- category: "agent",
589
- message: `Adding action: ${JSON.stringify(action)}`,
590
- level: 2,
591
- });
592
- // Click the target first only when coordinates are provided.
593
- if (typeof action.x === "number" && typeof action.y === "number") {
594
- actions.push({
595
- type: "click",
596
- x: action.x,
597
- y: action.y,
598
- button: "left",
599
- });
600
- }
601
- // If clear_before_typing is true (default), add a select all
602
- if (action.clearBeforeTyping) {
603
- // Select all text in the field
604
- actions.push({
605
- type: "keypress",
606
- keys: ["ControlOrMeta+A"],
607
- });
608
- actions.push({
609
- type: "keypress",
610
- keys: ["Backspace"],
611
- });
612
- }
613
- // Then add the type action
614
- actions.push(action);
615
- if (action.pressEnter) {
616
- actions.push({
617
- type: "keypress",
618
- keys: ["Enter"],
619
- });
620
- }
621
- }
622
- else {
623
- actions.push(action);
624
- }
625
- }
626
- else {
715
+ const action = this.convertFunctionCallToAction(functionCall, logger);
716
+ if (!action) {
627
717
  logger({
628
718
  category: "agent",
629
- message: `Warning: Could not convert function call ${part.functionCall.name} to action`,
719
+ message: `Warning: Could not convert function call ${functionCall.name} to action`,
630
720
  level: 1,
631
721
  });
722
+ calls.push({
723
+ functionCall,
724
+ actions: [],
725
+ error: `Unsupported or malformed computer-use action "${functionCall.name}" with args ${JSON.stringify(functionCall.args ?? {})}`,
726
+ });
727
+ continue;
632
728
  }
729
+ calls.push({ functionCall, actions: this.expandAction(action) });
633
730
  }
634
731
  }
732
+ const actions = calls.flatMap((call) => call.actions);
635
733
  // Log summary of what we found
636
734
  logger({
637
735
  category: "agent",
638
- message: `Found ${functionCalls.length} function calls, converted to ${actions.length} actions`,
736
+ message: `Found ${calls.length} function calls, converted to ${actions.length} actions`,
639
737
  level: 2,
640
738
  });
641
- // Check if task is completed
642
- const completed = functionCalls.length === 0 ||
643
- (candidate.finishReason && candidate.finishReason !== "STOP");
739
+ if (calls.length === 0 && !message.trim()) {
740
+ throw new LLMResponseError("agent", "Response has neither function calls nor text");
741
+ }
742
+ // Early stops (non-STOP finish reasons) are rejected in executeStep, so a
743
+ // turn is complete exactly when the model made no function calls.
644
744
  return {
645
745
  actions,
646
746
  message: message.trim(),
647
- completed,
648
- functionCalls,
747
+ completed: calls.length === 0,
748
+ calls,
649
749
  };
650
750
  }
751
+ /**
752
+ * Expand a converted action into the executor steps it needs. Typing may
753
+ * click the field first, clear it, and press Enter afterwards.
754
+ */
755
+ expandAction(action) {
756
+ if (action.type !== "type") {
757
+ return [action];
758
+ }
759
+ const steps = [];
760
+ // Click the target first only when coordinates are provided.
761
+ if (typeof action.x === "number" && typeof action.y === "number") {
762
+ steps.push({ type: "click", x: action.x, y: action.y, button: "left" });
763
+ }
764
+ if (action.clearBeforeTyping) {
765
+ steps.push({ type: "keypress", keys: ["ControlOrMeta+A"] });
766
+ steps.push({ type: "keypress", keys: ["Backspace"] });
767
+ }
768
+ steps.push(action);
769
+ if (action.pressEnter) {
770
+ steps.push({ type: "keypress", keys: ["Enter"] });
771
+ }
772
+ return steps;
773
+ }
651
774
  /**
652
775
  * Convert Google function call to Stagehand action
653
776
  */
@@ -689,7 +812,6 @@ export class GoogleCUAClient extends AgentClient {
689
812
  press_keys: "key_combination",
690
813
  hotkey: "key_combination",
691
814
  screenshot: "take_screenshot",
692
- wait: "wait_5_seconds",
693
815
  };
694
816
  const name = NAME_ALIASES[rawName] ?? rawName;
695
817
  // Predefined computer-use tools take precedence over custom tools. If a
@@ -761,15 +883,37 @@ export class GoogleCUAClient extends AgentClient {
761
883
  button: name === "right_click" ? "right" : "middle",
762
884
  };
763
885
  }
886
+ // gemini-3.x press/release primitives (e.g. a manual drag or a
887
+ // modifier held across clicks).
888
+ case "mouse_down":
889
+ case "mouse_up": {
890
+ if (!GoogleCUAClient.isFiniteCoord(args.x) ||
891
+ !GoogleCUAClient.isFiniteCoord(args.y)) {
892
+ return null;
893
+ }
894
+ const { x, y } = this.normalizeCoordinates(args.x, args.y);
895
+ return { type: name, x, y };
896
+ }
897
+ case "key_down":
898
+ case "key_up": {
899
+ if (typeof args.key !== "string" || !args.key.trim()) {
900
+ return null;
901
+ }
902
+ return { type: name, key: args.key.trim() };
903
+ }
764
904
  case "type_text_at": {
765
905
  // text is required; reject a malformed call rather than typing
766
906
  // "undefined". An empty string is valid (e.g. clear the field).
767
907
  if (typeof args.text !== "string") {
768
908
  return null;
769
909
  }
770
- // press_enter and clear_before_typing are shared across generations.
910
+ // gemini-2.5 type_text_at clears the field by default
911
+ // (clear_before_typing). gemini-3.x `type` has no such arg and types
912
+ // into the focused element, so only clear when asked or when the call
913
+ // targets a field by coordinates.
914
+ const hasCoords = typeof args.x === "number" && typeof args.y === "number";
771
915
  const pressEnter = args.press_enter ?? false;
772
- const clearBeforeTyping = args.clear_before_typing ?? true;
916
+ const clearBeforeTyping = args.clear_before_typing ?? hasCoords;
773
917
  const base = {
774
918
  type: "type",
775
919
  text: args.text,
@@ -780,7 +924,7 @@ export class GoogleCUAClient extends AgentClient {
780
924
  // gemini-3.x `type` has no coordinates and types into the element the
781
925
  // model already focused with a preceding click. Only attach coords when
782
926
  // present so the executor doesn't click at NaN.
783
- if (typeof args.x === "number" && typeof args.y === "number") {
927
+ if (hasCoords) {
784
928
  const { x, y } = this.normalizeCoordinates(args.x, args.y);
785
929
  return { ...base, x, y };
786
930
  }
@@ -826,12 +970,15 @@ export class GoogleCUAClient extends AgentClient {
826
970
  }
827
971
  const { x, y } = this.normalizeCoordinates(args.x, args.y);
828
972
  const direction = (args.direction || "down").toLowerCase();
829
- // 2.5 uses `magnitude`; gemini-3.x `scroll` uses `magnitude_in_pixels`.
973
+ // 2.5 uses `magnitude` (default 800); gemini-3.x `scroll` uses
974
+ // `magnitude_in_pixels` (default 300).
830
975
  const magnitude = GoogleCUAClient.isFiniteCoord(args.magnitude)
831
976
  ? args.magnitude
832
977
  : GoogleCUAClient.isFiniteCoord(args.magnitude_in_pixels)
833
978
  ? args.magnitude_in_pixels
834
- : 800;
979
+ : rawName === "scroll"
980
+ ? 300
981
+ : 800;
835
982
  let scroll_x = 0;
836
983
  let scroll_y = 0;
837
984
  if (direction === "up") {
@@ -881,6 +1028,16 @@ export class GoogleCUAClient extends AgentClient {
881
1028
  type: "wait",
882
1029
  timeMs: 5000, // Google CUA waits for 5 seconds
883
1030
  };
1031
+ case "wait": {
1032
+ // gemini-3.x `wait` takes optional `seconds` (default 1).
1033
+ const seconds = GoogleCUAClient.isFiniteCoord(args.seconds) && args.seconds >= 0
1034
+ ? args.seconds
1035
+ : 1;
1036
+ return {
1037
+ type: "wait",
1038
+ timeMs: Math.round(seconds * 1000),
1039
+ };
1040
+ }
884
1041
  case "hover_at": {
885
1042
  const { x, y } = this.normalizeCoordinates(args.x, args.y);
886
1043
  return {