toolnexus 0.27.0 → 0.29.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.
@@ -68,6 +68,14 @@ export const CLASSIFIER_BACKENDS = Object.freeze({
68
68
  imagesHeader: true,
69
69
  supports: () => ({ images: false }),
70
70
  },
71
+ openai: {
72
+ baseUrl: "https://api.openai.com/v1",
73
+ model: "gpt-6-luna",
74
+ apiKeyEnv: "OPENAI_API_KEY",
75
+ wire: "decisions",
76
+ imagesHeader: false,
77
+ supports: () => ({ images: true }),
78
+ },
71
79
  custom: {
72
80
  baseUrl: "",
73
81
  model: DEFAULT_CLASSIFIER_MODEL,
@@ -421,7 +429,7 @@ export class Classifier {
421
429
  this.style = opts.style ?? "systemone";
422
430
  this.backend = opts.backend ?? "typesafe";
423
431
  if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
424
- throw new Error(`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`);
432
+ throw new Error(`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel", "openai" or "custom")`);
425
433
  }
426
434
  const preset = CLASSIFIER_BACKENDS[this.backend];
427
435
  this.preset = preset;
@@ -646,7 +654,10 @@ export class Classifier {
646
654
  for (const [key, q] of Object.entries(questions))
647
655
  qs[key] = questionWire(q);
648
656
  let body;
649
- if (this.preset.wire === "cloudflare") {
657
+ if (this.preset.wire === "decisions") {
658
+ body = { model: this.model, input: decisionsInput(p), questions: decisionsQuestions(this.backend, this.model, questions) };
659
+ }
660
+ else if (this.preset.wire === "cloudflare") {
650
661
  const input = { questions: qs, state: p.state };
651
662
  if (p.images.length > 0)
652
663
  input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`);
@@ -723,6 +734,10 @@ export class Classifier {
723
734
  parsed = unwrapCloudflare(parsed);
724
735
  text = canonicalJson(parsed);
725
736
  }
737
+ else if (this.preset.wire === "decisions") {
738
+ text = decisionsReply(parsed, questions);
739
+ parsed = JSON.parse(text);
740
+ }
726
741
  const d = decodeDecision(parsed, this.model);
727
742
  const verr = validateDecision(questions, d);
728
743
  if (validate && verr)
@@ -747,7 +762,9 @@ export class Classifier {
747
762
  // `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
748
763
  // a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
749
764
  let endpoint = this.baseUrl.replace(/\/+$/, "");
750
- if (this.preset.wire !== "cloudflare")
765
+ if (this.preset.wire === "decisions")
766
+ endpoint += "/decisions";
767
+ else if (this.preset.wire !== "cloudflare")
751
768
  endpoint += "/systemone";
752
769
  const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint;
753
770
  const retries = this.opts.retries ?? 2;
@@ -1277,3 +1294,103 @@ export function chunkQuestions(questions, size) {
1277
1294
  }
1278
1295
  return out;
1279
1296
  }
1297
+ // ---------------------------------------------------------------- the Decisions wire (#157)
1298
+ /** The Decisions API's per-request question cap. */
1299
+ export const MAX_DECISIONS_QUESTIONS = 400;
1300
+ /**
1301
+ * Maps the question set to the Decisions API list, in ASCII key order. Caller keys are NOT sent
1302
+ * (no `name`): §8B keys are addressing, not content, and answers come back in request order.
1303
+ * Every check fires before any request.
1304
+ */
1305
+ function decisionsQuestions(backend, model, questions) {
1306
+ const keys = Object.keys(questions).sort();
1307
+ if (keys.length > MAX_DECISIONS_QUESTIONS) {
1308
+ throw new Error(`classifier: the decisions wire takes at most ${MAX_DECISIONS_QUESTIONS} questions, got ${keys.length}`);
1309
+ }
1310
+ return keys.map((k) => {
1311
+ const q = questions[k];
1312
+ if (isRawQuestion(q))
1313
+ throw new ClassifierUnsupported(backend, model, `raw question type "${q.type}"`);
1314
+ const m = q;
1315
+ switch (m.type) {
1316
+ case "noul": {
1317
+ let instructions = m.instructions;
1318
+ if (m.criteria !== undefined)
1319
+ instructions += `\n\nTrue means: ${m.criteria.true}\nFalse means: ${m.criteria.false}`;
1320
+ return { type: "predicate", instructions };
1321
+ }
1322
+ case "choice": {
1323
+ const ids = Object.keys(m.criteria ?? {}).sort();
1324
+ if (ids.length < 2) {
1325
+ throw new Error(`classifier: question "${k}": the decisions wire needs 2..${MAX_CHOICE_OPTIONS} choice options, got ${ids.length}`);
1326
+ }
1327
+ const choices = ids.map((id) => (m.criteria[id] === "" ? { value: id } : { value: id, description: m.criteria[id] }));
1328
+ return { type: "choice", instructions: m.instructions, choices };
1329
+ }
1330
+ case "score":
1331
+ return { type: "score", instructions: m.instructions, levels: m.criteria.map((label) => ({ label })) };
1332
+ }
1333
+ });
1334
+ }
1335
+ /** The state as sent: a string verbatim, else canonical JSON text; with images, one user message. */
1336
+ function decisionsInput(p) {
1337
+ const text = typeof p.state === "string" ? p.state : canonicalJson(p.state);
1338
+ if (p.images.length === 0)
1339
+ return text;
1340
+ const content = [{ type: "input_text", text }];
1341
+ for (const im of p.images)
1342
+ content.push({ type: "input_image", image_url: `data:image/${im.format};base64,${im.data}` });
1343
+ return [{ role: "user", content }];
1344
+ }
1345
+ /** A choice value or level index as the §8B key: string as written, boolean "true"/"false", number shortest. */
1346
+ function decisionsValue(v) {
1347
+ return typeof v === "string" ? v : String(v);
1348
+ }
1349
+ /**
1350
+ * Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
1351
+ * validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
1352
+ */
1353
+ export function decisionsReply(reply, questions) {
1354
+ const r = reply;
1355
+ if (r === null || typeof r !== "object" || Array.isArray(r) || !Array.isArray(r.answers)) {
1356
+ throw new Error("classifier: decisions: malformed reply");
1357
+ }
1358
+ const keys = Object.keys(questions).sort();
1359
+ if (r.answers.length !== keys.length) {
1360
+ throw new Error(`classifier: decisions: ${r.answers.length} answers for ${keys.length} questions`);
1361
+ }
1362
+ const num = (v) => (typeof v === "number" ? v : 0);
1363
+ const want = { noul: "predicate", choice: "choice", score: "score" };
1364
+ const answers = {};
1365
+ keys.forEach((k, i) => {
1366
+ const a = r.answers[i];
1367
+ if (a === null || typeof a !== "object" || Array.isArray(a))
1368
+ throw new Error("classifier: decisions: malformed reply");
1369
+ if (a.type === "refusal")
1370
+ throw new Error(`classifier: decisions: question "${k}" was refused`);
1371
+ const q = questions[k];
1372
+ const w = want[q.type] ?? "";
1373
+ if (a.type !== w)
1374
+ throw new Error(`classifier: decisions: answer "${k}" is a ${a.type} answer, not ${w}`);
1375
+ const probs = {};
1376
+ for (const p of Array.isArray(a.probabilities) ? a.probabilities : [])
1377
+ probs[decisionsValue(p?.value)] = num(p?.probability);
1378
+ if (q.type === "noul")
1379
+ answers[k] = { type: "noul", noul: num(a.probability) };
1380
+ else if (q.type === "choice") {
1381
+ answers[k] = { type: "choice", choice: decisionsValue(a.choice), probabilities: probs, confidence: num(a.confidence) };
1382
+ }
1383
+ else {
1384
+ const legend = {};
1385
+ q.criteria.forEach((l, j) => (legend[String(j)] = l));
1386
+ answers[k] = { type: "score", score: num(a.score), legend, probabilities: probs, confidence: num(a.confidence) };
1387
+ }
1388
+ });
1389
+ const u = r.usage ?? {};
1390
+ return canonicalJson({
1391
+ answers,
1392
+ calibrated: false,
1393
+ model: typeof r.model === "string" ? r.model : "",
1394
+ usage: { input_tokens: num(u.input_tokens), output_tokens: num(u.output_tokens) },
1395
+ });
1396
+ }
package/dist/client.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Unified LLM client — the host loop. Give it a plain base URL + a style
3
- * ("openai" | "anthropic"), and it runs the tool-calling agent loop against a
4
- * Toolkit. See ../../SPEC.md §8.
5
- */
6
1
  import type { Toolkit } from "./toolkit.js";
7
2
  import type { ToolHints, ToolResult, Request, Answer } from "./types.js";
8
3
  import { type PromptCacheOptions } from "./promptcache.js";
@@ -26,11 +21,20 @@ export interface ClientOptions {
26
21
  headers?: Record<string, string>;
27
22
  systemPrompt?: string;
28
23
  maxTurns?: number;
24
+ /** SPEC §8 "Stop-reason recovery". Default false. On, an empty answer is nudged once, a
25
+ * `length`/`max_tokens` cut is continued (up to 3 times, text carried) and malformed tool-call
26
+ * arguments get one corrective turn — each an ordinary turn, only while a turn remains. */
27
+ stopRecovery?: boolean;
29
28
  /** Cap on concurrency-safe tool calls of one response running at once (SPEC §8 "Tool
30
29
  * scheduling"). Absent ⇒ env `TOOLNEXUS_MAX_TOOL_CONCURRENCY` when a positive integer, else 10.
31
30
  * Unsafe calls (anything not read-only) always run alone. */
32
31
  maxToolConcurrency?: number;
33
32
  hooks?: Hooks;
33
+ /** SPEC §7F.1 reactive compaction. Called (with the `beforeLLM` signature) when the provider
34
+ * rejects a call as a context overflow (`isContextOverflow`); strictly fewer `messages` replace
35
+ * the transcript and the same turn is re-sent. Breaker: 3 rapid compactions ⇒ `ContextOverflowError`.
36
+ * Absent ⇒ the overflow surfaces as `LlmHttpError`, unchanged. */
37
+ compactOnOverflow?: Hooks["beforeLLM"];
34
38
  /** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
35
39
  * Widen the status set with `retryableStatuses`. */
36
40
  retries?: number;
@@ -97,6 +101,11 @@ export interface ClientOptions {
97
101
  * today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
98
102
  * There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
99
103
  onError?: (info: ErrorInfo) => ErrorTier;
104
+ /** §8 Resilience. Ordered fallback model ids tried, with a fresh retry budget each, when the
105
+ * current model's retry budget ends on an overload (`503`/`529`). Chain = [turnModel, ...these];
106
+ * empty/duplicate entries skipped. `TOOLNEXUS_DISABLE_MODEL_FALLBACK=1|true` disables it.
107
+ * `stream` emits `{ type: "fallback", from, to }` before switching. Omit ⇒ byte-identical. */
108
+ fallbackModels?: readonly string[];
100
109
  /** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
101
110
  * +33% base64 string). Unset ⇒ no limit, as today. */
102
111
  maxPartBytes?: number;
@@ -159,6 +168,26 @@ export declare class LlmHttpError extends Error {
159
168
  }
160
169
  /** Build the typed error from a non-2xx provider response (reads the body once). */
161
170
  export declare function llmHttpError(res: Response): Promise<LlmHttpError>;
171
+ /** SPEC §7F.1: true iff `status` is 400/413 AND the (redacted) body names a context overflow. */
172
+ export declare function isContextOverflow(status: number, body: string): boolean;
173
+ /** SPEC §7F.1: reactive compaction could not make the prompt fit (breaker tripped, or the
174
+ * `compactOnOverflow` hook made no progress). A failure, never a §10 pending. */
175
+ export declare class ContextOverflowError extends Error {
176
+ /** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
177
+ readonly compactions: number;
178
+ /** Status of the last provider error. */
179
+ readonly status: number;
180
+ /** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
181
+ readonly body: string;
182
+ readonly name = "ContextOverflowError";
183
+ constructor(
184
+ /** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
185
+ compactions: number,
186
+ /** Status of the last provider error. */
187
+ status: number,
188
+ /** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
189
+ body: string);
190
+ }
162
191
  /** §8 Resilience. What to do with a failed LLM call. */
163
192
  export type ErrorTier = "retry" | "fail";
164
193
  /** §8 Resilience. Context passed to `onError` for each failed LLM attempt. */
@@ -301,6 +330,21 @@ export interface Hooks {
301
330
  } | Promise<void | {
302
331
  result?: ToolResult;
303
332
  }>;
333
+ /** At each round boundary (SPEC §8, #192): after a round's tool results (final=false), or when the
334
+ * model stopped with a turn remaining (final=true, text = its answer). Each `inject` string is
335
+ * appended as a user message; a non-empty inject on a final round CONTINUES the loop. */
336
+ afterRound?(ev: AfterRoundEvent): void | RoundInjection | Promise<void | RoundInjection>;
337
+ }
338
+ /** The `afterRound` hook event. */
339
+ export interface AfterRoundEvent {
340
+ turn: number;
341
+ final: boolean;
342
+ text: string;
343
+ messages: any[];
344
+ }
345
+ /** What an `afterRound` hook may return. */
346
+ export interface RoundInjection {
347
+ inject?: string[];
304
348
  }
305
349
  /** A tool call the model made, plus its result + metadata. */
306
350
  export interface ToolCallRecord {
@@ -319,9 +363,12 @@ export type RunFile = {
319
363
  url?: string;
320
364
  data?: string;
321
365
  name?: string;
366
+ path?: string;
322
367
  from: string;
323
368
  callId: string;
324
369
  };
370
+ /** RunResult.files: every non-text part of every tool call, in call order (§8). */
371
+ export declare function runFiles(toolCalls: ToolCallRecord[]): RunFile[];
325
372
  /** Token usage, summed across every LLM round trip in the run. */
326
373
  export interface Usage {
327
374
  promptTokens: number;
@@ -379,6 +426,15 @@ export type StreamEvent = {
379
426
  name: string;
380
427
  args: Record<string, unknown>;
381
428
  via?: "call_tool" | "acp";
429
+ }
430
+ /** An ACP agent's `tool_call_update` carrying `rawInput`: the call's arguments, known after
431
+ * the initial `tool_call` (#188). Reported only; never executed by the loop. */
432
+ | {
433
+ type: "tool_call_args";
434
+ id: string;
435
+ name: string;
436
+ args: Record<string, unknown>;
437
+ via: "acp";
382
438
  } | {
383
439
  type: "tool_result";
384
440
  id: string;
@@ -390,9 +446,26 @@ export type StreamEvent = {
390
446
  } | {
391
447
  type: "usage";
392
448
  usage: Usage;
449
+ }
450
+ /** Only with `fallbackModels`: switching from model `from` to `to` after overload (§8). */
451
+ | {
452
+ type: "fallback";
453
+ from: string;
454
+ to: string;
393
455
  } | {
394
456
  type: "pending";
395
457
  request: Request;
458
+ }
459
+ /** Emitted just before each LLM retry wait (streaming only). `attempt` is 1-based (the
460
+ * failed zero-based attempt + 1), `max` the configured `retries`, `delayMs` the exact wait,
461
+ * `status` the HTTP status (absent on a network error), `reason` "HTTP <status>" | "network". */
462
+ | {
463
+ type: "retry";
464
+ attempt: number;
465
+ max: number;
466
+ delayMs: number;
467
+ status?: number;
468
+ reason: string;
396
469
  } | {
397
470
  type: "done";
398
471
  result: RunResult;
@@ -521,6 +594,20 @@ export declare class Client {
521
594
  * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
522
595
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
523
596
  private llmFetch;
597
+ /** The retry loop behind `llmFetch`, as a generator: yields a `retry` StreamEvent just before
598
+ * each retry wait (its `delayMs` is the exact wait slept) and returns the final Response.
599
+ * The streaming generators consume it with `yield*`. */
600
+ private llmFetchEvents;
601
+ /** §8 fallbackModels: the per-call model chain — [turnModel, ...fallbackModels], empty and
602
+ * duplicate entries skipped; just [turnModel] when the option is absent or the kill switch is set. */
603
+ private modelChain;
604
+ /** llmFetch over the model chain: each model spends the full retry budget; only a budget that
605
+ * ends on 503/529 (not an onError "fail") advances to the next model. Returns the response and
606
+ * the model that produced it. `build` rebuilds the same request for a model. */
607
+ private llmFetchChain;
608
+ /** The fallback chain as a generator for the streaming loops: the #189 `retry` events of each
609
+ * model's attempts and a `fallback` event at each switch reach the stream as they happen. */
610
+ private llmFetchChainEvents;
524
611
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
525
612
  private runTool;
526
613
  private runToolDirect;
@@ -550,11 +637,31 @@ export declare class Client {
550
637
  private emitTool;
551
638
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
552
639
  private llmCallJson;
640
+ /**
641
+ * SPEC §7F.1: one LLM call with reactive compaction. `call(messages)` makes the request and
642
+ * throws on a non-2xx (as `LlmHttpError`). On a context overflow with `compactOnOverflow` set,
643
+ * the hook compacts and the SAME turn is re-sent (no beforeLLM, no turn consumed), guarded by
644
+ * the rapid-refill breaker. Returns the call's value and the (possibly compacted) transcript.
645
+ */
646
+ private callWithOverflow;
647
+ /** `callWithOverflow` for the streaming loops: `call` is a generator so the #189 `retry` events
648
+ * of every attempt (including one after a compaction) still reach the stream before each wait. */
649
+ private callWithOverflowEvents;
553
650
  private runOpenAI;
554
651
  private runAnthropic;
555
652
  private streamOpenAI;
556
653
  private streamAnthropic;
654
+ /**
655
+ * §8 stop-reason recovery: classify the response just appended to `messages` and, when a rule
656
+ * fires, remove (or keep) that assistant message, append the pinned user message, and return
657
+ * true — the caller then takes another turn. Never on the last permitted turn.
658
+ */
659
+ private recover;
660
+ private newRecovery;
557
661
  private runOpenAIWithSignal;
662
+ /** §8 afterRound: fire the round-boundary hook and append its injections as user messages.
663
+ * Returns true when something was appended. A final round fires only when a turn remains. */
664
+ private roundEnd;
558
665
  private result;
559
666
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
560
667
  private pendingRun;