@pentatonic-ai/ai-agent-sdk 0.4.9 → 0.5.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 +59 -0
- package/bin/cli.js +70 -9
- package/dist/index.cjs +25 -3
- package/dist/index.js +25 -3
- package/package.json +4 -2
- package/packages/doctor/README.md +106 -0
- package/packages/doctor/__tests__/checks.test.js +187 -0
- package/packages/doctor/__tests__/detect.test.js +101 -0
- package/packages/doctor/__tests__/output.test.js +92 -0
- package/packages/doctor/__tests__/plugins.test.js +111 -0
- package/packages/doctor/__tests__/runner.test.js +131 -0
- package/packages/doctor/package.json +6 -0
- package/packages/doctor/src/checks/hosted-tes.js +109 -0
- package/packages/doctor/src/checks/local-memory.js +290 -0
- package/packages/doctor/src/checks/platform.js +170 -0
- package/packages/doctor/src/checks/universal.js +121 -0
- package/packages/doctor/src/detect.js +102 -0
- package/packages/doctor/src/index.js +33 -0
- package/packages/doctor/src/output.js +55 -0
- package/packages/doctor/src/plugins.js +81 -0
- package/packages/doctor/src/runner.js +136 -0
- package/packages/memory/migrations/005-atomic-memories.sql +16 -0
- package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
- package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
- package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
- package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
- package/packages/memory/openclaw-plugin/index.js +369 -58
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/distill.test.js +175 -0
- package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
- package/packages/memory/src/distill.js +162 -0
- package/packages/memory/src/index.js +1 -0
- package/packages/memory/src/ingest.js +10 -0
- package/packages/memory/src/openclaw/index.js +280 -23
- package/packages/memory/src/openclaw/package.json +1 -1
- package/packages/memory/src/server.js +27 -5
- package/src/normalizer.js +16 -0
- package/src/session.js +21 -2
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
- [SDK: Wrap Your LLM Client](#sdk-wrap-your-llm-client)
|
|
32
32
|
- [Supported Providers](#supported-providers)
|
|
33
33
|
- [API Reference](#api-reference)
|
|
34
|
+
- [Health Checks (`doctor`)](#health-checks-doctor)
|
|
34
35
|
- [Architecture](#architecture)
|
|
35
36
|
|
|
36
37
|
## Overview
|
|
@@ -325,6 +326,64 @@ import { normalizeResponse } from "@pentatonic-ai/ai-agent-sdk";
|
|
|
325
326
|
const { content, model, usage, toolCalls } = normalizeResponse(openaiResponse);
|
|
326
327
|
```
|
|
327
328
|
|
|
329
|
+
## Health Checks (`doctor`)
|
|
330
|
+
|
|
331
|
+
Run a full health check of your SDK install at any time:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
npx @pentatonic-ai/ai-agent-sdk doctor
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`doctor` auto-detects which install path you're on (Local Memory, Hosted
|
|
338
|
+
TES, or self-hosted Pentatonic platform) and runs only the checks that
|
|
339
|
+
apply. Exit code is `0` for all-clear, `1` for warnings, `2` for critical.
|
|
340
|
+
|
|
341
|
+
Common flags:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
npx @pentatonic-ai/ai-agent-sdk doctor --json # machine-readable
|
|
345
|
+
npx @pentatonic-ai/ai-agent-sdk doctor --alert # silent unless issues
|
|
346
|
+
npx @pentatonic-ai/ai-agent-sdk doctor --no-plugins
|
|
347
|
+
npx @pentatonic-ai/ai-agent-sdk doctor --path local
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
What gets checked:
|
|
351
|
+
|
|
352
|
+
- **Universal** — Node version, disk space, SDK config-file permissions
|
|
353
|
+
- **Local Memory** — Postgres + pgvector + migrations, embedding/LLM
|
|
354
|
+
endpoints, memory server port
|
|
355
|
+
- **Hosted TES** — endpoint reachable, API key authenticates
|
|
356
|
+
- **Self-hosted platform** — HybridRAG, Qdrant, Neo4j, vLLM (each
|
|
357
|
+
optional, skipped when its env var is unset)
|
|
358
|
+
|
|
359
|
+
### Plugins
|
|
360
|
+
|
|
361
|
+
Drop a `.mjs` file into `~/.config/pentatonic-ai/doctor-plugins/` to add
|
|
362
|
+
your own checks. Useful for app-specific things — internal APIs, ingest
|
|
363
|
+
freshness, custom infrastructure — without forking the SDK.
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
// ~/.config/pentatonic-ai/doctor-plugins/my-app.mjs
|
|
367
|
+
export default {
|
|
368
|
+
name: "my-app",
|
|
369
|
+
checks: [
|
|
370
|
+
{
|
|
371
|
+
name: "internal API",
|
|
372
|
+
severity: "warning",
|
|
373
|
+
run: async () => {
|
|
374
|
+
const res = await fetch("https://internal/health");
|
|
375
|
+
return res.ok
|
|
376
|
+
? { ok: true, msg: "200 OK" }
|
|
377
|
+
: { ok: false, msg: `HTTP ${res.status}` };
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
See [`packages/doctor/README.md`](packages/doctor/README.md) for the full
|
|
385
|
+
plugin contract and programmatic API.
|
|
386
|
+
|
|
328
387
|
## Architecture
|
|
329
388
|
|
|
330
389
|
```
|
package/bin/cli.js
CHANGED
|
@@ -12,17 +12,58 @@ function parseArgs() {
|
|
|
12
12
|
const args = process.argv.slice(2);
|
|
13
13
|
const flags = {};
|
|
14
14
|
for (let i = 0; i < args.length; i++) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
i
|
|
18
|
-
} else if (
|
|
19
|
-
flags.endpoint =
|
|
20
|
-
} else if (
|
|
21
|
-
flags.
|
|
15
|
+
const a = args[i];
|
|
16
|
+
if (a === "--endpoint" && args[i + 1]) {
|
|
17
|
+
flags.endpoint = args[++i];
|
|
18
|
+
} else if (a.startsWith("--endpoint=")) {
|
|
19
|
+
flags.endpoint = a.split("=")[1];
|
|
20
|
+
} else if (a === "--path" && args[i + 1]) {
|
|
21
|
+
flags.path = args[++i];
|
|
22
|
+
} else if (a.startsWith("--path=")) {
|
|
23
|
+
flags.path = a.split("=")[1];
|
|
24
|
+
} else if (a === "--timeout" && args[i + 1]) {
|
|
25
|
+
flags.timeout = parseInt(args[++i], 10);
|
|
26
|
+
} else if (a.startsWith("--timeout=")) {
|
|
27
|
+
flags.timeout = parseInt(a.split("=")[1], 10);
|
|
28
|
+
} else if (a === "--json") {
|
|
29
|
+
flags.json = true;
|
|
30
|
+
} else if (a === "--alert") {
|
|
31
|
+
flags.alert = true;
|
|
32
|
+
} else if (a === "--no-plugins") {
|
|
33
|
+
flags.noPlugins = true;
|
|
34
|
+
} else if (!a.startsWith("--")) {
|
|
35
|
+
flags.command = a;
|
|
22
36
|
}
|
|
23
37
|
}
|
|
24
38
|
return flags;
|
|
25
39
|
}
|
|
40
|
+
|
|
41
|
+
async function runDoctorCommand(flags) {
|
|
42
|
+
// Lazy-load to keep doctor's pg dep optional for users who only run
|
|
43
|
+
// `npx ai-agent-sdk init` or `memory`.
|
|
44
|
+
const { runDoctor, renderHuman, renderJson } = await import(
|
|
45
|
+
"../packages/doctor/src/index.js"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const report = await runDoctor({
|
|
49
|
+
path: flags.path || "auto",
|
|
50
|
+
plugins: !flags.noPlugins,
|
|
51
|
+
timeoutMs: flags.timeout,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const hasIssues = report.summary.warning + report.summary.critical > 0;
|
|
55
|
+
if (flags.alert && !hasIssues) return 0;
|
|
56
|
+
|
|
57
|
+
if (flags.json) {
|
|
58
|
+
process.stdout.write(renderJson(report) + "\n");
|
|
59
|
+
} else {
|
|
60
|
+
process.stdout.write(renderHuman(report) + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (report.summary.critical > 0) return 2;
|
|
64
|
+
if (report.summary.warning > 0) return 1;
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
26
67
|
const POLL_INTERVAL_MS = 3000;
|
|
27
68
|
const POLL_TIMEOUT_MS = 300000; // 5 minutes
|
|
28
69
|
|
|
@@ -33,13 +74,19 @@ function ask(question) {
|
|
|
33
74
|
}
|
|
34
75
|
|
|
35
76
|
function askSecret(question) {
|
|
77
|
+
// Non-TTY fallback: piped/redirected input can't use raw mode.
|
|
78
|
+
// rl.close() would discard buffered stdin, so use readline directly instead.
|
|
79
|
+
if (!process.stdin.isTTY) {
|
|
80
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
return new Promise((resolve) => {
|
|
37
84
|
// Close readline so it stops echoing input
|
|
38
85
|
rl.close();
|
|
39
86
|
|
|
40
87
|
process.stdout.write(question);
|
|
41
88
|
const stdin = process.stdin;
|
|
42
|
-
|
|
89
|
+
stdin.setRawMode(true);
|
|
43
90
|
stdin.resume();
|
|
44
91
|
|
|
45
92
|
let input = "";
|
|
@@ -47,7 +94,7 @@ function askSecret(question) {
|
|
|
47
94
|
const c = ch.toString();
|
|
48
95
|
if (c === "\n" || c === "\r") {
|
|
49
96
|
stdin.removeListener("data", onData);
|
|
50
|
-
|
|
97
|
+
stdin.setRawMode(false);
|
|
51
98
|
stdin.pause();
|
|
52
99
|
process.stdout.write("\n");
|
|
53
100
|
// Recreate readline for subsequent prompts
|
|
@@ -248,6 +295,12 @@ async function main() {
|
|
|
248
295
|
return;
|
|
249
296
|
}
|
|
250
297
|
|
|
298
|
+
if (flags.command === "doctor") {
|
|
299
|
+
const code = await runDoctorCommand(flags);
|
|
300
|
+
rl.close();
|
|
301
|
+
process.exit(code);
|
|
302
|
+
}
|
|
303
|
+
|
|
251
304
|
if (flags.command !== "init") {
|
|
252
305
|
console.log(`
|
|
253
306
|
@pentatonic-ai/ai-agent-sdk
|
|
@@ -255,8 +308,16 @@ async function main() {
|
|
|
255
308
|
Usage:
|
|
256
309
|
npx @pentatonic-ai/ai-agent-sdk init Set up hosted TES account
|
|
257
310
|
npx @pentatonic-ai/ai-agent-sdk memory Set up local memory stack
|
|
311
|
+
npx @pentatonic-ai/ai-agent-sdk doctor Run health checks (exit 0/1/2)
|
|
258
312
|
npx @pentatonic-ai/ai-agent-sdk init --endpoint URL Use a custom TES endpoint
|
|
259
313
|
|
|
314
|
+
doctor flags:
|
|
315
|
+
--json Emit a JSON report
|
|
316
|
+
--alert Suppress output when all green
|
|
317
|
+
--no-plugins Skip ~/.config/pentatonic-ai/doctor-plugins/*
|
|
318
|
+
--path local|hosted|platform|auto
|
|
319
|
+
--timeout <ms> Per-check timeout (default 10000)
|
|
320
|
+
|
|
260
321
|
For docs, see https://api.pentatonic.com
|
|
261
322
|
`);
|
|
262
323
|
process.exit(0);
|
package/dist/index.cjs
CHANGED
|
@@ -53,6 +53,16 @@ function empty() {
|
|
|
53
53
|
toolCalls: []
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
function extractCacheUsage(usage) {
|
|
57
|
+
const out = {};
|
|
58
|
+
if (typeof usage.cache_read_input_tokens === "number") {
|
|
59
|
+
out.cache_read_input_tokens = usage.cache_read_input_tokens;
|
|
60
|
+
}
|
|
61
|
+
if (typeof usage.cache_creation_input_tokens === "number") {
|
|
62
|
+
out.cache_creation_input_tokens = usage.cache_creation_input_tokens;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
56
66
|
function normalizeOpenAI(raw) {
|
|
57
67
|
const message = raw.choices?.[0]?.message || {};
|
|
58
68
|
const usage = raw.usage || {};
|
|
@@ -87,7 +97,8 @@ function normalizeAnthropic(raw) {
|
|
|
87
97
|
model: raw.model || null,
|
|
88
98
|
usage: {
|
|
89
99
|
prompt_tokens: usage.input_tokens || 0,
|
|
90
|
-
completion_tokens: usage.output_tokens || 0
|
|
100
|
+
completion_tokens: usage.output_tokens || 0,
|
|
101
|
+
...extractCacheUsage(usage)
|
|
91
102
|
},
|
|
92
103
|
toolCalls
|
|
93
104
|
};
|
|
@@ -262,18 +273,27 @@ var Session = class {
|
|
|
262
273
|
_reset() {
|
|
263
274
|
this._promptTokens = 0;
|
|
264
275
|
this._completionTokens = 0;
|
|
276
|
+
this._cacheReadTokens = 0;
|
|
277
|
+
this._cacheCreateTokens = 0;
|
|
265
278
|
this._rounds = 0;
|
|
266
279
|
this._toolCalls = [];
|
|
267
280
|
this._model = null;
|
|
268
281
|
this._systemPrompt = null;
|
|
269
282
|
}
|
|
270
283
|
get totalUsage() {
|
|
271
|
-
|
|
284
|
+
const usage = {
|
|
272
285
|
prompt_tokens: this._promptTokens,
|
|
273
286
|
completion_tokens: this._completionTokens,
|
|
274
|
-
total_tokens: this._promptTokens + this._completionTokens,
|
|
287
|
+
total_tokens: this._promptTokens + this._completionTokens + this._cacheReadTokens + this._cacheCreateTokens,
|
|
275
288
|
ai_rounds: this._rounds
|
|
276
289
|
};
|
|
290
|
+
if (this._cacheReadTokens) {
|
|
291
|
+
usage.cache_read_input_tokens = this._cacheReadTokens;
|
|
292
|
+
}
|
|
293
|
+
if (this._cacheCreateTokens) {
|
|
294
|
+
usage.cache_creation_input_tokens = this._cacheCreateTokens;
|
|
295
|
+
}
|
|
296
|
+
return usage;
|
|
277
297
|
}
|
|
278
298
|
get toolCalls() {
|
|
279
299
|
return this._toolCalls;
|
|
@@ -283,6 +303,8 @@ var Session = class {
|
|
|
283
303
|
const round = this._rounds;
|
|
284
304
|
this._promptTokens += normalized.usage.prompt_tokens;
|
|
285
305
|
this._completionTokens += normalized.usage.completion_tokens;
|
|
306
|
+
this._cacheReadTokens += normalized.usage.cache_read_input_tokens || 0;
|
|
307
|
+
this._cacheCreateTokens += normalized.usage.cache_creation_input_tokens || 0;
|
|
286
308
|
this._rounds += 1;
|
|
287
309
|
if (normalized.model) {
|
|
288
310
|
this._model = normalized.model;
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,16 @@ function empty() {
|
|
|
22
22
|
toolCalls: []
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
function extractCacheUsage(usage) {
|
|
26
|
+
const out = {};
|
|
27
|
+
if (typeof usage.cache_read_input_tokens === "number") {
|
|
28
|
+
out.cache_read_input_tokens = usage.cache_read_input_tokens;
|
|
29
|
+
}
|
|
30
|
+
if (typeof usage.cache_creation_input_tokens === "number") {
|
|
31
|
+
out.cache_creation_input_tokens = usage.cache_creation_input_tokens;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
25
35
|
function normalizeOpenAI(raw) {
|
|
26
36
|
const message = raw.choices?.[0]?.message || {};
|
|
27
37
|
const usage = raw.usage || {};
|
|
@@ -56,7 +66,8 @@ function normalizeAnthropic(raw) {
|
|
|
56
66
|
model: raw.model || null,
|
|
57
67
|
usage: {
|
|
58
68
|
prompt_tokens: usage.input_tokens || 0,
|
|
59
|
-
completion_tokens: usage.output_tokens || 0
|
|
69
|
+
completion_tokens: usage.output_tokens || 0,
|
|
70
|
+
...extractCacheUsage(usage)
|
|
60
71
|
},
|
|
61
72
|
toolCalls
|
|
62
73
|
};
|
|
@@ -231,18 +242,27 @@ var Session = class {
|
|
|
231
242
|
_reset() {
|
|
232
243
|
this._promptTokens = 0;
|
|
233
244
|
this._completionTokens = 0;
|
|
245
|
+
this._cacheReadTokens = 0;
|
|
246
|
+
this._cacheCreateTokens = 0;
|
|
234
247
|
this._rounds = 0;
|
|
235
248
|
this._toolCalls = [];
|
|
236
249
|
this._model = null;
|
|
237
250
|
this._systemPrompt = null;
|
|
238
251
|
}
|
|
239
252
|
get totalUsage() {
|
|
240
|
-
|
|
253
|
+
const usage = {
|
|
241
254
|
prompt_tokens: this._promptTokens,
|
|
242
255
|
completion_tokens: this._completionTokens,
|
|
243
|
-
total_tokens: this._promptTokens + this._completionTokens,
|
|
256
|
+
total_tokens: this._promptTokens + this._completionTokens + this._cacheReadTokens + this._cacheCreateTokens,
|
|
244
257
|
ai_rounds: this._rounds
|
|
245
258
|
};
|
|
259
|
+
if (this._cacheReadTokens) {
|
|
260
|
+
usage.cache_read_input_tokens = this._cacheReadTokens;
|
|
261
|
+
}
|
|
262
|
+
if (this._cacheCreateTokens) {
|
|
263
|
+
usage.cache_creation_input_tokens = this._cacheCreateTokens;
|
|
264
|
+
}
|
|
265
|
+
return usage;
|
|
246
266
|
}
|
|
247
267
|
get toolCalls() {
|
|
248
268
|
return this._toolCalls;
|
|
@@ -252,6 +272,8 @@ var Session = class {
|
|
|
252
272
|
const round = this._rounds;
|
|
253
273
|
this._promptTokens += normalized.usage.prompt_tokens;
|
|
254
274
|
this._completionTokens += normalized.usage.completion_tokens;
|
|
275
|
+
this._cacheReadTokens += normalized.usage.cache_read_input_tokens || 0;
|
|
276
|
+
this._cacheCreateTokens += normalized.usage.cache_creation_input_tokens || 0;
|
|
255
277
|
this._rounds += 1;
|
|
256
278
|
if (normalized.model) {
|
|
257
279
|
this._model = normalized.model;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"./memory": "./packages/memory/src/index.js",
|
|
14
14
|
"./memory/server": "./packages/memory/src/server.js",
|
|
15
|
-
"./memory/openclaw": "./packages/memory/src/openclaw/index.js"
|
|
15
|
+
"./memory/openclaw": "./packages/memory/src/openclaw/index.js",
|
|
16
|
+
"./doctor": "./packages/doctor/src/index.js"
|
|
16
17
|
},
|
|
17
18
|
"bin": {
|
|
18
19
|
"ai-agent-sdk": "./bin/cli.js"
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"src",
|
|
23
24
|
"bin",
|
|
24
25
|
"packages/memory",
|
|
26
|
+
"packages/doctor",
|
|
25
27
|
"build.js",
|
|
26
28
|
"README.md",
|
|
27
29
|
"LICENSE"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# doctor
|
|
2
|
+
|
|
3
|
+
Health check subsystem for the AI Agent SDK.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @pentatonic-ai/ai-agent-sdk doctor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Auto-detects which install path you're on (Local Memory, Hosted TES, or
|
|
12
|
+
self-hosted Pentatonic platform) and runs the relevant checks. Returns
|
|
13
|
+
exit code `0` for all-clear, `1` for warnings, `2` for critical.
|
|
14
|
+
|
|
15
|
+
### Flags
|
|
16
|
+
|
|
17
|
+
| Flag | Effect |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `--json` | Emit a JSON report to stdout instead of a human table |
|
|
20
|
+
| `--alert` | Suppress output unless something is non-ok (good for cron) |
|
|
21
|
+
| `--no-plugins` | Skip user-supplied plugins for this run |
|
|
22
|
+
| `--path <name>` | Force a specific path: `local`, `hosted`, `platform`, `auto` |
|
|
23
|
+
| `--timeout <ms>` | Per-check timeout (default 10000) |
|
|
24
|
+
|
|
25
|
+
## What gets checked
|
|
26
|
+
|
|
27
|
+
### Universal (always)
|
|
28
|
+
- Node version ≥ 18
|
|
29
|
+
- Disk space at `$HOME` and `$TMPDIR`
|
|
30
|
+
- SDK config files (`~/.claude/tes-memory.local.md`, etc) are mode 0600
|
|
31
|
+
|
|
32
|
+
### Local Memory path
|
|
33
|
+
Triggered when `DATABASE_URL` + `EMBEDDING_URL` + `LLM_URL` are all set,
|
|
34
|
+
or `~/.claude/tes-memory.local.md` exists.
|
|
35
|
+
- PostgreSQL reachable
|
|
36
|
+
- pgvector extension installed
|
|
37
|
+
- Schema migrations applied
|
|
38
|
+
- Embedding endpoint responds + serves the configured model
|
|
39
|
+
- LLM endpoint responds + has the configured model loaded
|
|
40
|
+
- Memory server bound on `PORT`
|
|
41
|
+
|
|
42
|
+
### Hosted TES path
|
|
43
|
+
Triggered when `TES_ENDPOINT` + `TES_API_KEY` are both set.
|
|
44
|
+
- TES endpoint reachable
|
|
45
|
+
- API key authenticates for `TES_CLIENT_ID`
|
|
46
|
+
|
|
47
|
+
### Self-hosted platform path
|
|
48
|
+
Triggered when `HYBRIDRAG_URL` is set or `~/.openclaw/openclaw.json`
|
|
49
|
+
exists. Each individual probe is skipped if its URL env var is unset, so
|
|
50
|
+
partial deployments don't false-fail.
|
|
51
|
+
- HybridRAG proxy
|
|
52
|
+
- Qdrant
|
|
53
|
+
- Neo4j (requires `NEO4J_PASSWORD`)
|
|
54
|
+
- vLLM
|
|
55
|
+
|
|
56
|
+
## Plugins
|
|
57
|
+
|
|
58
|
+
Drop a `.mjs` file into `~/.config/pentatonic-ai/doctor-plugins/` and
|
|
59
|
+
`doctor` will load it automatically. (Use `.mjs`, not `.js` — without a
|
|
60
|
+
sibling `package.json` Node treats `.js` as CommonJS.)
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// ~/.config/pentatonic-ai/doctor-plugins/my-app.mjs
|
|
64
|
+
export default {
|
|
65
|
+
name: "my-app",
|
|
66
|
+
checks: [
|
|
67
|
+
{
|
|
68
|
+
name: "internal API reachable",
|
|
69
|
+
severity: "warning", // 'critical' | 'warning' | 'info'
|
|
70
|
+
run: async () => {
|
|
71
|
+
const res = await fetch("https://internal/health");
|
|
72
|
+
return res.ok
|
|
73
|
+
? { ok: true, msg: "200 OK" }
|
|
74
|
+
: { ok: false, msg: `HTTP ${res.status}` };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Plugin checks appear in the report prefixed with the plugin name
|
|
82
|
+
(`my-app: internal API reachable`).
|
|
83
|
+
|
|
84
|
+
A broken plugin will not abort the run — failures are logged and the
|
|
85
|
+
loader moves on.
|
|
86
|
+
|
|
87
|
+
## Programmatic use
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { runDoctor, renderHuman } from "@pentatonic-ai/ai-agent-sdk/doctor";
|
|
91
|
+
|
|
92
|
+
const report = await runDoctor({ path: "auto" });
|
|
93
|
+
console.log(renderHuman(report));
|
|
94
|
+
|
|
95
|
+
if (report.summary.critical > 0) {
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`runDoctor` accepts:
|
|
101
|
+
- `path` — `'local' | 'hosted' | 'platform' | 'auto'`
|
|
102
|
+
- `plugins` — `false` to skip plugin loading
|
|
103
|
+
- `pluginDir` — override the plugin directory
|
|
104
|
+
- `timeoutMs` — per-check timeout
|
|
105
|
+
- `extraChecks` — additional check descriptors to merge in (useful in tests)
|
|
106
|
+
- `env` — override `process.env` for path detection
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { universalChecks } from "../src/checks/universal.js";
|
|
2
|
+
import { hostedTesChecks } from "../src/checks/hosted-tes.js";
|
|
3
|
+
import { platformChecks } from "../src/checks/platform.js";
|
|
4
|
+
|
|
5
|
+
// fetch mocking — we don't want any real network in unit tests.
|
|
6
|
+
const realFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
function mockFetch(handler) {
|
|
9
|
+
globalThis.fetch = async (url, opts) => handler(url, opts);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = realFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("universal checks", () => {
|
|
17
|
+
it("registers the expected names", () => {
|
|
18
|
+
const names = universalChecks().map((c) => c.name);
|
|
19
|
+
expect(names).toContain("node version");
|
|
20
|
+
expect(names).toContain("disk space");
|
|
21
|
+
expect(names).toContain("config file perms");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("node version returns ok on Node ≥18", async () => {
|
|
25
|
+
const node = universalChecks().find((c) => c.name === "node version");
|
|
26
|
+
const r = await node.run();
|
|
27
|
+
expect(r.ok).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("hosted TES checks", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
delete process.env.TES_ENDPOINT;
|
|
34
|
+
delete process.env.TES_API_KEY;
|
|
35
|
+
delete process.env.TES_CLIENT_ID;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("reports missing env clearly", async () => {
|
|
39
|
+
const reach = hostedTesChecks().find(
|
|
40
|
+
(c) => c.name === "TES endpoint reachable"
|
|
41
|
+
);
|
|
42
|
+
const r = await reach.run();
|
|
43
|
+
expect(r.ok).toBe(false);
|
|
44
|
+
expect(r.msg).toMatch(/TES_ENDPOINT/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("treats /api/health 200 as reachable", async () => {
|
|
48
|
+
process.env.TES_ENDPOINT = "https://example.test";
|
|
49
|
+
mockFetch(async () => ({
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
text: async () => "",
|
|
53
|
+
json: async () => ({}),
|
|
54
|
+
}));
|
|
55
|
+
const reach = hostedTesChecks().find(
|
|
56
|
+
(c) => c.name === "TES endpoint reachable"
|
|
57
|
+
);
|
|
58
|
+
const r = await reach.run();
|
|
59
|
+
expect(r.ok).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to graphql when /api/health 404s", async () => {
|
|
63
|
+
process.env.TES_ENDPOINT = "https://example.test";
|
|
64
|
+
let calls = 0;
|
|
65
|
+
mockFetch(async (url) => {
|
|
66
|
+
calls++;
|
|
67
|
+
if (url.endsWith("/api/health")) {
|
|
68
|
+
return { ok: false, status: 404, text: async () => "" };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
71
|
+
});
|
|
72
|
+
const reach = hostedTesChecks().find(
|
|
73
|
+
(c) => c.name === "TES endpoint reachable"
|
|
74
|
+
);
|
|
75
|
+
const r = await reach.run();
|
|
76
|
+
expect(r.ok).toBe(true);
|
|
77
|
+
expect(calls).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects 401 from auth check", async () => {
|
|
81
|
+
process.env.TES_ENDPOINT = "https://example.test";
|
|
82
|
+
process.env.TES_API_KEY = "bad";
|
|
83
|
+
process.env.TES_CLIENT_ID = "c";
|
|
84
|
+
mockFetch(async () => ({ ok: false, status: 401, text: async () => "" }));
|
|
85
|
+
const auth = hostedTesChecks().find((c) => c.name === "TES API key valid");
|
|
86
|
+
const r = await auth.run();
|
|
87
|
+
expect(r.ok).toBe(false);
|
|
88
|
+
expect(r.msg).toMatch(/auth rejected/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("accepts 200 from auth check", async () => {
|
|
92
|
+
process.env.TES_ENDPOINT = "https://example.test";
|
|
93
|
+
process.env.TES_API_KEY = "good";
|
|
94
|
+
process.env.TES_CLIENT_ID = "c";
|
|
95
|
+
mockFetch(async () => ({
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
json: async () => ({ data: { __schema: {} } }),
|
|
99
|
+
}));
|
|
100
|
+
const auth = hostedTesChecks().find((c) => c.name === "TES API key valid");
|
|
101
|
+
const r = await auth.run();
|
|
102
|
+
expect(r.ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("platform checks", () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
delete process.env.HYBRIDRAG_URL;
|
|
109
|
+
delete process.env.QDRANT_URL;
|
|
110
|
+
delete process.env.NEO4J_HTTP;
|
|
111
|
+
delete process.env.NEO4J_PASSWORD;
|
|
112
|
+
delete process.env.VLLM_URL;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips each check when its URL env is unset", async () => {
|
|
116
|
+
const checks = platformChecks();
|
|
117
|
+
for (const c of checks) {
|
|
118
|
+
const r = await c.run();
|
|
119
|
+
expect(r.ok).toBe(true);
|
|
120
|
+
expect(r.msg).toMatch(/not set \(skipped\)/);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("hybridrag falls back to search probe when /health 404s", async () => {
|
|
125
|
+
process.env.HYBRIDRAG_URL = "http://hybridrag:8031";
|
|
126
|
+
mockFetch(async (url) => {
|
|
127
|
+
if (url.endsWith("/health")) {
|
|
128
|
+
return { ok: false, status: 404, text: async () => "" };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, status: 200, json: async () => ({ results: [] }) };
|
|
131
|
+
});
|
|
132
|
+
const c = platformChecks().find((x) => x.name === "hybridrag reachable");
|
|
133
|
+
const r = await c.run();
|
|
134
|
+
expect(r.ok).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("neo4j requires NEO4J_PASSWORD when NEO4J_HTTP is set", async () => {
|
|
138
|
+
process.env.NEO4J_HTTP = "http://neo4j:7474";
|
|
139
|
+
const c = platformChecks().find((x) => x.name === "neo4j reachable");
|
|
140
|
+
const r = await c.run();
|
|
141
|
+
expect(r.ok).toBe(false);
|
|
142
|
+
expect(r.msg).toMatch(/NEO4J_PASSWORD/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("neo4j flags 401 specifically", async () => {
|
|
146
|
+
process.env.NEO4J_HTTP = "http://neo4j:7474";
|
|
147
|
+
process.env.NEO4J_PASSWORD = "wrong";
|
|
148
|
+
mockFetch(async () => ({
|
|
149
|
+
status: 401,
|
|
150
|
+
ok: false,
|
|
151
|
+
text: async () => "",
|
|
152
|
+
json: async () => ({}),
|
|
153
|
+
}));
|
|
154
|
+
const c = platformChecks().find((x) => x.name === "neo4j reachable");
|
|
155
|
+
const r = await c.run();
|
|
156
|
+
expect(r.ok).toBe(false);
|
|
157
|
+
expect(r.msg).toMatch(/auth rejected/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("qdrant lists collections", async () => {
|
|
161
|
+
process.env.QDRANT_URL = "http://qdrant:6333";
|
|
162
|
+
mockFetch(async () => ({
|
|
163
|
+
ok: true,
|
|
164
|
+
status: 200,
|
|
165
|
+
json: async () => ({
|
|
166
|
+
result: { collections: [{ name: "a" }, { name: "b" }] },
|
|
167
|
+
}),
|
|
168
|
+
}));
|
|
169
|
+
const c = platformChecks().find((x) => x.name === "qdrant reachable");
|
|
170
|
+
const r = await c.run();
|
|
171
|
+
expect(r.ok).toBe(true);
|
|
172
|
+
expect(r.detail.collections).toEqual(["a", "b"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("vllm flags 'no models loaded' when /v1/models is empty", async () => {
|
|
176
|
+
process.env.VLLM_URL = "http://vllm:8001";
|
|
177
|
+
mockFetch(async () => ({
|
|
178
|
+
ok: true,
|
|
179
|
+
status: 200,
|
|
180
|
+
json: async () => ({ data: [] }),
|
|
181
|
+
}));
|
|
182
|
+
const c = platformChecks().find((x) => x.name === "vllm reachable");
|
|
183
|
+
const r = await c.run();
|
|
184
|
+
expect(r.ok).toBe(false);
|
|
185
|
+
expect(r.msg).toMatch(/no models loaded/);
|
|
186
|
+
});
|
|
187
|
+
});
|