toolnexus 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { pendingOf } from "./types.js";
2
+ import { isRetryableStatus, retryAfterMs } from "./retry.js";
2
3
  import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
3
4
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
4
5
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
@@ -12,29 +13,6 @@ export class InMemoryConversationStore {
12
13
  this.map.set(id, [...messages]);
13
14
  }
14
15
  }
15
- const RETRYABLE = new Set([429, 500, 502, 503, 504]);
16
- /** ~68 years; the widest whole-second count all seven ports represent exactly. */
17
- const RETRY_AFTER_MAX_SECONDS = 2147483647;
18
- /**
19
- * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
20
- * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
21
- *
22
- * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
23
- * and `" "` (as 0), which is how this port came to wait for fractional seconds
24
- * that five of the other six rejected outright. Fractional, signed, HTTP-date and
25
- * out-of-range values are not delays we can honour, so the caller falls back to
26
- * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
27
- * `null`, and callers must use `??` rather than `||`.
28
- */
29
- function retryAfterMs(raw) {
30
- if (raw == null)
31
- return null;
32
- const s = raw.trim();
33
- if (!/^[0-9]+$/.test(s))
34
- return null;
35
- const secs = Number(s);
36
- return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null;
37
- }
38
16
  function delay(ms, signal) {
39
17
  return new Promise((resolve, reject) => {
40
18
  const t = setTimeout(resolve, ms);
@@ -468,7 +446,8 @@ export class Client {
468
446
  }
469
447
  return ctrl.signal;
470
448
  }
471
- /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
449
+ /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
450
+ * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
472
451
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
473
452
  async llmFetch(url, init, signal) {
474
453
  const retries = this.opts.retries ?? 2;
@@ -482,7 +461,7 @@ export class Client {
482
461
  const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
483
462
  if (res.ok)
484
463
  return res;
485
- const retryable = RETRYABLE.has(res.status);
464
+ const retryable = isRetryableStatus(res.status, this.opts.retryableStatuses);
486
465
  const tier = classify({ status: res.status, attempt, retryable });
487
466
  if (tier === "fail" || attempt === retries)
488
467
  return res; // caller surfaces the non-ok status
package/dist/index.d.ts CHANGED
@@ -13,4 +13,5 @@ export * from "./adapters.js";
13
13
  export * from "./translate.js";
14
14
  export * from "./toolkit.js";
15
15
  export * from "./client.js";
16
+ export * from "./classifier.js";
16
17
  export * as agents from "./agents/index.js";
package/dist/index.js CHANGED
@@ -13,4 +13,5 @@ export * from "./adapters.js";
13
13
  export * from "./translate.js";
14
14
  export * from "./toolkit.js";
15
15
  export * from "./client.js";
16
+ export * from "./classifier.js";
16
17
  export * as agents from "./agents/index.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The ONE §8 retry policy — the retryable-status set and the `Retry-After` rule.
3
+ *
4
+ * INTERNAL to the package. This module is deliberately NOT re-exported from
5
+ * `index.ts`: `client.ts` and `classifier.ts` (§8B) both need the policy, and a
6
+ * second copy is exactly the drift this file exists to prevent — but sharing it
7
+ * is an implementation detail, not npm API. Nothing here is in the parity-checked
8
+ * option surface, and nothing here may be added to `index.ts`.
9
+ */
10
+ /**
11
+ * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
12
+ * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
13
+ *
14
+ * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
15
+ * and `" "` (as 0), which is how this port came to wait for fractional seconds
16
+ * that five of the other six rejected outright. Fractional, signed, HTTP-date and
17
+ * out-of-range values are not delays we can honour, so the caller falls back to
18
+ * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
19
+ * `null`, and callers must use `??` rather than `||`.
20
+ */
21
+ export declare function retryAfterMs(raw: string | null | undefined): number | null;
22
+ /**
23
+ * Whether a status is retryable by default, optionally widened by a host's `retryableStatuses`.
24
+ *
25
+ * `extra` is ADDITIVE: it can only make more statuses retryable, never fewer, so a host cannot
26
+ * accidentally drop `429` and lose `Retry-After` handling with it. It decides the DEFAULT
27
+ * classification only — `onError` still runs afterwards and has the final say on every attempt,
28
+ * so `onError` returning `"fail"` overrides a status the host itself listed here.
29
+ */
30
+ export declare function isRetryableStatus(status: number, extra?: readonly number[] | null): boolean;
package/dist/retry.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The ONE §8 retry policy — the retryable-status set and the `Retry-After` rule.
3
+ *
4
+ * INTERNAL to the package. This module is deliberately NOT re-exported from
5
+ * `index.ts`: `client.ts` and `classifier.ts` (§8B) both need the policy, and a
6
+ * second copy is exactly the drift this file exists to prevent — but sharing it
7
+ * is an implementation detail, not npm API. Nothing here is in the parity-checked
8
+ * option surface, and nothing here may be added to `index.ts`.
9
+ */
10
+ /** ~68 years; the widest whole-second count all seven ports represent exactly. */
11
+ const RETRY_AFTER_MAX_SECONDS = 2147483647;
12
+ /**
13
+ * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
14
+ * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
15
+ *
16
+ * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
17
+ * and `" "` (as 0), which is how this port came to wait for fractional seconds
18
+ * that five of the other six rejected outright. Fractional, signed, HTTP-date and
19
+ * out-of-range values are not delays we can honour, so the caller falls back to
20
+ * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
21
+ * `null`, and callers must use `??` rather than `||`.
22
+ */
23
+ export function retryAfterMs(raw) {
24
+ if (raw == null)
25
+ return null;
26
+ const s = raw.trim();
27
+ if (!/^[0-9]+$/.test(s))
28
+ return null;
29
+ const secs = Number(s);
30
+ return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null;
31
+ }
32
+ /**
33
+ * The default retryable set: `429` plus the 5xx worth another try — and `529 Overloaded`,
34
+ * which TypeSafe documents as "retry with backoff" and which an enumeration made terminal.
35
+ *
36
+ * It is an ENUMERATION on purpose. "Any 5xx" would sweep in permanently-broken statuses
37
+ * (`501 Not Implemented`, `505 HTTP Version Not Supported`) and change the retry behaviour of
38
+ * every existing host without asking. A backend with its own transient status — a Cloudflare
39
+ * origin answering `520`–`527`, say — opts in declaratively through `retryableStatuses`, which
40
+ * ADDS to this set and cannot remove from it.
41
+ *
42
+ * Shared with §8B's classifier, which adds `408` to it rather than inventing a second policy.
43
+ */
44
+ const RETRYABLE = new Set([429, 500, 502, 503, 504, 529]);
45
+ /**
46
+ * Whether a status is retryable by default, optionally widened by a host's `retryableStatuses`.
47
+ *
48
+ * `extra` is ADDITIVE: it can only make more statuses retryable, never fewer, so a host cannot
49
+ * accidentally drop `429` and lose `Retry-After` handling with it. It decides the DEFAULT
50
+ * classification only — `onError` still runs afterwards and has the final say on every attempt,
51
+ * so `onError` returning `"fail"` overrides a status the host itself listed here.
52
+ */
53
+ export function isRetryableStatus(status, extra) {
54
+ return RETRYABLE.has(status) || (extra != null && extra.includes(status));
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,8 +17,10 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts",
21
- "example": "node --experimental-strip-types examples/basic.ts"
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts",
21
+ "example": "node --experimental-strip-types examples/basic.ts",
22
+ "example:judge": "node --experimental-strip-types examples/judge.ts",
23
+ "example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
22
24
  },
23
25
  "keywords": [
24
26
  "mcp",
@@ -72,6 +72,18 @@ export function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks |
72
72
  beforeTool: async (ev) => {
73
73
  for (const g of guardrails) {
74
74
  const verdict = g(ev)
75
+ // A guardrail is SYNCHRONOUS by contract. An async one returns a Promise,
76
+ // which is truthy and !== "allow", so it would silently DENY every call
77
+ // with `denied: [object Promise]`. Fail loudly instead, and say where
78
+ // asynchronous work belongs.
79
+ if (verdict != null && typeof verdict !== "string") {
80
+ const what = typeof (verdict as { then?: unknown }).then === "function" ? "a Promise" : typeof verdict
81
+ throw new TypeError(
82
+ `guardrail returned ${what}: a guardrail must return a string synchronously ` +
83
+ `("" or "allow" to permit, any other string to deny). ` +
84
+ `Do asynchronous work in hooks.beforeTool, which is awaited.`,
85
+ )
86
+ }
75
87
  if (verdict && verdict !== "allow") {
76
88
  return { result: { output: `denied: ${verdict}`, isError: true } }
77
89
  }