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/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
- out.push({ ...p, from: r.name, callId: callIds.get(r) ?? "" })
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 data = await this.llmCallJson(
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 data = await this.llmCallJson(
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
- const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
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
- await delay(base * 2 ** attempt + Math.random() * 100, signal)
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(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle, model: string): Promise<any> {
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 res = await this.llmFetch(url, init, signal)
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 data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
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(messages, tools, false, turnModel)),
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 (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
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 data: any = await this.llmCallJson(endpoint, {
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, messages, tools, false, turnModel)),
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 text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
1335
- return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage)
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 ?? {}], async (use: any) => {
1341
- let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal)
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 res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1410
- method: "POST",
1411
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
1412
- body: JSON.stringify(this.openaiBody(messages, tools, true, turnModel)),
1413
- }, signal)
1414
- if (!res.ok || !res.body) throw await llmHttpError(res)
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
- messages.push({ role: "assistant", content })
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
- messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
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 res = await this.llmFetch(endpoint, {
1523
- method: "POST",
1524
- headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
1525
- body: JSON.stringify(this.anthropicBody(system, messages, tools, true, turnModel)),
1526
- }, signal)
1527
- if (!res.ok || !res.body) throw await llmHttpError(res)
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
- if (stopReason !== "tool_use" || uses.length === 0) {
1551
- const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
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
- try {
1879
- return JSON.parse(s || "{}")
1880
- } catch {
1881
- return {}
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
- if (result.parts?.length) {
2188
+ const parts = modelParts(result)
2189
+ if (parts.length) {
1893
2190
  m.name = name
1894
- m.parts = result.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
- if (result.parts?.length) b.parts = result.parts
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
+ }