toolnexus 0.18.1 → 0.20.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,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":
@@ -433,8 +443,10 @@ export class Classifier {
433
443
  let lastErr;
434
444
  for (let attempt = 0;; attempt++) {
435
445
  const timer = new AbortController();
446
+ // NOT unref'd: this watchdog is the only thing that will end a request an
447
+ // injected `fetch` never settles, and an unref'd timer cannot keep the
448
+ // loop alive long enough to fire. It is cleared on every exit path below.
436
449
  const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
437
- t.unref?.();
438
450
  const onOuter = () => timer.abort(signal.reason);
439
451
  if (signal) {
440
452
  if (signal.aborted) {
@@ -512,7 +524,8 @@ export class Classifier {
512
524
  '{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
513
525
  'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
514
526
  '"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.';
515
- const run = await this.opts.client.run(prompt, { toolkit: EMPTY_TOOLKIT, signal });
527
+ // No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
528
+ const run = await this.opts.client.run(prompt, { signal });
516
529
  const payload = firstJsonObject(run.text);
517
530
  let parsed;
518
531
  try {
@@ -579,10 +592,22 @@ export function degenerateCriteria(criteria) {
579
592
  function cause(status, body) {
580
593
  if (status === 401 || status === 403)
581
594
  return "";
582
- const s = body.trim();
583
- if (!s)
595
+ // ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
596
+ const s = capErrorBody(redactErrorBody(body));
597
+ return s ? ": " + s : "";
598
+ }
599
+ /**
600
+ * The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
601
+ * pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
602
+ * with, or `""` when the pairing is fine.
603
+ */
604
+ export function backendMismatch(baseUrl, model) {
605
+ const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase();
606
+ if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai"))
607
+ return "";
608
+ if (!/^jev-/.test(model))
584
609
  return "";
585
- return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s);
610
+ return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`;
586
611
  }
587
612
  /** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
588
613
  function expandEnv(value) {
@@ -611,20 +636,3 @@ function sleep(ms) {
611
636
  setTimeout(resolve, ms);
612
637
  });
613
638
  }
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,13 +404,25 @@ 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
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
415
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
416
+ private readonly runTimers;
417
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
418
+ private releaseSignal;
419
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
420
+ *
421
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
422
+ * alive, so a run whose only pending work is a request that never settles — an injected
423
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
424
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
425
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
343
426
  private makeSignal;
344
427
  /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
345
428
  * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
@@ -352,6 +435,10 @@ export declare class Client {
352
435
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
353
436
  private llmCallJson;
354
437
  private runOpenAI;
438
+ private runAnthropic;
439
+ private streamOpenAI;
440
+ private streamAnthropic;
441
+ private runOpenAIWithSignal;
355
442
  private result;
356
443
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
357
444
  private pendingRun;
@@ -361,9 +448,9 @@ export declare class Client {
361
448
  * no `waitFor` is configured (the run should stop and surface the request).
362
449
  */
363
450
  private resolvePending;
364
- private runAnthropic;
365
- private streamOpenAI;
366
- private streamAnthropic;
451
+ private runAnthropicWithSignal;
452
+ private streamOpenAIWithSignal;
453
+ private streamAnthropicWithSignal;
367
454
  }
368
455
  export declare function createClient(opts: ClientOptions): Client;
369
456
  /** One tool call an in-process model asks for. Flat on purpose: the nested
@@ -400,6 +487,17 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
400
487
  /** Your model. Receives the assembled request, returns one assistant message. */
401
488
  generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
402
489
  };
490
+ /**
491
+ * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
492
+ * the reusable piece of {@link createInProcessClient} — assembles the wire request,
493
+ * calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
494
+ * caller that wants an in-process model behind a `fetch` slot (the top-level client,
495
+ * or the agent runtime's `inProcess` option) goes through this ONE function, so there
496
+ * is no second copy of the request/response assembly to drift.
497
+ *
498
+ * Streaming is refused rather than faked — see the thrown error.
499
+ */
500
+ export declare function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch;
403
501
  /**
404
502
  * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
405
503
  * HTTP types to construct.
@@ -407,18 +505,20 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
407
505
  * This is a second constructor, not a second seam: it builds on the same injectable
408
506
  * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
409
507
  * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
508
+ * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
509
+ * is just that adapter wired into a `Client`.
410
510
  *
411
- * Streaming is refused rather than faked — see the thrown error.
511
+ * Streaming is refused rather than faked — see {@link createInProcessFetch}.
412
512
  */
413
513
  export declare function createInProcessClient(opts: InProcessOptions): Client;
414
514
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
415
515
  export declare class Conversation {
416
516
  private readonly client;
417
- private readonly toolkit;
517
+ private readonly toolkit?;
418
518
  private readonly signal?;
419
519
  /** Full running transcript (system + user + assistant + tool messages). */
420
520
  messages: any[];
421
- constructor(client: Client, toolkit: Toolkit, signal?: AbortSignal | undefined);
521
+ constructor(client: Client, toolkit?: Toolkit | undefined, signal?: AbortSignal | undefined);
422
522
  /** Send the next user turn; prior history is retained automatically. */
423
523
  send(prompt: PromptInput): Promise<RunResult>;
424
524
  /** Reset the conversation memory. */