toolnexus 0.18.1 → 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.
@@ -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: "error", turns: h.turnsTotal, totalTokens: h.usageTotal,
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: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal });
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: "timeout", turns: h.turnsTotal, totalTokens: h.usageTotal });
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: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal };
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
- await this.resumeSuspended(leaf, answer, { inline: false });
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 "maxTokens";
440
+ return L.maxTokens;
381
441
  if (a.pool.toolCalls <= 0)
382
- return "maxToolCalls";
442
+ return L.maxToolCalls;
383
443
  if (a.deadline !== undefined && now >= a.deadline)
384
- return "maxWallMs";
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 ? "maxTurns" : undefined;
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
- const r = { text, isError: true, status: "incomplete", turns: h.turnsTotal, totalTokens: h.usageTotal };
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: "pending", pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal };
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: "pending", pending: stamped, turns: r.turns, totalTokens: r.usage.totalTokens };
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: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
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: "done", turns: r.turns, totalTokens: r.usage.totalTokens };
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
- status: closing ? "closed" : aborted ? "interrupted" : "error",
628
- turns: h.turnsTotal, totalTokens: h.usageTotal,
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: "error", turns: existing.turnsTotal, totalTokens: existing.usageTotal };
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
- entries = readdirSync(dir, { withFileTypes: true });
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
- const matches = [];
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
- if (matches.length >= limit)
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
- matches.push(`${file}:${i + 1}:${lines[i]}`);
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
- if (found.length >= limit)
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
  }
@@ -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;
@@ -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
- this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL;
265
- this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL;
266
- this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV;
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
- const run = await this.opts.client.run(prompt, { toolkit: EMPTY_TOOLKIT, signal });
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
- const s = body.trim();
583
- if (!s)
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 ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s);
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
- run(prompt: PromptInput, ctx: {
288
- toolkit: Toolkit;
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: 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: 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: 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 the thrown error.
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: Toolkit, signal?: AbortSignal | undefined);
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. */