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.
@@ -177,6 +177,33 @@ export async function runGated(
177
177
  }
178
178
  }
179
179
 
180
+ /**
181
+ * The spec fields a Loop drive CANNOT honour, named rather than dropped silently.
182
+ *
183
+ * A `Loop` is one client run: it has no runtime, so it has no handle tree to spawn a `team`
184
+ * into, no §10 suspension ledger for `waitFor`, and no per-agent metric sink routing. Those
185
+ * fields are honoured by `Agent.run` / `AgentRuntime` only. `uses.tools` is not honoured either
186
+ * — a Loop is handed its toolkit by the caller.
187
+ *
188
+ * Returns a FIXED CANONICAL vocabulary — `"tools"`, `"team"`, `"waitFor"`, `"onMetric"` —
189
+ * identical in all seven ports, exactly as the `limit` strings are, and NOT this language's own
190
+ * spelling of the field. Empty ⇒ nothing is lost.
191
+ * Additive and advisory: a Loop never refuses to run because of it (that would be breaking).
192
+ */
193
+ export function loopUnsupported(spec: {
194
+ uses?: { tools?: unknown[] }
195
+ team?: unknown[]
196
+ waitFor?: unknown
197
+ onMetric?: unknown
198
+ }): string[] {
199
+ const out: string[] = []
200
+ if (spec.uses?.tools && spec.uses.tools.length > 0) out.push("tools")
201
+ if (spec.team && spec.team.length > 0) out.push("team")
202
+ if (spec.waitFor) out.push("waitFor")
203
+ if (spec.onMetric) out.push("onMetric")
204
+ return out
205
+ }
206
+
180
207
  /** A live execution of an Agent. Its only verbs are `run` and reading state. */
