toolnexus 0.27.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/dist/client.js CHANGED
@@ -75,6 +75,35 @@ export async function llmHttpError(res) {
75
75
  const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw);
76
76
  return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined);
77
77
  }
78
+ /** SPEC §7F.1 markers of a context-overflow rejection (matched lowercased). */
79
+ const OVERFLOW_MARKERS = ["context_length_exceeded", "maximum context length", "prompt is too long", "prompt_too_long", "request_too_large", "context window"];
80
+ /** SPEC §7F.1: true iff `status` is 400/413 AND the (redacted) body names a context overflow. */
81
+ export function isContextOverflow(status, body) {
82
+ if (status !== 400 && status !== 413)
83
+ return false;
84
+ const b = (body ?? "").toLowerCase();
85
+ return OVERFLOW_MARKERS.some((m) => b.includes(m));
86
+ }
87
+ /** SPEC §7F.1: reactive compaction could not make the prompt fit (breaker tripped, or the
88
+ * `compactOnOverflow` hook made no progress). A failure, never a §10 pending. */
89
+ export class ContextOverflowError extends Error {
90
+ compactions;
91
+ status;
92
+ body;
93
+ name = "ContextOverflowError";
94
+ constructor(
95
+ /** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
96
+ compactions,
97
+ /** Status of the last provider error. */
98
+ status,
99
+ /** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
100
+ body) {
101
+ super(`context overflow: ${compactions} reactive compaction(s) did not make the prompt fit`);
102
+ this.compactions = compactions;
103
+ this.status = status;
104
+ this.body = body;
105
+ }
106
+ }
78
107
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
79
108
  export class InMemoryConversationStore {
80
109
  map = new Map();
@@ -424,11 +453,13 @@ export class Client {
424
453
  body.max_tokens = req.maxTokens;
425
454
  const signal = this.makeSignal(req.signal);
426
455
  try {
427
- const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
428
- method: "POST",
429
- headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
430
- body: JSON.stringify(this.finalizeBody(body)),
431
- }, signal, "openai", turnModel);
456
+ const r = await this.llmCallJson((m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
457
+ method: "POST",
458
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
459
+ body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
460
+ }], signal, "openai", turnModel);
461
+ const data = r.data;
462
+ turnModel = r.model;
432
463
  if (this.opts.hooks?.afterLLM)
433
464
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
434
465
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -488,16 +519,18 @@ export class Client {
488
519
  body.tool_choice = tc;
489
520
  const signal = this.makeSignal(req.signal);
490
521
  try {
491
- const data = await this.llmCallJson(endpoint, {
492
- method: "POST",
493
- headers: {
494
- "content-type": "application/json",
495
- "x-api-key": key,
496
- "anthropic-version": "2023-06-01",
497
- ...this.opts.headers,
498
- },
499
- body: JSON.stringify(this.finalizeBody(body)),
500
- }, signal, "anthropic", turnModel);
522
+ const r = await this.llmCallJson((m) => [endpoint, {
523
+ method: "POST",
524
+ headers: {
525
+ "content-type": "application/json",
526
+ "x-api-key": key,
527
+ "anthropic-version": "2023-06-01",
528
+ ...this.opts.headers,
529
+ },
530
+ body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
531
+ }], signal, "anthropic", turnModel);
532
+ const data = r.data;
533
+ turnModel = r.model;
501
534
  if (this.opts.hooks?.afterLLM)
502
535
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
503
536
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -650,7 +683,19 @@ export class Client {
650
683
  /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
651
684
  * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
652
685
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
653
- async llmFetch(url, init, signal) {
686
+ async llmFetch(url, init, signal, out) {
687
+ // Same loop as the streaming path; its retry events are simply dropped here.
688
+ const it = this.llmFetchEvents(url, init, signal, out);
689
+ for (;;) {
690
+ const r = await it.next();
691
+ if (r.done)
692
+ return r.value;
693
+ }
694
+ }
695
+ /** The retry loop behind `llmFetch`, as a generator: yields a `retry` StreamEvent just before
696
+ * each retry wait (its `delayMs` is the exact wait slept) and returns the final Response.
697
+ * The streaming generators consume it with `yield*`. */
698
+ async *llmFetchEvents(url, init, signal, out) {
654
699
  const retries = this.opts.retries ?? 2;
655
700
  const base = this.opts.retryBaseMs ?? 500;
656
701
  // Default classifier = today's behavior, expressed as an onError. A "retry" is always
@@ -658,17 +703,9 @@ export class Client {
658
703
  const classify = this.opts.onError ?? ((info) => (info.retryable ? "retry" : "fail"));
659
704
  let lastErr;
660
705
  for (let attempt = 0; attempt <= retries; attempt++) {
706
+ let res;
661
707
  try {
662
- const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
663
- if (res.ok)
664
- return res;
665
- const retryable = isRetryableStatus(res.status, this.opts.retryableStatuses);
666
- const tier = classify({ status: res.status, attempt, retryable });
667
- if (tier === "fail" || attempt === retries)
668
- return res; // caller surfaces the non-ok status
669
- const ra = retryAfterMs(res.headers.get("retry-after"));
670
- // `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
671
- await delay(ra ?? base * 2 ** attempt + Math.random() * 100, signal);
708
+ res = await (this.opts.fetch ?? (await defaultFetch()))(url, { ...init, signal });
672
709
  }
673
710
  catch (e) {
674
711
  if (signal.aborted)
@@ -677,11 +714,78 @@ export class Client {
677
714
  const tier = classify({ error: e, attempt, retryable: true });
678
715
  if (tier === "fail" || attempt === retries)
679
716
  throw e;
680
- await delay(base * 2 ** attempt + Math.random() * 100, signal);
717
+ const wait = base * 2 ** attempt + Math.random() * 100;
718
+ yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, reason: "network" };
719
+ await delay(wait, signal);
720
+ continue;
721
+ }
722
+ if (res.ok)
723
+ return res;
724
+ let retryable = isRetryableStatus(res.status, this.opts.retryableStatuses);
725
+ // The provider's own verdict wins over the status set (SPEC 8 x-should-retry).
726
+ const sr = res.headers.get("x-should-retry")?.trim().toLowerCase();
727
+ if (sr === "true")
728
+ retryable = true;
729
+ else if (sr === "false")
730
+ retryable = false;
731
+ const tier = classify({ status: res.status, attempt, retryable });
732
+ if (tier === "fail" || attempt === retries) {
733
+ if (out)
734
+ out.failed = tier === "fail";
735
+ return res; // caller surfaces the non-ok status
681
736
  }
737
+ const ra = retryAfterMs(res.headers.get("retry-after"));
738
+ // `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
739
+ const wait = ra ?? base * 2 ** attempt + Math.random() * 100;
740
+ yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, status: res.status, reason: `HTTP ${res.status}` };
741
+ await delay(wait, signal);
682
742
  }
683
743
  throw lastErr;
684
744
  }
745
+ /** §8 fallbackModels: the per-call model chain — [turnModel, ...fallbackModels], empty and
746
+ * duplicate entries skipped; just [turnModel] when the option is absent or the kill switch is set. */
747
+ modelChain(turnModel) {
748
+ const fb = this.opts.fallbackModels;
749
+ if (!fb || fb.length === 0)
750
+ return [turnModel];
751
+ const kill = (typeof process !== "undefined" ? process.env?.TOOLNEXUS_DISABLE_MODEL_FALLBACK : undefined)?.trim().toLowerCase();
752
+ if (kill === "1" || kill === "true")
753
+ return [turnModel];
754
+ const chain = [turnModel];
755
+ for (const m of fb)
756
+ if (m && !chain.includes(m))
757
+ chain.push(m);
758
+ return chain;
759
+ }
760
+ /** llmFetch over the model chain: each model spends the full retry budget; only a budget that
761
+ * ends on 503/529 (not an onError "fail") advances to the next model. Returns the response and
762
+ * the model that produced it. `build` rebuilds the same request for a model. */
763
+ async llmFetchChain(model, build, signal) {
764
+ const it = this.llmFetchChainEvents(model, build, signal);
765
+ for (;;) {
766
+ const r = await it.next();
767
+ if (r.done)
768
+ return r.value;
769
+ }
770
+ }
771
+ /** The fallback chain as a generator for the streaming loops: the #189 `retry` events of each
772
+ * model's attempts and a `fallback` event at each switch reach the stream as they happen. */
773
+ async *llmFetchChainEvents(model, build, signal) {
774
+ const chain = this.modelChain(model);
775
+ for (let i = 0;; i++) {
776
+ const m = chain[i];
777
+ const [url, init] = build(m);
778
+ const out = {};
779
+ const res = yield* this.llmFetchEvents(url, init, signal, out);
780
+ if (res.ok || out.failed || i + 1 >= chain.length || (res.status !== 503 && res.status !== 529))
781
+ return { res, model: m };
782
+ try {
783
+ await res.body?.cancel();
784
+ }
785
+ catch { /* ignore */ }
786
+ yield { type: "fallback", from: m, to: chain[i + 1] };
787
+ }
788
+ }
685
789
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
686
790
  async runTool(toolkit, name, args, id, turn, signal) {
687
791
  if (this.opts.toolTiers && name === "tool_search") {
@@ -818,22 +922,66 @@ export class Client {
818
922
  this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
819
923
  }
820
924
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
821
- async llmCallJson(url, init, signal, style, model) {
925
+ async llmCallJson(build, signal, style, turnModel) {
822
926
  const t0 = Date.now();
927
+ let model = turnModel;
823
928
  try {
824
- const res = await this.llmFetch(url, init, signal);
929
+ const r = await this.llmFetchChain(turnModel, build, signal);
930
+ model = r.model;
931
+ const res = r.res;
825
932
  if (!res.ok)
826
933
  throw await llmHttpError(res);
827
934
  const data = await res.json();
828
935
  const tok = perCall(data.usage, style);
829
936
  this.emit({ event: "llm", model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
830
- return data;
937
+ return { data, model };
831
938
  }
832
939
  catch (e) {
833
940
  this.emit({ event: "llm", model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
834
941
  throw e;
835
942
  }
836
943
  }
944
+ /**
945
+ * SPEC §7F.1: one LLM call with reactive compaction. `call(messages)` makes the request and
946
+ * throws on a non-2xx (as `LlmHttpError`). On a context overflow with `compactOnOverflow` set,
947
+ * the hook compacts and the SAME turn is re-sent (no beforeLLM, no turn consumed), guarded by
948
+ * the rapid-refill breaker. Returns the call's value and the (possibly compacted) transcript.
949
+ */
950
+ async callWithOverflow(st, ev, call) {
951
+ const it = this.callWithOverflowEvents(st, ev, async function* (m) { return await call(m); });
952
+ for (;;) {
953
+ const r = await it.next();
954
+ if (r.done)
955
+ return r.value;
956
+ }
957
+ }
958
+ /** `callWithOverflow` for the streaming loops: `call` is a generator so the #189 `retry` events
959
+ * of every attempt (including one after a compaction) still reach the stream before each wait. */
960
+ async *callWithOverflowEvents(st, ev, call) {
961
+ let messages = ev.messages;
962
+ for (;;) {
963
+ try {
964
+ const value = yield* call(messages);
965
+ st.okSince++;
966
+ return { value, messages };
967
+ }
968
+ catch (e) {
969
+ const hook = this.opts.compactOnOverflow;
970
+ if (!hook || !(e instanceof LlmHttpError) || !isContextOverflow(e.status, e.body))
971
+ throw e;
972
+ if (!(st.streak > 0 && st.okSince < 2))
973
+ st.streak = 0;
974
+ if (st.streak === 3)
975
+ throw new ContextOverflowError(3, e.status, e.body);
976
+ const ov = await hook({ messages, tools: ev.tools, model: ev.model, turn: ev.turn });
977
+ if (!ov?.messages || ov.messages.length >= messages.length)
978
+ throw new ContextOverflowError(st.streak, e.status, e.body);
979
+ messages = ov.messages;
980
+ st.streak++;
981
+ st.okSince = 0;
982
+ }
983
+ }
984
+ }
837
985
  // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
838
986
  // run leaves: return, throw, or a consumer abandoning the stream half-way.
839
987
  async runOpenAI(prompt, toolkit, external, history, onInterrupt) {
@@ -872,6 +1020,42 @@ export class Client {
872
1020
  this.releaseSignal(signal);
873
1021
  }
874
1022
  }
1023
+ /**
1024
+ * §8 stop-reason recovery: classify the response just appended to `messages` and, when a rule
1025
+ * fires, remove (or keep) that assistant message, append the pinned user message, and return
1026
+ * true — the caller then takes another turn. Never on the last permitted turn.
1027
+ */
1028
+ recover(rs, turn, messages, hasCalls, malformed, truncated, text) {
1029
+ if (!rs || turn >= (this.opts.maxTurns ?? 10) - 1)
1030
+ return false;
1031
+ let msg;
1032
+ if (hasCalls) {
1033
+ if (!malformed || rs.corrected)
1034
+ return false;
1035
+ rs.corrected = true;
1036
+ messages.pop();
1037
+ msg = CORRECTIVE;
1038
+ }
1039
+ else if (truncated) {
1040
+ if (rs.continues >= 3)
1041
+ return false;
1042
+ rs.continues++;
1043
+ rs.carry += text;
1044
+ msg = CONTINUE;
1045
+ }
1046
+ else {
1047
+ if (text.trim() !== "" || rs.nudged)
1048
+ return false;
1049
+ rs.nudged = true;
1050
+ messages.pop();
1051
+ msg = NUDGE;
1052
+ }
1053
+ messages.push({ role: "user", content: msg });
1054
+ return true;
1055
+ }
1056
+ newRecovery() {
1057
+ return this.opts.stopRecovery ? { nudged: false, continues: 0, corrected: false, carry: "" } : undefined;
1058
+ }
875
1059
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
876
1060
  async runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
877
1061
  const key = resolveKey(this.opts);
@@ -891,6 +1075,8 @@ export class Client {
891
1075
  let lastModel = this.opts.model;
892
1076
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
893
1077
  const done = new Map();
1078
+ const rs = this.newRecovery();
1079
+ const overflow = { okSince: 0, streak: 0 };
894
1080
  try {
895
1081
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
896
1082
  turns++;
@@ -905,19 +1091,33 @@ export class Client {
905
1091
  turnModel = ov.model;
906
1092
  }
907
1093
  lastModel = turnModel;
908
- const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
909
- method: "POST",
910
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
911
- body: JSON.stringify(this.openaiBody(messages, tools, false, turnModel)),
912
- }, signal, "openai", turnModel);
1094
+ const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1095
+ method: "POST",
1096
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
1097
+ body: JSON.stringify(this.openaiBody(msgs, tools, false, m)),
1098
+ }], signal, "openai", turnModel));
1099
+ messages = sent.messages;
1100
+ const data = sent.value.data;
1101
+ turnModel = lastModel = sent.value.model;
913
1102
  addUsage(usage, data.usage, "openai");
914
1103
  if (this.opts.hooks?.afterLLM)
915
1104
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
916
1105
  const msg = data.choices[0].message;
917
1106
  messages.push(msg);
918
1107
  const calls = msg.tool_calls ?? [];
919
- if (calls.length === 0)
920
- return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage);
1108
+ if (this.recover(rs, turn, messages, calls.length > 0, calls.some((c) => malformedArgs(c.function?.arguments)), data.choices[0].finish_reason === "length", typeof msg.content === "string" ? msg.content : ""))
1109
+ continue;
1110
+ if (calls.length === 0) {
1111
+ const text = (rs?.carry ?? "") + (msg.content ?? "");
1112
+ if (await this.roundEnd(messages, turn, true, text)) {
1113
+ if (rs)
1114
+ rs.carry = "";
1115
+ continue;
1116
+ }
1117
+ return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
1118
+ }
1119
+ if (rs)
1120
+ rs.carry = "";
921
1121
  // execute all tool calls in this turn concurrently (true parallel tool calling)
922
1122
  done.clear();
923
1123
  const settled = await untilAborted(signal, this.schedule(toolkit, calls, (call) => [call.function.name, safeJson(call.function.arguments)], async (call) => {
@@ -942,6 +1142,7 @@ export class Client {
942
1142
  if (s.halted)
943
1143
  return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage);
944
1144
  }
1145
+ await this.roundEnd(messages, turn, false, "");
945
1146
  }
946
1147
  return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true);
947
1148
  }
@@ -951,6 +1152,24 @@ export class Client {
951
1152
  throw e;
952
1153
  }
953
1154
  }
1155
+ /** §8 afterRound: fire the round-boundary hook and append its injections as user messages.
1156
+ * Returns true when something was appended. A final round fires only when a turn remains. */
1157
+ async roundEnd(messages, turn, final, text) {
1158
+ const h = this.opts.hooks?.afterRound;
1159
+ if (!h)
1160
+ return false;
1161
+ if (final && turn + 1 >= (this.opts.maxTurns ?? 10))
1162
+ return false;
1163
+ const out = await h({ turn, final, text, messages });
1164
+ let added = false;
1165
+ for (const m of out?.inject ?? []) {
1166
+ if (typeof m === "string" && m !== "") {
1167
+ messages.push({ role: "user", content: m });
1168
+ added = true;
1169
+ }
1170
+ }
1171
+ return added;
1172
+ }
954
1173
  result(model, text, messages, toolCalls, turns, usage, exhausted = false) {
955
1174
  const incomplete = exhausted && text === "";
956
1175
  const r = { text, messages, toolCalls, files: runFiles(toolCalls), toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" };
@@ -1007,6 +1226,8 @@ export class Client {
1007
1226
  let lastModel = this.opts.model;
1008
1227
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1009
1228
  const done = new Map();
1229
+ const rs = this.newRecovery();
1230
+ const overflow = { okSince: 0, streak: 0 };
1010
1231
  try {
1011
1232
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1012
1233
  turns++;
@@ -1021,29 +1242,42 @@ export class Client {
1021
1242
  turnModel = ov.model;
1022
1243
  }
1023
1244
  lastModel = turnModel;
1024
- const data = await this.llmCallJson(endpoint, {
1025
- method: "POST",
1026
- headers: {
1027
- "x-api-key": key,
1028
- "anthropic-version": "2023-06-01",
1029
- "Content-Type": "application/json",
1030
- ...this.opts.headers,
1031
- },
1032
- body: JSON.stringify(this.anthropicBody(system, messages, tools, false, turnModel)),
1033
- }, signal, "anthropic", turnModel);
1245
+ const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [endpoint, {
1246
+ method: "POST",
1247
+ headers: {
1248
+ "x-api-key": key,
1249
+ "anthropic-version": "2023-06-01",
1250
+ "Content-Type": "application/json",
1251
+ ...this.opts.headers,
1252
+ },
1253
+ body: JSON.stringify(this.anthropicBody(system, msgs, tools, false, m)),
1254
+ }], signal, "anthropic", turnModel));
1255
+ messages = sent.messages;
1256
+ const data = sent.value.data;
1257
+ turnModel = lastModel = sent.value.model;
1034
1258
  addUsage(usage, data.usage, "anthropic");
1035
1259
  if (this.opts.hooks?.afterLLM)
1036
1260
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
1037
1261
  messages.push({ role: "assistant", content: data.content });
1038
1262
  const uses = (data.content ?? []).filter((b) => b.type === "tool_use");
1263
+ const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
1264
+ if (this.recover(rs, turn, messages, uses.length > 0, uses.some((u) => u.input !== undefined && !isObj(u.input)), data.stop_reason === "max_tokens", text))
1265
+ continue;
1039
1266
  if (uses.length === 0) {
1040
- const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
1041
- return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
1267
+ const full = (rs?.carry ?? "") + text;
1268
+ if (await this.roundEnd(messages, turn, true, full)) {
1269
+ if (rs)
1270
+ rs.carry = "";
1271
+ continue;
1272
+ }
1273
+ return this.endRun(runStart, lastModel, full, messages, toolCalls, turns, usage);
1042
1274
  }
1275
+ if (rs)
1276
+ rs.carry = "";
1043
1277
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
1044
1278
  done.clear();
1045
- const settled = await untilAborted(signal, this.schedule(toolkit, uses, (use) => [use.name, use.input ?? {}], async (use) => {
1046
- let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal);
1279
+ const settled = await untilAborted(signal, this.schedule(toolkit, uses, (use) => [use.name, objArgs(use.input)], async (use) => {
1280
+ let { args, result } = await this.runTool(toolkit, use.name, objArgs(use.input), use.id, turn, signal);
1047
1281
  let halted;
1048
1282
  const req = pendingOf(result);
1049
1283
  if (req) {
@@ -1071,6 +1305,7 @@ export class Client {
1071
1305
  messages.push({ role: "user", content });
1072
1306
  if (halted)
1073
1307
  return this.pendingRun(runStart, lastModel, halted, messages, toolCalls, turns, usage);
1308
+ await this.roundEnd(messages, turn, false, "");
1074
1309
  }
1075
1310
  return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true);
1076
1311
  }
@@ -1099,6 +1334,8 @@ export class Client {
1099
1334
  let lastModel = this.opts.model;
1100
1335
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1101
1336
  const done = new Map();
1337
+ const rs = this.newRecovery();
1338
+ const overflow = { okSince: 0, streak: 0 };
1102
1339
  try {
1103
1340
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1104
1341
  turns++;
@@ -1116,14 +1353,23 @@ export class Client {
1116
1353
  const t0 = Date.now();
1117
1354
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
1118
1355
  let content = "";
1356
+ let finish = "";
1119
1357
  const acc = new Map();
1120
1358
  try {
1121
- const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1122
- method: "POST",
1123
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
1124
- body: JSON.stringify(this.openaiBody(messages, tools, true, turnModel)),
1125
- }, signal);
1126
- if (!res.ok || !res.body)
1359
+ const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self) {
1360
+ const r = yield* self.llmFetchChainEvents(turnModel, (m) => [`${self.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1361
+ method: "POST",
1362
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...self.opts.headers },
1363
+ body: JSON.stringify(self.openaiBody(msgs, tools, true, m)),
1364
+ }], signal);
1365
+ if (!r.res.ok || !r.res.body)
1366
+ throw await llmHttpError(r.res);
1367
+ return r;
1368
+ })(this));
1369
+ messages = sent.messages;
1370
+ const res = sent.value.res;
1371
+ turnModel = lastModel = sent.value.model;
1372
+ if (!res.body)
1127
1373
  throw await llmHttpError(res);
1128
1374
  for await (const line of sseLines(res.body)) {
1129
1375
  if (!line.startsWith("data:"))
@@ -1143,6 +1389,8 @@ export class Client {
1143
1389
  addUsage(usage, j.usage, "openai");
1144
1390
  const choice = j?.choices?.[0];
1145
1391
  const d = choice?.delta;
1392
+ if (choice?.finish_reason)
1393
+ finish = choice.finish_reason;
1146
1394
  if (d?.content) {
1147
1395
  content += d.content;
1148
1396
  yield { type: "text", delta: d.content };
@@ -1167,13 +1415,23 @@ export class Client {
1167
1415
  if (this.opts.hooks?.afterLLM)
1168
1416
  await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn });
1169
1417
  const calls = [...acc.values()];
1418
+ messages.push(calls.length === 0
1419
+ ? { role: "assistant", content }
1420
+ : { role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
1421
+ if (this.recover(rs, turn, messages, calls.length > 0, calls.some((c) => malformedArgs(c.args)), finish === "length", content))
1422
+ continue;
1170
1423
  if (calls.length === 0) {
1171
- messages.push({ role: "assistant", content });
1424
+ if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + content)) {
1425
+ if (rs)
1426
+ rs.carry = "";
1427
+ continue;
1428
+ }
1172
1429
  yield { type: "usage", usage };
1173
- yield { type: "done", result: this.endRun(runStart, lastModel, content, messages, toolCalls, turns, usage) };
1430
+ yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + content, messages, toolCalls, turns, usage) };
1174
1431
  return;
1175
1432
  }
1176
- messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
1433
+ if (rs)
1434
+ rs.carry = "";
1177
1435
  for (const c of calls)
1178
1436
  yield this.callEvent(c.id, c.name, safeJson(c.args));
1179
1437
  done.clear();
@@ -1202,6 +1460,7 @@ export class Client {
1202
1460
  messages.push(toolMessage(c.id, c.name, result));
1203
1461
  yield this.resultEvent(c.id, c.name, safeJson(c.args), result);
1204
1462
  }
1463
+ await this.roundEnd(messages, turn, false, "");
1205
1464
  }
1206
1465
  yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) };
1207
1466
  }
@@ -1227,6 +1486,8 @@ export class Client {
1227
1486
  let lastModel = this.opts.model;
1228
1487
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1229
1488
  const done = new Map();
1489
+ const rs = this.newRecovery();
1490
+ const overflow = { okSince: 0, streak: 0 };
1230
1491
  try {
1231
1492
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1232
1493
  turns++;
@@ -1246,12 +1507,20 @@ export class Client {
1246
1507
  const blocks = new Map();
1247
1508
  let stopReason = "";
1248
1509
  try {
1249
- const res = await this.llmFetch(endpoint, {
1250
- method: "POST",
1251
- headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
1252
- body: JSON.stringify(this.anthropicBody(system, messages, tools, true, turnModel)),
1253
- }, signal);
1254
- if (!res.ok || !res.body)
1510
+ const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self) {
1511
+ const r = yield* self.llmFetchChainEvents(turnModel, (m) => [endpoint, {
1512
+ method: "POST",
1513
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...self.opts.headers },
1514
+ body: JSON.stringify(self.anthropicBody(system, msgs, tools, true, m)),
1515
+ }], signal);
1516
+ if (!r.res.ok || !r.res.body)
1517
+ throw await llmHttpError(r.res);
1518
+ return r;
1519
+ })(this));
1520
+ messages = sent.messages;
1521
+ const res = sent.value.res;
1522
+ turnModel = lastModel = sent.value.model;
1523
+ if (!res.body)
1255
1524
  throw await llmHttpError(res);
1256
1525
  for await (const line of sseLines(res.body)) {
1257
1526
  if (!line.startsWith("data:"))
@@ -1288,12 +1557,22 @@ export class Client {
1288
1557
  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 });
1289
1558
  messages.push({ role: "assistant", content });
1290
1559
  const uses = content.filter((b) => b.type === "tool_use");
1291
- if (stopReason !== "tool_use" || uses.length === 0) {
1292
- const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
1560
+ const hasCalls = stopReason === "tool_use" && uses.length > 0;
1561
+ const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
1562
+ if (this.recover(rs, turn, messages, hasCalls, [...blocks.values()].some((b) => b.type === "tool_use" && malformedArgs(b.json)), stopReason === "max_tokens", text))
1563
+ continue;
1564
+ if (!hasCalls) {
1565
+ if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + text)) {
1566
+ if (rs)
1567
+ rs.carry = "";
1568
+ continue;
1569
+ }
1293
1570
  yield { type: "usage", usage };
1294
- yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) };
1571
+ yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + text, messages, toolCalls, turns, usage) };
1295
1572
  return;
1296
1573
  }
1574
+ if (rs)
1575
+ rs.carry = "";
1297
1576
  for (const u of uses)
1298
1577
  yield this.callEvent(u.id, u.name, u.input);
1299
1578
  done.clear();
@@ -1325,6 +1604,7 @@ export class Client {
1325
1604
  yield this.resultEvent(u.id, u.name, u.input, result);
1326
1605
  }
1327
1606
  messages.push({ role: "user", content: results });
1607
+ await this.roundEnd(messages, turn, false, "");
1328
1608
  }
1329
1609
  yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) };
1330
1610
  }
@@ -1554,13 +1834,22 @@ function safeParse(s) {
1554
1834
  return undefined;
1555
1835
  }
1556
1836
  }
1837
+ const NUDGE = "Your previous response contained no answer. Continue and give your answer.";
1838
+ const CONTINUE = "Your previous response was cut off at the output token limit. Continue exactly where it stopped, without repeating anything.";
1839
+ 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.";
1840
+ function isObj(v) {
1841
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1842
+ }
1843
+ /** A raw argument string that is non-empty (trimmed) and not a JSON object (§8). */
1844
+ function malformedArgs(s) {
1845
+ return typeof s === "string" && s.trim() !== "" && !isObj(safeParse(s));
1846
+ }
1847
+ /** Tool-call arguments as an object: anything that is not a JSON object becomes `{}` (§8). */
1557
1848
  function safeJson(s) {
1558
- try {
1559
- return JSON.parse(s || "{}");
1560
- }
1561
- catch {
1562
- return {};
1563
- }
1849
+ return objArgs(safeParse(s || "{}"));
1850
+ }
1851
+ function objArgs(v) {
1852
+ return isObj(v) ? v : {};
1564
1853
  }
1565
1854
  /**
1566
1855
  * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
@@ -1639,3 +1928,19 @@ function lastText(messages) {
1639
1928
  }
1640
1929
  return "";
1641
1930
  }
1931
+ /** The default LLM transport. On Node it is the keep-alive `node:http` transport in
1932
+ * `nodefetch.ts` (loaded lazily so this module stays free of `node:` imports for browser
1933
+ * bundles); on Bun, Deno, browsers, or if loading fails, it is the platform `fetch`. A
1934
+ * caller-supplied `fetch` option always wins and never reaches here. */
1935
+ const platformFetch = globalThis.fetch;
1936
+ let defaultFetchP;
1937
+ function defaultFetch() {
1938
+ // A host that replaced the global fetch (a stub, a proxy wrapper) keeps it.
1939
+ if (globalThis.fetch !== platformFetch)
1940
+ return Promise.resolve(globalThis.fetch);
1941
+ const g = globalThis;
1942
+ const onNode = !!g.process?.versions?.node && !g.Bun && !g.Deno;
1943
+ if (!onNode)
1944
+ return Promise.resolve((input, init) => globalThis.fetch(input, init));
1945
+ return (defaultFetchP ??= import("./nodefetch.js").then((m) => m.nodeFetch, () => (input, init) => globalThis.fetch(input, init)));
1946
+ }