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