181
208
  export class Loop {
182
209
  #status: Outcome["status"] | "idle" | "running" = "idle"
@@ -234,10 +261,21 @@ export class Loop {
234
261
  /** Applies a per-call model override via `requestParams` (`model` is not in the
235
262
  * forbidden set — the client forbids only messages/tools/stream). */
236
263
  #clientOptions(opts: LoopRunOptions): ClientOptions {
264
+ const spec = this.agent.spec
237
265
  const base: ClientOptions = {
238
266
  ...this.options,
239
- systemPrompt: this.agent.spec.soul ?? this.options.systemPrompt,
240
- hooks: guardedHooks(this.agent.spec.guardrails, this.agent.spec.hooks ?? this.options.hooks),
267
+ // CALLER-WINS (ADR 0024 / issue #87). A `systemPrompt` the caller put on the client
268
+ // options is an explicit instruction for THIS drive; the soul is the agent's default.
269
+ // js used to let the soul override it, which python and csharp never did.
270
+ systemPrompt: this.options.systemPrompt ?? spec.soul,
271
+ // The spec's model and turn ceiling become Loop DEFAULTS — the same spec drives both
272
+ // doors. `ClientOptions.model` is REQUIRED in js, so "the caller did not choose" is
273
+ // spelled the way the runtime already spells it: absent, or the `"inherit"` sentinel.
274
+ model: !this.options.model || this.options.model === "inherit"
275
+ ? (spec.model && spec.model !== "inherit" ? spec.model : this.options.model)
276
+ : this.options.model,
277
+ maxTurns: this.options.maxTurns ?? spec.budget?.maxTurns,
278
+ hooks: guardedHooks(spec.guardrails, spec.hooks ?? this.options.hooks),
241
279
  }
242
280
  if (!opts.model) return base
243
281
  return { ...base, requestParams: { ...(base.requestParams ?? {}), model: opts.model } }
@@ -15,7 +15,17 @@
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
17
  import type { PromptInput } from "../content.js"
18
- import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
18
+ import {
19
+ createClient,
20
+ createInProcessFetch,
21
+ InMemoryConversationStore,
22
+ type ClientStyle,
23
+ type ConversationStore,
24
+ type Hooks,
25
+ type InProcessRequest,
26
+ type InProcessResponse,
27
+ type MetricEvent,
28
+ } from "../client.js"
19
29
  import { runGated, type Completion } from "./loop.js"
20
30
  import { createToolkit, type Toolkit } from "../toolkit.js"
21
31
  import { defineTool } from "../native.js"
@@ -118,6 +128,63 @@ export interface InboxItem {
118
128
 
119
129
  export type HandleState = "idle" | "running" | "suspended" | "closed"
120
130
 
131
+ /**
132
+ * The §7D AGENT status vocabulary, as a value. It is a DIFFERENT, larger closed set than the
133
+ * §8 CLIENT vocabulary (`RUN_STATUSES` = done/pending/incomplete): only this one contains
134
+ * `"timeout"`, and `"timeout"` here means a `wait()` DEADLINE expired while the child keeps
135
+ * running — never that an LLM call timed out. Two fields are called `status`; they are not
136
+ * interchangeable (ADR 0027 D2.1).
137
+ */
138
+ export const TASK_STATUSES = [
139
+ "done", "pending", "incomplete", "interrupted", "closed", "timeout", "error",
140
+ ] as const
141
+
142
+ /**
143
+ * The CLOSED `limit` vocabulary (addendum A14). A value NAMES THE BUDGET FIELD that stopped the
144
+ * run, spelled exactly as `Budget` spells it, plus the two non-budget stops `completion` and
145
+ * `timeout`. Identical in all seven ports, like the `loopUnsupported` strings — "which limit
146
+ * stopped me" is only answerable if the answer is the same word everywhere.
147
+ *
148
+ * A port's INTERNAL pool/dimension name is an implementation detail and must never leak into
149
+ * the field; `canonicalLimit` is the boundary that enforces that here.
150
+ */
151
+ export const TASK_LIMITS = [
152
+ "maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth",
153
+ "completion", "timeout",
154
+ ] as const
155
+
156
+ export type TaskLimit = (typeof TASK_LIMITS)[number]
157
+
158
+ /** Map anything the internals produce onto the closed vocabulary; unknown ⇒ undefined, never a
159
+ * leaked internal name. */
160
+ export function canonicalLimit(limit: string | undefined): TaskLimit | undefined {
161
+ return limit && (TASK_LIMITS as readonly string[]).includes(limit) ? (limit as TaskLimit) : undefined
162
+ }
163
+
164
+ /**
165
+ * NAMED CONSTANTS for the two closed vocabularies. Every `TaskResult` construction site in this
166
+ * file spells its status and limit through these, never as a string literal, so a rename cannot
167
+ * silently desync the two fields — which is exactly how `status:"timeout"` came to ship with an
168
+ * empty `limit` in three of the seven ports (addendum A18).
169
+ */
170
+ export const S: Readonly<Record<TaskStatus, TaskStatus>> = Object.freeze({
171
+ done: "done", pending: "pending", incomplete: "incomplete", interrupted: "interrupted",
172
+ closed: "closed", timeout: "timeout", error: "error",
173
+ })
174
+
175
+ export const L: Readonly<Record<TaskLimit, TaskLimit>> = Object.freeze({
176
+ maxTurns: "maxTurns", maxTokens: "maxTokens", maxToolCalls: "maxToolCalls", maxWallMs: "maxWallMs",
177
+ maxChildren: "maxChildren", maxConcurrent: "maxConcurrent", maxDepth: "maxDepth",
178
+ completion: "completion", timeout: "timeout",
179
+ })
180
+
181
+ /**
182
+ * THE INVARIANT `S`/`L` exist to protect (addendum A18): a LIMIT stop (`incomplete`, `timeout`)
183
+ * names its limit; every other status carries none. It is asserted by the suite, not exported —
184
+ * a predicate that exists so our own tests can state a rule is not public API (A19b, ADR 0019).
185
+ * Ports copy the PREDICATE, not an export.
186
+ */
187
+
121
188
  export type TaskStatus =
122
189
  | "done"
123
190
  | "pending" // §10 durable suspension — resume via runtime.resume(answer)
@@ -135,8 +202,23 @@ export interface TaskResult {
135
202
  /** Present iff status === "pending". `data.path` carries the suspended handle's
136
203
  * id path (§10 agent-escalation addendum) — never a field grafted on Request. */
137
204
  pending?: Request
205
+ /** Cumulative LLM round trips for this handle's SUBTREE — never per-run, never reset. */
138
206
  turns: number
207
+ /**
208
+ * Cumulative TREE total: this handle's own spend plus every descendant's, on EVERY status
209
+ * (ADR 0025 D1). It was already the tree total on four of the seven status branches; this
210
+ * gives the field one meaning. `parent.totalTokens >= child.totalTokens` always holds.
211
+ */
139
212
  totalTokens: number
213
+ /** This handle's OWN accumulated spend, excluding children. */
214
+ ownTokens: number
215
+ /**
216
+ * WHICH limit stopped the run, as a value to branch on rather than prose to match. Drawn from
217
+ * the CLOSED, cross-port `TASK_LIMITS` vocabulary (addendum A14) — it names the `Budget` field,
218
+ * or `completion` / `timeout`. Absent otherwise. A stop you cannot explain to a user is a bug,
219
+ * not a state.
220
+ */
221
+ limit?: TaskLimit
140
222
  }
141
223
 
142
224
  /** Uniform verb failure — returned, never thrown (only the root throws to the host). */
@@ -206,6 +288,8 @@ export class Handle {
206
288
  readonly deadline?: number
207
289
  /** Total tokens attributed to this handle (own turns + descendant roll-up). */
208
290
  usageTotal = 0
291
+ /** Tokens spent by THIS handle's own turns, excluding descendants (ADR 0025 D1). */
292
+ ownTokens = 0
209
293
  /** Total tool calls attributed to this handle (own + descendant roll-up). */
210
294
  toolCallsTotal = 0
211
295
  /** LLM round trips run by this handle itself (never reset — resume grows it). */
@@ -270,8 +354,16 @@ export interface RuntimeOptions {
270
354
  registry: Record<string, AgentDef>
271
355
  /** LLM endpoint. Omit for hermetic runs against an injected `fetch` mock. */
272
356
  llm?: { baseUrl?: string; style?: ClientStyle; apiKey?: string; model?: string }
273
- /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam. */
357
+ /** HTTP transport for LLM requests (§8 Gap 2) — the hermetic-test seam.
358
+ * Mutually exclusive with `inProcess` — `new AgentRuntime(...)` throws if both are set. */
274
359
  fetch?: typeof fetch
360
+ /** A model running IN THIS PROCESS (ADR 0030): the semantic counterpart to
361
+ * `fetch`, so a host whose model is a plain function never has to build its own
362
+ * `fetch`-shaped adapter. Internally this is turned into a `createInProcessFetch`
363
+ * adapter and wrapped by the SAME global turn gate as `fetch` — there is no second
364
+ * code path. Mutually exclusive with `fetch` and with `llm` — `new AgentRuntime(...)`
365
+ * throws if more than one of `fetch`/`inProcess` is set. */
366
+ inProcess?: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
275
367
  /** Time source for every timer/timeout/deadline. Default: the system clock. */
276
368
  clock?: Clock
277
369
  /** The ONE ConversationStore for all handles (conversation id = handle id).
@@ -324,6 +416,20 @@ export class AgentRuntime {
324
416
  private readonly turnQueue: GateWaiter[] = []
325
417
 
326
418
  constructor(opts: RuntimeOptions) {
419
+ if (opts.fetch && opts.inProcess) {
420
+ throw new Error("toolnexus/agents: RuntimeOptions.fetch and RuntimeOptions.inProcess are mutually exclusive — set one, not both")
421
+ }
422
+ if (opts.llm && opts.inProcess) {
423
+ throw new Error(
424
+ "toolnexus/agents: RuntimeOptions.llm and RuntimeOptions.inProcess are mutually exclusive — an in-process model has no wire endpoint to point llm at",
425
+ )
426
+ }
427
+ if (opts.inProcess) {
428
+ // Reuse the SAME adapter createInProcessClient builds on (ADR 0030) — the
429
+ // runtime's in-process wiring is a CALLER of that export, not a second copy
430
+ // of the request/response assembly.
431
+ opts = { ...opts, fetch: createInProcessFetch(opts.inProcess) }
432
+ }
327
433
  this.opts = opts
328
434
  this.registry = opts.registry
329
435
  this.clock = opts.clock ?? systemClock
@@ -418,11 +524,11 @@ export class AgentRuntime {
418
524
  if (opts?.by && h.parent !== opts.by) {
419
525
  return Promise.resolve({
420
526
  text: `wait refused: only the spawner may wait on ${h.id}`,
421
- isError: true, status: "error", turns: h.turnsTotal, totalTokens: h.usageTotal,
527
+ isError: true, status: S.error, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
422
528
  })
423
529
  }
424
530
  if (h.state === "closed") {
425
- return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal })
531
+ return Promise.resolve(h.lastResult ?? { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens })
426
532
  }
427
533
  if (h.state === "suspended") return Promise.resolve(this.pendingResult(h))
428
534
  if (h.state === "idle" && h.lastResult && !this.isQueued(h)) return Promise.resolve(h.lastResult)
@@ -440,7 +546,7 @@ export class AgentRuntime {
440
546
  cancel = this.clock.setTimeout(() => {
441
547
  const i = h.waiters.indexOf(finish)
442
548
  if (i >= 0) h.waiters.splice(i, 1)
443
- finish({ text: `wait timeout after ${opts.timeoutMs}ms (child still ${h.state})`, isError: true, status: "timeout", turns: h.turnsTotal, totalTokens: h.usageTotal })
549
+ 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 })
444
550
  }, opts.timeoutMs)
445
551
  }
446
552
  })
@@ -496,7 +602,7 @@ export class AgentRuntime {
496
602
  await h.def.onClose?.(h, reason)
497
603
  const prev = h.state
498
604
  h.state = "closed"
499
- const final: TaskResult = { text: "closed", isError: true, status: "closed", turns: h.turnsTotal, totalTokens: h.usageTotal }
605
+ const final: TaskResult = { text: "closed", isError: true, status: S.closed, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
500
606
  for (const w of h.waiters.splice(0)) w(final)
501
607
  this.t(`${h.id}: ${prev}→closed (${reason})`)
502
608
  }
@@ -527,16 +633,19 @@ export class AgentRuntime {
527
633
  * REATTACHES to the existing child by task key — never spawns a duplicate.
528
634
  * The root alone may throw to the host.
529
635
  */
530
- async resume(answer: Answer): Promise<void> {
636
+ async resume(answer: Answer): Promise<TaskResult> {
531
637
  const leaf = this.deepestSuspended(this.root)
532
638
  if (!leaf) throw new Error("no suspended handle to resume")
533
639
  this.t(`${leaf.id}: resume with Answer(ok=${answer.ok}) at checkpoint (turns so far: ${leaf.turnsTotal})`)
534
- await this.resumeSuspended(leaf, answer, { inline: false })
640
+ // The value is the settled result of the TOPMOST handle the cascade re-ran — what the host
641
+ // would have got from `Agent.run` had the suspension never happened (ADR 0025 D3).
642
+ let topmost = await this.resumeSuspended(leaf, answer, { inline: false })
535
643
  for (let p = leaf.parent; p && p !== this.root; p = p.parent) {
536
644
  if (p.state !== "suspended") break
537
645
  this.t(`${p.id}: cascade resume (reattaching delegated work)`)
538
- await this.resumeSuspended(p, undefined, { inline: false })
646
+ topmost = await this.resumeSuspended(p, undefined, { inline: false })
539
647
  }
648
+ return topmost
540
649
  }
541
650
 
542
651
  private deepestSuspended(h: Handle): Handle | null {
@@ -580,25 +689,28 @@ export class AgentRuntime {
580
689
  // ---- budgets (live ancestor-chain enforcement + roll-up ledger) --------
581
690
 
582
691
  /** Walk the LIVE ancestor chain; name the first exhausted pool, if any. */
583
- private poolLimit(h: Handle): string | undefined {
692
+ private poolLimit(h: Handle): TaskLimit | undefined {
584
693
  const now = this.clock.now()
585
694
  for (let a: Handle | null = h; a; a = a.parent) {
586
- if (a.pool.tokens <= 0) return "maxTokens"
587
- if (a.pool.toolCalls <= 0) return "maxToolCalls"
588
- if (a.deadline !== undefined && now >= a.deadline) return "maxWallMs"
695
+ if (a.pool.tokens <= 0) return L.maxTokens
696
+ if (a.pool.toolCalls <= 0) return L.maxToolCalls
697
+ if (a.deadline !== undefined && now >= a.deadline) return L.maxWallMs
589
698
  }
590
699
  return undefined
591
700
  }
592
701
 
593
702
  /** Lifetime turn cap on the handle itself (turns never reset across resumes). */
594
- private turnCap(h: Handle): string | undefined {
595
- return h.turnsTotal >= h.eff.maxTurns ? "maxTurns" : undefined
703
+ private turnCap(h: Handle): TaskLimit | undefined {
704
+ return h.turnsTotal >= h.eff.maxTurns ? L.maxTurns : undefined
596
705
  }
597
706
 
598
707
  /** A limit stop is LOUD: settle an incomplete result (never silent, never a throw). */
599
- private settleRefused(h: Handle, limit: string): string {
708
+ private settleRefused(h: Handle, limit: TaskLimit): string {
709
+ // `limit` here is already a Budget field name (`poolLimit`/`turnCap` return them verbatim);
710
+ // `canonicalLimit` below is the guard that keeps it that way if either ever changes.
600
711
  const text = `budget exhausted (${limit}); partial work preserved`
601
- const r: TaskResult = { text, isError: true, status: "incomplete", turns: h.turnsTotal, totalTokens: h.usageTotal }
712
+ // The exhausted pool is COMPUTED here and used to be discarded into prose. `limit` carries it.
713
+ const r: TaskResult = { text, isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens, limit }
602
714
  h.lastResult = r
603
715
  for (const w of h.waiters.splice(0)) w(r)
604
716
  return text
@@ -606,6 +718,7 @@ export class AgentRuntime {
606
718
 
607
719
  /** Usage roll-up IS the budget ledger: every ancestor's pool drains live. */
608
720
  private rollUp(h: Handle, tokens: number, toolCalls: number): void {
721
+ h.ownTokens += tokens // own figure: outside the ancestor walk, by construction
609
722
  for (let a: Handle | null = h; a; a = a.parent) {
610
723
  a.usageTotal += tokens
611
724
  a.toolCallsTotal += toolCalls
@@ -728,7 +841,7 @@ export class AgentRuntime {
728
841
 
729
842
  private pendingResult(h: Handle): TaskResult {
730
843
  const req = h.pendingRequest
731
- return { text: req?.prompt ?? "", isError: false, status: "pending", pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal }
844
+ return { text: req?.prompt ?? "", isError: false, status: S.pending, pending: req, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
732
845
  }
733
846
 
734
847
  /** The escalation seam: this handle's §10 interpreter authority, with the
@@ -801,7 +914,7 @@ export class AgentRuntime {
801
914
  h.checkpoint = { input }
802
915
  h.drained = [] // drained items ride the checkpointed input, not the inbox
803
916
  this.t(`${h.id}: running→suspended (pending "${stamped.kind}")`)
804
- result = { text: r.text, isError: false, status: "pending", pending: stamped, turns: r.turns, totalTokens: r.usage.totalTokens }
917
+ result = { text: r.text, isError: false, status: S.pending, pending: stamped, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
805
918
  } else if (r.status === "incomplete") {
806
919
  h.state = "idle"
807
920
  h.drained = []
@@ -810,13 +923,17 @@ export class AgentRuntime {
810
923
  // generic sentence. Reading `limit` is how the runtime tells them apart.
811
924
  result = {
812
925
  text: r.limit === "completion" ? r.text : "hit maxTurns without a final answer",
813
- isError: true, status: "incomplete", turns: r.turns, totalTokens: r.usage.totalTokens,
926
+ isError: true, status: S.incomplete, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
927
+ // The LATENT instance golang found: forwarding the client's `limit` straight through
928
+ // would hand a host `incomplete` with an empty limit whenever the client omits it.
929
+ // The `?? L.maxTurns` is what makes the invariant hold by construction, not by luck.
930
+ limit: canonicalLimit(r.limit) ?? L.maxTurns,
814
931
  }
815
932
  } else {
816
933
  h.state = "idle"
817
934
  h.drained = []
818
935
  this.t(`${h.id}: running→idle (done, turns=${r.turns}, tokens=${r.usage.totalTokens})`)
819
- result = { text: r.text, isError: false, status: "done", turns: r.turns, totalTokens: r.usage.totalTokens }
936
+ result = { text: r.text, isError: false, status: S.done, turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens }
820
937
  }
821
938
  } catch (e) {
822
939
  const aborted = h.abort?.signal.aborted ?? false
@@ -828,8 +945,9 @@ export class AgentRuntime {
828
945
  this.t(`${h.id}: running→idle (${aborted ? "interrupted; inbox intact" : `error: ${msg}`})`)
829
946
  result = {
830
947
  text: msg, isError: true,
831
- status: closing ? "closed" : aborted ? "interrupted" : "error",
832
- turns: h.turnsTotal, totalTokens: h.usageTotal,
948
+ // None of these three is a limit stop, so none carries a `limit` (A18).
949
+ status: closing ? S.closed : aborted ? S.interrupted : S.error,
950
+ turns: h.turnsTotal, totalTokens: h.usageTotal, ownTokens: h.ownTokens,
833
951
  }
834
952
  } finally {
835
953
  this.releaseChildSlot(h)
@@ -904,7 +1022,7 @@ export class AgentRuntime {
904
1022
  } else if (existing.state === "running") {
905
1023
  r = await rt.wait(existing, { by: parent })
906
1024
  } else {
907
- r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: "error", turns: existing.turnsTotal, totalTokens: existing.usageTotal }
1025
+ r = existing.lastResult ?? { text: `no recorded result for ${existing.id}`, isError: true, status: S.error, turns: existing.turnsTotal, totalTokens: existing.usageTotal, ownTokens: existing.ownTokens }
908
1026
  }
909
1027
  } else {
910
1028
  const spawned = rt.spawn(parent, name)
package/src/builtin.ts 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 type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
15
16
  import { pending } from "./types.js"
@@ -115,7 +116,9 @@ function walkFiles(root: string): string[] {
115
116
  const dir = stack.pop()!
116
117
  let entries
117
118
  try {
118
- entries = readdirSync(dir, { withFileTypes: true })
119
+ // EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
120
+ // model is shown, not merely their order — the same content bug as <skill_files> had.
121
+ entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
119
122
  } catch {
120
123
  continue
121
124
  }
@@ -332,10 +335,13 @@ function grepTool(): Tool {
332
335
  const root = args.path ? String(args.path) : process.cwd()
333
336
  const include = args.include ? String(args.include) : undefined
334
337
  const limit = typeof args.limit === "number" ? args.limit : 100
335
- const matches: string[] = []
338
+ // Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
339
+ // CONTENT and the order of the results were whatever the walk happened to reach first.
340
+ // Now: collect every match, order by relative path (code point) then line number ascending,
341
+ // and truncate last. The emitted path and the sort key are the same string (A28).
342
+ const hits: Array<{ rel: string; line: number; text: string }> = []
336
343
  for (const file of walkFiles(root)) {
337
- if (matches.length >= limit) break
338
- const rel = path.relative(root, file)
344
+ const rel = toPosixPath(path.relative(root, file), path.sep)
339
345
  if (include && !matchGlob(rel, include)) continue
340
346
  let text: string
341
347
  try {
@@ -345,10 +351,11 @@ function grepTool(): Tool {
345
351
  }
346
352
  const lines = text.split("\n")
347
353
  for (let i = 0; i < lines.length; i++) {
348
- if (matches.length >= limit) break
349
- if (re.test(lines[i])) matches.push(`${file}:${i + 1}:${lines[i]}`)
354
+ if (re.test(lines[i])) hits.push({ rel, line: i + 1, text: lines[i] })
350
355
  }
351
356
  }
357
+ hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line)
358
+ const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`)
352
359
  return ok(matches.join("\n"), { count: matches.length })
353
360
  },
354
361
  )
@@ -373,13 +380,15 @@ function globTool(): Tool {
373
380
  if (!pattern) return err("glob: pattern is required")
374
381
  const root = args.path ? String(args.path) : process.cwd()
375
382
  const limit = typeof args.limit === "number" ? args.limit : 100
383
+ // Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
384
+ // the filesystem decide WHICH files the model sees, not merely their order. The emitted
385
+ // string and the sort key are the SAME `/`-separated relative path (A28).
376
386
  const found: string[] = []
377
387
  for (const file of walkFiles(root)) {
378
- if (found.length >= limit) break
379
- const rel = path.relative(root, file)
388
+ const rel = toPosixPath(path.relative(root, file), path.sep)
380
389
  if (matchGlob(rel, pattern)) found.push(rel)
381
390
  }
382
- found.sort()
391
+ found.sort(compareCodePoints)
383
392
  return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) })
384
393
  },
385
394
  )
package/src/classifier.ts CHANGED
@@ -10,9 +10,8 @@
10
10
  * here is a security control.
11
11
  */
12
12
  import type { Client } from "./client.js"
13
- import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
13
+ import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
14
14
  import { retryAfterMs, isRetryableStatus } from "./retry.js"
15
- import type { Toolkit } from "./toolkit.js"
16
15
 
17
16
  // ---------------------------------------------------------------- constants
18
17
 
@@ -25,6 +24,19 @@ export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
25
24
  /** Bounds ONE request; a classifier has no loop to bound. */
26
25
  export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
27
26
 
27
+ /**
28
+ * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
29
+ * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
30
+ * `typesafe/jev-1.13`, and mixing the two is issue #91.
31
+ */
32
+ export type ClassifierBackend = "typesafe" | "openrouter"
33
+
34
+ /** The two servable pairings, as configurations rather than six independently-choosable cells. */
35
+ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, { baseUrl: string; model: string; apiKeyEnv: string }>> = {
36
+ typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
37
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
38
+ }
39
+
28
40
  /** Client-side cap on a choice's named options (§8B). */
29
41
  export const MAX_CHOICE_OPTIONS = 255
30
42
  /** Bounds of a score rubric (§8B). */
@@ -392,6 +404,11 @@ export type EvaluateFn = (
392
404
  export interface ClassifierOptions {
393
405
  /** Default `"systemone"`. */
394
406
  style?: ClassifierStyle
407
+ /** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
408
+ * UNIT. Default `"typesafe"`. The three individual options still win where given, for a
409
+ * self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
410
+ * as an "Unknown model" 400. */
411
+ backend?: ClassifierBackend
395
412
  /** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
396
413
  baseUrl?: string
397
414
  /** Default `"jev-latest"`. Pin it once thresholds are tuned. */
@@ -456,9 +473,12 @@ export class Classifier {
456
473
 
457
474
  constructor(private readonly opts: ClassifierOptions) {
458
475
  this.style = opts.style ?? "systemone"
459
- this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL
460
- this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL
461
- this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV
476
+ const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"]
477
+ this.baseUrl = opts.baseUrl ?? preset.baseUrl
478
+ this.model = opts.model ?? preset.model
479
+ this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
480
+ const mismatch = backendMismatch(this.baseUrl, this.model)
481
+ if (mismatch) throw new Error(mismatch)
462
482
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
463
483
  switch (this.style) {
464
484
  case "systemone":
@@ -706,7 +726,8 @@ export class Classifier {
706
726
  '{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
707
727
  'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
708
728
  '"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
709
- const run = await this.opts.client!.run(prompt, { toolkit: EMPTY_TOOLKIT, signal })
729
+ // No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
730
+ const run = await this.opts.client!.run(prompt, { signal })
710
731
  const payload = firstJsonObject(run.text)
711
732
  let parsed: unknown
712
733
  try {
@@ -778,9 +799,21 @@ export function degenerateCriteria(criteria: Record<string, string>): string {
778
799
  */
779
800
  function cause(status: number, body: string): string {
780
801
  if (status === 401 || status === 403) return ""
781
- const s = body.trim()
782
- if (!s) return ""
783
- return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s)
802
+ // ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
803
+ const s = capErrorBody(redactErrorBody(body))
804
+ return s ? ": " + s : ""
805
+ }
806
+
807
+ /**
808
+ * The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
809
+ * pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
810
+ * with, or `""` when the pairing is fine.
811
+ */
812
+ export function backendMismatch(baseUrl: string, model: string): string {
813
+ const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase()
814
+ if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai")) return ""
815
+ if (!/^jev-/.test(model)) return ""
816
+ return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`
784
817
  }
785
818
 
786
819
  /** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
@@ -813,20 +846,3 @@ function sleep(ms: number): Promise<void> {
813
846
  })
814
847
  }
815
848
 
816
- /**
817
- * The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
818
- * `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
819
- * public "no tools" Toolkit constructor just for this path.
820
- */
821
- const EMPTY_TOOLKIT = {
822
- skillsPrompt: () => "",
823
- toOpenAI: () => [],
824
- toAnthropic: () => [],
825
- toGemini: () => [],
826
- get: () => undefined,
827
- list: () => [],
828
- execute: async () => {
829
- throw new Error("classifier: the llm style declares no tools")
830
- },
831
- close: async () => {},
832
- } as unknown as Toolkit