@9thprotocol/agent-core 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +10 -0
  3. package/dist/compaction.d.ts +69 -0
  4. package/dist/compaction.js +174 -0
  5. package/dist/delegate.d.ts +84 -0
  6. package/dist/delegate.js +135 -0
  7. package/dist/index.d.ts +18 -0
  8. package/dist/index.js +18 -0
  9. package/dist/mcp.d.ts +13 -0
  10. package/dist/mcp.js +78 -0
  11. package/dist/memory.d.ts +7 -0
  12. package/dist/memory.js +33 -0
  13. package/dist/model/openrouter.d.ts +61 -0
  14. package/dist/model/openrouter.js +135 -0
  15. package/dist/model/router.d.ts +60 -0
  16. package/dist/model/router.js +171 -0
  17. package/dist/permissions.d.ts +5 -0
  18. package/dist/permissions.js +16 -0
  19. package/dist/prompt.d.ts +5 -0
  20. package/dist/prompt.js +31 -0
  21. package/dist/scripts/compaction-live.d.ts +1 -0
  22. package/dist/scripts/compaction-live.js +80 -0
  23. package/dist/scripts/compaction-smoke.d.ts +1 -0
  24. package/dist/scripts/compaction-smoke.js +143 -0
  25. package/dist/scripts/delegation-live.d.ts +1 -0
  26. package/dist/scripts/delegation-live.js +122 -0
  27. package/dist/scripts/delegation-smoke.d.ts +1 -0
  28. package/dist/scripts/delegation-smoke.js +140 -0
  29. package/dist/scripts/router-live.d.ts +1 -0
  30. package/dist/scripts/router-live.js +73 -0
  31. package/dist/scripts/router-smoke.d.ts +1 -0
  32. package/dist/scripts/router-smoke.js +58 -0
  33. package/dist/scripts/smoke.d.ts +1 -0
  34. package/dist/scripts/smoke.js +52 -0
  35. package/dist/session.d.ts +73 -0
  36. package/dist/session.js +574 -0
  37. package/dist/skills.d.ts +14 -0
  38. package/dist/skills.js +56 -0
  39. package/dist/tools/bash.d.ts +2 -0
  40. package/dist/tools/bash.js +38 -0
  41. package/dist/tools/fs-tools.d.ts +5 -0
  42. package/dist/tools/fs-tools.js +115 -0
  43. package/dist/tools/registry.d.ts +5 -0
  44. package/dist/tools/registry.js +12 -0
  45. package/dist/tools/search-tools.d.ts +3 -0
  46. package/dist/tools/search-tools.js +84 -0
  47. package/dist/tools/types.d.ts +27 -0
  48. package/dist/tools/types.js +15 -0
  49. package/dist/types.d.ts +130 -0
  50. package/dist/types.js +2 -0
  51. package/dist/vault.d.ts +13 -0
  52. package/dist/vault.js +81 -0
  53. package/package.json +29 -0
