toolnexus 0.27.0 → 0.29.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 +61 -17
- package/dist/acp.d.ts +6 -0
- package/dist/acp.js +38 -16
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +11 -3
- package/dist/classifier.js +120 -3
- package/dist/client.d.ts +112 -5
- package/dist/client.js +397 -82
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +13 -1
- package/dist/media.d.ts +465 -54
- package/dist/media.js +1819 -230
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/dist/wire.d.ts +11 -1
- package/dist/wire.js +20 -3
- package/package.json +2 -2
- package/src/acp.ts +42 -15
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +117 -6
- package/src/client.ts +380 -64
- package/src/index.ts +1 -0
- package/src/mcp.ts +18 -1
- package/src/media.ts +2034 -271
- package/src/nodefetch.ts +143 -0
- package/src/transcript_search.ts +172 -0
- package/src/wire.ts +19 -2
package/src/client.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* ("openai" | "anthropic"), and it runs the tool-calling agent loop against a
|
|
4
4
|
* Toolkit. See ../../SPEC.md §8.
|
|
5
5
|
*/
|
|
6
|
+
import { mediaPartPath } from "./media.js"
|
|
6
7
|
import type { Toolkit } from "./toolkit.js"
|
|
7
8
|
import type { Tool, ToolHints, ToolResult, Request, Answer } from "./types.js"
|
|
8
9
|
import { pendingOf } from "./types.js"
|
|
@@ -12,7 +13,7 @@ import { buildToolPlan, toolSearch, unwrapCallTool, type ToolPlan, type ToolTier
|
|
|
12
13
|
import { isRetryableStatus, retryAfterMs } from "./retry.js"
|
|
13
14
|
import { isConcurrencySafe, resolveMaxToolConcurrency, scheduleCalls } from "./toolconcurrency.js"
|
|
14
15
|
import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
|
|
15
|
-
import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
|
|
16
|
+
import { toOpenAIWire, toAnthropicWire, checkPromptParts, modelParts, type WireOptions } from "./wire.js"
|
|
16
17
|
import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
|
|
17
18
|
import {
|
|
18
19
|
finishReasonFor,
|
|
@@ -42,11 +43,20 @@ export interface ClientOptions {
|
|
|
42
43
|
headers?: Record<string, string>
|
|
43
44
|
systemPrompt?: string
|
|
44
45
|
maxTurns?: number
|
|
46
|
+
/** SPEC §8 "Stop-reason recovery". Default false. On, an empty answer is nudged once, a
|
|
47
|
+
* `length`/`max_tokens` cut is continued (up to 3 times, text carried) and malformed tool-call
|
|
48
|
+
* arguments get one corrective turn — each an ordinary turn, only while a turn remains. */
|
|
49
|
+
stopRecovery?: boolean
|
|
45
50
|
/** Cap on concurrency-safe tool calls of one response running at once (SPEC §8 "Tool
|
|
46
51
|
* scheduling"). Absent ⇒ env `TOOLNEXUS_MAX_TOOL_CONCURRENCY` when a positive integer, else 10.
|
|
47
52
|
* Unsafe calls (anything not read-only) always run alone. */
|
|
48
53
|
maxToolConcurrency?: number
|
|
49
54
|
hooks?: Hooks
|
|
55
|
+
/** SPEC §7F.1 reactive compaction. Called (with the `beforeLLM` signature) when the provider
|
|
56
|
+
* rejects a call as a context overflow (`isContextOverflow`); strictly fewer `messages` replace
|
|
57
|
+
* the transcript and the same turn is re-sent. Breaker: 3 rapid compactions ⇒ `ContextOverflowError`.
|
|
58
|
+
* Absent ⇒ the overflow surfaces as `LlmHttpError`, unchanged. */
|
|
59
|
+
compactOnOverflow?: Hooks["beforeLLM"]
|
|
50
60
|
/** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
|
|
51
61
|
* Widen the status set with `retryableStatuses`. */
|
|
52
62
|
retries?: number
|
|
@@ -112,6 +122,11 @@ export interface ClientOptions {
|
|
|
112
122
|
* today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
|
|
113
123
|
* There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
|
|
114
124
|
onError?: (info: ErrorInfo) => ErrorTier
|
|
125
|
+
/** §8 Resilience. Ordered fallback model ids tried, with a fresh retry budget each, when the
|
|
126
|
+
* current model's retry budget ends on an overload (`503`/`529`). Chain = [turnModel, ...these];
|
|
127
|
+
* empty/duplicate entries skipped. `TOOLNEXUS_DISABLE_MODEL_FALLBACK=1|true` disables it.
|
|
128
|
+
* `stream` emits `{ type: "fallback", from, to }` before switching. Omit ⇒ byte-identical. */
|
|
129
|
+
fallbackModels?: readonly string[]
|
|
115
130
|
/** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
|
|
116
131
|
* +33% base64 string). Unset ⇒ no limit, as today. */
|
|
117
132
|
maxPartBytes?: number
|
|
@@ -192,6 +207,35 @@ export async function llmHttpError(res: Response): Promise<LlmHttpError> {
|
|
|
192
207
|
return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined)
|
|
193
208
|
}
|
|
194
209
|
|
|
210
|
+
/** SPEC §7F.1 markers of a context-overflow rejection (matched lowercased). */
|
|
211
|
+
const OVERFLOW_MARKERS = ["context_length_exceeded", "maximum context length", "prompt is too long", "prompt_too_long", "request_too_large", "context window"]
|
|
212
|
+
|
|
213
|
+
/** SPEC §7F.1: true iff `status` is 400/413 AND the (redacted) body names a context overflow. */
|
|
214
|
+
export function isContextOverflow(status: number, body: string): boolean {
|
|
215
|
+
if (status !== 400 && status !== 413) return false
|
|
216
|
+
const b = (body ?? "").toLowerCase()
|
|
217
|
+
return OVERFLOW_MARKERS.some((m) => b.includes(m))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** SPEC §7F.1: reactive compaction could not make the prompt fit (breaker tripped, or the
|
|
221
|
+
* `compactOnOverflow` hook made no progress). A failure, never a §10 pending. */
|
|
222
|
+
export class ContextOverflowError extends Error {
|
|
223
|
+
readonly name = "ContextOverflowError"
|
|
224
|
+
constructor(
|
|
225
|
+
/** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
|
|
226
|
+
readonly compactions: number,
|
|
227
|
+
/** Status of the last provider error. */
|
|
228
|
+
readonly status: number,
|
|
229
|
+
/** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
|
|
230
|
+
readonly body: string,
|
|
231
|
+
) {
|
|
232
|
+
super(`context overflow: ${compactions} reactive compaction(s) did not make the prompt fit`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Per-run breaker counters for §7F.1. */
|
|
237
|
+
interface OverflowState { okSince: number; streak: number }
|
|
238
|
+
|
|
195
239
|
/** §8 Resilience. What to do with a failed LLM call. */
|
|
196
240
|
export type ErrorTier = "retry" | "fail"
|
|
197
241
|
|
|
@@ -320,6 +364,23 @@ export interface Hooks {
|
|
|
320
364
|
beforeTool?(ev: { name: string; args: Record<string, unknown>; id?: string; turn: number; hints?: ToolHints }): void | { args?: Record<string, unknown>; result?: ToolResult } | Promise<void | { args?: Record<string, unknown>; result?: ToolResult }>
|
|
321
365
|
/** After a tool runs. Return { result } to replace the result. */
|
|
322
366
|
afterTool?(ev: { name: string; args: Record<string, unknown>; result: ToolResult; id?: string; turn: number }): void | { result?: ToolResult } | Promise<void | { result?: ToolResult }>
|
|
367
|
+
/** At each round boundary (SPEC §8, #192): after a round's tool results (final=false), or when the
|
|
368
|
+
* model stopped with a turn remaining (final=true, text = its answer). Each `inject` string is
|
|
369
|
+
* appended as a user message; a non-empty inject on a final round CONTINUES the loop. */
|
|
370
|
+
afterRound?(ev: AfterRoundEvent): void | RoundInjection | Promise<void | RoundInjection>
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The `afterRound` hook event. */
|
|
374
|
+
export interface AfterRoundEvent {
|
|
375
|
+
turn: number
|
|
376
|
+
final: boolean
|
|
377
|
+
text: string
|
|
378
|
+
messages: any[]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** What an `afterRound` hook may return. */
|
|
382
|
+
export interface RoundInjection {
|
|
383
|
+
inject?: string[]
|
|
323
384
|
}
|
|
324
385
|
|
|
325
386
|
/** A tool call the model made, plus its result + metadata. */
|
|
@@ -334,18 +395,20 @@ export interface ToolCallRecord {
|
|
|
334
395
|
}
|
|
335
396
|
|
|
336
397
|
/** §8 RunResult.files entry: a non-text part a tool call produced this run. */
|
|
337
|
-
export type RunFile = { type: "image" | "file" | "audio"; mimeType: string; url?: string; data?: string; name?: string; from: string; callId: string }
|
|
398
|
+
export type RunFile = { type: "image" | "file" | "audio"; mimeType: string; url?: string; data?: string; name?: string; path?: string; from: string; callId: string }
|
|
338
399
|
|
|
339
400
|
/** Model tool-call id of each record, kept off the record itself (§8 shape has no id). */
|
|
340
401
|
const callIds = new WeakMap<ToolCallRecord, string>()
|
|
341
402
|
|
|
342
403
|
/** RunResult.files: every non-text part of every tool call, in call order (§8). */
|
|
343
|
-
function runFiles(toolCalls: ToolCallRecord[]): RunFile[] {
|
|
404
|
+
export function runFiles(toolCalls: ToolCallRecord[]): RunFile[] {
|
|
344
405
|
const out: RunFile[] = []
|
|
345
406
|
for (const r of toolCalls) {
|
|
346
407
|
for (const p of r.parts ?? []) {
|
|
347
408
|
if (p.type === "text") continue
|
|
348
|
-
|
|
409
|
+
// D20: a media file's local path rides on the run file, never on the part.
|
|
410
|
+
const local = mediaPartPath(p)
|
|
411
|
+
out.push({ ...p, ...(local !== undefined ? { path: local } : {}), from: r.name, callId: callIds.get(r) ?? "" })
|
|
349
412
|
}
|
|
350
413
|
}
|
|
351
414
|
return out
|
|
@@ -399,9 +462,18 @@ export type StreamEvent =
|
|
|
399
462
|
| { type: "thought"; delta: string; via?: "acp" }
|
|
400
463
|
/** `via: "acp"` marks a tool the ACP agent ran itself: reported, never executed by the loop. */
|
|
401
464
|
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
465
|
+
/** An ACP agent's `tool_call_update` carrying `rawInput`: the call's arguments, known after
|
|
466
|
+
* the initial `tool_call` (#188). Reported only; never executed by the loop. */
|
|
467
|
+
| { type: "tool_call_args"; id: string; name: string; args: Record<string, unknown>; via: "acp" }
|
|
402
468
|
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean; metadata?: Record<string, unknown>; via?: "call_tool" | "acp" }
|
|
403
469
|
| { type: "usage"; usage: Usage }
|
|
470
|
+
/** Only with `fallbackModels`: switching from model `from` to `to` after overload (§8). */
|
|
471
|
+
| { type: "fallback"; from: string; to: string }
|
|
404
472
|
| { type: "pending"; request: Request }
|
|
473
|
+
/** Emitted just before each LLM retry wait (streaming only). `attempt` is 1-based (the
|
|
474
|
+
* failed zero-based attempt + 1), `max` the configured `retries`, `delayMs` the exact wait,
|
|
475
|
+
* `status` the HTTP status (absent on a network error), `reason` "HTTP <status>" | "network". */
|
|
476
|
+
| { type: "retry"; attempt: number; max: number; delayMs: number; status?: number; reason: string }
|
|
405
477
|
| { type: "done"; result: RunResult }
|
|
406
478
|
|
|
407
479
|
function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
|
|
@@ -717,17 +789,18 @@ export class Client {
|
|
|
717
789
|
|
|
718
790
|
const signal = this.makeSignal(req.signal)
|
|
719
791
|
try {
|
|
720
|
-
const
|
|
721
|
-
`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
|
|
722
|
-
{
|
|
792
|
+
const r = await this.llmCallJson(
|
|
793
|
+
(m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
723
794
|
method: "POST",
|
|
724
795
|
headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
|
|
725
|
-
body: JSON.stringify(this.finalizeBody(body)),
|
|
726
|
-
},
|
|
796
|
+
body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
|
|
797
|
+
}],
|
|
727
798
|
signal,
|
|
728
799
|
"openai",
|
|
729
800
|
turnModel,
|
|
730
801
|
)
|
|
802
|
+
const data = r.data
|
|
803
|
+
turnModel = r.model
|
|
731
804
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 })
|
|
732
805
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
733
806
|
addUsage(usage, data.usage, "openai")
|
|
@@ -784,9 +857,8 @@ export class Client {
|
|
|
784
857
|
|
|
785
858
|
const signal = this.makeSignal(req.signal)
|
|
786
859
|
try {
|
|
787
|
-
const
|
|
788
|
-
endpoint,
|
|
789
|
-
{
|
|
860
|
+
const r = await this.llmCallJson(
|
|
861
|
+
(m) => [endpoint, {
|
|
790
862
|
method: "POST",
|
|
791
863
|
headers: {
|
|
792
864
|
"content-type": "application/json",
|
|
@@ -794,12 +866,14 @@ export class Client {
|
|
|
794
866
|
"anthropic-version": "2023-06-01",
|
|
795
867
|
...this.opts.headers,
|
|
796
868
|
},
|
|
797
|
-
body: JSON.stringify(this.finalizeBody(body)),
|
|
798
|
-
},
|
|
869
|
+
body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
|
|
870
|
+
}],
|
|
799
871
|
signal,
|
|
800
872
|
"anthropic",
|
|
801
873
|
turnModel,
|
|
802
874
|
)
|
|
875
|
+
const data = r.data
|
|
876
|
+
turnModel = r.model
|
|
803
877
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 })
|
|
804
878
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
805
879
|
addUsage(usage, data.usage, "anthropic")
|
|
@@ -948,7 +1022,19 @@ export class Client {
|
|
|
948
1022
|
/** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
|
|
949
1023
|
* plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
|
|
950
1024
|
* The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
|
|
951
|
-
private async llmFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
1025
|
+
private async llmFetch(url: string, init: RequestInit, signal: AbortSignal, out?: { failed?: boolean }): Promise<Response> {
|
|
1026
|
+
// Same loop as the streaming path; its retry events are simply dropped here.
|
|
1027
|
+
const it = this.llmFetchEvents(url, init, signal, out)
|
|
1028
|
+
for (;;) {
|
|
1029
|
+
const r = await it.next()
|
|
1030
|
+
if (r.done) return r.value
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** The retry loop behind `llmFetch`, as a generator: yields a `retry` StreamEvent just before
|
|
1035
|
+
* each retry wait (its `delayMs` is the exact wait slept) and returns the final Response.
|
|
1036
|
+
* The streaming generators consume it with `yield*`. */
|
|
1037
|
+
private async *llmFetchEvents(url: string, init: RequestInit, signal: AbortSignal, out?: { failed?: boolean }): AsyncGenerator<StreamEvent, Response, unknown> {
|
|
952
1038
|
const retries = this.opts.retries ?? 2
|
|
953
1039
|
const base = this.opts.retryBaseMs ?? 500
|
|
954
1040
|
// Default classifier = today's behavior, expressed as an onError. A "retry" is always
|
|
@@ -956,26 +1042,78 @@ export class Client {
|
|
|
956
1042
|
const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
|
|
957
1043
|
let lastErr: unknown
|
|
958
1044
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1045
|
+
let res: Response
|
|
959
1046
|
try {
|
|
960
|
-
|
|
961
|
-
if (res.ok) return res
|
|
962
|
-
const retryable = isRetryableStatus(res.status, this.opts.retryableStatuses)
|
|
963
|
-
const tier = classify({ status: res.status, attempt, retryable })
|
|
964
|
-
if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
|
|
965
|
-
const ra = retryAfterMs(res.headers.get("retry-after"))
|
|
966
|
-
// `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
|
|
967
|
-
await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal)
|
|
1047
|
+
res = await (this.opts.fetch ?? (await defaultFetch()))(url, { ...init, signal })
|
|
968
1048
|
} catch (e) {
|
|
969
1049
|
if (signal.aborted) throw e // abort/timeout: don't retry
|
|
970
1050
|
lastErr = e
|
|
971
1051
|
const tier = classify({ error: e, attempt, retryable: true })
|
|
972
1052
|
if (tier === "fail" || attempt === retries) throw e
|
|
973
|
-
|
|
1053
|
+
const wait = base * 2 ** attempt + Math.random() * 100
|
|
1054
|
+
yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, reason: "network" }
|
|
1055
|
+
await delay(wait, signal)
|
|
1056
|
+
continue
|
|
974
1057
|
}
|
|
1058
|
+
if (res.ok) return res
|
|
1059
|
+
let retryable = isRetryableStatus(res.status, this.opts.retryableStatuses)
|
|
1060
|
+
// The provider's own verdict wins over the status set (SPEC 8 x-should-retry).
|
|
1061
|
+
const sr = res.headers.get("x-should-retry")?.trim().toLowerCase()
|
|
1062
|
+
if (sr === "true") retryable = true
|
|
1063
|
+
else if (sr === "false") retryable = false
|
|
1064
|
+
const tier = classify({ status: res.status, attempt, retryable })
|
|
1065
|
+
if (tier === "fail" || attempt === retries) {
|
|
1066
|
+
if (out) out.failed = tier === "fail"
|
|
1067
|
+
return res // caller surfaces the non-ok status
|
|
1068
|
+
}
|
|
1069
|
+
const ra = retryAfterMs(res.headers.get("retry-after"))
|
|
1070
|
+
// `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
|
|
1071
|
+
const wait = ra ?? base * 2 ** attempt + Math.random() * 100
|
|
1072
|
+
yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, status: res.status, reason: `HTTP ${res.status}` }
|
|
1073
|
+
await delay(wait, signal)
|
|
975
1074
|
}
|
|
976
1075
|
throw lastErr
|
|
977
1076
|
}
|
|
978
1077
|
|
|
1078
|
+
/** §8 fallbackModels: the per-call model chain — [turnModel, ...fallbackModels], empty and
|
|
1079
|
+
* duplicate entries skipped; just [turnModel] when the option is absent or the kill switch is set. */
|
|
1080
|
+
private modelChain(turnModel: string): string[] {
|
|
1081
|
+
const fb = this.opts.fallbackModels
|
|
1082
|
+
if (!fb || fb.length === 0) return [turnModel]
|
|
1083
|
+
const kill = (typeof process !== "undefined" ? process.env?.TOOLNEXUS_DISABLE_MODEL_FALLBACK : undefined)?.trim().toLowerCase()
|
|
1084
|
+
if (kill === "1" || kill === "true") return [turnModel]
|
|
1085
|
+
const chain: string[] = [turnModel]
|
|
1086
|
+
for (const m of fb) if (m && !chain.includes(m)) chain.push(m)
|
|
1087
|
+
return chain
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** llmFetch over the model chain: each model spends the full retry budget; only a budget that
|
|
1091
|
+
* ends on 503/529 (not an onError "fail") advances to the next model. Returns the response and
|
|
1092
|
+
* the model that produced it. `build` rebuilds the same request for a model. */
|
|
1093
|
+
private async llmFetchChain(model: string, build: (m: string) => [string, RequestInit], signal: AbortSignal): Promise<{ res: Response; model: string }> {
|
|
1094
|
+
const it = this.llmFetchChainEvents(model, build, signal)
|
|
1095
|
+
for (;;) {
|
|
1096
|
+
const r = await it.next()
|
|
1097
|
+
if (r.done) return r.value
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** The fallback chain as a generator for the streaming loops: the #189 `retry` events of each
|
|
1102
|
+
* model's attempts and a `fallback` event at each switch reach the stream as they happen. */
|
|
1103
|
+
private async *llmFetchChainEvents(model: string, build: (m: string) => [string, RequestInit], signal: AbortSignal): AsyncGenerator<StreamEvent, { res: Response; model: string }, unknown> {
|
|
1104
|
+
const chain = this.modelChain(model)
|
|
1105
|
+
for (let i = 0; ; i++) {
|
|
1106
|
+
const m = chain[i]
|
|
1107
|
+
const [url, init] = build(m)
|
|
1108
|
+
const out: { failed?: boolean } = {}
|
|
1109
|
+
const res = yield* this.llmFetchEvents(url, init, signal, out)
|
|
1110
|
+
if (res.ok || out.failed || i + 1 >= chain.length || (res.status !== 503 && res.status !== 529)) return { res, model: m }
|
|
1111
|
+
try { await res.body?.cancel() } catch { /* ignore */ }
|
|
1112
|
+
yield { type: "fallback", from: m, to: chain[i + 1] }
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
|
|
979
1117
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
980
1118
|
private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number, signal?: AbortSignal) {
|
|
981
1119
|
if (this.opts.toolTiers && name === "tool_search") {
|
|
@@ -1122,21 +1260,61 @@ export class Client {
|
|
|
1122
1260
|
}
|
|
1123
1261
|
|
|
1124
1262
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
1125
|
-
private async llmCallJson(
|
|
1263
|
+
private async llmCallJson(build: (m: string) => [string, RequestInit], signal: AbortSignal, style: ClientStyle, turnModel: string): Promise<{ data: any; model: string }> {
|
|
1126
1264
|
const t0 = Date.now()
|
|
1265
|
+
let model = turnModel
|
|
1127
1266
|
try {
|
|
1128
|
-
const
|
|
1267
|
+
const r = await this.llmFetchChain(turnModel, build, signal)
|
|
1268
|
+
model = r.model
|
|
1269
|
+
const res = r.res
|
|
1129
1270
|
if (!res.ok) throw await llmHttpError(res)
|
|
1130
1271
|
const data: any = await res.json()
|
|
1131
1272
|
const tok = perCall(data.usage, style)
|
|
1132
1273
|
this.emit({ event: "llm", model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
|
|
1133
|
-
return data
|
|
1274
|
+
return { data, model }
|
|
1134
1275
|
} catch (e) {
|
|
1135
1276
|
this.emit({ event: "llm", model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
|
|
1136
1277
|
throw e
|
|
1137
1278
|
}
|
|
1138
1279
|
}
|
|
1139
1280
|
|
|
1281
|
+
/**
|
|
1282
|
+
* SPEC §7F.1: one LLM call with reactive compaction. `call(messages)` makes the request and
|
|
1283
|
+
* throws on a non-2xx (as `LlmHttpError`). On a context overflow with `compactOnOverflow` set,
|
|
1284
|
+
* the hook compacts and the SAME turn is re-sent (no beforeLLM, no turn consumed), guarded by
|
|
1285
|
+
* the rapid-refill breaker. Returns the call's value and the (possibly compacted) transcript.
|
|
1286
|
+
*/
|
|
1287
|
+
private async callWithOverflow<T>(st: OverflowState, ev: BeforeLLMEvent, call: (messages: any[]) => Promise<T>): Promise<{ value: T; messages: any[] }> {
|
|
1288
|
+
const it = this.callWithOverflowEvents(st, ev, async function* (m: any[]) { return await call(m) })
|
|
1289
|
+
for (;;) {
|
|
1290
|
+
const r = await it.next()
|
|
1291
|
+
if (r.done) return r.value
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** `callWithOverflow` for the streaming loops: `call` is a generator so the #189 `retry` events
|
|
1296
|
+
* of every attempt (including one after a compaction) still reach the stream before each wait. */
|
|
1297
|
+
private async *callWithOverflowEvents<T>(st: OverflowState, ev: BeforeLLMEvent, call: (messages: any[]) => AsyncGenerator<StreamEvent, T, unknown>): AsyncGenerator<StreamEvent, { value: T; messages: any[] }, unknown> {
|
|
1298
|
+
let messages = ev.messages
|
|
1299
|
+
for (;;) {
|
|
1300
|
+
try {
|
|
1301
|
+
const value = yield* call(messages)
|
|
1302
|
+
st.okSince++
|
|
1303
|
+
return { value, messages }
|
|
1304
|
+
} catch (e) {
|
|
1305
|
+
const hook = this.opts.compactOnOverflow
|
|
1306
|
+
if (!hook || !(e instanceof LlmHttpError) || !isContextOverflow(e.status, e.body)) throw e
|
|
1307
|
+
if (!(st.streak > 0 && st.okSince < 2)) st.streak = 0
|
|
1308
|
+
if (st.streak === 3) throw new ContextOverflowError(3, e.status, e.body)
|
|
1309
|
+
const ov = await hook({ messages, tools: ev.tools, model: ev.model, turn: ev.turn })
|
|
1310
|
+
if (!ov?.messages || ov.messages.length >= messages.length) throw new ContextOverflowError(st.streak, e.status, e.body)
|
|
1311
|
+
messages = ov.messages
|
|
1312
|
+
st.streak++
|
|
1313
|
+
st.okSince = 0
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1140
1318
|
// The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
|
|
1141
1319
|
// run leaves: return, throw, or a consumer abandoning the stream half-way.
|
|
1142
1320
|
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
@@ -1175,6 +1353,38 @@ export class Client {
|
|
|
1175
1353
|
}
|
|
1176
1354
|
}
|
|
1177
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* §8 stop-reason recovery: classify the response just appended to `messages` and, when a rule
|
|
1358
|
+
* fires, remove (or keep) that assistant message, append the pinned user message, and return
|
|
1359
|
+
* true — the caller then takes another turn. Never on the last permitted turn.
|
|
1360
|
+
*/
|
|
1361
|
+
private recover(rs: Recovery | undefined, turn: number, messages: any[], hasCalls: boolean, malformed: boolean, truncated: boolean, text: string): boolean {
|
|
1362
|
+
if (!rs || turn >= (this.opts.maxTurns ?? 10) - 1) return false
|
|
1363
|
+
let msg: string
|
|
1364
|
+
if (hasCalls) {
|
|
1365
|
+
if (!malformed || rs.corrected) return false
|
|
1366
|
+
rs.corrected = true
|
|
1367
|
+
messages.pop()
|
|
1368
|
+
msg = CORRECTIVE
|
|
1369
|
+
} else if (truncated) {
|
|
1370
|
+
if (rs.continues >= 3) return false
|
|
1371
|
+
rs.continues++
|
|
1372
|
+
rs.carry += text
|
|
1373
|
+
msg = CONTINUE
|
|
1374
|
+
} else {
|
|
1375
|
+
if (text.trim() !== "" || rs.nudged) return false
|
|
1376
|
+
rs.nudged = true
|
|
1377
|
+
messages.pop()
|
|
1378
|
+
msg = NUDGE
|
|
1379
|
+
}
|
|
1380
|
+
messages.push({ role: "user", content: msg })
|
|
1381
|
+
return true
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
private newRecovery(): Recovery | undefined {
|
|
1385
|
+
return this.opts.stopRecovery ? { nudged: false, continues: 0, corrected: false, carry: "" } : undefined
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1178
1388
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
1179
1389
|
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
1180
1390
|
const key = resolveKey(this.opts)
|
|
@@ -1193,6 +1403,8 @@ export class Client {
|
|
|
1193
1403
|
let lastModel = this.opts.model
|
|
1194
1404
|
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1195
1405
|
const done = new Map<string, ToolResult>()
|
|
1406
|
+
const rs = this.newRecovery()
|
|
1407
|
+
const overflow: OverflowState = { okSince: 0, streak: 0 }
|
|
1196
1408
|
|
|
1197
1409
|
try {
|
|
1198
1410
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1205,17 +1417,27 @@ export class Client {
|
|
|
1205
1417
|
if (ov?.model) turnModel = ov.model
|
|
1206
1418
|
}
|
|
1207
1419
|
lastModel = turnModel
|
|
1208
|
-
const
|
|
1420
|
+
const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
1209
1421
|
method: "POST",
|
|
1210
1422
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
1211
|
-
body: JSON.stringify(this.openaiBody(
|
|
1212
|
-
}, signal, "openai", turnModel)
|
|
1423
|
+
body: JSON.stringify(this.openaiBody(msgs, tools, false, m)),
|
|
1424
|
+
}], signal, "openai", turnModel))
|
|
1425
|
+
messages = sent.messages
|
|
1426
|
+
const data: any = sent.value.data
|
|
1427
|
+
turnModel = lastModel = sent.value.model
|
|
1213
1428
|
addUsage(usage, data.usage, "openai")
|
|
1214
1429
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn })
|
|
1215
1430
|
const msg = data.choices[0].message
|
|
1216
1431
|
messages.push(msg)
|
|
1217
1432
|
const calls = msg.tool_calls ?? []
|
|
1218
|
-
if (
|
|
1433
|
+
if (this.recover(rs, turn, messages, calls.length > 0, calls.some((c: any) => malformedArgs(c.function?.arguments)),
|
|
1434
|
+
data.choices[0].finish_reason === "length", typeof msg.content === "string" ? msg.content : "")) continue
|
|
1435
|
+
if (calls.length === 0) {
|
|
1436
|
+
const text = (rs?.carry ?? "") + (msg.content ?? "")
|
|
1437
|
+
if (await this.roundEnd(messages, turn, true, text)) { if (rs) rs.carry = ""; continue }
|
|
1438
|
+
return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage)
|
|
1439
|
+
}
|
|
1440
|
+
if (rs) rs.carry = ""
|
|
1219
1441
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
1220
1442
|
done.clear()
|
|
1221
1443
|
const settled = await untilAborted(signal, this.schedule(toolkit,
|
|
@@ -1240,6 +1462,7 @@ export class Client {
|
|
|
1240
1462
|
messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
|
|
1241
1463
|
if (s.halted) return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage)
|
|
1242
1464
|
}
|
|
1465
|
+
await this.roundEnd(messages, turn, false, "")
|
|
1243
1466
|
}
|
|
1244
1467
|
return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true)
|
|
1245
1468
|
} catch (e) {
|
|
@@ -1249,6 +1472,20 @@ export class Client {
|
|
|
1249
1472
|
}
|
|
1250
1473
|
}
|
|
1251
1474
|
|
|
1475
|
+
/** §8 afterRound: fire the round-boundary hook and append its injections as user messages.
|
|
1476
|
+
* Returns true when something was appended. A final round fires only when a turn remains. */
|
|
1477
|
+
private async roundEnd(messages: any[], turn: number, final: boolean, text: string): Promise<boolean> {
|
|
1478
|
+
const h = this.opts.hooks?.afterRound
|
|
1479
|
+
if (!h) return false
|
|
1480
|
+
if (final && turn + 1 >= (this.opts.maxTurns ?? 10)) return false
|
|
1481
|
+
const out = await h({ turn, final, text, messages })
|
|
1482
|
+
let added = false
|
|
1483
|
+
for (const m of out?.inject ?? []) {
|
|
1484
|
+
if (typeof m === "string" && m !== "") { messages.push({ role: "user", content: m }); added = true }
|
|
1485
|
+
}
|
|
1486
|
+
return added
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1252
1489
|
private result(model: string, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
|
|
1253
1490
|
const incomplete = exhausted && text === ""
|
|
1254
1491
|
const r: RunResult = { text, messages, toolCalls, files: runFiles(toolCalls), toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" }
|
|
@@ -1304,6 +1541,8 @@ export class Client {
|
|
|
1304
1541
|
let lastModel = this.opts.model
|
|
1305
1542
|
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1306
1543
|
const done = new Map<string, ToolResult>()
|
|
1544
|
+
const rs = this.newRecovery()
|
|
1545
|
+
const overflow: OverflowState = { okSince: 0, streak: 0 }
|
|
1307
1546
|
|
|
1308
1547
|
try {
|
|
1309
1548
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1316,7 +1555,7 @@ export class Client {
|
|
|
1316
1555
|
if (ov?.model) turnModel = ov.model
|
|
1317
1556
|
}
|
|
1318
1557
|
lastModel = turnModel
|
|
1319
|
-
const
|
|
1558
|
+
const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [endpoint, {
|
|
1320
1559
|
method: "POST",
|
|
1321
1560
|
headers: {
|
|
1322
1561
|
"x-api-key": key,
|
|
@@ -1324,21 +1563,29 @@ export class Client {
|
|
|
1324
1563
|
"Content-Type": "application/json",
|
|
1325
1564
|
...this.opts.headers,
|
|
1326
1565
|
},
|
|
1327
|
-
body: JSON.stringify(this.anthropicBody(system,
|
|
1328
|
-
}, signal, "anthropic", turnModel)
|
|
1566
|
+
body: JSON.stringify(this.anthropicBody(system, msgs, tools, false, m)),
|
|
1567
|
+
}], signal, "anthropic", turnModel))
|
|
1568
|
+
messages = sent.messages
|
|
1569
|
+
const data: any = sent.value.data
|
|
1570
|
+
turnModel = lastModel = sent.value.model
|
|
1329
1571
|
addUsage(usage, data.usage, "anthropic")
|
|
1330
1572
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn })
|
|
1331
1573
|
messages.push({ role: "assistant", content: data.content })
|
|
1332
1574
|
const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
|
|
1575
|
+
const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
1576
|
+
if (this.recover(rs, turn, messages, uses.length > 0, uses.some((u: any) => u.input !== undefined && !isObj(u.input)),
|
|
1577
|
+
data.stop_reason === "max_tokens", text)) continue
|
|
1333
1578
|
if (uses.length === 0) {
|
|
1334
|
-
const
|
|
1335
|
-
|
|
1579
|
+
const full = (rs?.carry ?? "") + text
|
|
1580
|
+
if (await this.roundEnd(messages, turn, true, full)) { if (rs) rs.carry = ""; continue }
|
|
1581
|
+
return this.endRun(runStart, lastModel, full, messages, toolCalls, turns, usage)
|
|
1336
1582
|
}
|
|
1583
|
+
if (rs) rs.carry = ""
|
|
1337
1584
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
1338
1585
|
done.clear()
|
|
1339
1586
|
const settled = await untilAborted(signal, this.schedule(toolkit,
|
|
1340
|
-
uses, (use: any) => [use.name, use.input
|
|
1341
|
-
let { args, result } = await this.runTool(toolkit, use.name, use.input
|
|
1587
|
+
uses, (use: any) => [use.name, objArgs(use.input)], async (use: any) => {
|
|
1588
|
+
let { args, result } = await this.runTool(toolkit, use.name, objArgs(use.input), use.id, turn, signal)
|
|
1342
1589
|
let halted: Request | undefined
|
|
1343
1590
|
const req = pendingOf(result)
|
|
1344
1591
|
if (req) {
|
|
@@ -1362,6 +1609,7 @@ export class Client {
|
|
|
1362
1609
|
}
|
|
1363
1610
|
messages.push({ role: "user", content })
|
|
1364
1611
|
if (halted) return this.pendingRun(runStart, lastModel, halted, messages, toolCalls, turns, usage)
|
|
1612
|
+
await this.roundEnd(messages, turn, false, "")
|
|
1365
1613
|
}
|
|
1366
1614
|
return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true)
|
|
1367
1615
|
} catch (e) {
|
|
@@ -1389,6 +1637,8 @@ export class Client {
|
|
|
1389
1637
|
let lastModel = this.opts.model
|
|
1390
1638
|
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1391
1639
|
const done = new Map<string, ToolResult>()
|
|
1640
|
+
const rs = this.newRecovery()
|
|
1641
|
+
const overflow: OverflowState = { okSince: 0, streak: 0 }
|
|
1392
1642
|
|
|
1393
1643
|
try {
|
|
1394
1644
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1404,14 +1654,22 @@ export class Client {
|
|
|
1404
1654
|
const t0 = Date.now()
|
|
1405
1655
|
const beforeP = usage.promptTokens, beforeC = usage.completionTokens
|
|
1406
1656
|
let content = ""
|
|
1657
|
+
let finish = ""
|
|
1407
1658
|
const acc = new Map<number, { id: string; name: string; args: string }>()
|
|
1408
1659
|
try {
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1660
|
+
const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self: Client) {
|
|
1661
|
+
const r = yield* self.llmFetchChainEvents(turnModel, (m) => [`${self.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
1662
|
+
method: "POST",
|
|
1663
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...self.opts.headers },
|
|
1664
|
+
body: JSON.stringify(self.openaiBody(msgs, tools, true, m)),
|
|
1665
|
+
}], signal)
|
|
1666
|
+
if (!r.res.ok || !r.res.body) throw await llmHttpError(r.res)
|
|
1667
|
+
return r
|
|
1668
|
+
})(this))
|
|
1669
|
+
messages = sent.messages
|
|
1670
|
+
const res = sent.value.res
|
|
1671
|
+
turnModel = lastModel = sent.value.model
|
|
1672
|
+
if (!res.body) throw await llmHttpError(res)
|
|
1415
1673
|
for await (const line of sseLines(res.body)) {
|
|
1416
1674
|
if (!line.startsWith("data:")) continue
|
|
1417
1675
|
const payload = line.slice(5).trim()
|
|
@@ -1427,6 +1685,7 @@ export class Client {
|
|
|
1427
1685
|
if (j?.usage) addUsage(usage, j.usage, "openai")
|
|
1428
1686
|
const choice = j?.choices?.[0]
|
|
1429
1687
|
const d = choice?.delta
|
|
1688
|
+
if (choice?.finish_reason) finish = choice.finish_reason
|
|
1430
1689
|
if (d?.content) { content += d.content; yield { type: "text", delta: d.content } }
|
|
1431
1690
|
for (const tc of d?.tool_calls ?? []) {
|
|
1432
1691
|
const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" }
|
|
@@ -1444,13 +1703,17 @@ export class Client {
|
|
|
1444
1703
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn })
|
|
1445
1704
|
|
|
1446
1705
|
const calls = [...acc.values()]
|
|
1706
|
+
messages.push(calls.length === 0
|
|
1707
|
+
? { role: "assistant", content }
|
|
1708
|
+
: { role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
1709
|
+
if (this.recover(rs, turn, messages, calls.length > 0, calls.some((c) => malformedArgs(c.args)), finish === "length", content)) continue
|
|
1447
1710
|
if (calls.length === 0) {
|
|
1448
|
-
|
|
1711
|
+
if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + content)) { if (rs) rs.carry = ""; continue }
|
|
1449
1712
|
yield { type: "usage", usage }
|
|
1450
|
-
yield { type: "done", result: this.endRun(runStart, lastModel, content, messages, toolCalls, turns, usage) }
|
|
1713
|
+
yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + content, messages, toolCalls, turns, usage) }
|
|
1451
1714
|
return
|
|
1452
1715
|
}
|
|
1453
|
-
|
|
1716
|
+
if (rs) rs.carry = ""
|
|
1454
1717
|
for (const c of calls) yield this.callEvent(c.id, c.name, safeJson(c.args))
|
|
1455
1718
|
done.clear()
|
|
1456
1719
|
const settled = await untilAborted(signal, this.schedule(toolkit, calls, (c) => [c.name, safeJson(c.args)], async (c) => {
|
|
@@ -1477,6 +1740,7 @@ export class Client {
|
|
|
1477
1740
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1478
1741
|
yield this.resultEvent(c.id, c.name, safeJson(c.args), result)
|
|
1479
1742
|
}
|
|
1743
|
+
await this.roundEnd(messages, turn, false, "")
|
|
1480
1744
|
}
|
|
1481
1745
|
yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) }
|
|
1482
1746
|
} catch (e) {
|
|
@@ -1502,6 +1766,8 @@ export class Client {
|
|
|
1502
1766
|
let lastModel = this.opts.model
|
|
1503
1767
|
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1504
1768
|
const done = new Map<string, ToolResult>()
|
|
1769
|
+
const rs = this.newRecovery()
|
|
1770
|
+
const overflow: OverflowState = { okSince: 0, streak: 0 }
|
|
1505
1771
|
|
|
1506
1772
|
try {
|
|
1507
1773
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1519,12 +1785,19 @@ export class Client {
|
|
|
1519
1785
|
const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
|
|
1520
1786
|
let stopReason = ""
|
|
1521
1787
|
try {
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1788
|
+
const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self: Client) {
|
|
1789
|
+
const r = yield* self.llmFetchChainEvents(turnModel, (m) => [endpoint, {
|
|
1790
|
+
method: "POST",
|
|
1791
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...self.opts.headers },
|
|
1792
|
+
body: JSON.stringify(self.anthropicBody(system, msgs, tools, true, m)),
|
|
1793
|
+
}], signal)
|
|
1794
|
+
if (!r.res.ok || !r.res.body) throw await llmHttpError(r.res)
|
|
1795
|
+
return r
|
|
1796
|
+
})(this))
|
|
1797
|
+
messages = sent.messages
|
|
1798
|
+
const res = sent.value.res
|
|
1799
|
+
turnModel = lastModel = sent.value.model
|
|
1800
|
+
if (!res.body) throw await llmHttpError(res)
|
|
1528
1801
|
for await (const line of sseLines(res.body)) {
|
|
1529
1802
|
if (!line.startsWith("data:")) continue
|
|
1530
1803
|
const j = safeParse(line.slice(5).trim())
|
|
@@ -1547,12 +1820,17 @@ export class Client {
|
|
|
1547
1820
|
const content = [...blocks.values()].map((b) => b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: safeJson(b.json || "{}") } : { type: "text", text: b.text })
|
|
1548
1821
|
messages.push({ role: "assistant", content })
|
|
1549
1822
|
const uses = content.filter((b: any) => b.type === "tool_use")
|
|
1550
|
-
|
|
1551
|
-
|
|
1823
|
+
const hasCalls = stopReason === "tool_use" && uses.length > 0
|
|
1824
|
+
const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
1825
|
+
if (this.recover(rs, turn, messages, hasCalls, [...blocks.values()].some((b) => b.type === "tool_use" && malformedArgs(b.json)),
|
|
1826
|
+
stopReason === "max_tokens", text)) continue
|
|
1827
|
+
if (!hasCalls) {
|
|
1828
|
+
if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + text)) { if (rs) rs.carry = ""; continue }
|
|
1552
1829
|
yield { type: "usage", usage }
|
|
1553
|
-
yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) }
|
|
1830
|
+
yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + text, messages, toolCalls, turns, usage) }
|
|
1554
1831
|
return
|
|
1555
1832
|
}
|
|
1833
|
+
if (rs) rs.carry = ""
|
|
1556
1834
|
for (const u of uses as any[]) yield this.callEvent(u.id, u.name, u.input)
|
|
1557
1835
|
done.clear()
|
|
1558
1836
|
const settled = await untilAborted(signal, this.schedule(toolkit, uses as any[], (u: any) => [u.name, u.input ?? {}], async (u: any) => {
|
|
@@ -1582,6 +1860,7 @@ export class Client {
|
|
|
1582
1860
|
yield this.resultEvent(u.id, u.name, u.input, result)
|
|
1583
1861
|
}
|
|
1584
1862
|
messages.push({ role: "user", content: results })
|
|
1863
|
+
await this.roundEnd(messages, turn, false, "")
|
|
1585
1864
|
}
|
|
1586
1865
|
yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) }
|
|
1587
1866
|
} catch (e) {
|
|
@@ -1874,12 +2153,29 @@ function safeParse(s: string): any {
|
|
|
1874
2153
|
}
|
|
1875
2154
|
}
|
|
1876
2155
|
|
|
2156
|
+
const NUDGE = "Your previous response contained no answer. Continue and give your answer."
|
|
2157
|
+
const CONTINUE = "Your previous response was cut off at the output token limit. Continue exactly where it stopped, without repeating anything."
|
|
2158
|
+
const CORRECTIVE = "Your previous tool call could not be used: its arguments were not a valid JSON object. Call the tool again with arguments that are a single valid JSON object matching its input schema."
|
|
2159
|
+
|
|
2160
|
+
/** Per-run budgets + carried text of §8 stop-reason recovery. */
|
|
2161
|
+
interface Recovery { nudged: boolean; continues: number; corrected: boolean; carry: string }
|
|
2162
|
+
|
|
2163
|
+
function isObj(v: unknown): boolean {
|
|
2164
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/** A raw argument string that is non-empty (trimmed) and not a JSON object (§8). */
|
|
2168
|
+
function malformedArgs(s: unknown): boolean {
|
|
2169
|
+
return typeof s === "string" && s.trim() !== "" && !isObj(safeParse(s))
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/** Tool-call arguments as an object: anything that is not a JSON object becomes `{}` (§8). */
|
|
1877
2173
|
function safeJson(s: string): Record<string, unknown> {
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
}
|
|
2174
|
+
return objArgs(safeParse(s || "{}"))
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function objArgs(v: unknown): Record<string, unknown> {
|
|
2178
|
+
return isObj(v) ? (v as Record<string, unknown>) : {}
|
|
1883
2179
|
}
|
|
1884
2180
|
|
|
1885
2181
|
/**
|
|
@@ -1889,9 +2185,10 @@ function safeJson(s: string): Record<string, unknown> {
|
|
|
1889
2185
|
*/
|
|
1890
2186
|
function toolMessage(id: string | undefined, name: string, result: ToolResult): any {
|
|
1891
2187
|
const m: any = { role: "tool", tool_call_id: id, content: result.output }
|
|
1892
|
-
|
|
2188
|
+
const parts = modelParts(result)
|
|
2189
|
+
if (parts.length) {
|
|
1893
2190
|
m.name = name
|
|
1894
|
-
m.parts =
|
|
2191
|
+
m.parts = parts
|
|
1895
2192
|
}
|
|
1896
2193
|
return m
|
|
1897
2194
|
}
|
|
@@ -1899,7 +2196,8 @@ function toolMessage(id: string | undefined, name: string, result: ToolResult):
|
|
|
1899
2196
|
/** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
|
|
1900
2197
|
function toolResultBlock(id: string | undefined, result: ToolResult): any {
|
|
1901
2198
|
const b: any = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError }
|
|
1902
|
-
|
|
2199
|
+
const parts = modelParts(result)
|
|
2200
|
+
if (parts.length) b.parts = parts
|
|
1903
2201
|
return b
|
|
1904
2202
|
}
|
|
1905
2203
|
|
|
@@ -1955,3 +2253,21 @@ function lastText(messages: any[]): string {
|
|
|
1955
2253
|
}
|
|
1956
2254
|
return ""
|
|
1957
2255
|
}
|
|
2256
|
+
|
|
2257
|
+
/** The default LLM transport. On Node it is the keep-alive `node:http` transport in
|
|
2258
|
+
* `nodefetch.ts` (loaded lazily so this module stays free of `node:` imports for browser
|
|
2259
|
+
* bundles); on Bun, Deno, browsers, or if loading fails, it is the platform `fetch`. A
|
|
2260
|
+
* caller-supplied `fetch` option always wins and never reaches here. */
|
|
2261
|
+
const platformFetch = globalThis.fetch
|
|
2262
|
+
let defaultFetchP: Promise<typeof fetch> | undefined
|
|
2263
|
+
function defaultFetch(): Promise<typeof fetch> {
|
|
2264
|
+
// A host that replaced the global fetch (a stub, a proxy wrapper) keeps it.
|
|
2265
|
+
if (globalThis.fetch !== platformFetch) return Promise.resolve(globalThis.fetch)
|
|
2266
|
+
const g = globalThis as { Bun?: unknown; Deno?: unknown; process?: { versions?: { node?: string } } }
|
|
2267
|
+
const onNode = !!g.process?.versions?.node && !g.Bun && !g.Deno
|
|
2268
|
+
if (!onNode) return Promise.resolve((input, init) => globalThis.fetch(input, init))
|
|
2269
|
+
return (defaultFetchP ??= import("./nodefetch.js").then(
|
|
2270
|
+
(m) => m.nodeFetch,
|
|
2271
|
+
() => (input: Parameters<typeof fetch>[0], init?: RequestInit) => globalThis.fetch(input, init),
|
|
2272
|
+
))
|
|
2273
|
+
}
|