toolnexus 0.18.0 → 0.19.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 +17 -410
- package/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +64 -3
- package/dist/agents/runtime.js +88 -19
- package/dist/builtin.js +18 -12
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +30 -24
- package/dist/client.d.ts +98 -12
- package/dist/client.js +129 -31
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +142 -24
- package/src/builtin.ts +18 -9
- package/src/classifier.ts +42 -26
- package/src/client.ts +156 -43
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/dist/agents/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createClient, InMemoryConversationStore } from "../client.js";
|
|
1
|
+
import { createClient, createInProcessFetch, InMemoryConversationStore, } from "../client.js";
|
|
2
2
|
import { runGated } from "./loop.js";
|
|
3
3
|
import { createToolkit } from "../toolkit.js";
|
|
4
4
|
import { defineTool } from "../native.js";
|
|
@@ -11,6 +11,49 @@ export const systemClock = {
|
|
|
11
11
|
return () => clearTimeout(t);
|
|
12
12
|
},
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
|
|
16
|
+
* §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
|
|
17
|
+
* `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
|
|
18
|
+
* running — never that an LLM call timed out. Two fields are called `status`; they are not
|
|
19
|
+
* interchangeable (ADR 0027 D2.1).
|
|
20
|
+
*/
|
|
21
|
+
export const TASK_STATUSES = [
|
|
22
|
+
"done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
|
|
26
|
+
* run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
|
|
27
|
+
* `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
|
|
28
|
+
* stopped me" is only answerable if the answer is the same word everywhere.
|
|
29
|
+
*
|
|
30
|
+
* A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
|
|
31
|
+
* the field; `canonicalLimit` is the boundary that enforces that here.
|
|
32
|
+
*/
|
|
33
|
+
export const TASK_LIMITS = [
|
|
34
|
+
"maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
|
|
35
|
+
"completion", "timeout",
|
|
36
|
+
];
|
|
37
|
+
/** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
|
|
38
|
+
* leaked internal name. */
|
|
39
|
+
export function canonicalLimit(limit) {
|
|
40
|
+
return limit && TASK_LIMITS.includes(limit) ? limit : undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
|
|
44
|
+
* file spells its status and limit through these, never as a string literal, so a rename cannot
|
|
45
|
+
* silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
|
|
46
|
+
* empty `limit` in three of the seven ports (addendum A18).
|
|
47
|
+
*/
|
|
48
|
+
export const S = Object.freeze({
|
|
49
|
+
done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
|
|
50
|
+
closed: "closed", timeout: "timeout", error: "error",
|
|
51
|
+
});
|
|
52
|
+
export const L = Object.freeze({
|
|
53
|
+
maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
|
|
54
|
+
maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
|
|
55
|
+
completion: "completion", timeout: "timeout",
|
|
56
|
+
});
|
|
14
57
|
/** Type guard for the `spawn` return union. */
|
|
15
58
|
export function isVerbError(x) {
|
|
16
59
|
return typeof x === "object" && x !== null && "error" in x && typeof x.error === "string";
|
|
@@ -36,6 +79,8 @@ export class Handle {
|
|
|
36
79
|
deadline;
|
|
37
80
|
/** Total tokens attributed to this handle (own turns + descendant roll-up). */
|
|
38
81
|
usageTotal = 0;
|
|
82
|
+
/** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
|
|
83
|
+
ownTokens = 0;
|
|
39
84
|
/** Total tool calls attributed to this handle (own + descendant roll-up). */
|
|
40
85
|
toolCallsTotal = 0;
|
|
41
86
|
/** LLM round trips run by this handle itself (never reset — resume grows it). */
|
|
@@ -112,6 +157,18 @@ export class AgentRuntime {
|
|
|
112
157
|
turnSlots;
|
|
113
158
|
turnQueue = [];
|
|
114
159
|
constructor(opts) {
|
|
160
|
+
if (opts.fetch && opts.inProcess) {
|
|
161
|
+
throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both");
|
|
162
|
+
}
|
|
163
|
+
if (opts.llm && opts.inProcess) {
|
|
164
|
+
throw new Error("toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at");
|
|
165
|
+
}
|
|
166
|
+
if (opts.inProcess) {
|
|
167
|
+
// Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
|
|
168
|
+
// runtime's in-process wiring is a CALLER of that export, not a second copy
|
|
169
|
+
// of the request/response assembly.
|
|
170
|
+
opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) };
|
|
171
|
+
}
|
|
115
172
|
this.opts = opts;
|
|
116
173
|
this.registry = opts.registry;
|
|
117
174
|
this.clock = opts.clock ?? systemClock;
|
|
@@ -207,11 +264,11 @@ export class AgentRuntime {
|
|
|
207
264
|
if (opts?.by && h.parent !== opts.by) {
|
|
208
265
|
return Promise.resolve({
|
|
209
266
|
text: `wait refused: only the spawner may wait on ${h.id}`,
|
|
210
|
-
isError: true, status:
|
|
267
|
+
isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
211
268
|
});
|
|
212
269
|
}
|
|
213
270
|
if (h.state === "closed") {
|
|
214
|
-
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status:
|
|
271
|
+
return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens });
|
|
215
272
|
}
|
|
216
273
|
if (h.state === "suspended")
|
|
217
274
|
return Promise.resolve(this.pendingResult(h));
|
|
@@ -233,7 +290,7 @@ export class AgentRuntime {
|
|
|
233
290
|
const i = h.waiters.indexOf(finish);
|
|
234
291
|
if (i >= 0)
|
|
235
292
|
h.waiters.splice(i, 1);
|
|
236
|
-
finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status:
|
|
293
|
+
finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: S.timeout, limit: L.timeout, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens });
|
|
237
294
|
}, opts.timeoutMs);
|
|
238
295
|
}
|
|
239
296
|
});
|
|
@@ -290,7 +347,7 @@ export class AgentRuntime {
|
|
|
290
347
|
await h.def.onClose?.(h, reason);
|
|
291
348
|
const prev = h.state;
|
|
292
349
|
h.state = "closed";
|
|
293
|
-
const final = { text: "closed", isError: true, status:
|
|
350
|
+
const final = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
|
|
294
351
|
for (const w of h.waiters.splice(0))
|
|
295
352
|
w(final);
|
|
296
353
|
this.t(`${h.id}: ${prev}→closed (${reason})`);
|
|
@@ -324,13 +381,16 @@ export class AgentRuntime {
|
|
|
324
381
|
if (!leaf)
|
|
325
382
|
throw new Error("no suspended handle to resume");
|
|
326
383
|
this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`);
|
|
327
|
-
|
|
384
|
+
// The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
|
|
385
|
+
// would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
|
|
386
|
+
let topmost = await this.resumeSuspended(leaf, answer, { inline: false });
|
|
328
387
|
for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
|
|
329
388
|
if (p.state !== "suspended")
|
|
330
389
|
break;
|
|
331
390
|
this.t(`${p.id}: cascade resume (reattaching delegated work)`);
|
|
332
|
-
await this.resumeSuspended(p, undefined, { inline: false });
|
|
391
|
+
topmost = await this.resumeSuspended(p, undefined, { inline: false });
|
|
333
392
|
}
|
|
393
|
+
return topmost;
|
|
334
394
|
}
|
|
335
395
|
deepestSuspended(h) {
|
|
336
396
|
for (const c of h.children) {
|
|
@@ -377,22 +437,25 @@ export class AgentRuntime {
|
|
|
377
437
|
const now = this.clock.now();
|
|
378
438
|
for (let a = h; a; a = a.parent) {
|
|
379
439
|
if (a.pool.tokens <= 0)
|
|
380
|
-
return
|
|
440
|
+
return L.maxTokens;
|
|
381
441
|
if (a.pool.toolCalls <= 0)
|
|
382
|
-
return
|
|
442
|
+
return L.maxToolCalls;
|
|
383
443
|
if (a.deadline !== undefined && now >= a.deadline)
|
|
384
|
-
return
|
|
444
|
+
return L.maxWallMs;
|
|
385
445
|
}
|
|
386
446
|
return undefined;
|
|
387
447
|
}
|
|
388
448
|
/** Lifetime turn cap on the handle itself (turns never reset across resumes). */
|
|
389
449
|
turnCap(h) {
|
|
390
|
-
return h.turnsTotal >= h.eff.maxTurns ?
|
|
450
|
+
return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined;
|
|
391
451
|
}
|
|
392
452
|
/** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
|
|
393
453
|
settleRefused(h, limit) {
|
|
454
|
+
// `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
|
|
455
|
+
// `canonicalLimit` below is the guard that keeps it that way if either ever changes.
|
|
394
456
|
const text = `budget exhausted (${limit}); partial work preserved`;
|
|
395
|
-
|
|
457
|
+
// The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
|
|
458
|
+
const r = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit };
|
|
396
459
|
h.lastResult = r;
|
|
397
460
|
for (const w of h.waiters.splice(0))
|
|
398
461
|
w(r);
|
|
@@ -400,6 +463,7 @@ export class AgentRuntime {
|
|
|
400
463
|
}
|
|
401
464
|
/** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
|
|
402
465
|
rollUp(h, tokens, toolCalls) {
|
|
466
|
+
h.ownTokens += tokens; // own figure: outside the ancestor walk, by construction
|
|
403
467
|
for (let a = h; a; a = a.parent) {
|
|
404
468
|
a.usageTotal += tokens;
|
|
405
469
|
a.toolCallsTotal += toolCalls;
|
|
@@ -524,7 +588,7 @@ export class AgentRuntime {
|
|
|
524
588
|
}
|
|
525
589
|
pendingResult(h) {
|
|
526
590
|
const req = h.pendingRequest;
|
|
527
|
-
return { text: req?.prompt ?? "", isError: false, status:
|
|
591
|
+
return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
|
|
528
592
|
}
|
|
529
593
|
/** The escalation seam: this handle's §10 interpreter authority, with the
|
|
530
594
|
* escalation traced (nearest interpreter wins; the Request's `data.path` names
|
|
@@ -592,7 +656,7 @@ export class AgentRuntime {
|
|
|
592
656
|
h.checkpoint = { input };
|
|
593
657
|
h.drained = []; // drained items ride the checkpointed input, not the inbox
|
|
594
658
|
this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`);
|
|
595
|
-
result = { text: r.text, isError: false, status:
|
|
659
|
+
result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
|
|
596
660
|
}
|
|
597
661
|
else if (r.status === "incomplete") {
|
|
598
662
|
h.state = "idle";
|
|
@@ -602,14 +666,18 @@ export class AgentRuntime {
|
|
|
602
666
|
// generic sentence. Reading `limit` is how the runtime tells them apart.
|
|
603
667
|
result = {
|
|
604
668
|
text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
|
|
605
|
-
isError: true, status:
|
|
669
|
+
isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
670
|
+
// The LATENT instance golang found: forwarding the client's `limit` straight through
|
|
671
|
+
// would hand a host `incomplete` with an empty limit whenever the client omits it.
|
|
672
|
+
// The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
|
|
673
|
+
limit: canonicalLimit(r.limit) ?? L.maxTurns,
|
|
606
674
|
};
|
|
607
675
|
}
|
|
608
676
|
else {
|
|
609
677
|
h.state = "idle";
|
|
610
678
|
h.drained = [];
|
|
611
679
|
this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`);
|
|
612
|
-
result = { text: r.text, isError: false, status:
|
|
680
|
+
result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens };
|
|
613
681
|
}
|
|
614
682
|
}
|
|
615
683
|
catch (e) {
|
|
@@ -624,8 +692,9 @@ export class AgentRuntime {
|
|
|
624
692
|
this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`);
|
|
625
693
|
result = {
|
|
626
694
|
text: msg, isError: true,
|
|
627
|
-
|
|
628
|
-
|
|
695
|
+
// None of these three is a limit stop, so none carries a `limit` (A18).
|
|
696
|
+
status: closing ? S.closed : aborted ? S.interrupted : S.error,
|
|
697
|
+
turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
|
|
629
698
|
};
|
|
630
699
|
}
|
|
631
700
|
finally {
|
|
@@ -703,7 +772,7 @@ export class AgentRuntime {
|
|
|
703
772
|
r = await rt.wait(existing, { by: parent });
|
|
704
773
|
}
|
|
705
774
|
else {
|
|
706
|
-
r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status:
|
|
775
|
+
r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: S.error, turns: existing.turnsTotal, totalTokens: existing.usageTotal, ownTokens: existing.ownTokens };
|
|
707
776
|
}
|
|
708
777
|
}
|
|
709
778
|
else {
|
package/dist/builtin.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import fs from "node:fs/promises";
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { pending } from "./types.js";
|
|
15
16
|
import { mediaTypeFor } from "./content.js";
|
|
@@ -108,7 +109,9 @@ function walkFiles(root) {
|
|
|
108
109
|
const dir = stack.pop();
|
|
109
110
|
let entries;
|
|
110
111
|
try {
|
|
111
|
-
|
|
112
|
+
// EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
|
|
113
|
+
// model is shown, not merely their order — the same content bug as <skill_files> had.
|
|
114
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }));
|
|
112
115
|
}
|
|
113
116
|
catch {
|
|
114
117
|
continue;
|
|
@@ -311,11 +314,13 @@ function grepTool() {
|
|
|
311
314
|
const root = args.path ? String(args.path) : process.cwd();
|
|
312
315
|
const include = args.include ? String(args.include) : undefined;
|
|
313
316
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
314
|
-
|
|
317
|
+
// Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
|
|
318
|
+
// CONTENT and the order of the results were whatever the walk happened to reach first.
|
|
319
|
+
// Now: collect every match, order by relative path (code point) then line number ascending,
|
|
320
|
+
// and truncate last. The emitted path and the sort key are the same string (A28).
|
|
321
|
+
const hits = [];
|
|
315
322
|
for (const file of walkFiles(root)) {
|
|
316
|
-
|
|
317
|
-
break;
|
|
318
|
-
const rel = path.relative(root, file);
|
|
323
|
+
const rel = toPosixPath(path.relative(root, file), path.sep);
|
|
319
324
|
if (include && !matchGlob(rel, include))
|
|
320
325
|
continue;
|
|
321
326
|
let text;
|
|
@@ -327,12 +332,12 @@ function grepTool() {
|
|
|
327
332
|
}
|
|
328
333
|
const lines = text.split("\n");
|
|
329
334
|
for (let i = 0; i < lines.length; i++) {
|
|
330
|
-
if (matches.length >= limit)
|
|
331
|
-
break;
|
|
332
335
|
if (re.test(lines[i]))
|
|
333
|
-
|
|
336
|
+
hits.push({ rel, line: i + 1, text: lines[i] });
|
|
334
337
|
}
|
|
335
338
|
}
|
|
339
|
+
hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line);
|
|
340
|
+
const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`);
|
|
336
341
|
return ok(matches.join("\n"), { count: matches.length });
|
|
337
342
|
});
|
|
338
343
|
}
|
|
@@ -352,15 +357,16 @@ function globTool() {
|
|
|
352
357
|
return err("glob: pattern is required");
|
|
353
358
|
const root = args.path ? String(args.path) : process.cwd();
|
|
354
359
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
360
|
+
// Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
|
|
361
|
+
// the filesystem decide WHICH files the model sees, not merely their order. The emitted
|
|
362
|
+
// string and the sort key are the SAME `/`-separated relative path (A28).
|
|
355
363
|
const found = [];
|
|
356
364
|
for (const file of walkFiles(root)) {
|
|
357
|
-
|
|
358
|
-
break;
|
|
359
|
-
const rel = path.relative(root, file);
|
|
365
|
+
const rel = toPosixPath(path.relative(root, file), path.sep);
|
|
360
366
|
if (matchGlob(rel, pattern))
|
|
361
367
|
found.push(rel);
|
|
362
368
|
}
|
|
363
|
-
found.sort();
|
|
369
|
+
found.sort(compareCodePoints);
|
|
364
370
|
return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) });
|
|
365
371
|
});
|
|
366
372
|
}
|
package/dist/classifier.d.ts
CHANGED
|
@@ -19,6 +19,18 @@ export declare const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
|
|
|
19
19
|
export declare const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
|
|
20
20
|
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
21
21
|
export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10000;
|
|
22
|
+
/**
|
|
23
|
+
* A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
|
|
24
|
+
* a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
|
|
25
|
+
* `typesafe/jev-1.13`, and mixing the two is issue #91.
|
|
26
|
+
*/
|
|
27
|
+
export type ClassifierBackend = "typesafe" | "openrouter";
|
|
28
|
+
/** The two servable pairings, as configurations rather than six independently-choosable cells. */
|
|
29
|
+
export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
model: string;
|
|
32
|
+
apiKeyEnv: string;
|
|
33
|
+
}>>;
|
|
22
34
|
/** Client-side cap on a choice's named options (§8B). */
|
|
23
35
|
export declare const MAX_CHOICE_OPTIONS = 255;
|
|
24
36
|
/** Bounds of a score rubric (§8B). */
|
|
@@ -217,6 +229,11 @@ export type EvaluateFn = (state: unknown, questions: Record<string, Question>, s
|
|
|
217
229
|
export interface ClassifierOptions {
|
|
218
230
|
/** Default `"systemone"`. */
|
|
219
231
|
style?: ClassifierStyle;
|
|
232
|
+
/** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
|
|
233
|
+
* UNIT. Default `"typesafe"`. The three individual options still win where given, for a
|
|
234
|
+
* self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
|
|
235
|
+
* as an "Unknown model" 400. */
|
|
236
|
+
backend?: ClassifierBackend;
|
|
220
237
|
/** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
|
|
221
238
|
baseUrl?: string;
|
|
222
239
|
/** Default `"jev-latest"`. Pin it once thresholds are tuned. */
|
|
@@ -332,3 +349,9 @@ export declare function createClassifier(opts?: ClassifierOptions): Classifier;
|
|
|
332
349
|
* the reason, or "" when the criteria are fine.
|
|
333
350
|
*/
|
|
334
351
|
export declare function degenerateCriteria(criteria: Record<string, string>): string;
|
|
352
|
+
/**
|
|
353
|
+
* The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
|
|
354
|
+
* pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
|
|
355
|
+
* with, or `""` when the pairing is fine.
|
|
356
|
+
*/
|
|
357
|
+
export declare function backendMismatch(baseUrl: string, model: string): string;
|
package/dist/classifier.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactErrorBody, capErrorBody } from "./client.js";
|
|
1
2
|
import { retryAfterMs, isRetryableStatus } from "./retry.js";
|
|
2
3
|
// ---------------------------------------------------------------- constants
|
|
3
4
|
/** The System One endpoint base. */
|
|
@@ -8,6 +9,11 @@ export const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
|
|
|
8
9
|
export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
|
|
9
10
|
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
10
11
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000;
|
|
12
|
+
/** The two servable pairings, as configurations rather than six independently-choosable cells. */
|
|
13
|
+
export const CLASSIFIER_BACKENDS = {
|
|
14
|
+
typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
|
|
15
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
|
|
16
|
+
};
|
|
11
17
|
/** Client-side cap on a choice's named options (§8B). */
|
|
12
18
|
export const MAX_CHOICE_OPTIONS = 255;
|
|
13
19
|
/** Bounds of a score rubric (§8B). */
|
|
@@ -261,9 +267,13 @@ export class Classifier {
|
|
|
261
267
|
constructor(opts) {
|
|
262
268
|
this.opts = opts;
|
|
263
269
|
this.style = opts.style ?? "systemone";
|
|
264
|
-
|
|
265
|
-
this.
|
|
266
|
-
this.
|
|
270
|
+
const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"];
|
|
271
|
+
this.baseUrl = opts.baseUrl ?? preset.baseUrl;
|
|
272
|
+
this.model = opts.model ?? preset.model;
|
|
273
|
+
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv;
|
|
274
|
+
const mismatch = backendMismatch(this.baseUrl, this.model);
|
|
275
|
+
if (mismatch)
|
|
276
|
+
throw new Error(mismatch);
|
|
267
277
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
|
|
268
278
|
switch (this.style) {
|
|
269
279
|
case "systemone":
|
|
@@ -512,7 +522,8 @@ export class Classifier {
|
|
|
512
522
|
'{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
|
|
513
523
|
'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
|
|
514
524
|
'"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.';
|
|
515
|
-
|
|
525
|
+
// No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
|
|
526
|
+
const run = await this.opts.client.run(prompt, { signal });
|
|
516
527
|
const payload = firstJsonObject(run.text);
|
|
517
528
|
let parsed;
|
|
518
529
|
try {
|
|
@@ -579,10 +590,22 @@ export function degenerateCriteria(criteria) {
|
|
|
579
590
|
function cause(status, body) {
|
|
580
591
|
if (status === 401 || status === 403)
|
|
581
592
|
return "";
|
|
582
|
-
|
|
583
|
-
|
|
593
|
+
// ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
|
|
594
|
+
const s = capErrorBody(redactErrorBody(body));
|
|
595
|
+
return s ? ": " + s : "";
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
|
|
599
|
+
* pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
|
|
600
|
+
* with, or `""` when the pairing is fine.
|
|
601
|
+
*/
|
|
602
|
+
export function backendMismatch(baseUrl, model) {
|
|
603
|
+
const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase();
|
|
604
|
+
if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai"))
|
|
605
|
+
return "";
|
|
606
|
+
if (!/^jev-/.test(model))
|
|
584
607
|
return "";
|
|
585
|
-
return
|
|
608
|
+
return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`;
|
|
586
609
|
}
|
|
587
610
|
/** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
|
|
588
611
|
function expandEnv(value) {
|
|
@@ -611,20 +634,3 @@ function sleep(ms) {
|
|
|
611
634
|
setTimeout(resolve, ms);
|
|
612
635
|
});
|
|
613
636
|
}
|
|
614
|
-
/**
|
|
615
|
-
* The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
|
|
616
|
-
* `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
|
|
617
|
-
* public "no tools" Toolkit constructor just for this path.
|
|
618
|
-
*/
|
|
619
|
-
const EMPTY_TOOLKIT = {
|
|
620
|
-
skillsPrompt: () => "",
|
|
621
|
-
toOpenAI: () => [],
|
|
622
|
-
toAnthropic: () => [],
|
|
623
|
-
toGemini: () => [],
|
|
624
|
-
get: () => undefined,
|
|
625
|
-
list: () => [],
|
|
626
|
-
execute: async () => {
|
|
627
|
-
throw new Error("classifier: the llm style declares no tools");
|
|
628
|
-
},
|
|
629
|
-
close: async () => { },
|
|
630
|
-
};
|
package/dist/client.d.ts
CHANGED
|
@@ -41,7 +41,18 @@ export interface ClientOptions {
|
|
|
41
41
|
/** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
|
|
42
42
|
* this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
|
|
43
43
|
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
44
|
-
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
|
|
44
|
+
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
|
|
45
|
+
*
|
|
46
|
+
* IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
|
|
47
|
+
* turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
|
|
48
|
+
* REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
|
|
49
|
+
* itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
|
|
50
|
+
* bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
|
|
51
|
+
* side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
|
|
52
|
+
* responsibility. Any tool reachable in a suspendable turn must be idempotent.
|
|
53
|
+
* Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
|
|
54
|
+
* remove this requirement anyway.
|
|
55
|
+
*/
|
|
45
56
|
waitFor?: (request: Request) => Promise<Answer>;
|
|
46
57
|
/** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
|
|
47
58
|
* its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
|
|
@@ -68,6 +79,60 @@ export interface ClientOptions {
|
|
|
68
79
|
* text placeholder. Unset ⇒ attached parts error, derived parts degrade. */
|
|
69
80
|
onUnsupportedPart?: UnsupportedPartMode;
|
|
70
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
|
|
84
|
+
* §7D AGENT vocabulary (`agents.TASK_STATUSES`), which also contains `"timeout"`, `"closed"`,
|
|
85
|
+
* `"interrupted"` and `"error"`. Two fields are called `status`; they are not interchangeable.
|
|
86
|
+
* A run that fails at a deadline THROWS here — it never comes back as a `RunResult`.
|
|
87
|
+
*/
|
|
88
|
+
export declare const RUN_STATUSES: readonly ["done", "pending", "incomplete"];
|
|
89
|
+
export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
90
|
+
/** Account-identifier keys blanked out of a provider error body before it reaches a message,
|
|
91
|
+
* a log or a metric. The shape survives; the value does not (ADR 0027 D3.2). */
|
|
92
|
+
export declare const REDACTED_BODY_KEYS: readonly ["user_id", "account_id", "org_id", "organization"];
|
|
93
|
+
/** What a redacted value is replaced with — identical in all seven ports. */
|
|
94
|
+
export declare const REDACTION_PLACEHOLDER = "\u00ABredacted\u00BB";
|
|
95
|
+
/** A redacted body is capped at this many characters IN THE MESSAGE ONLY. The typed `body`
|
|
96
|
+
* field carries the full redacted body — a host that opted into a typed error asked for the
|
|
97
|
+
* whole thing (ADR 0027 D3 / addendum A5). */
|
|
98
|
+
export declare const ERROR_BODY_CAP = 200;
|
|
99
|
+
/**
|
|
100
|
+
* Redact known account identifiers, then cap. A CAP IS NOT REDACTION: the body that leaked
|
|
101
|
+
* `user_2…` in issue #92 was 96 bytes, well inside the cap, so both steps are needed and
|
|
102
|
+
* redaction runs FIRST (capping first could split a key/value pair and hide it from the regex).
|
|
103
|
+
*/
|
|
104
|
+
export declare function redactErrorBody(body: string): string;
|
|
105
|
+
/** Cap a already-redacted body for interpolation into a MESSAGE. Redaction is not a cap and a
|
|
106
|
+
* cap is not redaction: they are independent and both are needed. */
|
|
107
|
+
export declare function capErrorBody(body: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* A provider failure carried as a VALUE, not a sentence (ADR 0027 D3.1). A host that must
|
|
110
|
+
* decide what to log can read `status` / `retryAfter` instead of parsing a message.
|
|
111
|
+
*
|
|
112
|
+
* `body` is ALREADY redacted and capped, and is `""` on 401/403 — an auth body routinely
|
|
113
|
+
* reflects the credential or the header that was sent. `rawBody` is never populated by the
|
|
114
|
+
* library; the SPEC credentials guarantee now covers error messages too.
|
|
115
|
+
*/
|
|
116
|
+
export declare class LlmHttpError extends Error {
|
|
117
|
+
/** The HTTP status the provider answered with. */
|
|
118
|
+
readonly status: number;
|
|
119
|
+
/** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
|
|
120
|
+
* applies to `message` only. */
|
|
121
|
+
readonly body: string;
|
|
122
|
+
/** The `Retry-After` header verbatim, when the provider sent one. */
|
|
123
|
+
readonly retryAfter?: string | undefined;
|
|
124
|
+
readonly name = "LlmHttpError";
|
|
125
|
+
constructor(
|
|
126
|
+
/** The HTTP status the provider answered with. */
|
|
127
|
+
status: number,
|
|
128
|
+
/** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
|
|
129
|
+
* applies to `message` only. */
|
|
130
|
+
body: string,
|
|
131
|
+
/** The `Retry-After` header verbatim, when the provider sent one. */
|
|
132
|
+
retryAfter?: string | undefined);
|
|
133
|
+
}
|
|
134
|
+
/** Build the typed error from a non-2xx provider response (reads the body once). */
|
|
135
|
+
export declare function llmHttpError(res: Response): Promise<LlmHttpError>;
|
|
71
136
|
/** §8 Resilience. What to do with a failed LLM call. */
|
|
72
137
|
export type ErrorTier = "retry" | "fail";
|
|
73
138
|
/** §8 Resilience. Context passed to `onError` for each failed LLM attempt. */
|
|
@@ -284,8 +349,14 @@ export declare class Client {
|
|
|
284
349
|
private endRun;
|
|
285
350
|
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
286
351
|
private emitRunError;
|
|
287
|
-
|
|
288
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
|
|
354
|
+
* COMPLETION (SPEC §0.10). The system message is then the system prompt alone, and the
|
|
355
|
+
* request body carries no `tools` and no `tool_choice` key (not an empty array). An empty
|
|
356
|
+
* toolkit and no toolkit are observably identical on the wire.
|
|
357
|
+
*/
|
|
358
|
+
run(prompt: PromptInput, ctx?: {
|
|
359
|
+
toolkit?: Toolkit;
|
|
289
360
|
signal?: AbortSignal;
|
|
290
361
|
history?: any[];
|
|
291
362
|
}): Promise<RunResult>;
|
|
@@ -317,15 +388,15 @@ export declare class Client {
|
|
|
317
388
|
* the answer — so the next `ask` with the same `id` continues it. Without an
|
|
318
389
|
* `id` it is a stateless one-shot (identical to `run`).
|
|
319
390
|
*/
|
|
320
|
-
ask(prompt: PromptInput, ctx
|
|
321
|
-
toolkit
|
|
391
|
+
ask(prompt: PromptInput, ctx?: {
|
|
392
|
+
toolkit?: Toolkit;
|
|
322
393
|
id?: string;
|
|
323
394
|
on_text?: (delta: string) => void;
|
|
324
395
|
signal?: AbortSignal;
|
|
325
396
|
}): Promise<RunResult>;
|
|
326
397
|
/** A stateful multi-turn conversation that retains history across sends. */
|
|
327
|
-
conversation(ctx
|
|
328
|
-
toolkit
|
|
398
|
+
conversation(ctx?: {
|
|
399
|
+
toolkit?: Toolkit;
|
|
329
400
|
signal?: AbortSignal;
|
|
330
401
|
}): Conversation;
|
|
331
402
|
/**
|
|
@@ -333,11 +404,13 @@ export declare class Client {
|
|
|
333
404
|
* With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
|
|
334
405
|
* streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
|
|
335
406
|
*/
|
|
336
|
-
stream(prompt: PromptInput, ctx
|
|
337
|
-
toolkit
|
|
407
|
+
stream(prompt: PromptInput, ctx?: {
|
|
408
|
+
toolkit?: Toolkit;
|
|
338
409
|
id?: string;
|
|
339
410
|
signal?: AbortSignal;
|
|
340
411
|
}): AsyncGenerator<StreamEvent, void, unknown>;
|
|
412
|
+
/** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
|
|
413
|
+
* the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
|
|
341
414
|
private system;
|
|
342
415
|
/** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
|
|
343
416
|
private makeSignal;
|
|
@@ -400,6 +473,17 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
|
|
|
400
473
|
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
401
474
|
generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
|
|
402
475
|
};
|
|
476
|
+
/**
|
|
477
|
+
* Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
|
|
478
|
+
* the reusable piece of {@link createInProcessClient} — assembles the wire request,
|
|
479
|
+
* calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
|
|
480
|
+
* caller that wants an in-process model behind a `fetch` slot (the top-level client,
|
|
481
|
+
* or the agent runtime's `inProcess` option) goes through this ONE function, so there
|
|
482
|
+
* is no second copy of the request/response assembly to drift.
|
|
483
|
+
*
|
|
484
|
+
* Streaming is refused rather than faked — see the thrown error.
|
|
485
|
+
*/
|
|
486
|
+
export declare function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch;
|
|
403
487
|
/**
|
|
404
488
|
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
405
489
|
* HTTP types to construct.
|
|
@@ -407,18 +491,20 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
|
|
|
407
491
|
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
408
492
|
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
409
493
|
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
494
|
+
* The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
|
|
495
|
+
* is just that adapter wired into a `Client`.
|
|
410
496
|
*
|
|
411
|
-
* Streaming is refused rather than faked — see
|
|
497
|
+
* Streaming is refused rather than faked — see {@link createInProcessFetch}.
|
|
412
498
|
*/
|
|
413
499
|
export declare function createInProcessClient(opts: InProcessOptions): Client;
|
|
414
500
|
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
415
501
|
export declare class Conversation {
|
|
416
502
|
private readonly client;
|
|
417
|
-
private readonly toolkit
|
|
503
|
+
private readonly toolkit?;
|
|
418
504
|
private readonly signal?;
|
|
419
505
|
/** Full running transcript (system + user + assistant + tool messages). */
|
|
420
506
|
messages: any[];
|
|
421
|
-
constructor(client: Client, toolkit
|
|
507
|
+
constructor(client: Client, toolkit?: Toolkit | undefined, signal?: AbortSignal | undefined);
|
|
422
508
|
/** Send the next user turn; prior history is retained automatically. */
|
|
423
509
|
send(prompt: PromptInput): Promise<RunResult>;
|
|
424
510
|
/** Reset the conversation memory. */
|