toolnexus 0.9.4 → 0.10.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/src/client.ts CHANGED
@@ -47,6 +47,26 @@ export interface ClientOptions {
47
47
  /** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
48
48
  * LLM path only; MCP transports use the SDK's own client. */
49
49
  fetch?: typeof fetch
50
+ /** §8 Resilience. Classify each LLM-call failure into "retry" or "fail". Omit ⇒ the default
51
+ * classifier (retryable status/network within budget ⇒ retry, else fail) — byte-identical to
52
+ * today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
53
+ * There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
54
+ onError?: (info: ErrorInfo) => ErrorTier
55
+ }
56
+
57
+ /** §8 Resilience. What to do with a failed LLM call. */
58
+ export type ErrorTier = "retry" | "fail"
59
+
60
+ /** §8 Resilience. Context passed to `onError` for each failed LLM attempt. */
61
+ export interface ErrorInfo {
62
+ /** The thrown network/transport error, when the failure was not an HTTP response. */
63
+ error?: unknown
64
+ /** The HTTP status, when the failure was a non-ok response. */
65
+ status?: number
66
+ /** Zero-based attempt index (0 = first try). */
67
+ attempt: number
68
+ /** Whether `status`/the error is in the default retryable set (429/5xx/network). */
69
+ retryable: boolean
50
70
  }
51
71
 
52
72
  /**
@@ -135,10 +155,14 @@ export interface RunResult {
135
155
  usage: Usage
136
156
  /** The model used. */
137
157
  model: string
138
- /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
139
- status: "done" | "pending"
158
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10);
159
+ * "incomplete" iff a limit stopped the run loudly (§7D) — a maxTurns stop with no final
160
+ * text is never a silent "done". */
161
+ status: "done" | "pending" | "incomplete"
140
162
  /** The unresolved suspension — present iff status === "pending" (§10). */
141
163
  pending?: Request
164
+ /** The limit that stopped the run (e.g. "maxTurns") — present iff status === "incomplete". */
165
+ limit?: string
142
166
  }
143
167
 
144
168
  /** Events yielded by client.stream(). */
@@ -359,10 +383,11 @@ export class Client {
359
383
  this.opts.onMetric?.(ev)
360
384
  }
361
385
 
362
- /** Emit the terminal `run` metric event and build the RunResult. */
363
- private endRun(runStart: number, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
386
+ /** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
387
+ * maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
388
+ private endRun(runStart: number, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
364
389
  this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
365
- return this.result(text, messages, toolCalls, turns, usage)
390
+ return this.result(text, messages, toolCalls, turns, usage, exhausted)
366
391
  }
367
392
 
368
393
  /** Emit a `run` error metric event (once, on a thrown run). */
@@ -439,21 +464,29 @@ export class Client {
439
464
  return ctrl.signal
440
465
  }
441
466
 
442
- /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal. */
467
+ /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
468
+ * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
443
469
  private async llmFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
444
470
  const retries = this.opts.retries ?? 2
445
471
  const base = this.opts.retryBaseMs ?? 500
472
+ // Default classifier = today's behavior, expressed as an onError. A "retry" is always
473
+ // capped by the budget below (attempt === retries stops regardless of the classifier).
474
+ const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
446
475
  let lastErr: unknown
447
476
  for (let attempt = 0; attempt <= retries; attempt++) {
448
477
  try {
449
478
  const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
450
- if (res.ok || !RETRYABLE.has(res.status) || attempt === retries) return res
479
+ if (res.ok) return res
480
+ const retryable = RETRYABLE.has(res.status)
481
+ const tier = classify({ status: res.status, attempt, retryable })
482
+ if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
451
483
  const ra = Number(res.headers.get("retry-after"))
452
484
  await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
453
485
  } catch (e) {
454
486
  if (signal.aborted) throw e // abort/timeout: don't retry
455
487
  lastErr = e
456
- if (attempt === retries) throw e
488
+ const tier = classify({ error: e, attempt, retryable: true })
489
+ if (tier === "fail" || attempt === retries) throw e
457
490
  await delay(base * 2 ** attempt + Math.random() * 100, signal)
458
491
  }
459
492
  }
@@ -568,15 +601,18 @@ export class Client {
568
601
  if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
569
602
  }
570
603
  }
571
- return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
604
+ return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true)
572
605
  } catch (e) {
573
606
  this.emitRunError(runStart, toolCalls, turns, usage, e)
574
607
  throw e
575
608
  }
576
609
  }
577
610
 
578
- private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
579
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" }
611
+ private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
612
+ const incomplete = exhausted && text === ""
613
+ const r: RunResult = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: incomplete ? "incomplete" : "done" }
614
+ if (incomplete) r.limit = "maxTurns"
615
+ return r
580
616
  }
