toolnexus 0.19.0 → 0.21.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/dist/builtin.js CHANGED
@@ -7,13 +7,151 @@
7
7
  * ToolResult{isError:true}, never a thrown exception across the boundary. Paths
8
8
  * resolve relative to the process working directory unless absolute.
9
9
  */
10
- import { spawn } from "node:child_process";
10
+ import { spawn, spawnSync } from "node:child_process";
11
11
  import fs from "node:fs/promises";
12
- import { existsSync, readdirSync } from "node:fs";
12
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
13
13
  import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js";
14
14
  import path from "node:path";
15
15
  import { pending } from "./types.js";
16
16
  import { mediaTypeFor } from "./content.js";
17
+ /**
18
+ * How long a job gets between "please stop" and "stop". Fixed, and identical in
19
+ * every port, so a timeout means the same thing everywhere.
20
+ */
21
+ const KILL_GRACE_MS = 2000;
22
+ const WIN_RESERVED = new Set([
23
+ "CON", "PRN", "AUX", "NUL",
24
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
25
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
26
+ ]);
27
+ /** Does `name` resolve as an executable on PATH? (PATHEXT-aware on Windows.) */
28
+ function resolvesOnPath(name) {
29
+ if (name.includes(path.sep) || name.includes("/"))
30
+ return existsSync(name);
31
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
32
+ const exts = process.platform === "win32"
33
+ ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
34
+ : [""];
35
+ for (const dir of dirs) {
36
+ for (const ext of exts) {
37
+ if (existsSync(path.join(dir, name + ext)))
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * The interpreters tried when the host names none. %COMSPEC% leads on Windows
45
+ * because PowerShell is routinely blocked by execution or application-control
46
+ * policy, while %COMSPEC% is always present.
47
+ */
48
+ function shellCandidates() {
49
+ // `/bin/sh` first, then a PATH lookup: `shell: true` means literally
50
+ // `/bin/sh`, while spawning the bare name `sh` is a PATH lookup — two
51
+ // different binaries on a machine that has both. Naming the absolute path
52
+ // first is what makes dropping `shell: true` byte-identical here.
53
+ if (process.platform !== "win32")
54
+ return [["/bin/sh", "-c"], ["sh", "-c"]];
55
+ const out = [];
56
+ if (process.env.COMSPEC)
57
+ out.push([process.env.COMSPEC, "/d", "/s", "/c"]);
58
+ out.push(["cmd.exe", "/d", "/s", "/c"], ["pwsh", "-NoProfile", "-Command"], ["powershell", "-NoProfile", "-Command"], ["bash", "-lc"]);
59
+ return out;
60
+ }
61
+ /**
62
+ * The host boundary the builtins are allowed to know about. Constructed once,
63
+ * at toolkit construction: a missing interpreter is a configuration fact, and
64
+ * turn fourteen of a paid run is the expensive place to learn it.
65
+ */
66
+ class BuiltinEnv {
67
+ shell;
68
+ shellError;
69
+ baseDir;
70
+ confine;
71
+ constructor(cfg) {
72
+ const obj = typeof cfg === "object" && cfg !== null ? cfg : {};
73
+ this.baseDir = obj.baseDir ?? "";
74
+ this.confine = obj.confineToBaseDir === true;
75
+ if (obj.shell && obj.shell.length > 0) {
76
+ this.shell = obj.shell;
77
+ return;
78
+ }
79
+ const tried = [];
80
+ for (const argv of shellCandidates()) {
81
+ tried.push(argv[0]);
82
+ if (resolvesOnPath(argv[0])) {
83
+ this.shell = argv;
84
+ return;
85
+ }
86
+ }
87
+ this.shell = [];
88
+ this.shellError = new Error(`no shell interpreter found (tried: ${tried.join(", ")}); set builtins.shell, or disable the bash builtin with builtins.tools.bash = false`);
89
+ }
90
+ /** The interpreter argv, or the detection error when there is none. */
91
+ shellArgv() {
92
+ if (this.shellError)
93
+ throw this.shellError;
94
+ return this.shell;
95
+ }
96
+ get shellLabel() {
97
+ return this.shell.join(" ");
98
+ }
99
+ /** The directory a command or a walk starts from. */
100
+ dir() {
101
+ return this.baseDir || process.cwd();
102
+ }
103
+ /**
104
+ * Map a tool-supplied path onto the filesystem: relative to baseDir (or, with
105
+ * none, exactly as before), and refused when confinement is on and the
106
+ * canonical target is outside the base.
107
+ */
108
+ resolvePath(p) {
109
+ if (this.confine && !this.baseDir) {
110
+ throw new Error("confineToBaseDir is set but baseDir is empty");
111
+ }
112
+ const full = this.baseDir && !path.isAbsolute(p) ? path.join(this.baseDir, p) : p;
113
+ if (!this.confine)
114
+ return full;
115
+ if (process.platform === "win32") {
116
+ const base = path.basename(full).split(".")[0].trim().toUpperCase();
117
+ if (WIN_RESERVED.has(base)) {
118
+ throw new Error(`${p} names a reserved device, which is not a file inside ${this.baseDir}`);
119
+ }
120
+ }
121
+ const canonBase = canonicalPath(this.baseDir);
122
+ const canonTarget = canonicalPath(full);
123
+ const rel = path.relative(canonBase, canonTarget);
124
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
125
+ throw new Error(`${p} resolves outside baseDir ${this.baseDir}`);
126
+ }
127
+ return full;
128
+ }
129
+ }
130
+ /**
131
+ * Resolve a path for comparison. Symlinks — and, on Windows, directory
132
+ * junctions and 8.3 short names — are resolved on the DEEPEST EXISTING
133
+ * ancestor and the remaining segments re-attached, because a file `write` is
134
+ * about to create has no real path, and a check that only works on existing
135
+ * files is not a check for `write`.
136
+ */
137
+ function canonicalPath(p) {
138
+ const abs = path.resolve(p);
139
+ let cur = abs;
140
+ let tail = "";
141
+ for (;;) {
142
+ try {
143
+ const resolved = realpathSync.native(cur);
144
+ return tail ? path.join(resolved, tail) : resolved;
145
+ }
146
+ catch {
147
+ const parent = path.dirname(cur);
148
+ if (parent === cur)
149
+ return abs;
150
+ tail = tail ? path.join(path.basename(cur), tail) : path.basename(cur);
151
+ cur = parent;
152
+ }
153
+ }
154
+ }
17
155
  /**
18
156
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
19
157
  * `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
@@ -39,10 +177,14 @@ export function selectBuiltins(cfg) {
39
177
  if (!builtinsEnabled(cfg))
40
178
  return [];
41
179
  const map = typeof cfg === "object" ? cfg.tools : undefined;
42
- const all = createBuiltinTools();
43
- if (!map)
44
- return all;
45
- return all.filter((t) => map[t.name] !== false);
180
+ const all = createBuiltinTools(cfg);
181
+ const selected = map ? all.filter((t) => map[t.name] !== false) : all;
182
+ // A missing interpreter is a construction-time failure, and only when `bash`
183
+ // survived the toggles: a host that disabled it should run fine on a box with
184
+ // no shell at all (ADR 0034 D1).
185
+ if (selected.some((t) => t.name === "bash"))
186
+ builtinShell(cfg);
187
+ return selected;
46
188
  }
47
189
  const err = (output, metadata) => ({ output, isError: true, metadata });
48
190
  const ok = (output, metadata) => ({ output, isError: false, metadata });
@@ -133,7 +275,7 @@ function walkFiles(root) {
133
275
  // ---------------------------------------------------------------------------
134
276
  // individual tools
135
277
  // ---------------------------------------------------------------------------
136
- function bashTool() {
278
+ function bashTool(env) {
137
279
  return builtin("bash", "Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.", {
138
280
  type: "object",
139
281
  properties: {
@@ -144,37 +286,131 @@ function bashTool() {
144
286
  },
145
287
  required: ["command"],
146
288
  additionalProperties: false,
147
- }, (args) => new Promise((resolve) => {
289
+ }, (args, ctx) => new Promise((resolve) => {
148
290
  const command = String(args.command ?? "");
149
291
  if (!command)
150
292
  return resolve(err("bash: command is required"));
151
- const workdir = args.workdir ? String(args.workdir) : process.cwd();
293
+ let argv;
294
+ let workdir;
295
+ try {
296
+ argv = env.shellArgv();
297
+ workdir = args.workdir ? env.resolvePath(String(args.workdir)) : env.dir();
298
+ }
299
+ catch (e) {
300
+ return resolve(err(`bash: ${e.message}`));
301
+ }
152
302
  const timeout = typeof args.timeout === "number" ? args.timeout : 60_000;
153
- const child = spawn(command, { shell: true, cwd: workdir });
303
+ const meta = { shell: env.shellLabel };
304
+ // NOT `shell: true`: on Windows that silently means cmd.exe while every
305
+ // other port fails loudly, and it hides WHICH interpreter ran. `detached`
306
+ // puts the child in its own process group so a kill reaches the command
307
+ // AND everything it started — without it the kill reaches the
308
+ // interpreter only and the real work runs on, reparented (ADR 0034 D4).
309
+ const child = spawn(argv[0], [...argv.slice(1), command], {
310
+ cwd: workdir,
311
+ detached: process.platform !== "win32",
312
+ });
154
313
  let out = "";
155
- let timedOut = false;
156
- const timer = setTimeout(() => {
157
- timedOut = true;
158
- child.kill("SIGKILL");
159
- }, timeout);
314
+ let stopped = "";
315
+ let killedTree = false;
316
+ let settled = false;
317
+ // Captured once, at spawn: `detached` makes the child a process-group
318
+ // LEADER, so its pid is the pgid. Reading it later from `child` would
319
+ // risk signalling by a pid the runtime has already let go of.
320
+ const pgid = child.pid;
321
+ const killJob = (graceful) => {
322
+ // Nothing may be signalled once this call has settled: a stale timer
323
+ // firing after the fact would aim at a pid the OS may have reissued,
324
+ // and a NEGATED pid is a whole process group.
325
+ if (settled)
326
+ return false;
327
+ if (typeof pgid !== "number" || !Number.isInteger(pgid) || pgid <= 1)
328
+ return false;
329
+ try {
330
+ if (process.platform === "win32") {
331
+ // Windows has NO graceful termination for a console process, and
332
+ // asking anyway is worse than not asking: measured on a native
333
+ // Windows box, `taskkill /T` without `/F` refuses every console
334
+ // process in the tree ("can only be terminated forcefully") while
335
+ // still being able to take the parent down — which reparents the
336
+ // grandchild and leaves it running. So the job is terminated at
337
+ // once there, and the grace window is a POSIX effect (ADR 0034 D4).
338
+ const r = spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)]);
339
+ return r.status === 0;
340
+ }
341
+ // Never signal a REAPED child by negated pid: that pid may belong to
342
+ // something else by now, and the blast radius is a whole process
343
+ // GROUP. (On Windows above, taskkill targets one pid and a dead pid
344
+ // is merely an error, so the same guard is not needed there — and
345
+ // must not be applied, because the tree can outlive the child.)
346
+ if (child.exitCode !== null || child.signalCode !== null)
347
+ return false;
348
+ process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL");
349
+ return true;
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ };
355
+ // Ask, wait out the grace window, then insist. A runner that gets
356
+ // SIGTERM removes its temp directories; one that gets SIGKILL does not.
357
+ const stop = (why) => {
358
+ if (stopped)
359
+ return;
360
+ stopped = why;
361
+ killedTree = killJob(true);
362
+ graceTimer = setTimeout(() => {
363
+ if (killJob(false))
364
+ killedTree = true;
365
+ }, KILL_GRACE_MS);
366
+ };
367
+ let graceTimer;
368
+ const timer = setTimeout(() => stop("timeout"), timeout);
369
+ const signal = ctx?.signal;
370
+ const onAbort = () => stop("cancelled");
371
+ signal?.addEventListener?.("abort", onAbort, { once: true });
372
+ const finish = (result) => {
373
+ if (settled)
374
+ return;
375
+ settled = true;
376
+ clearTimeout(timer);
377
+ if (graceTimer)
378
+ clearTimeout(graceTimer);
379
+ signal?.removeEventListener?.("abort", onAbort);
380
+ resolve(result);
381
+ };
160
382
  child.stdout?.on("data", (d) => (out += d.toString()));
161
383
  child.stderr?.on("data", (d) => (out += d.toString()));
162
- child.on("error", (e) => {
163
- clearTimeout(timer);
164
- resolve(err(`bash: ${e.message}`));
165
- });
384
+ child.on("error", (e) => finish(err(`bash: ${e.message}`, meta)));
166
385
  child.on("close", (code) => {
167
- clearTimeout(timer);
168
- if (timedOut)
169
- return resolve(err(`bash: command timed out after ${timeout}ms\n${out}`));
170
- if (code !== 0) {
171
- return resolve(err(`${out}\nbash: command exited with code ${code}`, { exitCode: code }));
386
+ if (stopped) {
387
+ // WINDOWS ONLY, and the platform guard is the point. There, the
388
+ // direct child closing does not mean the job is gone — a detached
389
+ // grandchild outlives it — so the tree is terminated here by pid.
390
+ //
391
+ // On POSIX this must NOT run. `close` only fires once the stdio the
392
+ // grandchild inherited has closed, so the job is already gone; and
393
+ // signalling `-pid` after the child has been reaped aims at whatever
394
+ // process now owns that pid. PIDs recycle fast on a busy CI box, and
395
+ // the blast radius of a negated pid is a whole process GROUP — which
396
+ // is how this killed sibling test workers on the GitHub runner while
397
+ // passing on macOS, in a container, and on its own.
398
+ if (!killedTree && process.platform === "win32")
399
+ killedTree = killJob(false);
400
+ meta.timedOut = stopped === "timeout";
401
+ meta.killedTree = killedTree;
402
+ return finish(err(stopped === "timeout"
403
+ ? `bash: command timed out after ${timeout}ms\n${out}`
404
+ : `bash: command cancelled\n${out}`, meta));
172
405
  }
173
- resolve(ok(out, { exitCode: code }));
406
+ meta.exitCode = code;
407
+ if (code !== 0)
408
+ return finish(err(`${out}\nbash: command exited with code ${code}`, meta));
409
+ finish(ok(out, meta));
174
410
  });
175
411
  }));
176
412
  }
177
- function readTool() {
413
+ function readTool(env) {
178
414
  return builtin("read", "Read a file. Recognised media (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a content part; anything else is read as UTF-8 text, with offset/limit returning that line window.", {
179
415
  type: "object",
180
416
  properties: {
@@ -189,15 +425,17 @@ function readTool() {
189
425
  if (!p)
190
426
  return err("read: path is required");
191
427
  let bytes;
428
+ let full;
192
429
  try {
193
- bytes = await fs.readFile(p);
430
+ full = env.resolvePath(p);
431
+ bytes = await fs.readFile(full);
194
432
  }
195
433
  catch (e) {
196
434
  return err(`read: ${e instanceof Error ? e.message : String(e)}`);
197
435
  }
198
436
  // §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
199
437
  // sniffing and no platform mime database, whose contents vary per machine.
200
- const media = mediaTypeFor(p);
438
+ const media = mediaTypeFor(full);
201
439
  if (media) {
202
440
  return {
203
441
  output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
@@ -224,7 +462,7 @@ function readTool() {
224
462
  return ok(lines.slice(start, start + limit).join("\n"));
225
463
  });
226
464
  }
227
- function writeTool() {
465
+ function writeTool(env) {
228
466
  return builtin("write", "Write content to a file (create/overwrite), creating parent directories.", {
229
467
  type: "object",
230
468
  properties: {
@@ -238,13 +476,20 @@ function writeTool() {
238
476
  if (!p)
239
477
  return err("write: path is required");
240
478
  const content = typeof args.content === "string" ? args.content : String(args.content ?? "");
241
- await fs.mkdir(path.dirname(path.resolve(p)), { recursive: true });
242
- await fs.writeFile(p, content, "utf8");
479
+ let full;
480
+ try {
481
+ full = env.resolvePath(p);
482
+ }
483
+ catch (e) {
484
+ return err(`write: ${e instanceof Error ? e.message : String(e)}`);
485
+ }
486
+ await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true });
487
+ await fs.writeFile(full, content, "utf8");
243
488
  const bytes = Buffer.byteLength(content, "utf8");
244
489
  return ok(`Wrote ${bytes} bytes to ${p}`, { bytes });
245
490
  });
246
491
  }
247
- function editTool() {
492
+ function editTool(env) {
248
493
  return builtin("edit", "Exact-string replace in a file. Default replaces a single unique occurrence; replaceAll replaces all.", {
249
494
  type: "object",
250
495
  properties: {
@@ -265,8 +510,10 @@ function editTool() {
265
510
  const oldString = args.oldString;
266
511
  const newString = typeof args.newString === "string" ? args.newString : String(args.newString ?? "");
267
512
  let content;
513
+ let full;
268
514
  try {
269
- content = await fs.readFile(p, "utf8");
515
+ full = env.resolvePath(p);
516
+ content = await fs.readFile(full, "utf8");
270
517
  }
271
518
  catch (e) {
272
519
  return err(`edit: ${e instanceof Error ? e.message : String(e)}`);
@@ -289,7 +536,7 @@ function editTool() {
289
536
  });
290
537
  });
291
538
  }
292
- function grepTool() {
539
+ function grepTool(env) {
293
540
  return builtin("grep", "Search file contents by regex under a directory. Output is file:line:text matches.", {
294
541
  type: "object",
295
542
  properties: {
@@ -311,7 +558,13 @@ function grepTool() {
311
558
  catch (e) {
312
559
  return err(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`);
313
560
  }
314
- const root = args.path ? String(args.path) : process.cwd();
561
+ let root;
562
+ try {
563
+ root = args.path ? env.resolvePath(String(args.path)) : env.dir();
564
+ }
565
+ catch (e) {
566
+ return err(`grep: ${e instanceof Error ? e.message : String(e)}`);
567
+ }
315
568
  const include = args.include ? String(args.include) : undefined;
316
569
  const limit = typeof args.limit === "number" ? args.limit : 100;
317
570
  // Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
@@ -341,7 +594,7 @@ function grepTool() {
341
594
  return ok(matches.join("\n"), { count: matches.length });
342
595
  });
343
596
  }
344
- function globTool() {
597
+ function globTool(env) {
345
598
  return builtin("glob", "List files matching a glob under a directory. Output is newline-joined relative paths.", {
346
599
  type: "object",
347
600
  properties: {
@@ -355,7 +608,13 @@ function globTool() {
355
608
  const pattern = String(args.pattern ?? "");
356
609
  if (!pattern)
357
610
  return err("glob: pattern is required");
358
- const root = args.path ? String(args.path) : process.cwd();
611
+ let root;
612
+ try {
613
+ root = args.path ? env.resolvePath(String(args.path)) : env.dir();
614
+ }
615
+ catch (e) {
616
+ return err(`glob: ${e instanceof Error ? e.message : String(e)}`);
617
+ }
359
618
  const limit = typeof args.limit === "number" ? args.limit : 100;
360
619
  // Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
361
620
  // the filesystem decide WHICH files the model sees, not merely their order. The emitted
@@ -581,7 +840,7 @@ function applyUpdate(content, body) {
581
840
  }
582
841
  return result;
583
842
  }
584
- function applyPatchTool() {
843
+ function applyPatchTool(env) {
585
844
  return builtin("apply_patch", "Apply a patch (Begin/End Patch grammar: Add/Update/Delete File). Atomic — a non-matching hunk aborts with no writes.", {
586
845
  type: "object",
587
846
  properties: {
@@ -600,6 +859,16 @@ function applyPatchTool() {
600
859
  catch (e) {
601
860
  return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
602
861
  }
862
+ // The paths live INSIDE the patch text, not in the arguments, so a host
863
+ // cannot rewrite them from a hook — this is the one case that genuinely
864
+ // needs the base directory to be library-side (ADR 0034 D2).
865
+ try {
866
+ for (const op of ops)
867
+ op.path = env.resolvePath(op.path);
868
+ }
869
+ catch (e) {
870
+ return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
871
+ }
603
872
  // Stage every write/delete first; only touch the filesystem once all hunks apply.
604
873
  const writes = [];
605
874
  const deletes = [];
@@ -643,17 +912,26 @@ function applyPatchTool() {
643
912
  * for parity: bash, read, write, edit, grep, glob, webfetch, question,
644
913
  * apply_patch, todowrite.
645
914
  */
646
- export function createBuiltinTools() {
915
+ export function createBuiltinTools(cfg) {
916
+ const env = new BuiltinEnv(cfg);
647
917
  return [
648
- bashTool(),
649
- readTool(),
650
- writeTool(),
651
- editTool(),
652
- grepTool(),
653
- globTool(),
918
+ bashTool(env),
919
+ readTool(env),
920
+ writeTool(env),
921
+ editTool(env),
922
+ grepTool(env),
923
+ globTool(env),
654
924
  webfetchTool(),
655
925
  questionTool(),
656
- applyPatchTool(),
926
+ applyPatchTool(env),
657
927
  todowriteTool(),
658
928
  ];
659
929
  }
930
+ /**
931
+ * The interpreter the builtins resolved for `bash` — what a host prints, and
932
+ * what `metadata.shell` carries on every bash result. Throws the same error
933
+ * `selectBuiltins` fails construction with when nothing resolves.
934
+ */
935
+ export function builtinShell(cfg) {
936
+ return new BuiltinEnv(cfg).shellArgv();
937
+ }
@@ -19,6 +19,8 @@ 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
+ /** `evaluateBatch`: at most this many states in flight by default. */
23
+ export declare const DEFAULT_BATCH_CONCURRENCY = 16;
22
24
  /**
23
25
  * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
24
26
  * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
@@ -95,6 +97,8 @@ export declare function score(instructions: string, criteria: string[]): ScoreQu
95
97
  * obligation on `ChoiceQuestion`.
96
98
  */
97
99
  export declare function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion;
100
+ /** The wire form of one question — the object itself, minus an absent `criteria`. */
101
+ export declare function questionWire(q: Question): Record<string, unknown>;
98
102
  /**
99
103
  * Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
100
104
  * reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
@@ -298,6 +302,16 @@ export declare class Classifier {
298
302
  * INDEPENDENT — one answer is never context for another.
299
303
  */
300
304
  evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision>;
305
+ /**
306
+ * The same questions over many states (SPEC §8B "Batch"). Each state goes through
307
+ * `evaluate`, at most `concurrency` (default 16) in flight; decisions come back in STATE
308
+ * order. Fails closed: the first failing state's index is named and no decisions return.
309
+ * No states is an error and sends nothing.
310
+ */
311
+ evaluateBatch(states: readonly unknown[], questions: Record<string, Question>, opts?: {
312
+ signal?: AbortSignal;
313
+ concurrency?: number;
314
+ }): Promise<Decision[]>;
301
315
  /**
302
316
  * Emits ONE warning per degenerate question key per classifier, naming the key, and changes
303
317
  * nothing about the request. Repairing would invent option descriptions the caller did not
@@ -9,6 +9,8 @@ export const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
9
9
  export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
10
10
  /** Bounds ONE request; a classifier has no loop to bound. */
11
11
  export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000;
12
+ /** `evaluateBatch`: at most this many states in flight by default. */
13
+ export const DEFAULT_BATCH_CONCURRENCY = 16;
12
14
  /** The two servable pairings, as configurations rather than six independently-choosable cells. */
13
15
  export const CLASSIFIER_BACKENDS = {
14
16
  typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
@@ -45,7 +47,7 @@ export function choiceOver(instructions, items) {
45
47
  return choice(instructions, items);
46
48
  }
47
49
  /** The wire form of one question — the object itself, minus an absent `criteria`. */
48
- function questionWire(q) {
50
+ export function questionWire(q) {
49
51
  switch (q.type) {
50
52
  case "noul":
51
53
  return q.criteria === undefined
@@ -362,6 +364,37 @@ export class Classifier {
362
364
  });
363
365
  return decision;
364
366
  }
367
+ /**
368
+ * The same questions over many states (SPEC §8B "Batch"). Each state goes through
369
+ * `evaluate`, at most `concurrency` (default 16) in flight; decisions come back in STATE
370
+ * order. Fails closed: the first failing state's index is named and no decisions return.
371
+ * No states is an error and sends nothing.
372
+ */
373
+ async evaluateBatch(states, questions, opts = {}) {
374
+ if (!states || states.length === 0)
375
+ throw new Error("classifier: no states to evaluate");
376
+ const limit = Math.max(1, opts.concurrency ?? DEFAULT_BATCH_CONCURRENCY);
377
+ const out = new Array(states.length);
378
+ const errs = new Array(states.length);
379
+ let next = 0;
380
+ const worker = async () => {
381
+ while (next < states.length) {
382
+ const i = next++;
383
+ try {
384
+ out[i] = await this.evaluate(states[i], questions, opts.signal);
385
+ }
386
+ catch (e) {
387
+ errs[i] = e;
388
+ }
389
+ }
390
+ };
391
+ await Promise.all(Array.from({ length: Math.min(limit, states.length) }, worker));
392
+ for (let i = 0; i < states.length; i++) {
393
+ if (i in errs)
394
+ throw new Error(`classifier: state ${i}: ${errText(errs[i])}`, { cause: errs[i] });
395
+ }
396
+ return out;
397
+ }
365
398
  // ---------------------------------------------------------------- degenerate
366
399
  /**
367
400
  * Emits ONE warning per degenerate question key per classifier, naming the key, and changes
@@ -443,8 +476,10 @@ export class Classifier {
443
476
  let lastErr;
444
477
  for (let attempt = 0;; attempt++) {
445
478
  const timer = new AbortController();
479
+ // NOT unref'd: this watchdog is the only thing that will end a request an
480
+ // injected `fetch` never settles, and an unref'd timer cannot keep the
481
+ // loop alive long enough to fire. It is cleared on every exit path below.
446
482
  const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
447
- t.unref?.();
448
483
  const onOuter = () => timer.abort(signal.reason);
449
484
  if (signal) {
450
485
  if (signal.aborted) {