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/README.md +46 -0
- package/dist/agents/agent.d.ts +11 -1
- package/dist/agents/agent.js +0 -9
- package/dist/agents/compaction.d.ts +0 -25
- package/dist/agents/compaction.js +41 -2
- package/dist/agents/loop.d.ts +3 -2
- package/dist/agents/loop.js +10 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/classifier.d.ts +334 -0
- package/dist/classifier.js +630 -0
- package/dist/client.d.ts +40 -7
- package/dist/client.js +39 -33
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- package/dist/retry.d.ts +30 -0
- package/dist/retry.js +55 -0
- package/dist/translate.js +30 -2
- package/dist/types.d.ts +8 -0
- package/dist/wire.d.ts +40 -0
- package/dist/wire.js +104 -0
- package/package.json +5 -3
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +17 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/classifier.ts +832 -0
- package/src/client.ts +84 -44
- package/src/content.ts +448 -0
- package/src/index.ts +3 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- package/src/retry.ts +57 -0
- package/src/translate.ts +25 -1
- package/src/types.ts +9 -0
- package/src/wire.ts +131 -0
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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:
|
|
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
|