@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.
- package/README.md +6 -3
- package/build/index.js +43 -148
- package/build/src/cli/headless-execution-config.js +28 -2
- package/build/src/cli/headless-instruction-target.js +64 -0
- package/build/src/cli/headless-runner.js +245 -0
- package/build/src/cli/ink-shell.js +1 -0
- package/build/src/commands/help.js +2 -1
- package/build/src/commands/loadmill.js +52 -41
- package/build/src/core/cua-backends.js +11 -0
- package/build/src/core/cua-models.js +47 -2
- package/build/src/core/runtime-config.js +252 -8
- package/build/src/device/loadmill.js +41 -78
- package/build/src/device/openai.js +31 -4
- package/build/src/integrations/loadmill/executor.js +38 -22
- package/build/src/integrations/loadmill/interpreter.js +4 -4
- package/build/src/modes/execution-mode.js +45 -0
- package/build/src/prompts/execution.js +1 -1
- package/build/src/prompts/loadmill.js +4 -8
- package/build/src/utils/cua-debug-tracer.js +15 -0
- package/package.json +6 -2
|
@@ -35,6 +35,7 @@ export async function startInkShell(session, executionEngine, options = {}) {
|
|
|
35
35
|
engine: executionEngine,
|
|
36
36
|
appContextPath: options.appContextPath ?? null,
|
|
37
37
|
runtimeContext: options.runtimeContext ?? null,
|
|
38
|
+
runtimeConfig: options.runtimeContext?.config ?? null,
|
|
38
39
|
openAiClient: options.runtimeContext?.openAiClient ?? null,
|
|
39
40
|
loadmillClient: options.runtimeContext?.loadmillClient ?? null,
|
|
40
41
|
runtimeCredentials: options.runtimeContext?.credentials ?? null
|
|
@@ -28,7 +28,8 @@ export async function handleHelp(args, session, context) {
|
|
|
28
28
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
29
29
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
30
30
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
31
|
-
addOutput({ type: 'info', text: ' --
|
|
31
|
+
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
32
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
|
|
32
33
|
addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
|
|
33
34
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
34
35
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
4
|
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
|
|
6
6
|
/**
|
|
7
7
|
* Handle /loadmill command
|
|
8
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -21,12 +21,12 @@ export async function handleLoadmill(args, session, context) {
|
|
|
21
21
|
addOutput({ type: 'info', text: ' /loadmill <command> Execute a Loadmill flow' });
|
|
22
22
|
addOutput({ type: 'info', text: '' });
|
|
23
23
|
addOutput({ type: 'info', text: 'Examples:' });
|
|
24
|
-
addOutput({ type: 'info', text: ' /loadmill
|
|
24
|
+
addOutput({ type: 'info', text: ' /loadmill login flow' });
|
|
25
25
|
addOutput({ type: 'info', text: ' /loadmill run checkout flow with user=test123' });
|
|
26
26
|
addOutput({ type: 'info', text: ' /loadmill run payment test with amount=100' });
|
|
27
27
|
addOutput({ type: 'info', text: '' });
|
|
28
28
|
addOutput({ type: 'info', text: 'In test scripts (.dcua files):' });
|
|
29
|
-
addOutput({ type: 'info', text: ' loadmill
|
|
29
|
+
addOutput({ type: 'info', text: ' run loadmill user authentication flow' });
|
|
30
30
|
addOutput({ type: 'info', text: '' });
|
|
31
31
|
addOutput({ type: 'info', text: 'Configuration:' });
|
|
32
32
|
addOutput({ type: 'info', text: ' Set LOADMILL_API_TOKEN in your .env file' });
|
|
@@ -48,49 +48,60 @@ export async function handleLoadmill(args, session, context) {
|
|
|
48
48
|
if (context.setAgentWorking) {
|
|
49
49
|
context.setAgentWorking(true, 'Running Loadmill flow...');
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
result.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
51
|
+
try {
|
|
52
|
+
const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
|
|
53
|
+
const runtimeConfig = context.runtimeConfig || resolveRuntimeConfigFromEnv();
|
|
54
|
+
const loadmillClient = context.loadmillClient || createLoadmillApiClient({
|
|
55
|
+
token: runtimeCredentials.loadmillApiToken,
|
|
56
|
+
baseUrl: runtimeCredentials.loadmillBaseUrl,
|
|
57
|
+
});
|
|
58
|
+
const openAiClient = context.openAiClient || createResolvedLlmClient({
|
|
59
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
60
|
+
credentials: runtimeCredentials,
|
|
61
|
+
clientName: "cli",
|
|
62
|
+
});
|
|
63
|
+
// Execute the command
|
|
64
|
+
const result = await executeLoadmillCommand(command, {
|
|
65
|
+
loadmillClient,
|
|
66
|
+
openAiClient,
|
|
67
|
+
onProgress: ({ message }) => {
|
|
68
|
+
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Display results
|
|
72
|
+
if (result.success) {
|
|
73
|
+
if (result.action === "search") {
|
|
74
|
+
addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
|
|
75
|
+
result.result.flows.forEach((flow, i) => {
|
|
76
|
+
const name = flow.description || flow.name || "Unknown";
|
|
77
|
+
const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
|
|
78
|
+
addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
|
|
79
|
+
addOutput({ type: 'info', text: ` ID: ${flow.id}` });
|
|
80
|
+
});
|
|
81
|
+
if (result.result.selectedFlow) {
|
|
82
|
+
const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
|
|
83
|
+
addOutput({ type: 'info', text: '' });
|
|
84
|
+
addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
|
|
89
|
+
if (result.runId) {
|
|
90
|
+
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
91
|
+
}
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
else {
|
|
86
|
-
addOutput({ type: '
|
|
87
|
-
if (result.runId) {
|
|
88
|
-
addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
|
|
89
|
-
}
|
|
95
|
+
addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
addOutput({ type: 'error', text:
|
|
98
|
+
catch (error) {
|
|
99
|
+
addOutput({ type: 'error', text: error instanceof Error ? error.message : 'Loadmill command failed.' });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (context.setAgentWorking) {
|
|
103
|
+
context.setAgentWorking(false);
|
|
104
|
+
}
|
|
94
105
|
}
|
|
95
106
|
return true; // Continue loop
|
|
96
107
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CUA_BACKENDS = ["openai", "fara"];
|
|
2
|
+
export const DEFAULT_CUA_BACKEND = "openai";
|
|
3
|
+
export const DEFAULT_FARA_BASE_URL = "http://localhost:5000/v1";
|
|
4
|
+
export const DEFAULT_FARA_MODEL = "Fara-7B";
|
|
5
|
+
const VALID_CUA_BACKENDS = new Set(CUA_BACKENDS);
|
|
6
|
+
export function isValidCuaBackend(value) {
|
|
7
|
+
return VALID_CUA_BACKENDS.has(value);
|
|
8
|
+
}
|
|
9
|
+
export function resolveCuaBackend(value) {
|
|
10
|
+
return isValidCuaBackend(value) ? value : DEFAULT_CUA_BACKEND;
|
|
11
|
+
}
|
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
1
|
+
export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
2
|
+
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
|
+
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
|
+
export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
5
|
+
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
|
|
6
|
+
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
|
|
7
|
+
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
8
|
+
Object.freeze({
|
|
9
|
+
id: "loadmill-smart",
|
|
10
|
+
providerMode: "loadmill",
|
|
11
|
+
providerLabel: "Loadmill",
|
|
12
|
+
name: "Loadmill Smart",
|
|
13
|
+
label: "Loadmill Smart (recommended)",
|
|
14
|
+
}),
|
|
15
|
+
Object.freeze({
|
|
16
|
+
id: "gpt-5.4",
|
|
17
|
+
providerMode: "openai",
|
|
18
|
+
providerLabel: "OpenAI",
|
|
19
|
+
name: "GPT-5.4",
|
|
20
|
+
label: "OpenAI GPT-5.4",
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: "gpt-5.4-mini",
|
|
24
|
+
providerMode: "openai",
|
|
25
|
+
providerLabel: "OpenAI",
|
|
26
|
+
name: "GPT-5.4 mini",
|
|
27
|
+
label: "OpenAI GPT-5.4 mini (faster)",
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
3
30
|
const VALID_CUA_MODELS = new Set(CUA_MODELS);
|
|
31
|
+
const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
|
|
32
|
+
const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
|
|
33
|
+
const CUA_MODEL_PROVIDER_MODE_MAP = new Map(CUA_MODEL_OPTIONS.map((option) => [option.id, option.providerMode]));
|
|
4
34
|
export function isValidCuaModel(value) {
|
|
5
35
|
return VALID_CUA_MODELS.has(value);
|
|
6
36
|
}
|
|
37
|
+
export function isOpenAiCuaModel(value) {
|
|
38
|
+
return OPENAI_CUA_MODEL_SET.has(value);
|
|
39
|
+
}
|
|
40
|
+
export function isLoadmillCuaModel(value) {
|
|
41
|
+
return LOADMILL_CUA_MODEL_SET.has(value);
|
|
42
|
+
}
|
|
7
43
|
export function resolveCuaModel(value) {
|
|
8
44
|
return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
|
|
9
45
|
}
|
|
46
|
+
export function resolveCuaModelProviderMode(value) {
|
|
47
|
+
const normalized = resolveCuaModel(value);
|
|
48
|
+
return CUA_MODEL_PROVIDER_MODE_MAP.get(normalized) || "openai";
|
|
49
|
+
}
|
|
50
|
+
export function resolveDefaultCuaModelForProvider(providerMode) {
|
|
51
|
+
return providerMode === "loadmill"
|
|
52
|
+
? DEFAULT_LOADMILL_CUA_MODEL
|
|
53
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
54
|
+
}
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
|
|
2
3
|
import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
|
|
3
|
-
import { resolveCuaModel } from "./cua-models.js";
|
|
4
|
+
import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
|
|
5
|
+
import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
|
|
4
6
|
import { normalizeStepDelayMs } from "../utils/step-delay.js";
|
|
7
|
+
export const LOADMILL_MODEL_ALIASES = Object.freeze({
|
|
8
|
+
DEFAULT_AGENT_MODEL: "default-agent-model",
|
|
9
|
+
FAST: "fast",
|
|
10
|
+
SMART: "smart",
|
|
11
|
+
COMPUTER_USE: "computer-use",
|
|
12
|
+
});
|
|
13
|
+
const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
|
|
14
|
+
export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
|
|
15
|
+
export const DEFAULT_LLM_PROVIDER_MODE = "loadmill";
|
|
16
|
+
export const DEFAULT_LOADMILL_BASE_URL = "https://app.loadmill.com";
|
|
17
|
+
const VALID_LLM_PROVIDER_MODES = new Set(LLM_PROVIDER_MODES);
|
|
18
|
+
export function isValidLlmProviderMode(value) {
|
|
19
|
+
return VALID_LLM_PROVIDER_MODES.has(value);
|
|
20
|
+
}
|
|
21
|
+
export function resolveLlmProviderMode(value) {
|
|
22
|
+
return isValidLlmProviderMode(value) ? value : DEFAULT_LLM_PROVIDER_MODE;
|
|
23
|
+
}
|
|
5
24
|
export function createEmptyPromptCustomizations() {
|
|
6
25
|
return {
|
|
7
26
|
basePromptInstructions: "",
|
|
@@ -28,16 +47,95 @@ function trimNullableString(value) {
|
|
|
28
47
|
const trimmed = value.trim();
|
|
29
48
|
return trimmed || null;
|
|
30
49
|
}
|
|
31
|
-
|
|
50
|
+
function normalizeLoadmillSiteBaseUrl(value) {
|
|
51
|
+
const trimmed = trimNullableString(value) || DEFAULT_LOADMILL_BASE_URL;
|
|
52
|
+
const normalizedCandidate = normalizeOptionalAutomationUrl(trimmed) || trimmed;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(normalizedCandidate);
|
|
55
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
56
|
+
parsed.pathname = pathname
|
|
57
|
+
.replace(/\/api$/, "")
|
|
58
|
+
.replace(/\/llm\/v1$/, "")
|
|
59
|
+
.replace(/\/llm$/, "");
|
|
60
|
+
parsed.search = "";
|
|
61
|
+
parsed.hash = "";
|
|
62
|
+
return parsed.toString().replace(/\/$/, "");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return normalizedCandidate
|
|
66
|
+
.replace(/\/api\/?$/, "")
|
|
67
|
+
.replace(/\/llm\/v1\/?$/, "")
|
|
68
|
+
.replace(/\/llm\/?$/, "")
|
|
69
|
+
.replace(/\/$/, "") || null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function buildLoadmillLlmBaseUrl(value) {
|
|
73
|
+
const siteBaseUrl = normalizeLoadmillSiteBaseUrl(value);
|
|
74
|
+
return siteBaseUrl ? `${siteBaseUrl}/llm/v1` : null;
|
|
75
|
+
}
|
|
76
|
+
export function hasLoadmillLlmAccess(credentials = {}) {
|
|
77
|
+
return Boolean(trimNullableString(credentials.loadmillApiToken) &&
|
|
78
|
+
buildLoadmillLlmBaseUrl(credentials.loadmillBaseUrl));
|
|
79
|
+
}
|
|
80
|
+
export function hasOpenAiLlmAccess(credentials = {}) {
|
|
81
|
+
return Boolean(trimNullableString(credentials.openAiApiKey));
|
|
82
|
+
}
|
|
83
|
+
export function inferLlmProviderModeFromCredentials(credentials = {}) {
|
|
84
|
+
if (hasLoadmillLlmAccess(credentials)) {
|
|
85
|
+
return "loadmill";
|
|
86
|
+
}
|
|
87
|
+
if (hasOpenAiLlmAccess(credentials)) {
|
|
88
|
+
return "openai";
|
|
89
|
+
}
|
|
90
|
+
return DEFAULT_LLM_PROVIDER_MODE;
|
|
91
|
+
}
|
|
92
|
+
export function resolveGatewayCuaModelAlias({ cuaBackend, cuaModel } = {}) {
|
|
93
|
+
if (resolveCuaBackend(cuaBackend) === "fara") {
|
|
94
|
+
return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
|
|
95
|
+
}
|
|
96
|
+
const normalizedCuaModel = resolveCuaModel(cuaModel);
|
|
97
|
+
return normalizedCuaModel === "gpt-5.4-mini"
|
|
98
|
+
? LOADMILL_MODEL_ALIASES.FAST
|
|
99
|
+
: LOADMILL_MODEL_ALIASES.SMART;
|
|
100
|
+
}
|
|
101
|
+
export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaBackend = null, cuaModel = null, } = {}) {
|
|
102
|
+
const providerMode = resolveLlmProviderMode(llmProviderMode);
|
|
103
|
+
if (providerMode === "loadmill") {
|
|
104
|
+
return resolveGatewayCuaModelAlias({ cuaBackend, cuaModel });
|
|
105
|
+
}
|
|
106
|
+
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
107
|
+
? resolveCuaModel(cuaModel)
|
|
108
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
109
|
+
}
|
|
110
|
+
export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel = null, } = {}) {
|
|
111
|
+
if (resolveLlmProviderMode(llmProviderMode) === "loadmill") {
|
|
112
|
+
return LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL;
|
|
113
|
+
}
|
|
114
|
+
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
115
|
+
? resolveCuaModel(cuaModel)
|
|
116
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
117
|
+
}
|
|
118
|
+
export function resolveRuntimeCredentials({ openAiApiKey = null, faraApiKey = null, faraBaseUrl = null, faraModel = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
|
|
32
119
|
return {
|
|
33
120
|
openAiApiKey: trimNullableString(openAiApiKey),
|
|
121
|
+
faraApiKey: trimNullableString(faraApiKey),
|
|
122
|
+
faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
|
|
123
|
+
faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
|
|
34
124
|
loadmillApiToken: trimNullableString(loadmillApiToken),
|
|
35
|
-
loadmillBaseUrl:
|
|
125
|
+
loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
|
|
36
126
|
};
|
|
37
127
|
}
|
|
38
|
-
export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
128
|
+
export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
129
|
+
const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
|
|
130
|
+
? cuaModel
|
|
131
|
+
: resolveCuaModel(cuaModel);
|
|
132
|
+
const normalizedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
39
133
|
return {
|
|
40
|
-
|
|
134
|
+
llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
|
|
135
|
+
? "loadmill"
|
|
136
|
+
: normalizedProviderMode,
|
|
137
|
+
cuaBackend: resolveCuaBackend(cuaBackend),
|
|
138
|
+
cuaModel: normalizedCuaModel,
|
|
41
139
|
stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
|
|
42
140
|
contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
|
|
43
141
|
contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
|
|
@@ -62,6 +160,12 @@ export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined,
|
|
|
62
160
|
export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
63
161
|
const contextOptimization = readContextOptimizationEnv(env);
|
|
64
162
|
return resolveRuntimeConfig({
|
|
163
|
+
llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
|
|
164
|
+
// Only the explicit backend flag should influence CUA model routing.
|
|
165
|
+
// Legacy screenshot-provider env vars must not silently remap Loadmill
|
|
166
|
+
// model aliases like "smart" to backend-specific aliases like
|
|
167
|
+
// "computer-use".
|
|
168
|
+
cuaBackend: env.DROID_CUA_BACKEND,
|
|
65
169
|
cuaModel: env.OPENAI_CUA_MODEL,
|
|
66
170
|
stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
|
|
67
171
|
contextOptimizationEnabled: contextOptimization.enabled,
|
|
@@ -72,6 +176,9 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
72
176
|
export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
|
|
73
177
|
return resolveRuntimeCredentials({
|
|
74
178
|
openAiApiKey: env.OPENAI_API_KEY,
|
|
179
|
+
faraApiKey: env.DROID_CUA_FARA_API_KEY,
|
|
180
|
+
faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
|
|
181
|
+
faraModel: env.DROID_CUA_FARA_MODEL,
|
|
75
182
|
loadmillApiToken: env.LOADMILL_API_TOKEN,
|
|
76
183
|
loadmillBaseUrl: env.LOADMILL_BASE_URL,
|
|
77
184
|
...overrides,
|
|
@@ -82,14 +189,151 @@ export function createOpenAiClient(apiKey) {
|
|
|
82
189
|
apiKey: trimNullableString(apiKey) || undefined,
|
|
83
190
|
});
|
|
84
191
|
}
|
|
192
|
+
function missingCredentialErrorMessage(llmProviderMode) {
|
|
193
|
+
return resolveLlmProviderMode(llmProviderMode) === "loadmill"
|
|
194
|
+
? "Loadmill authentication is required to use the Loadmill model."
|
|
195
|
+
: "An OpenAI API key is required to use OpenAI models.";
|
|
196
|
+
}
|
|
197
|
+
function isLoadmillGatewayErrorPayload(payload) {
|
|
198
|
+
return Boolean(payload &&
|
|
199
|
+
typeof payload === "object" &&
|
|
200
|
+
typeof payload.message === "string" &&
|
|
201
|
+
typeof payload.type === "string" &&
|
|
202
|
+
typeof payload.code === "string");
|
|
203
|
+
}
|
|
204
|
+
function normalizeLoadmillGatewayErrorBody(body) {
|
|
205
|
+
const payload = body?.payload;
|
|
206
|
+
if (!isLoadmillGatewayErrorPayload(payload)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
error: {
|
|
211
|
+
message: payload.message,
|
|
212
|
+
type: payload.type,
|
|
213
|
+
code: payload.code,
|
|
214
|
+
param: payload.param ?? null,
|
|
215
|
+
provider: typeof payload.provider === "string" ? payload.provider : null,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function normalizeLoadmillGatewayErrorResponse(response) {
|
|
220
|
+
const contentType = response.headers.get("content-type") || "";
|
|
221
|
+
if (!contentType.includes("application/json")) {
|
|
222
|
+
return response;
|
|
223
|
+
}
|
|
224
|
+
const responseClone = response.clone();
|
|
225
|
+
const bodyText = await responseClone.text();
|
|
226
|
+
if (!bodyText) {
|
|
227
|
+
return response;
|
|
228
|
+
}
|
|
229
|
+
let parsedBody;
|
|
230
|
+
try {
|
|
231
|
+
parsedBody = JSON.parse(bodyText);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return response;
|
|
235
|
+
}
|
|
236
|
+
const normalizedBody = normalizeLoadmillGatewayErrorBody(parsedBody);
|
|
237
|
+
if (!normalizedBody) {
|
|
238
|
+
return response;
|
|
239
|
+
}
|
|
240
|
+
const headers = new Headers(response.headers);
|
|
241
|
+
headers.set("content-type", "application/json");
|
|
242
|
+
return new Response(JSON.stringify(normalizedBody), {
|
|
243
|
+
status: response.status,
|
|
244
|
+
statusText: response.statusText,
|
|
245
|
+
headers,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function createLoadmillGatewayFetch(baseFetch = fetch) {
|
|
249
|
+
return async (input, init) => {
|
|
250
|
+
const response = await baseFetch(input, init);
|
|
251
|
+
if (response.ok) {
|
|
252
|
+
return response;
|
|
253
|
+
}
|
|
254
|
+
return await normalizeLoadmillGatewayErrorResponse(response);
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* @param {{
|
|
259
|
+
* loadmillApiToken?: string | null,
|
|
260
|
+
* loadmillBaseUrl?: string | null,
|
|
261
|
+
* clientName?: string,
|
|
262
|
+
* maxRetries?: number,
|
|
263
|
+
* }} [options]
|
|
264
|
+
*/
|
|
265
|
+
export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
266
|
+
const apiKey = trimNullableString(loadmillApiToken);
|
|
267
|
+
const baseURL = buildLoadmillLlmBaseUrl(loadmillBaseUrl);
|
|
268
|
+
if (!apiKey || !baseURL) {
|
|
269
|
+
throw new Error("Loadmill authentication is required for LLM access.");
|
|
270
|
+
}
|
|
271
|
+
return new OpenAI({
|
|
272
|
+
apiKey,
|
|
273
|
+
baseURL,
|
|
274
|
+
maxRetries,
|
|
275
|
+
fetch: createLoadmillGatewayFetch(),
|
|
276
|
+
defaultHeaders: {
|
|
277
|
+
"X-Loadmill-Client": clientName,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
export function createFaraClient({ apiKey, baseUrl }) {
|
|
282
|
+
return new OpenAI({
|
|
283
|
+
apiKey: trimNullableString(apiKey) || "",
|
|
284
|
+
baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
288
|
+
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
289
|
+
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
290
|
+
if (resolvedProviderMode === "loadmill") {
|
|
291
|
+
if (!hasLoadmillLlmAccess(resolvedCredentials)) {
|
|
292
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
293
|
+
}
|
|
294
|
+
return createLlmClient({
|
|
295
|
+
loadmillApiToken: resolvedCredentials.loadmillApiToken,
|
|
296
|
+
loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
|
|
297
|
+
clientName,
|
|
298
|
+
maxRetries,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
302
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
303
|
+
}
|
|
304
|
+
return createOpenAiClient(resolvedCredentials.openAiApiKey);
|
|
305
|
+
}
|
|
85
306
|
export function createRunContext({ config, credentials }) {
|
|
86
307
|
const resolvedConfig = resolveRuntimeConfig(config);
|
|
87
308
|
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
309
|
+
const resolvedProviderMode = resolveLlmProviderMode(resolvedConfig.llmProviderMode);
|
|
310
|
+
const effectiveConfig = {
|
|
311
|
+
...resolvedConfig,
|
|
312
|
+
llmProviderMode: resolvedProviderMode,
|
|
313
|
+
cuaModel: resolveEffectiveCuaModel({
|
|
314
|
+
llmProviderMode: resolvedProviderMode,
|
|
315
|
+
cuaBackend: resolvedConfig.cuaBackend,
|
|
316
|
+
cuaModel: resolvedConfig.cuaModel,
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
const effectiveCredentials = {
|
|
320
|
+
...resolvedCredentials,
|
|
321
|
+
};
|
|
88
322
|
return {
|
|
89
|
-
config:
|
|
90
|
-
credentials:
|
|
323
|
+
config: effectiveConfig,
|
|
324
|
+
credentials: effectiveCredentials,
|
|
91
325
|
clients: {
|
|
92
|
-
openai:
|
|
326
|
+
openai: createResolvedLlmClient({
|
|
327
|
+
llmProviderMode: resolvedProviderMode,
|
|
328
|
+
credentials: resolvedCredentials,
|
|
329
|
+
clientName: "desktop",
|
|
330
|
+
}),
|
|
331
|
+
fara: resolvedConfig.cuaBackend === "fara"
|
|
332
|
+
? createFaraClient({
|
|
333
|
+
apiKey: resolvedCredentials.faraApiKey,
|
|
334
|
+
baseUrl: resolvedCredentials.faraBaseUrl,
|
|
335
|
+
})
|
|
336
|
+
: null,
|
|
93
337
|
},
|
|
94
338
|
};
|
|
95
339
|
}
|
|
@@ -8,34 +8,6 @@ function getLoadmillSiteBaseUrl(loadmillClient = null) {
|
|
|
8
8
|
const rawBaseUrl = loadmillClient?.getBaseUrl?.() || "https://app.loadmill.com/api";
|
|
9
9
|
return rawBaseUrl.replace(/\/api\/?$/, "");
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
* Check if an instruction is a Loadmill command
|
|
13
|
-
* @param {string} userInput - The instruction text
|
|
14
|
-
* @returns {boolean}
|
|
15
|
-
*/
|
|
16
|
-
export function isLoadmillInstruction(userInput) {
|
|
17
|
-
const trimmed = userInput.trim();
|
|
18
|
-
const lower = trimmed.toLowerCase();
|
|
19
|
-
return lower.startsWith("loadmill:") || lower.startsWith("loadmill ");
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Extract the Loadmill command from an instruction
|
|
23
|
-
* @param {string} userInput - The instruction text
|
|
24
|
-
* @returns {string} - The extracted command
|
|
25
|
-
*/
|
|
26
|
-
export function extractLoadmillCommand(userInput) {
|
|
27
|
-
const trimmed = userInput.trim();
|
|
28
|
-
const lower = trimmed.toLowerCase();
|
|
29
|
-
// Handle "loadmill:" or "Loadmill:"
|
|
30
|
-
if (lower.startsWith("loadmill:")) {
|
|
31
|
-
return trimmed.substring("loadmill:".length).trim();
|
|
32
|
-
}
|
|
33
|
-
// Handle "loadmill " or "Loadmill "
|
|
34
|
-
if (lower.startsWith("loadmill ")) {
|
|
35
|
-
return trimmed.substring("loadmill".length).trim();
|
|
36
|
-
}
|
|
37
|
-
return trimmed;
|
|
38
|
-
}
|
|
39
11
|
/**
|
|
40
12
|
* Execute a Loadmill instruction and handle the result
|
|
41
13
|
* @param {string} command - The Loadmill command to execute
|
|
@@ -163,57 +135,48 @@ export function handleLoadmillSuccess(command, result, context, stepContext = nu
|
|
|
163
135
|
const payload = result.runId
|
|
164
136
|
? { loadmillSuiteRunId: result.runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl(loadmillClient) }
|
|
165
137
|
: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl(loadmillClient) };
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
result.
|
|
169
|
-
|
|
170
|
-
|
|
138
|
+
addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed`, eventType: 'system_message', payload, ...meta });
|
|
139
|
+
if (result.runId) {
|
|
140
|
+
addOutput({ type: 'info', text: ` Run ID: ${result.runId}`, eventType: 'system_message', payload, ...meta });
|
|
141
|
+
}
|
|
142
|
+
const note = result.runtimeParameterContext?.note;
|
|
143
|
+
if (result.runtimeParameterContext) {
|
|
144
|
+
emitDesktopDebug("loadmill.runtime_parameters", "execution", {
|
|
145
|
+
runId: context?.runId,
|
|
146
|
+
stepId: stepContext?.stepId,
|
|
147
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
148
|
+
}, {
|
|
149
|
+
command,
|
|
150
|
+
flowName: result.flowName,
|
|
151
|
+
flowRunId: result.runtimeParameterContext.flowRunId,
|
|
152
|
+
parameterNames: result.runtimeParameterContext.parameterNames,
|
|
153
|
+
selectedParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
154
|
+
futureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
155
|
+
noteAdded: typeof note === "string" && note.trim().length > 0,
|
|
171
156
|
});
|
|
172
157
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
? result.runtimeParameterContext.selectedParameterNames.filter((name) => typeof name === "string" && name.trim())
|
|
197
|
-
: [];
|
|
198
|
-
const selectedNamesText = selectedNames.length > 0
|
|
199
|
-
? ` (${selectedNames.join(", ")})`
|
|
200
|
-
: "";
|
|
201
|
-
context?.session?.addMessage?.("system", note);
|
|
202
|
-
context?.session?.addToTranscript?.(`[Loadmill Context] ${note}`);
|
|
203
|
-
addOutput({
|
|
204
|
-
type: 'info',
|
|
205
|
-
text: `[Loadmill] Added ${result.runtimeParameterContext.selectedCount} parameter(s)${selectedNamesText} to agent context`,
|
|
206
|
-
eventType: 'system_message',
|
|
207
|
-
payload: {
|
|
208
|
-
...payload,
|
|
209
|
-
loadmillFlowRunId: result.runtimeParameterContext.flowRunId,
|
|
210
|
-
loadmillRuntimeParameterCount: result.runtimeParameterContext.selectedCount,
|
|
211
|
-
loadmillRuntimeParameterNames: result.runtimeParameterContext.parameterNames,
|
|
212
|
-
loadmillSelectedRuntimeParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
213
|
-
loadmillParameterSelectionFutureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
214
|
-
},
|
|
215
|
-
...meta
|
|
216
|
-
});
|
|
217
|
-
}
|
|
158
|
+
if (typeof note === "string" && note.trim()) {
|
|
159
|
+
const selectedNames = Array.isArray(result.runtimeParameterContext.selectedParameterNames)
|
|
160
|
+
? result.runtimeParameterContext.selectedParameterNames.filter((name) => typeof name === "string" && name.trim())
|
|
161
|
+
: [];
|
|
162
|
+
const selectedNamesText = selectedNames.length > 0
|
|
163
|
+
? ` (${selectedNames.join(", ")})`
|
|
164
|
+
: "";
|
|
165
|
+
context?.session?.addMessage?.("system", note);
|
|
166
|
+
context?.session?.addToTranscript?.(`[Loadmill Context] ${note}`);
|
|
167
|
+
addOutput({
|
|
168
|
+
type: 'info',
|
|
169
|
+
text: `[Loadmill] Added ${result.runtimeParameterContext.selectedCount} parameter(s)${selectedNamesText} to agent context`,
|
|
170
|
+
eventType: 'system_message',
|
|
171
|
+
payload: {
|
|
172
|
+
...payload,
|
|
173
|
+
loadmillFlowRunId: result.runtimeParameterContext.flowRunId,
|
|
174
|
+
loadmillRuntimeParameterCount: result.runtimeParameterContext.selectedCount,
|
|
175
|
+
loadmillRuntimeParameterNames: result.runtimeParameterContext.parameterNames,
|
|
176
|
+
loadmillSelectedRuntimeParameterNames: result.runtimeParameterContext.selectedParameterNames,
|
|
177
|
+
loadmillParameterSelectionFutureInstructions: result.runtimeParameterContext.futureInstructions,
|
|
178
|
+
},
|
|
179
|
+
...meta
|
|
180
|
+
});
|
|
218
181
|
}
|
|
219
182
|
}
|