@evoclock/pi-agentic-driver 0.9.2 → 0.9.3
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 +78 -54
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/package.json +3 -2
- package/scripts/enforcement/router_schemas_pi.js +63 -13
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Unit tests: single-flight per evalInputDigest (M9) and the global
|
|
5
|
+
// concurrency cap with busy rejection.
|
|
6
|
+
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { SingleFlight } from "../janus/single_flight.js";
|
|
10
|
+
import { JanusError } from "../janus/errors.js";
|
|
11
|
+
|
|
12
|
+
test("identical digests coalesce onto one task", async () => {
|
|
13
|
+
const sf = new SingleFlight(4);
|
|
14
|
+
let runs = 0;
|
|
15
|
+
const task = () =>
|
|
16
|
+
sf.run("digest-a", async () => {
|
|
17
|
+
runs += 1;
|
|
18
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
19
|
+
return "result";
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const [a, b, c] = await Promise.all([task(), task(), task()]);
|
|
23
|
+
assert.equal(runs, 1);
|
|
24
|
+
assert.equal(a, "result");
|
|
25
|
+
assert.equal(b, "result");
|
|
26
|
+
assert.equal(c, "result");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("distinct digests run as separate tasks", async () => {
|
|
30
|
+
const sf = new SingleFlight(4);
|
|
31
|
+
let runs = 0;
|
|
32
|
+
const make = (digest: string) =>
|
|
33
|
+
sf.run(digest, async () => {
|
|
34
|
+
runs += 1;
|
|
35
|
+
return digest;
|
|
36
|
+
});
|
|
37
|
+
await Promise.all([make("d1"), make("d2"), make("d3")]);
|
|
38
|
+
assert.equal(runs, 3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("global cap rejects distinct digests with busy", async () => {
|
|
42
|
+
const sf = new SingleFlight(2);
|
|
43
|
+
const release: (() => void)[] = [];
|
|
44
|
+
const make = (digest: string) =>
|
|
45
|
+
sf.run(digest, () => new Promise<string>((resolvePromise) => release.push(() => resolvePromise(digest))));
|
|
46
|
+
|
|
47
|
+
void make("d1");
|
|
48
|
+
void make("d2");
|
|
49
|
+
assert.throws(() => make("d3"), (error: unknown) => {
|
|
50
|
+
return error instanceof JanusError && error.code === "busy";
|
|
51
|
+
});
|
|
52
|
+
for (const r of release) r();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("digests are released after completion", async () => {
|
|
56
|
+
const sf = new SingleFlight(1);
|
|
57
|
+
await sf.run("d1", async () => "x");
|
|
58
|
+
// Cap of 1 must allow the next request after the first completes.
|
|
59
|
+
assert.equal(await sf.run("d2", async () => "y"), "y");
|
|
60
|
+
assert.equal(await sf.run("d1", async () => "z"), "z");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("failed flights release their digest", async () => {
|
|
64
|
+
const sf = new SingleFlight(1);
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
sf.run("d1", async () => {
|
|
67
|
+
throw new Error("boom");
|
|
68
|
+
}),
|
|
69
|
+
/boom/,
|
|
70
|
+
);
|
|
71
|
+
assert.equal(await sf.run("d1", async () => "recovered"), "recovered");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("coalesced waiters share a failed flight's rejection", async () => {
|
|
75
|
+
const sf = new SingleFlight(4);
|
|
76
|
+
const task = () =>
|
|
77
|
+
sf.run("digest-fail", async () => {
|
|
78
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
79
|
+
throw new Error("gateway down");
|
|
80
|
+
});
|
|
81
|
+
const results = await Promise.allSettled([task(), task()]);
|
|
82
|
+
assert.equal(results[0]?.status, "rejected");
|
|
83
|
+
assert.equal(results[1]?.status, "rejected");
|
|
84
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"noFallthroughCasesInSwitch": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noEmit": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["janus/**/*.ts", "tests/**/*.ts"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!--
|
|
4
|
+
janus launchd template — a STARTING POINT, not a finished service file.
|
|
5
|
+
Everything marked ADJUST below is user-specific by design; the template
|
|
6
|
+
ships with placeholders because the label, paths, and log locations belong
|
|
7
|
+
to the user's machine, not to this repository.
|
|
8
|
+
|
|
9
|
+
Install:
|
|
10
|
+
1. Copy the template somewhere out of the repo:
|
|
11
|
+
cp janus/user.janus.plist ~/Library/LaunchAgents/user.janus.plist
|
|
12
|
+
2. ADJUST the values marked below (Label, WorkingDirectory, log paths).
|
|
13
|
+
3. Load it:
|
|
14
|
+
launchctl load ~/Library/LaunchAgents/user.janus.plist
|
|
15
|
+
|
|
16
|
+
The service binds 127.0.0.1 only and reads the gateway key from the
|
|
17
|
+
Keychain at runtime; no environment variables are required.
|
|
18
|
+
-->
|
|
19
|
+
<plist version="1.0">
|
|
20
|
+
<dict>
|
|
21
|
+
<key>Label</key>
|
|
22
|
+
<!-- ADJUST: any label you like; the file name should match it. -->
|
|
23
|
+
<string>user.janus</string>
|
|
24
|
+
<key>ProgramArguments</key>
|
|
25
|
+
<array>
|
|
26
|
+
<!-- ADJUST if your tooling lives elsewhere (e.g. /usr/local/bin/npx). -->
|
|
27
|
+
<string>/opt/homebrew/bin/npx</string>
|
|
28
|
+
<string>tsx</string>
|
|
29
|
+
<string>janus/server.ts</string>
|
|
30
|
+
</array>
|
|
31
|
+
<key>WorkingDirectory</key>
|
|
32
|
+
<!-- ADJUST: the janus/ directory of your checkout. -->
|
|
33
|
+
<string>/path/to/pi-agentic-driver/janus</string>
|
|
34
|
+
<key>RunAtLoad</key>
|
|
35
|
+
<true/>
|
|
36
|
+
<key>KeepAlive</key>
|
|
37
|
+
<true/>
|
|
38
|
+
<key>StandardOutPath</key>
|
|
39
|
+
<!-- ADJUST: where stdout goes. -->
|
|
40
|
+
<string>/tmp/janus.stdout.log</string>
|
|
41
|
+
<key>StandardErrorPath</key>
|
|
42
|
+
<!-- ADJUST: where stderr goes. -->
|
|
43
|
+
<string>/tmp/janus.stderr.log</string>
|
|
44
|
+
<key>EnvironmentVariables</key>
|
|
45
|
+
<dict>
|
|
46
|
+
<key>PATH</key>
|
|
47
|
+
<!-- ADJUST if your npx lives on a different PATH. -->
|
|
48
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
49
|
+
</dict>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -80,7 +80,8 @@
|
|
|
80
80
|
"scripts/enforcement/router_replacement_pi.js",
|
|
81
81
|
"scripts/enforcement/router_runtime_pi.js",
|
|
82
82
|
".agentic-driver/router.defaults.json",
|
|
83
|
-
"skills/pulse/SKILL.md"
|
|
83
|
+
"skills/pulse/SKILL.md",
|
|
84
|
+
"janus"
|
|
84
85
|
],
|
|
85
86
|
"pi": {
|
|
86
87
|
"extensions": [
|
|
@@ -28,9 +28,16 @@ export const QUOTA_OBSERVATION_SCHEMA = "agentic-driver.quota-observation.v1";
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
|
|
30
30
|
export const SEAT_KINDS = Object.freeze(["local", "hosted-api", "subscription"]);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Built-in provider registry: the most common API routers plus the local
|
|
32
|
+
// serving adapter. Users extend this list through the config's
|
|
33
|
+
// `providers` section — the registry is OPEN by configuration, closed by
|
|
34
|
+
// default. Nothing in the code branches on specific provider names.
|
|
35
|
+
export const BUILTIN_PROVIDERS = Object.freeze([
|
|
36
|
+
"openrouter", "vercel-ai-gateway", "opencode", "fireworks-ai", "baseten",
|
|
37
|
+
"particle-ai", "cerebras", "together-ai", "merge-gateway", "anthropic",
|
|
38
|
+
"openai", "llama-server",
|
|
33
39
|
]);
|
|
40
|
+
export const PROVIDERS = BUILTIN_PROVIDERS;
|
|
34
41
|
export const CAPABILITIES = Object.freeze([
|
|
35
42
|
"implement", "review", "final-verification", "design", "research",
|
|
36
43
|
]);
|
|
@@ -73,7 +80,7 @@ function isPosInt(value) {
|
|
|
73
80
|
// §2 Seat record: agentic-driver.seat.v1
|
|
74
81
|
// ---------------------------------------------------------------------------
|
|
75
82
|
|
|
76
|
-
export function wellFormedSeat(seat, { endpoints = null } = {}) {
|
|
83
|
+
export function wellFormedSeat(seat, { endpoints = null, providerRegistry = BUILTIN_PROVIDERS } = {}) {
|
|
77
84
|
if (!isPlainObject(seat) || !exactKeys(seat, [
|
|
78
85
|
"schema", "seatId", "kind", "provider", "accountId", "endpointRef",
|
|
79
86
|
"model", "capabilities", "containmentTier", "maxConcurrency",
|
|
@@ -88,8 +95,8 @@ export function wellFormedSeat(seat, { endpoints = null } = {}) {
|
|
|
88
95
|
if (!SEAT_KINDS.includes(seat.kind)) {
|
|
89
96
|
return { ok: false, reason: `seat kind "${seat.kind}" is not one of ${SEAT_KINDS.join(", ")}` };
|
|
90
97
|
}
|
|
91
|
-
if (!
|
|
92
|
-
return { ok: false, reason: `seat provider "${seat.provider}" is not in the
|
|
98
|
+
if (!providerRegistry.includes(seat.provider)) {
|
|
99
|
+
return { ok: false, reason: `seat provider "${seat.provider}" is not in the provider registry (built-ins plus configured extensions)` };
|
|
93
100
|
}
|
|
94
101
|
// accountId is REQUIRED iff kind is subscription (§2).
|
|
95
102
|
if (seat.kind === "subscription") {
|
|
@@ -191,11 +198,24 @@ export const ELIGIBILITY_RULES = Object.freeze([
|
|
|
191
198
|
|
|
192
199
|
export function validateRouterConfig(config) {
|
|
193
200
|
if (!isPlainObject(config)) return { ok: false, errors: ["router config must be an object"] };
|
|
194
|
-
|
|
201
|
+
// exactKeys closed, with one governed extension point: the optional
|
|
202
|
+
// "providers" section (user-added provider ids). Built-ins always apply;
|
|
203
|
+
// extensions add to them, never remove.
|
|
204
|
+
const requiredKeys = [
|
|
195
205
|
"schema", "revision", "endpoints", "seats", "models", "collectors",
|
|
196
206
|
"eligibility", "preferences", "ranking", "localHealth", "secrets",
|
|
197
|
-
]
|
|
198
|
-
|
|
207
|
+
];
|
|
208
|
+
const optionalKeys = ["providers"];
|
|
209
|
+
const actualKeys = Object.keys(config).sort();
|
|
210
|
+
const allowed = [...requiredKeys, ...optionalKeys].sort();
|
|
211
|
+
const missing = requiredKeys.filter((key) => !actualKeys.includes(key));
|
|
212
|
+
const unknown = actualKeys.filter((key) => !allowed.includes(key));
|
|
213
|
+
if (missing.length || unknown.length) {
|
|
214
|
+
return { ok: false, errors: [
|
|
215
|
+
`router config has unknown or missing fields (exactKeys closed)` +
|
|
216
|
+
(missing.length ? `; missing: ${missing.join(", ")}` : "") +
|
|
217
|
+
(unknown.length ? `; unknown: ${unknown.join(", ")}` : ""),
|
|
218
|
+
] };
|
|
199
219
|
}
|
|
200
220
|
const errors = [];
|
|
201
221
|
if (config.schema !== ROUTER_CONFIG_SCHEMA) {
|
|
@@ -204,6 +224,36 @@ export function validateRouterConfig(config) {
|
|
|
204
224
|
if (!isPosInt(config.revision)) {
|
|
205
225
|
errors.push("config revision must be a positive integer");
|
|
206
226
|
}
|
|
227
|
+
// providers{}: optional user extension of the registry. Each entry is a
|
|
228
|
+
// slug; entries add to the built-ins, never shadow or remove them.
|
|
229
|
+
let providerRegistry = BUILTIN_PROVIDERS;
|
|
230
|
+
if (config.providers !== undefined) {
|
|
231
|
+
if (!isPlainObject(config.providers) || Object.keys(config.providers).length === 0) {
|
|
232
|
+
errors.push("providers must be a non-empty object keyed by provider slug when present");
|
|
233
|
+
} else {
|
|
234
|
+
const extended = [];
|
|
235
|
+
for (const [name, entry] of Object.entries(config.providers)) {
|
|
236
|
+
if (!SLUG_RE.test(name) || name.length > 64) {
|
|
237
|
+
errors.push(`provider "${name}" must be a slug (max 64 chars)`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (BUILTIN_PROVIDERS.includes(name)) {
|
|
241
|
+
errors.push(`provider "${name}" shadows a built-in provider`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Entry shape is closed: {auth} — how the provider authenticates.
|
|
245
|
+
// "keychain" (default posture) is the only supported auth kind at
|
|
246
|
+
// launch; the entry exists so provider addition needs no code change.
|
|
247
|
+
if (!isPlainObject(entry) || !exactKeys(entry, ["auth"])
|
|
248
|
+
|| entry.auth !== "keychain") {
|
|
249
|
+
errors.push(`provider "${name}" must have exactly {auth: "keychain"}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
extended.push(name);
|
|
253
|
+
}
|
|
254
|
+
providerRegistry = Object.freeze([...BUILTIN_PROVIDERS, ...extended]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
207
257
|
// endpoints{}: named, closed-shape endpoint records. URLs are validated
|
|
208
258
|
// structurally (http/https); there is no unrestricted-URL escape hatch.
|
|
209
259
|
if (!isPlainObject(config.endpoints)) {
|
|
@@ -222,8 +272,8 @@ export function validateRouterConfig(config) {
|
|
|
222
272
|
|| !/^https?:\/\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(endpoint.url)) {
|
|
223
273
|
errors.push(`endpoint "${name}" url must be an http(s) URL`);
|
|
224
274
|
}
|
|
225
|
-
if (!
|
|
226
|
-
errors.push(`endpoint "${name}" kind "${endpoint.kind}" is not in the
|
|
275
|
+
if (!providerRegistry.includes(endpoint.kind)) {
|
|
276
|
+
errors.push(`endpoint "${name}" kind "${endpoint.kind}" is not in the provider registry (built-ins plus configured extensions)`);
|
|
227
277
|
}
|
|
228
278
|
}
|
|
229
279
|
}
|
|
@@ -233,7 +283,7 @@ export function validateRouterConfig(config) {
|
|
|
233
283
|
} else {
|
|
234
284
|
const seen = new Set();
|
|
235
285
|
for (const seat of config.seats) {
|
|
236
|
-
const check = wellFormedSeat(seat, { endpoints: config.endpoints });
|
|
286
|
+
const check = wellFormedSeat(seat, { endpoints: config.endpoints, providerRegistry });
|
|
237
287
|
if (!check.ok) {
|
|
238
288
|
errors.push(`seat ${seat?.seatId ?? "(unidentified)"}: ${check.reason}`);
|
|
239
289
|
continue;
|
|
@@ -408,8 +458,8 @@ export function validateRouterConfig(config) {
|
|
|
408
458
|
errors.push("secrets must be an object keyed by provider id");
|
|
409
459
|
} else {
|
|
410
460
|
for (const [provider, ref] of Object.entries(config.secrets)) {
|
|
411
|
-
if (!
|
|
412
|
-
errors.push(`secrets key "${provider}" is not in the
|
|
461
|
+
if (!providerRegistry.includes(provider)) {
|
|
462
|
+
errors.push(`secrets key "${provider}" is not in the provider registry (built-ins plus configured extensions)`);
|
|
413
463
|
continue;
|
|
414
464
|
}
|
|
415
465
|
if (!isPlainObject(ref) || !exactKeys(ref, SECRET_REF_FIELDS)
|