@@ -0,0 +1,143 @@
1
+ import { applyCompaction, estimateTokens, findCutpoint, planCompaction, shouldCompact, } from "../compaction.js";
2
+ let failures = 0;
3
+ function check(label, actual, expected) {
4
+ const ok = actual === expected;
5
+ if (!ok)
6
+ failures++;
7
+ console.log(`${ok ? "✓" : "✗"} ${label}${ok ? "" : `: got ${actual}, want ${expected}`}`);
8
+ }
9
+ /** Every tool_call id must be answered, and no tool result may be orphaned. */
10
+ function pairingIsValid(messages) {
11
+ const answered = new Set();
12
+ for (const m of messages)
13
+ if (m.role === "tool")
14
+ answered.add(m.tool_call_id);
15
+ const called = new Set();
16
+ for (const m of messages) {
17
+ if (m.role === "assistant")
18
+ for (const tc of m.tool_calls ?? [])
19
+ called.add(tc.id);
20
+ }
21
+ for (const id of called)
22
+ if (!answered.has(id))
23
+ return false; // dangling call
24
+ for (const id of answered)
25
+ if (!called.has(id))
26
+ return false; // orphaned result
27
+ return true;
28
+ }
29
+ /** A session with interleaved tool exchanges, like a real one. */
30
+ function buildHistory(exchanges) {
31
+ const out = [{ role: "system", content: "system prompt" }];
32
+ for (let i = 0; i < exchanges; i++) {
33
+ out.push({ role: "user", content: `user message ${i} ${"x".repeat(200)}` });
34
+ out.push({
35
+ role: "assistant",
36
+ content: null,
37
+ tool_calls: [
38
+ { id: `call_${i}`, type: "function", function: { name: "read", arguments: `{"path":"f${i}"}` } },
39
+ ],
40
+ });
41
+ out.push({ role: "tool", tool_call_id: `call_${i}`, content: `file contents ${"y".repeat(300)}` });
42
+ out.push({ role: "assistant", content: `done with ${i}` });
43
+ }
44
+ return out;
45
+ }
46
+ console.log("- estimation -");
47
+ check("empty history is 0 tokens", estimateTokens([]), 0);
48
+ check("400 chars ≈ 100 tokens", estimateTokens([{ role: "user", content: "x".repeat(400) }]), 100);
49
+ console.log("\n- threshold -");
50
+ const small = buildHistory(2);
51
+ check("small history under threshold", shouldCompact(small, 200_000), false);
52
+ check("same history against a tiny window", shouldCompact(small, 100), true);
53
+ console.log("\n- cut points -");
54
+ const history = buildHistory(10);
55
+ const body = history.slice(1); // drop the system prefix, as planCompaction does
56
+ // Sized against a realistic window: with a 200k window this history is tiny
57
+ // and correctly would not be compacted at all.
58
+ const cut = findCutpoint(body, 2000);
59
+ check("cut lands on a user message", body[cut]?.role, "user");
60
+ check("cut is greater than zero", cut > 0, true);
61
+ console.log("\n- pairing preserved -");
62
+ check("fixture itself is well-formed", pairingIsValid(history), true);
63
+ const plan = planCompaction(history, 2000);
64
+ check("plan produced", plan !== null, true);
65
+ if (plan) {
66
+ const compacted = applyCompaction(plan, "SUMMARY OF EARLIER WORK");
67
+ check("compacted history is well-formed", pairingIsValid(compacted), true);
68
+ check("system prompt survives", compacted[0]?.role, "system");
69
+ check("summary is inserted after system", compacted[1]?.role, "user");
70
+ check("compacted history is shorter", compacted.length < history.length, true);
71
+ check("summary text present", compacted[1].content.includes("SUMMARY OF EARLIER WORK"), true);
72
+ }
73
+ console.log("\n- pairing holds at every history length -");
74
+ let badLength = null;
75
+ for (let n = 1; n <= 40; n++) {
76
+ const h = buildHistory(n);
77
+ const p = planCompaction(h, 2000);
78
+ if (!p)
79
+ continue;
80
+ if (!pairingIsValid(applyCompaction(p, "S"))) {
81
+ badLength = n;
82
+ break;
83
+ }
84
+ }
85
+ check("no history length produces broken pairing", badLength, null);
86
+ console.log("\n- few messages, huge content -");
87
+ // The regression this guards: a fixed "keep the last N messages" rule could
88
+ // never compact a session that is short but token-heavy (a few big file reads),
89
+ // so it would overflow the window and every turn would fail.
90
+ const fewButHuge = [
91
+ { role: "system", content: "system prompt" },
92
+ { role: "user", content: "read the big files" },
93
+ {
94
+ role: "assistant",
95
+ content: null,
96
+ tool_calls: [
97
+ { id: "big1", type: "function", function: { name: "read", arguments: '{"path":"a"}' } },
98
+ ],
99
+ },
100
+ { role: "tool", tool_call_id: "big1", content: "A".repeat(200_000) },
101
+ { role: "user", content: "now the second one" },
102
+ {
103
+ role: "assistant",
104
+ content: null,
105
+ tool_calls: [
106
+ { id: "big2", type: "function", function: { name: "read", arguments: '{"path":"b"}' } },
107
+ ],
108
+ },
109
+ { role: "tool", tool_call_id: "big2", content: "B".repeat(200_000) },
110
+ { role: "assistant", content: "read both" },
111
+ ];
112
+ check("fixture exceeds a 100k window", shouldCompact(fewButHuge, 100_000), true);
113
+ const hugePlan = planCompaction(fewButHuge, 100_000);
114
+ check("short-but-huge history IS compacted", hugePlan !== null, true);
115
+ if (hugePlan) {
116
+ const out = applyCompaction(hugePlan, "S");
117
+ check("result stays well-formed", pairingIsValid(out), true);
118
+ check("result is meaningfully smaller", estimateTokens(out) <= estimateTokens(fewButHuge) * 0.55, true);
119
+ }
120
+ console.log("\n- refuses unsafe compaction -");
121
+ check("short history is not compacted", planCompaction(buildHistory(1), 2000), null);
122
+ check("system-only history is not compacted", planCompaction([{ role: "system", content: "s" }], 2000), null);
123
+ // A history whose only boundary is inside a tool exchange must be left alone
124
+ // rather than cut somewhere that strands a tool result.
125
+ const noSafeCut = [
126
+ { role: "system", content: "s" },
127
+ { role: "user", content: "only user message" },
128
+ ...Array.from({ length: 20 }, (_, i) => [
129
+ {
130
+ role: "assistant",
131
+ content: null,
132
+ tool_calls: [
133
+ { id: `c${i}`, type: "function", function: { name: "read", arguments: "{}" } },
134
+ ],
135
+ },
136
+ { role: "tool", tool_call_id: `c${i}`, content: "result" },
137
+ ]).flat(),
138
+ ];
139
+ const unsafePlan = planCompaction(noSafeCut, 1000);
140
+ check("history with no safe boundary is left intact", unsafePlan === null || pairingIsValid(applyCompaction(unsafePlan, "S")), true);
141
+ console.log(`\n${failures ? `${failures} failed` : "all compaction checks passed"}`);
142
+ process.exit(failures ? 1 : 0);
143
+ //# sourceMappingURL=compaction-smoke.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Live delegation check: prove a real worker call works, and that a real agent
3
+ * turn routes a large file through it instead of into its own context.
4
+ *
5
+ * The offline checks prove the gate refuses and the corpus is well-formed.
6
+ * Only this proves a worker model actually answers, that the answer is usable,
7
+ * and that the burn lands on the session's totals.
8
+ *
9
+ * OPENROUTER_API_KEY=... node dist/scripts/delegation-live.js # BYOK
10
+ * PLATFORM_URL=... PLATFORM_TOKEN=... node dist/scripts/delegation-live.js
11
+ *
12
+ * Run it from a directory with a file over the threshold; it picks the largest
13
+ * source file it can find under cwd.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { AgentSession } from "../session.js";
18
+ import { DELEGATION_MIN_LINES, buildReadCorpus, delegate } from "../delegate.js";
19
+ import { workerModel } from "../model/router.js";
20
+ const platformUrl = process.env.PLATFORM_URL;
21
+ const platformToken = process.env.PLATFORM_TOKEN;
22
+ const byokKey = process.env.OPENROUTER_API_KEY;
23
+ const apiKey = platformToken ?? byokKey;
24
+ if (!apiKey) {
25
+ console.error("Set OPENROUTER_API_KEY, or PLATFORM_URL + PLATFORM_TOKEN");
26
+ process.exit(1);
27
+ }
28
+ const platform = platformUrl && platformToken ? { baseUrl: platformUrl } : undefined;
29
+ const cwd = process.cwd();
30
+ /** Largest source file under cwd, since the point is to exercise the gate. */
31
+ function findLargest(dir, depth = 0) {
32
+ if (depth > 4)
33
+ return null;
34
+ let best = null;
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
37
+ continue;
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ const inner = findLargest(full, depth + 1);
41
+ if (inner && (!best || inner.lines > best.lines))
42
+ best = inner;
43
+ }
44
+ else if (/\.(ts|tsx|js|md|py|go|java)$/.test(entry.name)) {
45
+ const lines = fs.readFileSync(full, "utf8").split("\n").length;
46
+ if (!best || lines > best.lines)
47
+ best = { file: full, lines };
48
+ }
49
+ }
50
+ return best;
51
+ }
52
+ const target = findLargest(cwd);
53
+ if (!target) {
54
+ console.error(`No source files under ${cwd}`);
55
+ process.exit(1);
56
+ }
57
+ console.log(`transport: ${platform ? `platform (${platform.baseUrl})` : "BYOK direct"}`);
58
+ console.log(`target: ${target.file} (${target.lines} lines, threshold ${DELEGATION_MIN_LINES})`);
59
+ if (target.lines <= DELEGATION_MIN_LINES) {
60
+ console.log("note: under the threshold, so the gate will not fire on it this run");
61
+ }
62
+ let failures = 0;
63
+ function check(label, ok, detail = "") {
64
+ if (!ok)
65
+ failures++;
66
+ console.log(`${ok ? "✓" : "✗"} ${label}${detail ? `: ${detail}` : ""}`);
67
+ }
68
+ // 1. the worker itself
69
+ console.log("\n- worker call -");
70
+ const content = fs.readFileSync(target.file, "utf8");
71
+ const corpus = buildReadCorpus([{ path: target.file, content }], "What does this file do, and what are its main exports? Cite path:line.");
72
+ const model = workerModel();
73
+ const started = Date.now();
74
+ const result = await delegate({
75
+ apiKey,
76
+ model,
77
+ mode: "bulk-reader",
78
+ message: corpus,
79
+ cwd,
80
+ sessionId: "delegation-live",
81
+ ...(platform ? { platform } : {}),
82
+ });
83
+ const ms = Date.now() - started;
84
+ check("worker answered", result.text.length > 0);
85
+ check("answer is bullets, not prose", /^[-*•]/m.test(result.text));
86
+ check("worker burn was reported", result.usage.inputTokens > 0, `${result.usage.inputTokens} in / ${result.usage.outputTokens} out`);
87
+ console.log(` ${model} · ${ms}ms · corpus ${Math.ceil(corpus.length / 4)} tok → answer ${Math.ceil(result.text.length / 4)} tok ` +
88
+ `(${Math.round((1 - result.text.length / corpus.length) * 100)}% smaller)`);
89
+ console.log(result.text.split("\n").slice(0, 6).map((l) => ` | ${l}`).join("\n"));
90
+ // 2. a real agent turn, which must not read the file into its own context
91
+ console.log("\n- agent turn -");
92
+ const session = new AgentSession({
93
+ apiKey,
94
+ model: "auto",
95
+ cwd,
96
+ mode: "bypass",
97
+ ...(platform ? { platform } : {}),
98
+ });
99
+ let text = "";
100
+ const toolCalls = [];
101
+ let error = null;
102
+ for await (const ev of session.send(`Read ${target.file} and tell me in one sentence what it is for. Do not edit anything.`)) {
103
+ if (ev.type === "text_delta")
104
+ text += ev.text;
105
+ if (ev.type === "tool_end")
106
+ toolCalls.push(ev.call.name);
107
+ if (ev.type === "error")
108
+ error = ev.message;
109
+ }
110
+ check("turn completed", !error, error ?? "");
111
+ check("the agent answered", text.trim().length > 0);
112
+ console.log(` tools used: ${toolCalls.join(", ") || "(none)"}`);
113
+ console.log(` delegated: ${session.delegated.calls} call(s), ${session.delegated.contextTokensSaved} tokens kept out of context`);
114
+ console.log(` session: ${session.usage.inputTokens} in / ${session.usage.outputTokens} out over ${session.usage.requests} request(s)`);
115
+ if (target.lines > DELEGATION_MIN_LINES) {
116
+ check("a large file was delegated, not read whole", session.delegated.calls > 0);
117
+ check("worker burn rolled into the session total", session.usage.requests > session.delegated.workerUsage.requests);
118
+ }
119
+ console.log(` answer: ${text.trim().slice(0, 200)}`);
120
+ console.log(`\n${failures ? `${failures} failed` : "all live delegation checks passed"}`);
121
+ process.exit(failures ? 1 : 0);
122
+ //# sourceMappingURL=delegation-live.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Delegation checks, pure, no network.
3
+ * node dist/scripts/delegation-smoke.js
4
+ *
5
+ * Covers the two things that decide whether delegation is safe rather than just
6
+ * cheap: the read gate refuses without ever marking a file as read, and the
7
+ * tools appear only where delegating actually saves something.
8
+ */
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { AgentSession } from "../session.js";
13
+ import { readTool } from "../tools/fs-tools.js";
14
+ import { workerModel } from "../model/router.js";
15
+ import { DELEGATION_MIN_LINES, MAX_CORPUS_CHARS, buildReadCorpus, buildWriteCorpus, delegate, estimateCorpusTokens, stripFences, } from "../delegate.js";
16
+ let failures = 0;
17
+ function check(label, actual, expected) {
18
+ const ok = actual === expected;
19
+ if (!ok)
20
+ failures++;
21
+ console.log(`${ok ? "✓" : "✗"} ${label}${ok ? "" : `: got ${actual}, want ${expected}`}`);
22
+ }
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "9p-delegation-"));
24
+ const big = path.join(dir, "big.ts");
25
+ const small = path.join(dir, "small.ts");
26
+ fs.writeFileSync(big, Array.from({ length: 900 }, (_, i) => `const line${i} = ${i};`).join("\n"));
27
+ fs.writeFileSync(small, "const a = 1;\nconst b = 2;\n");
28
+ function ctx(armed) {
29
+ return {
30
+ cwd: dir,
31
+ readFiles: new Set(),
32
+ ...(armed ? { delegation: { minLines: DELEGATION_MIN_LINES } } : {}),
33
+ };
34
+ }
35
+ console.log("- read gate -");
36
+ {
37
+ const c = ctx(true);
38
+ const out = await readTool.run({ file_path: big }, c);
39
+ check("900-line whole-file read is refused", out.startsWith("Refused:"), true);
40
+ check("refusal names bulk_read", out.includes("bulk_read"), true);
41
+ check("refusal offers the targeted-read escape hatch", out.includes("offset/limit"), true);
42
+ // The invariant that keeps write/edit honest: a refused read must not count
43
+ // as having seen the file, or read-before-modify passes on a body nobody saw.
44
+ check("refused read does NOT mark the file as read", c.readFiles.has(big), false);
45
+ }
46
+ {
47
+ const c = ctx(true);
48
+ const out = await readTool.run({ file_path: big, offset: 10, limit: 5 }, c);
49
+ check("targeted read passes", out.startsWith("Refused:"), false);
50
+ check("targeted read returns 5 lines", out.split("\n").filter((l) => /^\d+\t/.test(l)).length, 5);
51
+ check("targeted read marks the file as read", c.readFiles.has(big), true);
52
+ }
53
+ {
54
+ const c = ctx(true);
55
+ const out = await readTool.run({ file_path: big, limit: 5 }, c);
56
+ check("limit alone counts as targeted", out.startsWith("Refused:"), false);
57
+ }
58
+ {
59
+ const c = ctx(true);
60
+ const out = await readTool.run({ file_path: small }, c);
61
+ check("small file passes whole", out.startsWith("Refused:"), false);
62
+ }
63
+ {
64
+ const c = ctx(false);
65
+ const out = await readTool.run({ file_path: big }, c);
66
+ check("gate is disarmed when delegation is off", out.startsWith("Refused:"), false);
67
+ check("disarmed read marks the file as read", c.readFiles.has(big), true);
68
+ }
69
+ console.log("\n- corpus building -");
70
+ {
71
+ const corpus = buildReadCorpus([{ path: "a.ts", content: "x\ny" }], "what?");
72
+ check("wraps files in a tagged block", corpus.includes('<file path="a.ts">'), true);
73
+ check("numbers lines from 1", corpus.includes("1\tx"), true);
74
+ check("numbers the second line", corpus.includes("2\ty"), true);
75
+ check("appends the question", corpus.trimEnd().endsWith("Question: what?"), true);
76
+ const write = buildWriteCorpus("make tests", [{ path: "r.ts", content: "ref" }]);
77
+ check("write corpus leads with the spec", write.startsWith("Spec: make tests"), true);
78
+ check("write corpus tags the reference", write.includes('<reference path="r.ts">'), true);
79
+ }
80
+ console.log("\n- fence stripping -");
81
+ check("strips a wrapping fence", stripFences("```ts\nconst a = 1;\n```"), "const a = 1;");
82
+ check("strips a bare fence", stripFences("```\nx\n```"), "x");
83
+ check("leaves unfenced code alone", stripFences("const a = 1;"), "const a = 1;");
84
+ // A fence inside a template literal is content, not formatting; only whole
85
+ // fence lines go, so indented ones survive.
86
+ check("keeps an indented fence inside code", stripFences("const s = `\n ```\n`;").includes(" ```"), true);
87
+ console.log("\n- worker model -");
88
+ {
89
+ const corePlan = [
90
+ { id: "z-ai/glm-4.7-flash", tier: "economy" },
91
+ { id: "anthropic/claude-sonnet-5", tier: "standard" },
92
+ ];
93
+ check("picks the cheapest economy model", workerModel(corePlan), "z-ai/glm-4.7-flash");
94
+ check("skips a locked economy model", workerModel([{ id: "z-ai/glm-4.7-flash", tier: "economy", locked: true }, ...corePlan.slice(1)]), "moonshotai/kimi-k2.7-code");
95
+ check("BYOK with no catalog takes the ladder head", workerModel(), "z-ai/glm-4.7-flash");
96
+ }
97
+ console.log("\n- oversized corpus is refused before any request -");
98
+ {
99
+ let message = "";
100
+ try {
101
+ await delegate({
102
+ apiKey: "unused",
103
+ model: "z-ai/glm-4.7-flash",
104
+ mode: "bulk-reader",
105
+ message: "x".repeat(MAX_CORPUS_CHARS + 1),
106
+ cwd: dir,
107
+ sessionId: "smoke",
108
+ });
109
+ }
110
+ catch (err) {
111
+ message = err instanceof Error ? err.message : String(err);
112
+ }
113
+ check("oversized corpus throws", message.includes("over the"), true);
114
+ check("and never reached the network", message.includes("fetch"), false);
115
+ }
116
+ console.log("\n- tool availability -");
117
+ {
118
+ const base = { apiKey: "k", model: "auto", cwd: dir };
119
+ const on = new AgentSession({ ...base });
120
+ check("bulk_read is on by default", on.toolNames.includes("bulk_read"), true);
121
+ check("code_write is on by default", on.toolNames.includes("code_write"), true);
122
+ const off = new AgentSession({ ...base, delegation: { enabled: false } });
123
+ check("delegation can be turned off", off.toolNames.includes("bulk_read"), false);
124
+ // An explore sub-agent already runs on the worker tier; delegating from it
125
+ // would pay a round trip to move work from a flash model to a flash model.
126
+ const explore = new AgentSession({ ...base, subagent: "explore", toolset: "read-only" });
127
+ check("explore sub-agents do not delegate", explore.toolNames.includes("bulk_read"), false);
128
+ const general = new AgentSession({ ...base, subagent: "general" });
129
+ check("general sub-agents do delegate", general.toolNames.includes("bulk_read"), true);
130
+ const readOnly = new AgentSession({ ...base, toolset: "read-only" });
131
+ check("read-only keeps bulk_read", readOnly.toolNames.includes("bulk_read"), true);
132
+ check("read-only drops code_write", readOnly.toolNames.includes("code_write"), false);
133
+ check("nothing delegated yet", on.delegated.calls, 0);
134
+ }
135
+ console.log("\n- token estimate matches compaction's -");
136
+ check("4 chars per token", estimateCorpusTokens(400), 100);
137
+ fs.rmSync(dir, { recursive: true, force: true });
138
+ console.log(`\n${failures ? `${failures} failed` : "all delegation checks passed"}`);
139
+ process.exit(failures ? 1 : 0);
140
+ //# sourceMappingURL=delegation-smoke.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Live check that Auto routing picks a model AND that the pick actually runs
3
+ * through the metered proxy (PLAN.md §5.5).
4
+ * PLATFORM_URL=http://localhost:4009/v1 PLATFORM_TOKEN=<jwt> node dist/scripts/router-live.js
5
+ */
6
+ import { AgentSession } from "../session.js";
7
+ import { AUTO_MODEL } from "../model/router.js";
8
+ const platformUrl = process.env.PLATFORM_URL;
9
+ const token = process.env.PLATFORM_TOKEN;
10
+ if (!platformUrl || !token) {
11
+ console.error("Set PLATFORM_URL + PLATFORM_TOKEN");
12
+ process.exit(1);
13
+ }
14
+ const res = await fetch(`${platformUrl}/models`, {
15
+ headers: { Authorization: `Bearer ${token}` },
16
+ });
17
+ const catalog = (await res.json()).models.map((m) => ({ id: m.id, tier: m.tier, locked: m.locked }));
18
+ console.log(`catalog: ${catalog.length} models, ${catalog.filter((m) => m.locked).length} locked\n`);
19
+ // One prompt per complexity band; each must route differently and still answer.
20
+ const cases = [
21
+ { label: "trivial", prompt: "what is 2+2? reply with just the number.", want: "z-ai/glm-4.7-flash" },
22
+ {
23
+ label: "normal",
24
+ prompt: "add a logout button to the settings page please",
25
+ want: "moonshotai/kimi-k2.7-code",
26
+ },
27
+ {
28
+ label: "complex",
29
+ prompt: "refactor the auth module to remove duplicated token parsing",
30
+ want: "anthropic/claude-sonnet-5",
31
+ },
32
+ ];
33
+ let failures = 0;
34
+ for (const c of cases) {
35
+ const session = new AgentSession({
36
+ apiKey: token,
37
+ model: AUTO_MODEL,
38
+ autoRouter: { bias: "balanced", catalog },
39
+ cwd: process.cwd(),
40
+ // NOT plan mode: plan mode forces every turn to "complex" by design, which
41
+ // would collapse all three cases onto the same model and prove nothing.
42
+ mode: "default",
43
+ platform: { baseUrl: platformUrl },
44
+ maxTurnsPerMessage: 1,
45
+ });
46
+ let picked = "";
47
+ let text = "";
48
+ let error = "";
49
+ for await (const ev of session.send(c.prompt)) {
50
+ if (ev.type === "model_selected")
51
+ picked = ev.model;
52
+ if (ev.type === "text_delta")
53
+ text += ev.text;
54
+ if (ev.type === "error")
55
+ error = ev.message;
56
+ }
57
+ const routedRight = picked === c.want;
58
+ if (!routedRight)
59
+ failures++;
60
+ // Upstream 402s (our OpenRouter balance) are reported but don't fail the
61
+ // routing check. The pick is what this script exists to verify.
62
+ const upstreamBroke = error.includes('"code":402');
63
+ const detail = error
64
+ ? upstreamBroke
65
+ ? "upstream 402 (OpenRouter account balance)"
66
+ : `ERROR ${error.slice(0, 90)}`
67
+ : `replied ${text.trim().slice(0, 40).replace(/\n/g, " ")}…`;
68
+ console.log(`${routedRight ? "✓" : "✗"} ${c.label.padEnd(8)} → ${picked || "(no pick)"}` +
69
+ `${routedRight ? "" : ` (want ${c.want})`}, ${detail}`);
70
+ }
71
+ console.log(`\n${failures ? `${failures} failed` : "live routing OK"}`);
72
+ process.exit(failures ? 1 : 0);
73
+ //# sourceMappingURL=router-live.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Auto router checks, pure, no network (PLAN.md §5.5).
3
+ * node dist/scripts/router-smoke.js
4
+ */
5
+ import { classify, route } from "../model/router.js";
6
+ let failures = 0;
7
+ function check(label, actual, expected) {
8
+ const ok = actual === expected;
9
+ if (!ok)
10
+ failures++;
11
+ console.log(`${ok ? "✓" : "✗"} ${label}${ok ? "" : `: got ${actual}, want ${expected}`}`);
12
+ }
13
+ // A Core-plan catalog: economy + standard usable, premium locked.
14
+ // K3 is premium as of 2026-08-14, priced above Sonnet 5 per heavy session.
15
+ const corePlan = [
16
+ { id: "z-ai/glm-4.7-flash", tier: "economy" },
17
+ { id: "z-ai/glm-4.7", tier: "economy" },
18
+ { id: "moonshotai/kimi-k2.7-code", tier: "economy" },
19
+ { id: "anthropic/claude-sonnet-5", tier: "standard" },
20
+ { id: "moonshotai/kimi-k3", tier: "premium", locked: true },
21
+ { id: "anthropic/claude-fable-5", tier: "premium", locked: true },
22
+ { id: "anthropic/claude-opus-4.8", tier: "premium", locked: true },
23
+ ];
24
+ const freePlan = corePlan.map((m) => m.tier === "economy" ? m : { ...m, locked: true });
25
+ console.log("- classification -");
26
+ check("typo fix is trivial", classify("fix the typo in the README"), "trivial");
27
+ check("short question is trivial", classify("what is this repo?"), "trivial");
28
+ check("plain feature ask is normal", classify("add a logout button to the settings page please"), "normal");
29
+ check("refactor is complex", classify("refactor the auth module"), "complex");
30
+ check("short debug ask is complex", classify("debug this"), "complex");
31
+ check("long prompt is complex", classify("a".repeat(700)), "complex");
32
+ check("plan mode forces complex", classify("rename x to y", true), "complex");
33
+ console.log("\n- routing (Core plan) -");
34
+ check("trivial → cheapest economy", route({ text: "fix the typo", available: corePlan }).model, "z-ai/glm-4.7-flash");
35
+ check("normal → K2.7-code anchor", route({ text: "add a logout button to the settings page please", available: corePlan }).model, "moonshotai/kimi-k2.7-code");
36
+ check("complex → Sonnet 5 (cheaper per heavy session than K3)", route({ text: "refactor the auth module", available: corePlan }).model, "anthropic/claude-sonnet-5");
37
+ check("Auto never routes to K3, even when fully unlocked", route({
38
+ text: "refactor the auth module",
39
+ available: corePlan.map((m) => ({ ...m, locked: false })),
40
+ }).model !== "moonshotai/kimi-k3", true);
41
+ console.log("\n- bias -");
42
+ check("economy bias downgrades a complex turn", route({ text: "refactor the auth module", bias: "economy", available: corePlan }).model, "moonshotai/kimi-k2.7-code");
43
+ check("quality bias on a complex turn wants premium, but Core is locked out", route({ text: "refactor the auth module", bias: "quality", available: corePlan }).model, "anthropic/claude-sonnet-5");
44
+ check("quality bias reaches premium when unlocked", route({
45
+ text: "refactor the auth module",
46
+ bias: "quality",
47
+ available: corePlan.map((m) => ({ ...m, locked: false })),
48
+ }).model, "anthropic/claude-fable-5");
49
+ console.log("\n- plan locks -");
50
+ check("Free plan never escapes economy on a complex turn", route({ text: "refactor the auth module", available: freePlan }).model, "moonshotai/kimi-k2.7-code");
51
+ console.log("\n- sub-agents -");
52
+ check("explore sub-agent is pinned to economy despite complex text", route({ text: "refactor the auth module", subagent: "explore", available: corePlan }).model, "z-ai/glm-4.7-flash");
53
+ check("general sub-agent routes normally", route({ text: "refactor the auth module", subagent: "general", available: corePlan }).model, "anthropic/claude-sonnet-5");
54
+ console.log("\n- BYOK (no catalog) -");
55
+ check("no catalog still routes to the complex ladder head", route({ text: "refactor the auth module" }).model, "anthropic/claude-sonnet-5");
56
+ console.log(`\n${failures ? `${failures} failed` : "all router checks passed"}`);
57
+ process.exit(failures ? 1 : 0);
58
+ //# sourceMappingURL=router-smoke.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Live end-to-end check of the agent loop against OpenRouter (bypass mode, cheap model).
3
+ * OPENROUTER_API_KEY=... node dist/scripts/smoke.js ["custom prompt"]
4
+ */
5
+ import { AgentSession } from "../session.js";
6
+ import { McpManager } from "../mcp.js";
7
+ // Platform mode: PLATFORM_URL=http://localhost:4009/v1 PLATFORM_TOKEN=<jwt>
8
+ // Direct mode: OPENROUTER_API_KEY=sk-or-...
9
+ const platformUrl = process.env.PLATFORM_URL;
10
+ const apiKey = platformUrl ? process.env.PLATFORM_TOKEN : process.env.OPENROUTER_API_KEY;
11
+ if (!apiKey) {
12
+ console.error("Set OPENROUTER_API_KEY, or PLATFORM_URL + PLATFORM_TOKEN");
13
+ process.exit(1);
14
+ }
15
+ const mcp = await McpManager.fromCwd(process.cwd());
16
+ if (mcp.servers.length) {
17
+ console.log(`[mcp] connected: ${mcp.servers.join(", ")} (${mcp.tools.length} tools)`);
18
+ }
19
+ const session = new AgentSession({
20
+ apiKey,
21
+ mcp,
22
+ model: process.env.SMOKE_MODEL ?? "z-ai/glm-4.7-flash",
23
+ cwd: process.cwd(),
24
+ mode: "bypass",
25
+ ...(platformUrl ? { platform: { baseUrl: platformUrl } } : {}),
26
+ });
27
+ const prompt = process.argv[2] ??
28
+ "Find every package.json in this workspace with glob, read the root one, and report each workspace package's name and license on one line each.";
29
+ for await (const ev of session.send(prompt)) {
30
+ switch (ev.type) {
31
+ case "text_delta":
32
+ process.stdout.write(ev.text);
33
+ break;
34
+ case "tool_start":
35
+ console.log(`\n[tool] ${ev.summary}`);
36
+ break;
37
+ case "tool_end":
38
+ console.log(`[done ${ev.durationMs}ms${ev.isError ? " ERROR" : ""}] ${ev.output.split("\n")[0]?.slice(0, 100) ?? ""}`);
39
+ break;
40
+ case "permission_denied":
41
+ console.log(`\n[denied] ${ev.summary}`);
42
+ break;
43
+ case "turn_end":
44
+ console.log(`\n\n[usage] ${ev.usage.requests} calls · in=${ev.usage.inputTokens} (cached=${ev.usage.cachedTokens}) · out=${ev.usage.outputTokens}`);
45
+ break;
46
+ case "error":
47
+ console.error(`\n[error] ${ev.message}`);
48
+ process.exitCode = 1;
49
+ }
50
+ }
51
+ await mcp.close();
52
+ //# sourceMappingURL=smoke.js.map