@loadmill/droid-cua 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,25 @@
1
1
  import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
2
- import { createOpenAiClient, resolveRuntimeConfig } from "../core/runtime-config.js";
2
+ import { createOpenAiClient, LOADMILL_MODEL_ALIASES, resolveEffectiveHelperModel, resolveRuntimeConfig, } from "../core/runtime-config.js";
3
3
  import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
4
4
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD } from "../core/context-optimization.js";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
7
7
  const cuaDebugTracer = new CuaDebugTracer(logger);
8
+ function resolveOpenAiBaseUrl(client) {
9
+ return client?.baseURL || client?.baseUrl || client?._options?.baseURL || null;
10
+ }
11
+ function resolveOpenAiEndpointUrl(client, endpointPath) {
12
+ const baseURL = resolveOpenAiBaseUrl(client);
13
+ if (!baseURL) {
14
+ return null;
15
+ }
16
+ try {
17
+ return new URL(endpointPath.replace(/^\/+/, ''), `${String(baseURL).replace(/\/+$/, '')}/`).toString();
18
+ }
19
+ catch {
20
+ return `${String(baseURL).replace(/\/+$/, '')}/${endpointPath.replace(/^\/+/, '')}`;
21
+ }
22
+ }
8
23
  function normalizeMessageContent(content) {
9
24
  if (Array.isArray(content)) {
10
25
  return content.map((item) => {
@@ -81,7 +96,7 @@ const closeCurrentTabTool = {
81
96
  const loadmillTool = {
82
97
  type: "function",
83
98
  name: "loadmill",
84
- description: "Run an explicit Loadmill command. Use this only when the instruction explicitly mentions Loadmill and asks for a Loadmill-specific action.",
99
+ description: "Search Loadmill for the requested flow, run the best match, and wait for completion. Use this when the instruction explicitly mentions running or using a Loadmill flow.",
85
100
  strict: true,
86
101
  parameters: {
87
102
  type: "object",
@@ -225,14 +240,15 @@ export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_AS
225
240
  * Revise a test script based on user feedback using simple chat completion
226
241
  * @param {{
227
242
  * client: import("openai").default,
243
+ * model?: string,
228
244
  * originalScript: string,
229
245
  * revisionRequest: string,
230
246
  * }} options
231
247
  * @returns {Promise<string>} - The revised test script
232
248
  */
233
- export async function reviseTestScript({ client, originalScript, revisionRequest }) {
249
+ export async function reviseTestScript({ client, model = LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL, originalScript, revisionRequest, }) {
234
250
  const response = await client.chat.completions.create({
235
- model: "gpt-5.4",
251
+ model,
236
252
  messages: [{
237
253
  role: "system",
238
254
  content: buildTestRevisionSystemPrompt(originalScript, revisionRequest)
@@ -274,7 +290,14 @@ export async function sendCUARequest({ client, cuaModel, aimAssistMode = DEFAULT
274
290
  debugContext,
275
291
  previousResponseId
276
292
  });
293
+ trace.endpoint = {
294
+ baseURL: resolveOpenAiBaseUrl(client),
295
+ responsesURL: resolveOpenAiEndpointUrl(client, "responses"),
296
+ model: requestParams.model,
297
+ previousResponseId: requestParams.previous_response_id ?? null,
298
+ };
277
299
  logger.debug("CUA Request:", trace.requestLog);
300
+ logger.debug("CUA Request endpoint:", trace.endpoint);
278
301
  try {
279
302
  const response = await client.responses.create(requestParams);
280
303
  cuaDebugTracer.onResponse(trace, response);
@@ -311,6 +334,10 @@ export function createOpenAiExecutor({ client, config }) {
311
334
  async reviseTestScript(originalScript, revisionRequest) {
312
335
  return await reviseTestScript({
313
336
  client: openAiClient,
337
+ model: resolveEffectiveHelperModel({
338
+ llmProviderMode: runtimeConfig.llmProviderMode,
339
+ cuaModel: runtimeConfig.cuaModel,
340
+ }),
314
341
  originalScript,
315
342
  revisionRequest,
316
343
  });
@@ -4,9 +4,30 @@
4
4
  import { createLoadmillApiClient } from "./client.js";
5
5
  import { interpretLoadmillCommand, selectBestFlow } from "./interpreter.js";
6
6
  import { extractLoadmillRuntimeParameters, renderLoadmillParameterContextNote, selectLoadmillParameterCandidates, } from "./parameters.js";
7
+ import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
7
8
  import { logger } from "../../utils/logger.js";
8
9
  const POLL_INTERVAL_MS = 5000; // 5 seconds
9
- const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
10
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
11
+ function readTimeoutMsFromEnv() {
12
+ const raw = process.env.LOADMILL_RUN_TIMEOUT_MS;
13
+ if (!raw) {
14
+ return DEFAULT_TIMEOUT_MS;
15
+ }
16
+ const parsed = Number.parseInt(raw, 10);
17
+ if (Number.isFinite(parsed) && parsed > 0) {
18
+ return parsed;
19
+ }
20
+ logger.debug("Ignoring invalid LOADMILL_RUN_TIMEOUT_MS value", { value: raw });
21
+ return DEFAULT_TIMEOUT_MS;
22
+ }
23
+ function formatDuration(ms) {
24
+ const totalSeconds = Math.ceil(ms / 1000);
25
+ if (totalSeconds >= 60 && totalSeconds % 60 === 0) {
26
+ const minutes = totalSeconds / 60;
27
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
28
+ }
29
+ return `${totalSeconds} ${totalSeconds === 1 ? "second" : "seconds"}`;
30
+ }
10
31
  function buildParameterSelectionMessages({ command, flowName, futureInstructions, parameters, }) {
11
32
  const parameterList = parameters
12
33
  .map((param) => `- ${param.key}`)
@@ -42,7 +63,7 @@ async function selectRelevantParameterNames({ openAiClient, command, flowName, f
42
63
  }
43
64
  try {
44
65
  const response = await openAiClient.chat.completions.create({
45
- model: "gpt-5.4",
66
+ model: LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL,
46
67
  messages: buildParameterSelectionMessages({
47
68
  command,
48
69
  flowName,
@@ -127,9 +148,10 @@ function sleep(ms) {
127
148
  * @param {Function} onStatusUpdate - Callback for status updates
128
149
  * @returns {Promise<Object>} - Final run status
129
150
  */
130
- async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => { }) {
131
- const startTime = Date.now();
132
- while (Date.now() - startTime < TIMEOUT_MS) {
151
+ async function pollForCompletion(runId, loadmillClient, options = {}) {
152
+ const { onStatusUpdate = () => { }, timeoutMs = readTimeoutMsFromEnv(), pollIntervalMs = POLL_INTERVAL_MS, now = Date.now, sleepFn = sleep, } = options;
153
+ const startTime = now();
154
+ while (now() - startTime < timeoutMs) {
133
155
  try {
134
156
  const status = await loadmillClient.getTestRunStatus(runId);
135
157
  onStatusUpdate(status);
@@ -158,7 +180,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
158
180
  return { success: false, status: "stopped", result: status, error: "Test flow was stopped" };
159
181
  }
160
182
  // Still running, wait and poll again
161
- await sleep(POLL_INTERVAL_MS);
183
+ await sleepFn(pollIntervalMs);
162
184
  }
163
185
  catch (err) {
164
186
  logger.error("Error polling Loadmill run status", { runId, error: err.message });
@@ -166,7 +188,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
166
188
  }
167
189
  }
168
190
  // Timeout
169
- return { success: false, status: "timeout", error: "Test run timed out after 5 minutes" };
191
+ return { success: false, status: "timeout", error: `Test run timed out after ${formatDuration(timeoutMs)}` };
170
192
  }
171
193
  /**
172
194
  * Execute a Loadmill command from natural language input
@@ -176,7 +198,7 @@ async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => {
176
198
  * @returns {Promise<{success: boolean, error?: string, result?: Object}>}
177
199
  */
178
200
  export async function executeLoadmillCommand(userInput, options = {}) {
179
- const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, futureInstructions = [], } = options;
201
+ const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, futureInstructions = [], pollTimeoutMs, pollIntervalMs, now, sleepFn, } = options;
180
202
  // Check for API token
181
203
  if (!loadmillClient.getApiToken()) {
182
204
  return {
@@ -214,18 +236,6 @@ export async function executeLoadmillCommand(userInput, options = {}) {
214
236
  error: "Could not select a matching flow"
215
237
  };
216
238
  }
217
- // If action is "search", just return the results
218
- if (interpreted.action === "search") {
219
- return {
220
- success: true,
221
- action: "search",
222
- result: {
223
- flows,
224
- selectedFlow,
225
- confidence
226
- }
227
- };
228
- }
229
239
  // Step 4: Run the flow
230
240
  const flowName = selectedFlow.description || selectedFlow.name || "Unknown";
231
241
  onProgress({
@@ -244,8 +254,14 @@ export async function executeLoadmillCommand(userInput, options = {}) {
244
254
  }
245
255
  // Step 5: Poll for completion
246
256
  onProgress({ step: "polling", message: `Test started (ID: ${runId}). Waiting for completion...`, runId });
247
- const finalResult = await pollForCompletion(runId, loadmillClient, (status) => {
248
- onProgress({ step: "polling", message: `Status: ${status.status}...`, runId });
257
+ const finalResult = await pollForCompletion(runId, loadmillClient, {
258
+ timeoutMs: pollTimeoutMs,
259
+ pollIntervalMs,
260
+ now,
261
+ sleepFn,
262
+ onStatusUpdate: (status) => {
263
+ onProgress({ step: "polling", message: `Status: ${status.status}...`, runId });
264
+ },
249
265
  });
250
266
  const runtimeParameterContext = finalResult.success
251
267
  ? await buildRuntimeParameterContext({
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * AI-powered text interpretation for Loadmill commands
3
3
  */
4
+ import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
4
5
  import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionMessages, } from "../../prompts/loadmill.js";
5
6
  /**
6
7
  * Interpret a natural language Loadmill command into structured data
7
8
  * @param {string} userInput - Natural language command
8
- * @returns {Promise<{searchQuery: string, parameters: Object, action: 'run'|'search'}>}
9
+ * @returns {Promise<{searchQuery: string, parameters: Object}>}
9
10
  */
10
11
  export async function interpretLoadmillCommand(userInput, { client }) {
11
12
  const response = await client.chat.completions.create({
12
- model: "gpt-4o-mini",
13
+ model: LOADMILL_MODEL_ALIASES.FAST,
13
14
  messages: buildLoadmillCommandInterpretationMessages(userInput),
14
15
  response_format: { type: "json_object" }
15
16
  });
@@ -18,7 +19,6 @@ export async function interpretLoadmillCommand(userInput, { client }) {
18
19
  return {
19
20
  searchQuery: parsed.searchQuery || userInput,
20
21
  parameters: parsed.parameters || {},
21
- action: parsed.action || "run"
22
22
  };
23
23
  }
24
24
  /**
@@ -42,7 +42,7 @@ export async function selectBestFlow(flows, originalQuery, { client }) {
42
42
  return `${i + 1}. ID: ${f.id}, Name: "${name}"${suite ? `, Suite: "${suite}"` : ""}`;
43
43
  }).join("\n");
44
44
  const response = await client.chat.completions.create({
45
- model: "gpt-4o-mini",
45
+ model: LOADMILL_MODEL_ALIASES.FAST,
46
46
  messages: buildLoadmillFlowSelectionMessages(originalQuery, flowList),
47
47
  response_format: { type: "json_object" }
48
48
  });
@@ -8,6 +8,31 @@ import { isAssertion } from "../device/assertions.js";
8
8
  import { logger } from "../utils/logger.js";
9
9
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
10
10
  import { printCliOutput } from "../utils/console-output.js";
11
+ // Mirrors the codes emitted by the Loadmill LLM gateway (see services/app/private/llm
12
+ // in the server repo): a transient burst limit plus day/week/fixed-budget request and
13
+ // token quotas. Free plans run on the fixed-budget quota.
14
+ const LLM_USAGE_LIMIT_ERROR_CODES = new Set([
15
+ "llm_rate_limit_exceeded",
16
+ "llm_day_quota_exceeded",
17
+ "llm_week_quota_exceeded",
18
+ "llm_fixed_budget_quota_exceeded",
19
+ "llm_day_token_quota_exceeded",
20
+ "llm_week_token_quota_exceeded",
21
+ "llm_fixed_budget_token_quota_exceeded",
22
+ ]);
23
+ const LOADMILL_UPGRADE_URL = "https://www.loadmill.com/book-a-demo";
24
+ function isLlmUsageLimitError(err) {
25
+ if (!err || typeof err !== "object") {
26
+ return false;
27
+ }
28
+ if (typeof err.code === "string" && LLM_USAGE_LIMIT_ERROR_CODES.has(err.code)) {
29
+ return true;
30
+ }
31
+ return err.status === 429 && err.type === "rate_limit_error";
32
+ }
33
+ function getLlmUsageLimitMessage() {
34
+ return `Execution stopped: you've reached the AI usage limit for this account. Contact the Loadmill team to upgrade: ${LOADMILL_UPGRADE_URL}`;
35
+ }
11
36
  /**
12
37
  * Execution Mode - Run test scripts line-by-line
13
38
  * Each instruction is executed in isolation (messages cleared after each turn)
@@ -267,6 +292,26 @@ export class ExecutionMode {
267
292
  stack: err.stack
268
293
  });
269
294
  const addOutput = context.addOutput || printCliOutput;
295
+ if (isLlmUsageLimitError(err)) {
296
+ const stopMessage = getLlmUsageLimitMessage();
297
+ logger.debug('Stopping execution because an LLM usage limit was reached', {
298
+ instruction,
299
+ message: err.message,
300
+ status: err.status,
301
+ code: err.code,
302
+ type: err.type,
303
+ });
304
+ this.emit(addOutput, 'error', stopMessage, context, stepContext, {
305
+ eventType: 'error',
306
+ payload: {
307
+ message: stopMessage,
308
+ status: err.status,
309
+ code: err.code,
310
+ type: err.type,
311
+ }
312
+ });
313
+ return { success: false, error: stopMessage, usageLimitReached: true };
314
+ }
270
315
  // Check if we've exceeded max retries
271
316
  if (retryCount >= MAX_RETRIES) {
272
317
  emitDesktopDebug("reconnect.attempt", "device", {
@@ -44,7 +44,7 @@ Your process:
44
44
  - Before calling \`assert\`, make sure the current screen contains the evidence needed to validate the condition. If needed, perform only minimal preparation first, such as taking a screenshot, waiting briefly, closing the keyboard, scrolling within the relevant view, or opening the relevant menu.
45
45
  - Once you call \`assert\`, validation is read-only. The assertion tool will not click, type, wait, scroll, request screenshots, or change the device state.
46
46
  - Use \`navigate\` for direct browser navigation instead of trying to manipulate the address bar
47
- - Use \`loadmill\` only when the instruction explicitly references the word "loadmill"
47
+ - Use the \`loadmill\` tool whenever the instruction explicitly references running or using a Loadmill flow. Pass the requested flow name/intent and parameters as the command; the tool will search Loadmill, select the best matching flow, run it, and wait for completion. Do not handle Loadmill by typing in the app UI.
48
48
  3. Execute the required actions
49
49
  4. Before tapping a mutating action, dismiss the keyboard if it is open and not needed
50
50
  5. After a mutating action, inspect the resulting screen for success cues such as cleared fields, reset buttons, changed status, refreshed content, or navigation
@@ -7,25 +7,21 @@ export function buildLoadmillCommandInterpretationMessages(userInput) {
7
7
  Extract the following from the user's input:
8
8
  1. searchQuery: The flow name or description to search for (required). FIX any obvious typos or misspellings.
9
9
  2. parameters: Any key=value pairs mentioned (as an object)
10
- 3. action: Either "run" (if user wants to execute) or "search" (if user just wants to find flows)
11
10
 
12
11
  Output JSON only, no markdown or explanation.
13
12
 
14
13
  Examples:
15
14
  Input: "run the checkout flow with user=test123"
16
- Output: {"searchQuery": "checkout flow", "parameters": {"user": "test123"}, "action": "run"}
17
-
18
- Input: "search for login test"
19
- Output: {"searchQuery": "login test", "parameters": {}, "action": "search"}
15
+ Output: {"searchQuery": "checkout flow", "parameters": {"user": "test123"}}
20
16
 
21
17
  Input: "run user authentication with email=test@example.com password=secret123"
22
- Output: {"searchQuery": "user authentication", "parameters": {"email": "test@example.com", "password": "secret123"}, "action": "run"}
18
+ Output: {"searchQuery": "user authentication", "parameters": {"email": "test@example.com", "password": "secret123"}}
23
19
 
24
20
  Input: "execute payment flow"
25
- Output: {"searchQuery": "payment flow", "parameters": {}, "action": "run"}
21
+ Output: {"searchQuery": "payment flow", "parameters": {}}
26
22
 
27
23
  Input: "create a transction with amount=200"
28
- Output: {"searchQuery": "transaction", "parameters": {"amount": "200"}, "action": "run"}`
24
+ Output: {"searchQuery": "transaction", "parameters": {"amount": "200"}}`
29
25
  },
30
26
  {
31
27
  role: "user",
@@ -23,6 +23,17 @@ function safeHeadersSnapshot(headers) {
23
23
  }
24
24
  return out;
25
25
  }
26
+ function summarizeErrorCause(error) {
27
+ const cause = error?.cause;
28
+ if (!cause || typeof cause !== "object") {
29
+ return null;
30
+ }
31
+ return {
32
+ name: typeof cause.name === "string" ? cause.name : null,
33
+ message: typeof cause.message === "string" ? cause.message : String(cause),
34
+ code: typeof cause.code === "string" ? cause.code : null,
35
+ };
36
+ }
26
37
  function summarizeContent(content) {
27
38
  if (typeof content === "string") {
28
39
  return truncateForDebug(content, 1600);
@@ -394,6 +405,7 @@ export class CuaDebugTracer {
394
405
  : null;
395
406
  const requestId = err?.request_id || responseError?.request_id || requestIdFromHeaders || null;
396
407
  const headers = safeHeadersSnapshot(err?.headers);
408
+ const provider = typeof responseError?.provider === "string" ? responseError.provider : null;
397
409
  emitDesktopDebug("device.error", "device", trace.ids, {
398
410
  localRequestId: trace.localRequestId,
399
411
  operation: "cua.request",
@@ -403,8 +415,11 @@ export class CuaDebugTracer {
403
415
  code: err?.code ?? responseError?.code ?? null,
404
416
  type: responseError?.type ?? null,
405
417
  param: responseError?.param ?? null,
418
+ provider,
406
419
  requestId,
407
420
  headers,
421
+ endpoint: trace.endpoint || null,
422
+ cause: summarizeErrorCause(err),
408
423
  chain: {
409
424
  lastResponseId: trace.lastResponseMeta?.id ?? null,
410
425
  lastResponseOutputTypes: trace.lastResponseMeta?.outputTypes ?? [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -15,15 +15,19 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "start": "tsx index.js",
18
- "bench": "npm run bench:preflight && npm run bench:run && npm run bench:report",
18
+ "bench": "node scripts/benchmark.js",
19
19
  "bench:preflight": "node scripts/benchmark-preflight.js",
20
20
  "bench:run": "tsx index.js --platform android --instructions benchmarks/benchmark.dcua --context benchmarks/context.md --debug",
21
21
  "bench:report": "node scripts/benchmark-report.js",
22
+ "bench:report:web": "node scripts/benchmark-report-web.js",
22
23
  "execution:profile": "node scripts/execution-profile.js",
23
24
  "test": "node --test unit-tests/*.test.js",
24
25
  "build": "tsc",
25
26
  "clean": "rm -rf build",
26
27
  "typecheck": "tsc --noEmit",
28
+ "typecheck:desktop": "npm --prefix desktop-app run typecheck",
29
+ "typecheck:all": "npm run typecheck && npm run typecheck:desktop",
30
+ "prepare": "node scripts/install-git-hooks.mjs",
27
31
  "prepublishOnly": "npm run build"
28
32
  },
29
33
  "repository": {