toolnexus 0.16.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/src/client.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  import type { Toolkit } from "./toolkit.js"
7
7
  import type { ToolResult, Request, Answer } from "./types.js"
8
8
  import { pendingOf } from "./types.js"
9
+ import { isRetryableStatus, retryAfterMs } from "./retry.js"
10
+ import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
11
+ import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
9
12
  import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
10
13
  import {
11
14
  finishReasonFor,
@@ -28,10 +31,20 @@ export interface ClientOptions {
28
31
  systemPrompt?: string
29
32
  maxTurns?: number
30
33
  hooks?: Hooks
31
- /** Retries on transient LLM errors (429/5xx/network). Default 2. */
34
+ /** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
35
+ * Widen the status set with `retryableStatuses`. */
32
36
  retries?: number
33
37
  /** Base backoff in ms (exponential + jitter). Default 500. */
34
38
  retryBaseMs?: number
39
+ /**
40
+ * Extra HTTP statuses to treat as retryable, ADDED to the default set
41
+ * (`429`/`500`/`502`/`503`/`504`/`529`). It can only widen: a host cannot remove `429` and
42
+ * lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
43
+ * runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
44
+ * listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
45
+ */
46
+ retryableStatuses?: readonly number[]
47
+
35
48
  /** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
36
49
  timeoutMs?: number
37
50
  /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
@@ -62,6 +75,13 @@ export interface ClientOptions {
62
75
  * today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
63
76
  * There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
64
77
  onError?: (info: ErrorInfo) => ErrorTier
78
+ /** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
79
+ * +33% base64 string). Unset ⇒ no limit, as today. */
80
+ maxPartBytes?: number
81
+ /** §8A. Overrides the provenance rule uniformly: `"error"` fails on any part the style
82
+ * cannot represent (attached or tool-derived), `"text"` degrades every one of them to a
83
+ * text placeholder. Unset ⇒ attached parts error, derived parts degrade. */
84
+ onUnsupportedPart?: UnsupportedPartMode
65
85
  }
66
86
 
67
87
  /** §8 Resilience. What to do with a failed LLM call. */
@@ -75,7 +95,8 @@ export interface ErrorInfo {
75
95
  status?: number
76
96
  /** Zero-based attempt index (0 = first try). */
77
97
  attempt: number
78
- /** Whether `status`/the error is in the default retryable set (429/5xx/network). */
98
+ /** Whether `status`/the error is in the default retryable set
99
+ * (429/500/502/503/504/529/network, plus anything `retryableStatuses` added). */
79
100
  retryable: boolean
80
101
  }
81
102
 
@@ -88,6 +109,20 @@ export type MetricEvent =
88
109
  | { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
89
110
  | { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
90
111
  | { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
112
+ // §8B. The classifier emits into this SAME sink. Neither event is folded into the Prometheus
113
+ // registry, so `client.metrics()` text stays byte-identical to a build with no classifier.
114
+ | {
115
+ event: "classifier.evaluate"
116
+ model: string
117
+ status: "ok" | "error"
118
+ ms: number
119
+ promptTokens: number
120
+ completionTokens: number
121
+ error?: string
122
+ }
123
+ // `warning`, never `error`: a degenerate-criteria report is advisory, and a consumer that
124
+ // counts "has an error" as a failure must not count this.
125
+ | { event: "classifier.warning"; question: string; warning: string }
91
126
 
92
127
  /**
93
128
  * Where `ask()` conversations are remembered — two methods. Ship the in-memory
@@ -112,30 +147,6 @@ export class InMemoryConversationStore implements ConversationStore {
112
147
  }
113
148
  }
114
149
 
115
- const RETRYABLE = new Set([429, 500, 502, 503, 504])
116
-
117
- /** ~68 years; the widest whole-second count all seven ports represent exactly. */
118
- const RETRY_AFTER_MAX_SECONDS = 2147483647
119
-
120
- /**
121
- * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
122
- * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
123
- *
124
- * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
125
- * and `" "` (as 0), which is how this port came to wait for fractional seconds
126
- * that five of the other six rejected outright. Fractional, signed, HTTP-date and
127
- * out-of-range values are not delays we can honour, so the caller falls back to
128
- * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
129
- * `null`, and callers must use `??` rather than `||`.
130
- */
131
- function retryAfterMs(raw: string | null | undefined): number | null {
132
- if (raw == null) return null
133
- const s = raw.trim()
134
- if (!/^[0-9]+$/.test(s)) return null
135
- const secs = Number(s)
136
- return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
137
- }
138
-
139
150
  function delay(ms: number, signal: AbortSignal): Promise<void> {
140
151
  return new Promise((resolve, reject) => {
141
152
  const t = setTimeout(resolve, ms)
@@ -384,7 +395,7 @@ export class Client {
384
395
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
385
396
  * matches the pre-change body so a no-options call is byte-identical. */
386
397
  private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
387
- const b: Record<string, any> = { model: this.opts.model, messages }
398
+ const b: Record<string, any> = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) }
388
399
  if (tools.length) {
389
400
  b.tools = tools
390
401
  b.tool_choice = "auto"
@@ -398,12 +409,17 @@ export class Client {
398
409
 
399
410
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
400
411
  private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
401
- const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
412
+ const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) }
402
413
  if (tools.length) b.tools = tools
403
414
  if (stream) b.stream = true
404
415
  return this.finalizeBody(b)
405
416
  }
406
417
 
418
+ /** The §8A/§1B shaping options every wire build shares. */
419
+ private wireOpts(): WireOptions {
420
+ return { onUnsupportedPart: this.opts.onUnsupportedPart, maxPartBytes: this.opts.maxPartBytes }
421
+ }
422
+
407
423
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
408
424
  metrics(): string {
409
425
  return this.registry.render()
@@ -427,7 +443,8 @@ export class Client {
427
443
  this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
428
444
  }
429
445
 
430
- async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
446
+ async run(prompt: PromptInput, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
447
+ checkPromptParts(prompt, this.wireOpts())
431
448
  return this.opts.style === "anthropic"
432
449
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
433
450
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
@@ -592,7 +609,7 @@ export class Client {
592
609
  * the answer — so the next `ask` with the same `id` continues it. Without an
593
610
  * `id` it is a stateless one-shot (identical to `run`).
594
611
  */
595
- async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
612
+ async ask(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
596
613
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
597
614
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
598
615
  if (ctx.on_text) {
@@ -620,7 +637,8 @@ export class Client {
620
637
  * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
621
638
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
622
639
  */
623
- async *stream(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
640
+ async *stream(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
641
+ checkPromptParts(prompt, this.wireOpts())
624
642
  const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
625
643
  const gen = this.opts.style === "anthropic"
626
644
  ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
@@ -649,7 +667,8 @@ export class Client {
649
667
  return ctrl.signal
650
668
  }
651
669
 
652
- /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
670
+ /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
671
+ * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
653
672
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
654
673
  private async llmFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
655
674
  const retries = this.opts.retries ?? 2
@@ -662,7 +681,7 @@ export class Client {
662
681
  try {
663
682
  const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
664
683
  if (res.ok) return res
665
- const retryable = RETRYABLE.has(res.status)
684
+ const retryable = isRetryableStatus(res.status, this.opts.retryableStatuses)
666
685
  const tier = classify({ status: res.status, attempt, retryable })
667
686
  if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
668
687
  const ra = retryAfterMs(res.headers.get("retry-after"))
@@ -730,7 +749,7 @@ export class Client {
730
749
  }
731
750
 
732
751
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
733
- private async runOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
752
+ private async runOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
734
753
  const key = resolveKey(this.opts)
735
754
  const signal = this.makeSignal(external)
736
755
  const runStart = Date.now()
@@ -783,7 +802,7 @@ export class Client {
783
802
  // on resume). Mirrors the streaming path.
784
803
  for (const s of settled) {
785
804
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
786
- messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output })
805
+ messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
787
806
  if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
788
807
  }
789
808
  }
@@ -830,7 +849,7 @@ export class Client {
830
849
  }
831
850
 
832
851
  // ---- Anthropic-style: POST {baseUrl}/messages ----
833
- private async runAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
852
+ private async runAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
834
853
  const key = resolveKey(this.opts)
835
854
  const signal = this.makeSignal(external)
836
855
  const base = this.opts.baseUrl.replace(/\/$/, "")
@@ -890,7 +909,7 @@ export class Client {
890
909
  let halted: Request | undefined
891
910
  for (const s of settled) {
892
911
  toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
893
- content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError })
912
+ content.push(toolResultBlock(s.use.id, s.result))
894
913
  if (s.halted) { halted = s.halted; break }
895
914
  }
896
915
  messages.push({ role: "user", content })
@@ -904,7 +923,7 @@ export class Client {
904
923
  }
905
924
 
906
925
  // ---- Streaming: OpenAI-style ----
907
- private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
926
+ private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
908
927
  const key = resolveKey(this.opts)
909
928
  const signal = this.makeSignal(external)
910
929
  const runStart = Date.now()
@@ -982,13 +1001,13 @@ export class Client {
982
1001
  result = r.result
983
1002
  if (r.halted) {
984
1003
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
985
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
1004
+ messages.push(toolMessage(c.id, c.name, result))
986
1005
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
987
1006
  return
988
1007
  }
989
1008
  }
990
1009
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
991
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
1010
+ messages.push(toolMessage(c.id, c.name, result))
992
1011
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
993
1012
  }
994
1013
  }
@@ -1000,7 +1019,7 @@ export class Client {
1000
1019
  }
1001
1020
 
1002
1021
  // ---- Streaming: Anthropic-style ----
1003
- private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1022
+ private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1004
1023
  const key = resolveKey(this.opts)
1005
1024
  const signal = this.makeSignal(external)
1006
1025
  const runStart = Date.now()
@@ -1073,14 +1092,14 @@ export class Client {
1073
1092
  result = r.result
1074
1093
  if (r.halted) {
1075
1094
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1076
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
1095
+ results.push(toolResultBlock(u.id, result))
1077
1096
  messages.push({ role: "user", content: results })
1078
1097
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
1079
1098
  return
1080
1099
  }
1081
1100
  }
1082
1101
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1083
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
1102
+ results.push(toolResultBlock(u.id, result))
1084
1103
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
1085
1104
  }
1086
1105
  messages.push({ role: "user", content: results })
@@ -1233,7 +1252,7 @@ export class Conversation {
1233
1252
  constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
1234
1253
 
1235
1254
  /** Send the next user turn; prior history is retained automatically. */
1236
- async send(prompt: string): Promise<RunResult> {
1255
+ async send(prompt: PromptInput): Promise<RunResult> {
1237
1256
  const result = await this.client.run(prompt, { toolkit: this.toolkit, signal: this.signal, history: this.messages })
1238
1257
  this.messages = result.messages
1239
1258
  return result
@@ -1279,6 +1298,27 @@ function safeJson(s: string): Record<string, unknown> {
1279
1298
  }
1280
1299
  }
1281
1300
 
1301
+ /**
1302
+ * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
1303
+ * with parts it also carries them (and the tool `name` the §8A relocation header needs) —
1304
+ * both consumed and stripped by the wire build, so neither reaches OpenAI.
1305
+ */
1306
+ function toolMessage(id: string | undefined, name: string, result: ToolResult): any {
1307
+ const m: any = { role: "tool", tool_call_id: id, content: result.output }
1308
+ if (result.parts?.length) {
1309
+ m.name = name
1310
+ m.parts = result.parts
1311
+ }
1312
+ return m
1313
+ }
1314
+
1315
+ /** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
1316
+ function toolResultBlock(id: string | undefined, result: ToolResult): any {
1317
+ const b: any = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError }
1318
+ if (result.parts?.length) b.parts = result.parts
1319
+ return b
1320
+ }
1321
+
1282
1322
  function lastText(messages: any[]): string {
1283
1323
  for (let i = messages.length - 1; i >= 0; i--) {
1284
1324
  if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content