581
617
 
582
618
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
@@ -674,7 +710,7 @@ export class Client {
674
710
  messages.push({ role: "user", content })
675
711
  if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
676
712
  }
677
- return this.endRun(runStart, "", messages, toolCalls, turns, usage)
713
+ return this.endRun(runStart, "", messages, toolCalls, turns, usage, true)
678
714
  } catch (e) {
679
715
  this.emitRunError(runStart, toolCalls, turns, usage, e)
680
716
  throw e
@@ -770,7 +806,7 @@ export class Client {
770
806
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
771
807
  }
772
808
  }
773
- yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) }
809
+ yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true) }
774
810
  } catch (e) {
775
811
  this.emitRunError(runStart, toolCalls, turns, usage, e)
776
812
  throw e
@@ -863,7 +899,7 @@ export class Client {
863
899
  }
864
900
  messages.push({ role: "user", content: results })
865
901
  }
866
- yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) }
902
+ yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) }
867
903
  } catch (e) {
868
904
  this.emitRunError(runStart, toolCalls, turns, usage, e)
869
905
  throw e
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./mcpserve.js"
10
10
  export * from "./adapters.js"
11
11
  export * from "./toolkit.js"
12
12
  export * from "./client.js"
13
+ export * as agents from "./agents/index.js"
package/src/toolkit.ts CHANGED
@@ -21,6 +21,13 @@ export interface ToolkitOptions {
21
21
  skillProvider?: () => SkillDef[] | Promise<SkillDef[]>
22
22
  /** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
23
23
  skillsFilter?: Record<string, boolean>
24
+ /** Drop tools by their FINAL exposed name across every source (MCP `server_tool`, builtins,
25
+ * native, a2a, extras), applied after aggregation — the simple "just turn these off" list.
26
+ * Empty/undefined ⇒ nothing disabled. Composes with builtins/skillsFilter/server allowlists. */
27
+ disableTools?: string[]
28
+ /** Drop skills by name from the `skill` catalog — sugar over a drop-list in `skillsFilter`
29
+ * (each name folded in as `false`, without overriding an explicit filter entry). */
30
+ disableSkills?: string[]
24
31
  /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
25
32
  skillSampleLimit?: number
26
33
  extraTools?: Tool[]
@@ -52,14 +59,18 @@ export class Toolkit {
52
59
  private readonly a2aConfig: A2AConfig | undefined,
53
60
  /** MCP serve profile read from a top-level `mcpServer` config block. */
54
61
  private readonly mcpServerConfig: MCPServeConfig | undefined,
62
+ /** Final-name drop-list (§4A) applied across every source after aggregation. */
63
+ disableTools: string[] = [],
55
64
  ) {
56
65
  // Builtins are the lowest-precedence source: a host extraTools entry with
57
66
  // the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
58
67
  // front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
59
68
  const extraNames = new Set(extraTools.map((t) => t.name))
60
69
  const activeBuiltins = builtins.filter((b) => !extraNames.has(b.name))
70
+ const disabled = new Set(disableTools)
61
71
  const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...activeBuiltins, ...agents, ...extraTools]
62
72
  for (const t of all) {
73
+ if (disabled.has(t.name)) continue // §4A drop-list by final exposed name
63
74
  if (this.byName.has(t.name)) {
64
75
  console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
65
76
  continue
@@ -85,10 +96,16 @@ export class Toolkit {
85
96
  }
86
97
  }
87
98
  const dataDefs = [...(opts.skills ?? []), ...providerDefs]
99
+ // disableSkills is sugar over a skillsFilter drop-list: fold each name in as false
100
+ // WITHOUT overriding an explicit skillsFilter entry (explicit entries win).
101
+ const filter =
102
+ opts.disableSkills && opts.disableSkills.length > 0
103
+ ? { ...Object.fromEntries(opts.disableSkills.map((n) => [n, false])), ...opts.skillsFilter }
104
+ : opts.skillsFilter
88
105
  skill = loadSkills({
89
106
  dirs: opts.skillsDir,
90
107
  skills: dataDefs.length > 0 ? dataDefs : undefined,
91
- filter: opts.skillsFilter,
108
+ filter,
92
109
  sampleLimit: opts.skillSampleLimit,
93
110
  })
94
111
  }
@@ -137,7 +154,7 @@ export class Toolkit {
137
154
  mcpServerConfig = (opts.mcpConfig as { mcpServer?: MCPServeConfig }).mcpServer
138
155
  }
139
156
 
140
- return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig)
157
+ return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig, opts.disableTools ?? [])
141
158
  }
142
159
 
143
160
  tools(): Tool[] {