@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/package.json +2 -2
- package/registry.json +49 -2
- package/src/adapters/antigravity-stream.js +242 -0
- package/src/adapters/antigravity.js +390 -0
- package/src/adapters/base.js +13 -0
- package/src/adapters/claude.js +10 -2
- package/src/adapters/index.js +4 -1
- package/src/cli.js +33 -0
- package/src/daemon.js +116 -1
- package/src/index.js +9 -0
- package/src/probe.js +318 -0
- package/src/registry.js +2 -0
- package/src/workspace-client.js +1 -0
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.
|
|
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;
|