@openagents-org/agent-launcher 0.2.171 → 0.2.173

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/src/daemon.js CHANGED
@@ -47,9 +47,86 @@ class Daemon {
47
47
  this._runningCommands = new Set(); // node-command ids currently executing
48
48
  this._runtimes = []; // detected agent runtimes for the node view
49
49
  this._runtimesInterval = null;
50
+ this._probes = this._loadProbes(); // type → last smoke-test result (persisted)
51
+ this._probeInterval = null;
52
+ this._probeStartupTimer = null;
53
+ this._probeInFlight = new Set(); // types being probed right now
50
54
  this._reloadInFlight = null; // serialize concurrent _reload() calls
51
55
  }
52
56
 
57
+ // ── Agent smoke tests (probes) ─────────────────────────────────────────
58
+ // A probe runs one tiny end-to-end prompt through an agent type (see
59
+ // probe.js) — the check that catches "connected but never answers".
60
+ // Results are persisted so a daemon restart doesn't re-spend LLM calls,
61
+ // and are reported to every paired workspace on the node heartbeat.
62
+
63
+ _probesFile() {
64
+ return path.join(os.homedir(), '.openagents', 'probes.json');
65
+ }
66
+
67
+ _loadProbes() {
68
+ try {
69
+ const data = JSON.parse(fs.readFileSync(this._probesFile(), 'utf-8'));
70
+ return data && typeof data === 'object' ? data : {};
71
+ } catch { return {}; }
72
+ }
73
+
74
+ _saveProbes() {
75
+ try {
76
+ fs.mkdirSync(path.dirname(this._probesFile()), { recursive: true });
77
+ fs.writeFileSync(this._probesFile(), JSON.stringify(this._probes, null, 2), 'utf-8');
78
+ } catch {}
79
+ }
80
+
81
+ /**
82
+ * Probe one type via a child `agn probe --json` (same off-event-loop
83
+ * pattern as _refreshRuntimes; a probe can block for its full timeout).
84
+ */
85
+ async _probeType(type) {
86
+ if (this._probeInFlight.has(type)) return this._probes[type] || null;
87
+ this._probeInFlight.add(type);
88
+ try {
89
+ const r = await this._runAgn(['probe', type, '--json']);
90
+ let parsed = null;
91
+ try { parsed = JSON.parse(r.stdout.trim()); } catch {}
92
+ if (!parsed || typeof parsed !== 'object') {
93
+ parsed = {
94
+ type, ok: false, method: 'none', code: 'probe_error',
95
+ message: (r.stderr || 'probe failed').slice(0, 400),
96
+ guidance: [], at: new Date().toISOString(),
97
+ };
98
+ }
99
+ this._probes[type] = parsed;
100
+ this._saveProbes();
101
+ return parsed;
102
+ } finally {
103
+ this._probeInFlight.delete(type);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Periodic sweep: probe each type that has a configured agent and whose
109
+ * last result is missing or stale. Sequential on purpose — probes cost a
110
+ * (tiny) model call each; there is no hurry.
111
+ */
112
+ async _probeConfiguredTypes(maxAgeMs) {
113
+ const types = [...new Set(this.config.getAgents().map((a) => a.type || 'openclaw'))];
114
+ for (const type of types) {
115
+ const last = this._probes[type];
116
+ const age = last && last.at ? Date.now() - Date.parse(last.at) : Infinity;
117
+ if (age < maxAgeMs) continue;
118
+ try { await this._probeType(type); } catch {}
119
+ }
120
+ this._nodeHeartbeat();
121
+ }
122
+
123
+ /** Runtimes annotated with each type's last probe, for the heartbeat. */
124
+ _runtimesWithProbes() {
125
+ return (this._runtimes || []).map((r) =>
126
+ this._probes[r.type] ? { ...r, probe: this._probes[r.type] } : r
127
+ );
128
+ }
129
+
53
130
  /**
54
131
  * Heartbeat this device (node) to EVERY workspace it is paired to,
55
132
  * independent of any agent, so each workspace's "connected devices" view
@@ -67,7 +144,7 @@ class Daemon {
67
144
  const nodeCfg = require('./node-config');
68
145
  const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
69
146
  if (!pairings.length) return; // no node connected
70
- const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes, fs: this._buildFs() };
147
+ const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimesWithProbes(), fs: this._buildFs() };
71
148
  await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
72
149
  }
73
150
 
@@ -270,6 +347,10 @@ class Daemon {
270
347
  if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
271
348
  ok = true;
272
349
  message = `Agent '${name}' created`;
350
+ // First-connect smoke test, in the background (a probe can take its
351
+ // full timeout — never hold the create result hostage). The outcome
352
+ // reaches the workspace via the heartbeat's runtimes[].probe.
353
+ this._probeType(type).then(() => this._nodeHeartbeat()).catch(() => {});
273
354
  } else if (action === 'start_agent') {
274
355
  const r = await this._runAgn(['start', name]);
275
356
  ok = r.code === 0;
@@ -304,6 +385,24 @@ class Daemon {
304
385
  }
305
386
  ok = true;
306
387
  message = `Agent '${name}' reconfigured`;
388
+ } else if (action === 'probe_agent') {
389
+ // Smoke-test on demand ("Test agent" in the workspace). Accepts a
390
+ // type directly, or an agent name (must belong to this workspace).
391
+ let type = (args.type || '').trim();
392
+ if (!type && name) {
393
+ const agent = this.config.getAgent(name);
394
+ if (!agent || !this._agentOnNodeWorkspace(agent, n)) {
395
+ throw new Error(`Agent '${name}' is not managed by this workspace`);
396
+ }
397
+ type = agent.type || 'openclaw';
398
+ }
399
+ if (!type) throw new Error('Missing agent type');
400
+ const probe = await this._probeType(type);
401
+ ok = !!(probe && probe.ok);
402
+ message = (probe && probe.message) || (ok ? 'Smoke test passed' : 'Smoke test failed');
403
+ data = { probe };
404
+ // Push the fresh result right away (same pattern as detect_runtimes).
405
+ this._nodeHeartbeat();
307
406
  } else if (action === 'detect_runtimes') {
308
407
  await this._refreshRuntimes();
309
408
  // Push the fresh detection right away rather than waiting for the next
@@ -428,6 +527,20 @@ class Daemon {
428
527
  this._refreshRuntimes();
429
528
  this._runtimesInterval = setInterval(() => this._refreshRuntimes(), 120000);
430
529
 
530
+ // Smoke-test configured agent types periodically (each probe spends one
531
+ // tiny model call, so: only types with agents, only when the last result
532
+ // is a day old, and a few minutes after startup so install/login churn
533
+ // settles first). Persisted results keep restarts from re-spending calls.
534
+ const PROBE_MAX_AGE_MS = 24 * 3600 * 1000;
535
+ this._probeStartupTimer = setTimeout(
536
+ () => this._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
537
+ 3 * 60 * 1000,
538
+ );
539
+ this._probeInterval = setInterval(
540
+ () => this._probeConfiguredTypes(PROBE_MAX_AGE_MS).catch(() => {}),
541
+ 6 * 3600 * 1000,
542
+ );
543
+
431
544
  // Watch config file for hot-reload
432
545
  this._watchConfig();
433
546
 
@@ -455,6 +568,8 @@ class Daemon {
455
568
  if (this._cmdInterval) clearInterval(this._cmdInterval);
456
569
  if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
457
570
  if (this._runtimesInterval) clearInterval(this._runtimesInterval);
571
+ if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
572
+ if (this._probeInterval) clearInterval(this._probeInterval);
458
573
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
459
574
 
460
575
  // Kill all child processes
package/src/index.js CHANGED
@@ -289,6 +289,15 @@ class AgentConnector {
289
289
  const { testLLMConnection } = require('./utils');
290
290
  return testLLMConnection(env);
291
291
  }
292
+
293
+ /**
294
+ * Smoke-test an agent type end to end (tiny "hi" prompt through its CLI or
295
+ * LLM API) and classify any failure into actionable guidance. See probe.js.
296
+ */
297
+ async probeAgentType(agentType, opts) {
298
+ const { probeAgentType } = require('./probe');
299
+ return probeAgentType(this, agentType, opts);
300
+ }
292
301
  }
293
302
 
294
303
  const adapters = require('./adapters');
package/src/probe.js ADDED
@@ -0,0 +1,318 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent smoke test ("probe") — answers the question static health checks
5
+ * can't: does this agent actually produce a response on this device?
6
+ *
7
+ * healthCheck() proves a binary exists and credentials LOOK present; users
8
+ * still hit "No response generated", invalid-key errors, and silent hangs at
9
+ * message time. The probe closes that gap by running one tiny end-to-end
10
+ * prompt ("hi") the same way the adapter would, then classifying whatever
11
+ * came back into a machine code plus human guidance ("run `claude login` on
12
+ * the device", "the API key is invalid", ...).
13
+ *
14
+ * Tiering (cheapest thing that proves liveness):
15
+ * 1. healthCheck gate — not installed / not configured fails fast with the
16
+ * existing auth guidance, no process spawned, no tokens spent.
17
+ * 2. Live check, picked from the registry entry:
18
+ * - `probe.args` → run the agent CLI itself (e.g. --print hi)
19
+ * - `check_ready.alt_check` → run the entry's declared liveness command
20
+ * - saved API key → one direct LLM API call (testLLMConnection)
21
+ * - none of the above → report static checks only (never guess a
22
+ * CLI invocation; a wrong flag would misreport a healthy agent).
23
+ *
24
+ * Results carry NO environment values. CLI output is truncated and scrubbed
25
+ * of anything key-shaped before it leaves this module.
26
+ */
27
+
28
+ const os = require('os');
29
+ const { spawn } = require('child_process');
30
+ const { getEnhancedEnv } = require('./paths');
31
+ const { formatAuthGuidance } = require('./auth-guidance');
32
+ const { testLLMConnection } = require('./utils');
33
+
34
+ const PROBE_PROMPT = 'hi';
35
+ const DEFAULT_TIMEOUT_MS = 90_000;
36
+ const OUTPUT_CAP = 400; // max chars of CLI output kept for diagnostics
37
+ const REPLY_CAP = 160; // max chars of a successful reply echoed back
38
+
39
+ /** Failure codes, ordered roughly by how actionable they are for the user. */
40
+ const CODE = {
41
+ OK: 'ok',
42
+ STATIC_ONLY: 'static_only',
43
+ UNKNOWN_TYPE: 'unknown_type',
44
+ NOT_INSTALLED: 'not_installed',
45
+ NOT_READY: 'not_ready',
46
+ INVALID_API_KEY: 'invalid_api_key',
47
+ MISSING_API_KEY: 'missing_api_key',
48
+ NOT_LOGGED_IN: 'not_logged_in',
49
+ OUT_OF_CREDIT: 'out_of_credit',
50
+ RATE_LIMITED: 'rate_limited',
51
+ NETWORK: 'network',
52
+ TIMEOUT: 'timeout',
53
+ EMPTY_RESPONSE: 'empty_response',
54
+ CLI_ERROR: 'cli_error',
55
+ };
56
+
57
+ /** Redact anything that looks like a secret before output leaves the device. */
58
+ function scrub(text) {
59
+ if (!text) return '';
60
+ return String(text)
61
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-****')
62
+ .replace(/(api[-_ ]?key["':\s=]+)[A-Za-z0-9_-]{8,}/gi, '$1****')
63
+ .replace(/Bearer\s+[A-Za-z0-9._-]{8,}/gi, 'Bearer ****');
64
+ }
65
+
66
+ function tail(text, cap) {
67
+ const s = scrub(String(text || '').trim());
68
+ return s.length > cap ? '…' + s.slice(-cap) : s;
69
+ }
70
+
71
+ /**
72
+ * Whether this agent authenticates via API key (configured/expected) rather
73
+ * than a CLI login. Steers the wording for auth-flavored failures.
74
+ */
75
+ function authFlavor(entry, agentEnv) {
76
+ const cr = (entry && entry.check_ready) || {};
77
+ const envCfg = (entry && entry.env_config) || [];
78
+ const hasKeyField = envCfg.some((f) => f && /key/i.test(f.name || ''));
79
+ const hasSavedKey = !!(agentEnv && (agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY));
80
+ if (hasSavedKey || (hasKeyField && !cr.login_command)) return 'api_key';
81
+ if (cr.login_command) return 'cli_login';
82
+ return hasKeyField ? 'api_key' : 'cli_login';
83
+ }
84
+
85
+ /**
86
+ * Classify a failed live check into a code. Pattern-matches the combined
87
+ * output; callers turn the code into guidance via buildGuidance().
88
+ */
89
+ function classifyFailure(output, { timedOut = false, spawnError = null } = {}) {
90
+ if (spawnError && /ENOENT/.test(String(spawnError))) return CODE.NOT_INSTALLED;
91
+ if (timedOut) return CODE.TIMEOUT;
92
+ const text = String(output || '').toLowerCase();
93
+ if (!text && spawnError) return CODE.CLI_ERROR;
94
+
95
+ if (/credit balance|insufficient[_ ]?(quota|credits|funds)|payment required|\b402\b|billing/.test(text)) return CODE.OUT_OF_CREDIT;
96
+ if (/rate[- ]?limit|too many requests|\b429\b|overloaded|\b529\b/.test(text)) return CODE.RATE_LIMITED;
97
+ if (/enotfound|econnrefused|econnreset|etimedout|eai_again|fetch failed|socket hang up|network error|self[- ]signed certificate|unable to get local issuer/.test(text)) return CODE.NETWORK;
98
+ if (/api key.{0,24}(not set|not found|missing)|no api key|missing api key|environment variable.{0,40}(is )?(not set|required)/.test(text)) return CODE.MISSING_API_KEY;
99
+ if (/invalid.{0,12}(api.?key|x-api-key|token)|incorrect api key|authentication[_ ]?error|invalid credentials|\b401\b|unauthorized|forbidden|\b403\b/.test(text)) return CODE.INVALID_API_KEY;
100
+ if (/not logged in|not authenticated|login required|please (log ?in|sign ?in|authenticate)|run .{0,40}login|no credentials/.test(text)) return CODE.NOT_LOGGED_IN;
101
+ return CODE.CLI_ERROR;
102
+ }
103
+
104
+ /** Human next-steps for a code. Plain strings, no markup, no secrets. */
105
+ function buildGuidance(code, entry, agentEnv, extra = {}) {
106
+ const label = (entry && (entry.label || entry.name)) || 'This agent';
107
+ const type = (entry && entry.name) || 'agent';
108
+ const login = entry && entry.check_ready && entry.check_ready.login_command;
109
+ const flavor = authFlavor(entry, agentEnv);
110
+ const lines = [];
111
+ switch (code) {
112
+ case CODE.NOT_INSTALLED:
113
+ lines.push(`${label} is not installed on this device. Install it from the launcher's Install page.`);
114
+ if (entry && entry.install_command) lines.push(`Or install manually: ${entry.install_command}`);
115
+ break;
116
+ case CODE.INVALID_API_KEY:
117
+ case CODE.MISSING_API_KEY:
118
+ if (flavor === 'cli_login' && login) {
119
+ lines.push(`${label}'s sign-in looks invalid or expired. On the device, run: ${login}`);
120
+ } else {
121
+ lines.push(code === CODE.MISSING_API_KEY
122
+ ? `${label} has no API key configured. Add one in the launcher (Agents → ${label} → Configure) or edit the agent in the workspace and enter a key.`
123
+ : `${label}'s API key was rejected by the provider. Replace it in the launcher (Agents → ${label} → Configure) or edit the agent in the workspace.`);
124
+ lines.push(`To verify a key on the device: agn test-llm ${type}`);
125
+ }
126
+ break;
127
+ case CODE.NOT_LOGGED_IN:
128
+ lines.push(login
129
+ ? `${label} is not signed in on this device. In a terminal there, run: ${login}`
130
+ : `${label} is not signed in on this device. Sign in with its CLI, then re-run the test.`);
131
+ break;
132
+ case CODE.OUT_OF_CREDIT:
133
+ lines.push(`The provider account for ${label} is out of credit or quota. Add credit, or switch the agent to a different API key or model.`);
134
+ break;
135
+ case CODE.RATE_LIMITED:
136
+ lines.push(`The provider is rate-limiting ${label}. Wait a few minutes and test again.`);
137
+ break;
138
+ case CODE.NETWORK:
139
+ lines.push(`The device could not reach the model provider. Check the device's network, proxy, and firewall, then test again.`);
140
+ break;
141
+ case CODE.TIMEOUT:
142
+ lines.push(`${label} did not answer within ${Math.round((extra.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000)}s. It may be waiting for interactive input (a first-run consent or login prompt).`);
143
+ lines.push(`On the device, run the agent's CLI once in a terminal to clear any prompt, then test again.`);
144
+ break;
145
+ case CODE.EMPTY_RESPONSE:
146
+ lines.push(`${label} exited without producing a response. Run its CLI manually on the device to see what it prints.`);
147
+ break;
148
+ case CODE.NOT_READY:
149
+ // Caller supplies the static-auth guidance lines instead.
150
+ break;
151
+ default:
152
+ lines.push(`${label} failed to respond. Run its CLI manually on the device to inspect the error.`);
153
+ break;
154
+ }
155
+ return lines;
156
+ }
157
+
158
+ /** Run a CLI to completion with a hard timeout. Never rejects. */
159
+ function runCommand(cmd, args, { env, timeoutMs, shell = false }) {
160
+ return new Promise((resolve) => {
161
+ let child;
162
+ try {
163
+ child = spawn(cmd, args, {
164
+ env,
165
+ cwd: os.homedir(),
166
+ shell,
167
+ windowsHide: true,
168
+ stdio: ['ignore', 'pipe', 'pipe'],
169
+ });
170
+ } catch (e) {
171
+ resolve({ code: 1, stdout: '', stderr: '', spawnError: e.message, timedOut: false });
172
+ return;
173
+ }
174
+ let stdout = '';
175
+ let stderr = '';
176
+ let timedOut = false;
177
+ let settled = false;
178
+ const timer = setTimeout(() => {
179
+ timedOut = true;
180
+ try { child.kill('SIGKILL'); } catch {}
181
+ }, timeoutMs);
182
+ const finish = (code, spawnError) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timer);
186
+ resolve({ code, stdout, stderr, spawnError: spawnError || null, timedOut });
187
+ };
188
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
189
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
190
+ child.on('error', (e) => finish(1, e.message));
191
+ child.on('close', (code) => finish(code == null ? 1 : code));
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Smoke-test one agent type end to end. Returns a result object; never throws.
197
+ *
198
+ * @param {object} connector AgentConnector (registry/installer/env access)
199
+ * @param {string} type agent type name (registry entry name)
200
+ * @param {object} [opts] { timeoutMs }
201
+ */
202
+ async function probeAgentType(connector, type, opts = {}) {
203
+ const startedAt = Date.now();
204
+ const done = (fields) => ({
205
+ type,
206
+ at: new Date().toISOString(),
207
+ durationMs: Date.now() - startedAt,
208
+ guidance: [],
209
+ ...fields,
210
+ });
211
+
212
+ const entry = connector.registry.getEntry(type);
213
+ if (!entry) {
214
+ return done({ ok: false, method: 'none', code: CODE.UNKNOWN_TYPE, message: `Unknown agent type '${type}'` });
215
+ }
216
+
217
+ let health = {};
218
+ try { health = connector.healthCheck(type) || {}; } catch (e) {
219
+ health = { installed: false, ready: false, message: e.message };
220
+ }
221
+
222
+ let agentEnv = {};
223
+ try {
224
+ const saved = connector.getAgentEnv(type) || {};
225
+ const resolved = connector.resolveAgentEnv(type, saved) || {};
226
+ agentEnv = { ...saved, ...resolved };
227
+ } catch {}
228
+
229
+ if (!health.installed) {
230
+ return done({
231
+ ok: false, method: 'none', code: CODE.NOT_INSTALLED,
232
+ message: 'Not installed',
233
+ guidance: buildGuidance(CODE.NOT_INSTALLED, entry, agentEnv),
234
+ });
235
+ }
236
+
237
+ // Definitively unconfigured (auth_status 'unknown' still gets a live try:
238
+ // the real run is the final authority for unverifiable logins).
239
+ if (!health.ready && health.auth_status !== 'unknown') {
240
+ let lines = [];
241
+ try { lines = formatAuthGuidance(entry, health).lines; } catch {}
242
+ return done({
243
+ ok: false, method: 'none', code: CODE.NOT_READY,
244
+ message: health.message || 'Not configured',
245
+ guidance: lines.length ? lines : buildGuidance(CODE.NOT_LOGGED_IN, entry, agentEnv),
246
+ });
247
+ }
248
+
249
+ const timeoutMs = opts.timeoutMs
250
+ || (entry.probe && entry.probe.timeout_s ? entry.probe.timeout_s * 1000 : DEFAULT_TIMEOUT_MS);
251
+ const env = { ...getEnhancedEnv(), ...agentEnv };
252
+
253
+ // ── Live tier 1: the agent's own CLI, when the registry declares how ──
254
+ const probeArgs = entry.probe && Array.isArray(entry.probe.args) ? entry.probe.args : null;
255
+ const altCheck = entry.check_ready && entry.check_ready.alt_check;
256
+ if ((probeArgs && health.binary) || altCheck) {
257
+ const res = probeArgs && health.binary
258
+ ? await runCommand(health.binary, probeArgs, { env, timeoutMs })
259
+ : await runCommand(altCheck, [], { env, timeoutMs, shell: true });
260
+ const output = `${res.stdout}\n${res.stderr}`;
261
+ if (!res.timedOut && !res.spawnError && res.code === 0 && res.stdout.trim()) {
262
+ return done({
263
+ ok: true, method: 'cli', code: CODE.OK,
264
+ message: 'Responded',
265
+ reply: tail(res.stdout, REPLY_CAP),
266
+ });
267
+ }
268
+ let code = res.code === 0 && !res.timedOut && !res.spawnError
269
+ ? CODE.EMPTY_RESPONSE
270
+ : classifyFailure(output, res);
271
+ // A CLI probe spawn-ENOENT despite installed=true means the binary went
272
+ // away between checks — report it as not installed, not a mystery error.
273
+ if (code === CODE.NOT_INSTALLED) {
274
+ return done({
275
+ ok: false, method: 'cli', code,
276
+ message: 'The agent binary could not be executed',
277
+ guidance: buildGuidance(code, entry, agentEnv),
278
+ });
279
+ }
280
+ return done({
281
+ ok: false, method: 'cli', code,
282
+ message: tail(res.stderr || res.stdout, OUTPUT_CAP) || (res.timedOut ? 'Timed out' : `Exited with code ${res.code}`),
283
+ guidance: buildGuidance(code, entry, agentEnv, { timeoutMs }),
284
+ });
285
+ }
286
+
287
+ // ── Live tier 2: direct LLM API call for API-key agents (e.g. OpenClaw) ──
288
+ const hasApiKey = !!(agentEnv.LLM_API_KEY || agentEnv.OPENAI_API_KEY || agentEnv.ANTHROPIC_API_KEY
289
+ || agentEnv.KIMI_API_KEY || agentEnv.MOONSHOT_API_KEY);
290
+ if (hasApiKey || (entry.install && entry.install.api_only)) {
291
+ let res;
292
+ try { res = await testLLMConnection(agentEnv); } catch (e) { res = { success: false, error: e.message }; }
293
+ if (res && res.success) {
294
+ return done({
295
+ ok: true, method: 'llm_api', code: CODE.OK,
296
+ message: 'Responded',
297
+ reply: tail(res.response || '', REPLY_CAP),
298
+ });
299
+ }
300
+ const code = classifyFailure(res && res.error, {});
301
+ return done({
302
+ ok: false, method: 'llm_api', code,
303
+ message: tail(res && res.error, OUTPUT_CAP) || 'LLM API test failed',
304
+ guidance: buildGuidance(code, entry, agentEnv),
305
+ });
306
+ }
307
+
308
+ // ── No safe live probe — report the static result honestly rather than
309
+ // inventing a CLI invocation that could misfire. ──
310
+ return done({
311
+ ok: health.ready === true, method: 'none', code: CODE.STATIC_ONLY,
312
+ message: health.ready
313
+ ? 'Static checks passed (no live probe is defined for this agent type)'
314
+ : (health.message || 'Not configured'),
315
+ });
316
+ }
317
+
318
+ module.exports = { probeAgentType, classifyFailure, buildGuidance, authFlavor, scrub, CODE, DEFAULT_TIMEOUT_MS };
package/src/registry.js CHANGED
@@ -100,6 +100,7 @@ class Registry {
100
100
  if (b.install) entry.install = { ...b.install };
101
101
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
102
102
  if (!entry.launch && b.launch) entry.launch = b.launch;
103
+ if (!entry.probe && b.probe) entry.probe = b.probe;
103
104
  // Always take featured/order/support from bundled (source of truth for ordering)
104
105
  if (b.featured !== undefined) entry.featured = b.featured;
105
106
  if (b.order !== undefined) entry.order = b.order;
@@ -146,6 +147,7 @@ class Registry {
146
147
  if (b.install) entry.install = { ...b.install };
147
148
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
148
149
  if (!entry.launch && b.launch) entry.launch = b.launch;
150
+ if (!entry.probe && b.probe) entry.probe = b.probe;
149
151
  if ((!entry.env_config || !entry.env_config.length) && b.env_config?.length) entry.env_config = b.env_config;
150
152
  }
151
153
  return entry;
@@ -517,6 +517,7 @@ class WorkspaceClient {
517
517
  role: a.role || 'member',
518
518
  status: a.status || 'offline',
519
519
  enabledSkills: a.enabled_skills || null,
520
+ model: a.model || null,
520
521
  }));
521
522
  }
522
523