@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.
@@ -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: ' --cua-model <model> Headless CUA model override: gpt-5.4 or gpt-5.4-mini' });
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 { createOpenAiClient, resolveRuntimeCredentialsFromEnv } from "../core/runtime-config.js";
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 search for login flow' });
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: run user authentication flow' });
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
- const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
52
- const loadmillClient = context.loadmillClient || createLoadmillApiClient({
53
- token: runtimeCredentials.loadmillApiToken,
54
- baseUrl: runtimeCredentials.loadmillBaseUrl,
55
- });
56
- const openAiClient = context.openAiClient || createOpenAiClient(runtimeCredentials.openAiApiKey);
57
- // Execute the command
58
- const result = await executeLoadmillCommand(command, {
59
- loadmillClient,
60
- openAiClient,
61
- onProgress: ({ message }) => {
62
- addOutput({ type: 'info', text: `[Loadmill] ${message}` });
63
- }
64
- });
65
- // Clear agent working status
66
- if (context.setAgentWorking) {
67
- context.setAgentWorking(false);
68
- }
69
- // Display results
70
- if (result.success) {
71
- if (result.action === "search") {
72
- addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
73
- result.result.flows.forEach((flow, i) => {
74
- const name = flow.description || flow.name || "Unknown";
75
- const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
76
- addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
77
- addOutput({ type: 'info', text: ` ID: ${flow.id}` });
78
- });
79
- if (result.result.selectedFlow) {
80
- const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
81
- addOutput({ type: 'info', text: '' });
82
- addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
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: 'success', text: `Flow "${result.flowName}" passed` });
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
- else {
93
- addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
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 CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
2
- export const DEFAULT_CUA_MODEL = "gpt-5.4";
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
- export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
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: trimNullableString(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
- cuaModel: resolveCuaModel(cuaModel),
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: resolvedConfig,
90
- credentials: resolvedCredentials,
323
+ config: effectiveConfig,
324
+ credentials: effectiveCredentials,
91
325
  clients: {
92
- openai: createOpenAiClient(resolvedCredentials.openAiApiKey),
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
- if (result.action === "search") {
167
- addOutput({ type: 'success', text: `[Loadmill] Found ${result.result.flows.length} flow(s)`, eventType: 'system_message', payload, ...meta });
168
- result.result.flows.forEach((flow, i) => {
169
- const name = flow.description || flow.name || "Unknown";
170
- addOutput({ type: 'info', text: ` ${i + 1}. ${name} (ID: ${flow.id})`, eventType: 'system_message', payload, ...meta });
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
- else {
174
- addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed`, eventType: 'system_message', payload, ...meta });
175
- if (result.runId) {
176
- addOutput({ type: 'info', text: ` Run ID: ${result.runId}`, eventType: 'system_message', payload, ...meta });
177
- }
178
- const note = result.runtimeParameterContext?.note;
179
- if (result.runtimeParameterContext) {
180
- emitDesktopDebug("loadmill.runtime_parameters", "execution", {
181
- runId: context?.runId,
182
- stepId: stepContext?.stepId,
183
- instructionIndex: stepContext?.instructionIndex,
184
- }, {
185
- command,
186
- flowName: result.flowName,
187
- flowRunId: result.runtimeParameterContext.flowRunId,
188
- parameterNames: result.runtimeParameterContext.parameterNames,
189
- selectedParameterNames: result.runtimeParameterContext.selectedParameterNames,
190
- futureInstructions: result.runtimeParameterContext.futureInstructions,
191
- noteAdded: typeof note === "string" && note.trim().length > 0,
192
- });
193
- }
194
- if (typeof note === "string" && note.trim()) {
195
- const selectedNames = Array.isArray(result.runtimeParameterContext.selectedParameterNames)
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
  }