@loadmill/droid-cua 2.15.1 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/build/index.js +77 -0
- package/build/src/cli/headless-execution-config.js +43 -2
- package/build/src/cli/headless-runner.js +19 -7
- package/build/src/cli/ink-shell.js +1 -0
- package/build/src/cli/scenario-planner.js +1 -0
- package/build/src/commands/help.js +5 -1
- package/build/src/commands/loadmill.js +52 -25
- package/build/src/core/cua-backends.js +11 -0
- package/build/src/core/cua-models.js +47 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/runtime-config.js +252 -8
- package/build/src/core/scenario-planner.js +85 -0
- package/build/src/core/test-data-import.js +62 -0
- package/build/src/core/test-data.js +103 -0
- package/build/src/device/openai.js +30 -3
- package/build/src/integrations/loadmill/executor.js +2 -1
- package/build/src/integrations/loadmill/interpreter.js +3 -2
- package/build/src/modes/execution-mode.js +45 -0
- package/build/src/prompts/execution.js +13 -1
- package/build/src/prompts/scenario-planner.js +48 -0
- package/build/src/prompts/test-data-import.js +49 -0
- package/build/src/utils/cua-debug-tracer.js +15 -0
- package/package.json +3 -2
|
@@ -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", {
|
|
@@ -9,6 +9,17 @@ export function buildAppContextSection(text) {
|
|
|
9
9
|
The following describes the app you are testing.
|
|
10
10
|
Use this to understand screen layouts, terminology, navigation, and expected behavior.
|
|
11
11
|
|
|
12
|
+
${trimmed}`;
|
|
13
|
+
}
|
|
14
|
+
export function buildTestDataContextSection(text) {
|
|
15
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
16
|
+
if (!trimmed) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
return `TEST DATA FOR THIS RUN:
|
|
20
|
+
The following values are test data for this execution. Use them as literal run data when performing the test.
|
|
21
|
+
They do not replace or override the test instructions.
|
|
22
|
+
|
|
12
23
|
${trimmed}`;
|
|
13
24
|
}
|
|
14
25
|
export function buildExecutionModePrompt(deviceInfo, customInstructions = {}, appContextText = "", options = {}) {
|
|
@@ -16,6 +27,7 @@ export function buildExecutionModePrompt(deviceInfo, customInstructions = {}, ap
|
|
|
16
27
|
const aimAssistMode = typeof options.aimAssistMode === "string" ? options.aimAssistMode : DEFAULT_AIM_ASSIST_MODE;
|
|
17
28
|
const basePrompt = buildBaseSystemPrompt(deviceInfo, { aimAssistMode });
|
|
18
29
|
const appContextSection = buildAppContextSection(appContextText);
|
|
30
|
+
const testDataContextSection = buildTestDataContextSection(options.testDataContext);
|
|
19
31
|
const browserTarget = isBrowserTarget(deviceInfo);
|
|
20
32
|
const prompt = `${basePrompt}
|
|
21
33
|
|
|
@@ -52,7 +64,7 @@ Your process:
|
|
|
52
64
|
7. Stop immediately - no commentary, no questions
|
|
53
65
|
|
|
54
66
|
Each instruction is independent. Do not reference previous instructions or ask about next steps.
|
|
55
|
-
${appContextSection ? `\n\n${appContextSection}` : ""}`;
|
|
67
|
+
${appContextSection ? `\n\n${appContextSection}` : ""}${testDataContextSection ? `\n\n${testDataContextSection}` : ""}`;
|
|
56
68
|
return appendCustomSections(prompt, [
|
|
57
69
|
{ title: "Base Prompt Instructions", text: customInstructions.basePromptInstructions },
|
|
58
70
|
{ title: "Execution Mode Instructions", text: executionCustomText }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function formatCandidateTests(tests) {
|
|
2
|
+
return tests.map((test, index) => {
|
|
3
|
+
const content = Array.isArray(test.instructions) ? test.instructions.join("\n") : "";
|
|
4
|
+
return [
|
|
5
|
+
`TEST ${index + 1}`,
|
|
6
|
+
`Name: ${test.testName}`,
|
|
7
|
+
"Instructions:",
|
|
8
|
+
content || "(empty)",
|
|
9
|
+
].join("\n");
|
|
10
|
+
}).join("\n\n---\n\n");
|
|
11
|
+
}
|
|
12
|
+
export function buildScenarioPlannerMessages({ scenarioPrompt, tests, appContextText = "", testDataText = "", }) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
role: "system",
|
|
16
|
+
content: [
|
|
17
|
+
"You prepare concrete test executions for a computer-using agent.",
|
|
18
|
+
"The agent runs manual-style app tests: it receives a test brief, app context, and optional run data, then operates the app UI.",
|
|
19
|
+
"Your job is to turn the user's scenario request into the exact ordered cases the agent should run.",
|
|
20
|
+
"Return JSON only with this shape:",
|
|
21
|
+
"{\"cases\":[{\"testName\":\"exact-test-name.dcua\",\"caseName\":\"test-name.dcua / data label\",\"testDataContext\":\"raw data text for this case\"}]}",
|
|
22
|
+
"Rules:",
|
|
23
|
+
"- testName must exactly match one of the provided candidate test names.",
|
|
24
|
+
"- Create an ordered flat list of cases to run.",
|
|
25
|
+
"- caseName is an ephemeral display name for logs and reports. Keep it short and human-readable.",
|
|
26
|
+
"- testDataContext must contain only the concrete data relevant to that case.",
|
|
27
|
+
"- Do not invent data that was not provided in test data, context, or the scenario prompt.",
|
|
28
|
+
"- If no extra data is needed for a case, use an empty string for testDataContext.",
|
|
29
|
+
].join("\n")
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
role: "user",
|
|
33
|
+
content: [
|
|
34
|
+
"Scenario prompt:",
|
|
35
|
+
scenarioPrompt,
|
|
36
|
+
"",
|
|
37
|
+
"App context:",
|
|
38
|
+
appContextText && appContextText.trim() ? appContextText.trim() : "(none)",
|
|
39
|
+
"",
|
|
40
|
+
"Project test data:",
|
|
41
|
+
testDataText && testDataText.trim() ? testDataText.trim() : "(none)",
|
|
42
|
+
"",
|
|
43
|
+
"Candidate tests:",
|
|
44
|
+
formatCandidateTests(tests),
|
|
45
|
+
].join("\n")
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DEFAULT_TEST_DATA_TEMPLATE, isDefaultTestDataTemplate } from "../core/test-data.js";
|
|
2
|
+
export function buildTestDataImportMessages({ currentContent = "", importedFileName, importedFileText, instructions = "", }) {
|
|
3
|
+
const currentIsTemplateOnly = isDefaultTestDataTemplate(currentContent);
|
|
4
|
+
const userInstructions = typeof instructions === "string" && instructions.trim()
|
|
5
|
+
? instructions.trim()
|
|
6
|
+
: "(none)";
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
role: "system",
|
|
10
|
+
content: [
|
|
11
|
+
"You convert imported data into durable project test data for computer-using-agent scenario runs.",
|
|
12
|
+
"The durable format is test-data.md: readable markdown that describes concrete entities and run data the agent can use.",
|
|
13
|
+
"Return JSON only with this shape:",
|
|
14
|
+
"{\"content\":\"# Test Data\\n...complete markdown...\"}",
|
|
15
|
+
"Rules:",
|
|
16
|
+
"- Return the complete replacement content for test-data.md, not a patch.",
|
|
17
|
+
"- Do not invent values that are not present in the current file, imported file, or user instructions.",
|
|
18
|
+
"- If currentContent is template-only, replace it with real imported data and do not keep commented template examples.",
|
|
19
|
+
"- If currentContent is not template-only, preserve its useful structure and add or update entities within it.",
|
|
20
|
+
"- For matrix-like CSV rows, prefer splitting repeated dimensions into separate entities when the structure is clear.",
|
|
21
|
+
"- If rows are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
|
|
22
|
+
"- User instructions can guide structure and naming, but content must remain concrete data, not test instructions.",
|
|
23
|
+
"- Output markdown only inside the JSON content string; do not use code fences.",
|
|
24
|
+
].join("\n")
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
role: "user",
|
|
28
|
+
content: [
|
|
29
|
+
"Current test-data.md status:",
|
|
30
|
+
currentIsTemplateOnly ? "template-only" : "existing user content",
|
|
31
|
+
"",
|
|
32
|
+
"Default template used for template-only detection:",
|
|
33
|
+
DEFAULT_TEST_DATA_TEMPLATE.trimEnd(),
|
|
34
|
+
"",
|
|
35
|
+
"Current test-data.md content:",
|
|
36
|
+
String(currentContent ?? "").trim() ? String(currentContent) : "(empty)",
|
|
37
|
+
"",
|
|
38
|
+
"Imported file:",
|
|
39
|
+
importedFileName,
|
|
40
|
+
"",
|
|
41
|
+
"Imported file text:",
|
|
42
|
+
importedFileText,
|
|
43
|
+
"",
|
|
44
|
+
"User import instructions:",
|
|
45
|
+
userInstructions,
|
|
46
|
+
].join("\n")
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "2.17.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,10 +15,11 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "tsx index.js",
|
|
18
|
-
"bench": "
|
|
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",
|