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/dist/client.js CHANGED
@@ -1,10 +1,16 @@
1
+ /**
2
+ * Unified LLM client — the host loop. Give it a plain base URL + a style
3
+ * ("openai" | "anthropic"), and it runs the tool-calling agent loop against a
4
+ * Toolkit. See ../../SPEC.md §8.
5
+ */
6
+ import { mediaPartPath } from "./media.js";
1
7
  import { pendingOf } from "./types.js";
2
8
  import { toOpenAI, toAnthropic } from "./adapters.js";
3
9
  import { applyPromptCache } from "./promptcache.js";
4
10
  import { buildToolPlan, toolSearch, unwrapCallTool } from "./tooltiers.js";
5
11
  import { isRetryableStatus, retryAfterMs } from "./retry.js";
6
12
  import { isConcurrencySafe, resolveMaxToolConcurrency, scheduleCalls } from "./toolconcurrency.js";
7
- import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
13
+ import { toOpenAIWire, toAnthropicWire, checkPromptParts, modelParts } from "./wire.js";
8
14
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
9
15
  /**
10
16
  * The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
@@ -75,6 +81,35 @@ export async function llmHttpError(res) {
75
81
  const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw);
76
82
  return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined);
77
83
  }
84
+ /** SPEC §7F.1 markers of a context-overflow rejection (matched lowercased). */
85
+ const OVERFLOW_MARKERS = ["context_length_exceeded", "maximum context length", "prompt is too long", "prompt_too_long", "request_too_large", "context window"];
86
+ /** SPEC §7F.1: true iff `status` is 400/413 AND the (redacted) body names a context overflow. */
87
+ export function isContextOverflow(status, body) {
88
+ if (status !== 400 && status !== 413)
89
+ return false;
90
+ const b = (body ?? "").toLowerCase();
91
+ return OVERFLOW_MARKERS.some((m) => b.includes(m));
92
+ }
93
+ /** SPEC §7F.1: reactive compaction could not make the prompt fit (breaker tripped, or the
94
+ * `compactOnOverflow` hook made no progress). A failure, never a §10 pending. */
95
+ export class ContextOverflowError extends Error {
96
+ compactions;
97
+ status;
98
+ body;
99
+ name = "ContextOverflowError";
100
+ constructor(
101
+ /** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
102
+ compactions,
103
+ /** Status of the last provider error. */
104
+ status,
105
+ /** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
106
+ body) {
107
+ super(`context overflow: ${compactions} reactive compaction(s) did not make the prompt fit`);
108
+ this.compactions = compactions;
109
+ this.status = status;
110
+ this.body = body;
111
+ }
112
+ }
78
113
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
79
114
  export class InMemoryConversationStore {
80
115
  map = new Map();
@@ -110,13 +145,15 @@ function delay(ms, signal) {
110
145
  /** Model tool-call id of each record, kept off the record itself (§8 shape has no id). */
111
146
  const callIds = new WeakMap();
112
147
  /** RunResult.files: every non-text part of every tool call, in call order (§8). */
113
- function runFiles(toolCalls) {
148
+ export function runFiles(toolCalls) {
114
149
  const out = [];
115
150
  for (const r of toolCalls) {
116
151
  for (const p of r.parts ?? []) {
117
152
  if (p.type === "text")
118
153
  continue;
119
- out.push({ ...p, from: r.name, callId: callIds.get(r) ?? "" });
154
+ // D20: a media file's local path rides on the run file, never on the part.
155
+ const local = mediaPartPath(p);
156
+ out.push({ ...p, ...(local !== undefined ? { path: local } : {}), from: r.name, callId: callIds.get(r) ?? "" });
120
157
  }
121
158
  }
122
159
  return out;
@@ -424,11 +461,13 @@ export class Client {
424
461
  body.max_tokens = req.maxTokens;
425
462
  const signal = this.makeSignal(req.signal);
426
463
  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);
464
+ const r = await this.llmCallJson((m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
465
+ method: "POST",
466
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
467
+ body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
468
+ }], signal, "openai", turnModel);
469
+ const data = r.data;
470
+ turnModel = r.model;
432
471
  if (this.opts.hooks?.afterLLM)
433
472
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
434
473
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -488,16 +527,18 @@ export class Client {
488
527
  body.tool_choice = tc;
489
528
  const signal = this.makeSignal(req.signal);
490
529
  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);
530
+ const r = await this.llmCallJson((m) => [endpoint, {
531
+ method: "POST",
532
+ headers: {
533
+ "content-type": "application/json",
534
+ "x-api-key": key,
535
+ "anthropic-version": "2023-06-01",
536
+ ...this.opts.headers,
537
+ },
538
+ body: JSON.stringify(this.finalizeBody({ ...body, model: m })),
539
+ }], signal, "anthropic", turnModel);
540
+ const data = r.data;
541
+ turnModel = r.model;
501
542
  if (this.opts.hooks?.afterLLM)
502
543
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
503
544
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -650,7 +691,19 @@ export class Client {
650
691
  /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
651
692
  * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
652
693
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
653
- async llmFetch(url, init, signal) {
694
+ async llmFetch(url, init, signal, out) {
695
+ // Same loop as the streaming path; its retry events are simply dropped here.
696
+ const it = this.llmFetchEvents(url, init, signal, out);
697
+ for (;;) {
698
+ const r = await it.next();
699
+ if (r.done)
700
+ return r.value;
701
+ }
702
+ }
703
+ /** The retry loop behind `llmFetch`, as a generator: yields a `retry` StreamEvent just before
704
+ * each retry wait (its `delayMs` is the exact wait slept) and returns the final Response.
705
+ * The streaming generators consume it with `yield*`. */
706
+ async *llmFetchEvents(url, init, signal, out) {
654
707
  const retries = this.opts.retries ?? 2;
655
708
  const base = this.opts.retryBaseMs ?? 500;
656
709
  // Default classifier = today's behavior, expressed as an onError. A "retry" is always
@@ -658,17 +711,9 @@ export class Client {
658
711
  const classify = this.opts.onError ?? ((info) => (info.retryable ? "retry" : "fail"));
659
712
  let lastErr;
660
713
  for (let attempt = 0; attempt <= retries; attempt++) {
714
+ let res;
661
715
  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);
716
+ res = await (this.opts.fetch ?? (await defaultFetch()))(url, { ...init, signal });
672
717
  }
673
718
  catch (e) {
674
719
  if (signal.aborted)
@@ -677,11 +722,78 @@ export class Client {
677
722
  const tier = classify({ error: e, attempt, retryable: true });
678
723
  if (tier === "fail" || attempt === retries)
679
724
  throw e;
680
- await delay(base * 2 ** attempt + Math.random() * 100, signal);
725
+ const wait = base * 2 ** attempt + Math.random() * 100;
726
+ yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, reason: "network" };
727
+ await delay(wait, signal);
728
+ continue;
681
729
  }
730
+ if (res.ok)
731
+ return res;
732
+ let retryable = isRetryableStatus(res.status, this.opts.retryableStatuses);
733
+ // The provider's own verdict wins over the status set (SPEC 8 x-should-retry).
734
+ const sr = res.headers.get("x-should-retry")?.trim().toLowerCase();
735
+ if (sr === "true")
736
+ retryable = true;
737
+ else if (sr === "false")
738
+ retryable = false;
739
+ const tier = classify({ status: res.status, attempt, retryable });
740
+ if (tier === "fail" || attempt === retries) {
741
+ if (out)
742
+ out.failed = tier === "fail";
743
+ return res; // caller surfaces the non-ok status
744
+ }
745
+ const ra = retryAfterMs(res.headers.get("retry-after"));
746
+ // `?? `, not `|| `: Retry-After: 0 means "retry now", not "no opinion".
747
+ const wait = ra ?? base * 2 ** attempt + Math.random() * 100;
748
+ yield { type: "retry", attempt: attempt + 1, max: retries, delayMs: wait, status: res.status, reason: `HTTP ${res.status}` };
749
+ await delay(wait, signal);
682
750
  }
683
751
  throw lastErr;
684
752
  }
753
+ /** §8 fallbackModels: the per-call model chain — [turnModel, ...fallbackModels], empty and
754
+ * duplicate entries skipped; just [turnModel] when the option is absent or the kill switch is set. */
755
+ modelChain(turnModel) {
756
+ const fb = this.opts.fallbackModels;
757
+ if (!fb || fb.length === 0)
758
+ return [turnModel];
759
+ const kill = (typeof process !== "undefined" ? process.env?.TOOLNEXUS_DISABLE_MODEL_FALLBACK : undefined)?.trim().toLowerCase();
760
+ if (kill === "1" || kill === "true")
761
+ return [turnModel];
762
+ const chain = [turnModel];
763
+ for (const m of fb)
764
+ if (m && !chain.includes(m))
765
+ chain.push(m);
766
+ return chain;
767
+ }
768
+ /** llmFetch over the model chain: each model spends the full retry budget; only a budget that
769
+ * ends on 503/529 (not an onError "fail") advances to the next model. Returns the response and
770
+ * the model that produced it. `build` rebuilds the same request for a model. */
771
+ async llmFetchChain(model, build, signal) {
772
+ const it = this.llmFetchChainEvents(model, build, signal);
773
+ for (;;) {
774
+ const r = await it.next();
775
+ if (r.done)
776
+ return r.value;
777
+ }
778
+ }
779
+ /** The fallback chain as a generator for the streaming loops: the #189 `retry` events of each
780
+ * model's attempts and a `fallback` event at each switch reach the stream as they happen. */
781
+ async *llmFetchChainEvents(model, build, signal) {
782
+ const chain = this.modelChain(model);
783
+ for (let i = 0;; i++) {
784
+ const m = chain[i];
785
+ const [url, init] = build(m);
786
+ const out = {};
787
+ const res = yield* this.llmFetchEvents(url, init, signal, out);
788
+ if (res.ok || out.failed || i + 1 >= chain.length || (res.status !== 503 && res.status !== 529))
789
+ return { res, model: m };
790
+ try {
791
+ await res.body?.cancel();
792
+ }
793
+ catch { /* ignore */ }
794
+ yield { type: "fallback", from: m, to: chain[i + 1] };
795
+ }
796
+ }
685
797
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
686
798
  async runTool(toolkit, name, args, id, turn, signal) {
687
799
  if (this.opts.toolTiers && name === "tool_search") {
@@ -818,22 +930,66 @@ export class Client {
818
930
  this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
819
931
  }
820
932
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
821
- async llmCallJson(url, init, signal, style, model) {
933
+ async llmCallJson(build, signal, style, turnModel) {
822
934
  const t0 = Date.now();
935
+ let model = turnModel;
823
936
  try {
824
- const res = await this.llmFetch(url, init, signal);
937
+ const r = await this.llmFetchChain(turnModel, build, signal);
938
+ model = r.model;
939
+ const res = r.res;
825
940
  if (!res.ok)
826
941
  throw await llmHttpError(res);
827
942
  const data = await res.json();
828
943
  const tok = perCall(data.usage, style);
829
944
  this.emit({ event: "llm", model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
830
- return data;
945
+ return { data, model };
831
946
  }
832
947
  catch (e) {
833
948
  this.emit({ event: "llm", model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
834
949
  throw e;
835
950
  }
836
951
  }
952
+ /**
953
+ * SPEC §7F.1: one LLM call with reactive compaction. `call(messages)` makes the request and
954
+ * throws on a non-2xx (as `LlmHttpError`). On a context overflow with `compactOnOverflow` set,
955
+ * the hook compacts and the SAME turn is re-sent (no beforeLLM, no turn consumed), guarded by
956
+ * the rapid-refill breaker. Returns the call's value and the (possibly compacted) transcript.
957
+ */
958
+ async callWithOverflow(st, ev, call) {
959
+ const it = this.callWithOverflowEvents(st, ev, async function* (m) { return await call(m); });
960
+ for (;;) {
961
+ const r = await it.next();
962
+ if (r.done)
963
+ return r.value;
964
+ }
965
+ }
966
+ /** `callWithOverflow` for the streaming loops: `call` is a generator so the #189 `retry` events
967
+ * of every attempt (including one after a compaction) still reach the stream before each wait. */
968
+ async *callWithOverflowEvents(st, ev, call) {
969
+ let messages = ev.messages;
970
+ for (;;) {
971
+ try {
972
+ const value = yield* call(messages);
973
+ st.okSince++;
974
+ return { value, messages };
975
+ }
976
+ catch (e) {
977
+ const hook = this.opts.compactOnOverflow;
978
+ if (!hook || !(e instanceof LlmHttpError) || !isContextOverflow(e.status, e.body))
979
+ throw e;
980
+ if (!(st.streak > 0 && st.okSince < 2))
981
+ st.streak = 0;
982
+ if (st.streak === 3)
983
+ throw new ContextOverflowError(3, e.status, e.body);
984
+ const ov = await hook({ messages, tools: ev.tools, model: ev.model, turn: ev.turn });
985
+ if (!ov?.messages || ov.messages.length >= messages.length)
986
+ throw new ContextOverflowError(st.streak, e.status, e.body);
987
+ messages = ov.messages;
988
+ st.streak++;
989
+ st.okSince = 0;
990
+ }
991
+ }
992
+ }
837
993
  // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
838
994
  // run leaves: return, throw, or a consumer abandoning the stream half-way.
839
995
  async runOpenAI(prompt, toolkit, external, history, onInterrupt) {
@@ -872,6 +1028,42 @@ export class Client {
872
1028
  this.releaseSignal(signal);
873
1029
  }
874
1030
  }
1031
+ /**
1032
+ * §8 stop-reason recovery: classify the response just appended to `messages` and, when a rule
1033
+ * fires, remove (or keep) that assistant message, append the pinned user message, and return
1034
+ * true — the caller then takes another turn. Never on the last permitted turn.
1035
+ */
1036
+ recover(rs, turn, messages, hasCalls, malformed, truncated, text) {
1037
+ if (!rs || turn >= (this.opts.maxTurns ?? 10) - 1)
1038
+ return false;
1039
+ let msg;
1040
+ if (hasCalls) {
1041
+ if (!malformed || rs.corrected)
1042
+ return false;
1043
+ rs.corrected = true;
1044
+ messages.pop();
1045
+ msg = CORRECTIVE;
1046
+ }
1047
+ else if (truncated) {
1048
+ if (rs.continues >= 3)
1049
+ return false;
1050
+ rs.continues++;
1051
+ rs.carry += text;
1052
+ msg = CONTINUE;
1053
+ }
1054
+ else {
1055
+ if (text.trim() !== "" || rs.nudged)
1056
+ return false;
1057
+ rs.nudged = true;
1058
+ messages.pop();
1059
+ msg = NUDGE;
1060
+ }
1061
+ messages.push({ role: "user", content: msg });
1062
+ return true;
1063
+ }
1064
+ newRecovery() {
1065
+ return this.opts.stopRecovery ? { nudged: false, continues: 0, corrected: false, carry: "" } : undefined;
1066
+ }
875
1067
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
876
1068
  async runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
877
1069
  const key = resolveKey(this.opts);
@@ -891,6 +1083,8 @@ export class Client {
891
1083
  let lastModel = this.opts.model;
892
1084
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
893
1085
  const done = new Map();
1086
+ const rs = this.newRecovery();
1087
+ const overflow = { okSince: 0, streak: 0 };
894
1088
  try {
895
1089
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
896
1090
  turns++;
@@ -905,19 +1099,33 @@ export class Client {
905
1099
  turnModel = ov.model;
906
1100
  }
907
1101
  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);
1102
+ const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1103
+ method: "POST",
1104
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
1105
+ body: JSON.stringify(this.openaiBody(msgs, tools, false, m)),
1106
+ }], signal, "openai", turnModel));
1107
+ messages = sent.messages;
1108
+ const data = sent.value.data;
1109
+ turnModel = lastModel = sent.value.model;
913
1110
  addUsage(usage, data.usage, "openai");
914
1111
  if (this.opts.hooks?.afterLLM)
915
1112
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
916
1113
  const msg = data.choices[0].message;
917
1114
  messages.push(msg);
918
1115
  const calls = msg.tool_calls ?? [];
919
- if (calls.length === 0)
920
- return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage);
1116
+ 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 : ""))
1117
+ continue;
1118
+ if (calls.length === 0) {
1119
+ const text = (rs?.carry ?? "") + (msg.content ?? "");
1120
+ if (await this.roundEnd(messages, turn, true, text)) {
1121
+ if (rs)
1122
+ rs.carry = "";
1123
+ continue;
1124
+ }
1125
+ return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
1126
+ }
1127
+ if (rs)
1128
+ rs.carry = "";
921
1129
  // execute all tool calls in this turn concurrently (true parallel tool calling)
922
1130
  done.clear();
923
1131
  const settled = await untilAborted(signal, this.schedule(toolkit, calls, (call) => [call.function.name, safeJson(call.function.arguments)], async (call) => {
@@ -942,6 +1150,7 @@ export class Client {
942
1150
  if (s.halted)
943
1151
  return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage);
944
1152
  }
1153
+ await this.roundEnd(messages, turn, false, "");
945
1154
  }
946
1155
  return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true);
947
1156
  }
@@ -951,6 +1160,24 @@ export class Client {
951
1160
  throw e;
952
1161
  }
953
1162
  }
1163
+ /** §8 afterRound: fire the round-boundary hook and append its injections as user messages.
1164
+ * Returns true when something was appended. A final round fires only when a turn remains. */
1165
+ async roundEnd(messages, turn, final, text) {
1166
+ const h = this.opts.hooks?.afterRound;
1167
+ if (!h)
1168
+ return false;
1169
+ if (final && turn + 1 >= (this.opts.maxTurns ?? 10))
1170
+ return false;
1171
+ const out = await h({ turn, final, text, messages });
1172
+ let added = false;
1173
+ for (const m of out?.inject ?? []) {
1174
+ if (typeof m === "string" && m !== "") {
1175
+ messages.push({ role: "user", content: m });
1176
+ added = true;
1177
+ }
1178
+ }
1179
+ return added;
1180
+ }
954
1181
  result(model, text, messages, toolCalls, turns, usage, exhausted = false) {
955
1182
  const incomplete = exhausted && text === "";
956
1183
  const r = { text, messages, toolCalls, files: runFiles(toolCalls), toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" };
@@ -1007,6 +1234,8 @@ export class Client {
1007
1234
  let lastModel = this.opts.model;
1008
1235
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1009
1236
  const done = new Map();
1237
+ const rs = this.newRecovery();
1238
+ const overflow = { okSince: 0, streak: 0 };
1010
1239
  try {
1011
1240
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1012
1241
  turns++;
@@ -1021,29 +1250,42 @@ export class Client {
1021
1250
  turnModel = ov.model;
1022
1251
  }
1023
1252
  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);
1253
+ const sent = await this.callWithOverflow(overflow, { messages, tools, model: turnModel, turn }, (msgs) => this.llmCallJson((m) => [endpoint, {
1254
+ method: "POST",
1255
+ headers: {
1256
+ "x-api-key": key,
1257
+ "anthropic-version": "2023-06-01",
1258
+ "Content-Type": "application/json",
1259
+ ...this.opts.headers,
1260
+ },
1261
+ body: JSON.stringify(this.anthropicBody(system, msgs, tools, false, m)),
1262
+ }], signal, "anthropic", turnModel));
1263
+ messages = sent.messages;
1264
+ const data = sent.value.data;
1265
+ turnModel = lastModel = sent.value.model;
1034
1266
  addUsage(usage, data.usage, "anthropic");
1035
1267
  if (this.opts.hooks?.afterLLM)
1036
1268
  await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
1037
1269
  messages.push({ role: "assistant", content: data.content });
1038
1270
  const uses = (data.content ?? []).filter((b) => b.type === "tool_use");
1271
+ const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
1272
+ if (this.recover(rs, turn, messages, uses.length > 0, uses.some((u) => u.input !== undefined && !isObj(u.input)), data.stop_reason === "max_tokens", text))
1273
+ continue;
1039
1274
  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);
1275
+ const full = (rs?.carry ?? "") + text;
1276
+ if (await this.roundEnd(messages, turn, true, full)) {
1277
+ if (rs)
1278
+ rs.carry = "";
1279
+ continue;
1280
+ }
1281
+ return this.endRun(runStart, lastModel, full, messages, toolCalls, turns, usage);
1042
1282
  }
1283
+ if (rs)
1284
+ rs.carry = "";
1043
1285
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
1044
1286
  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);
1287
+ const settled = await untilAborted(signal, this.schedule(toolkit, uses, (use) => [use.name, objArgs(use.input)], async (use) => {
1288
+ let { args, result } = await this.runTool(toolkit, use.name, objArgs(use.input), use.id, turn, signal);
1047
1289
  let halted;
1048
1290
  const req = pendingOf(result);
1049
1291
  if (req) {
@@ -1071,6 +1313,7 @@ export class Client {
1071
1313
  messages.push({ role: "user", content });
1072
1314
  if (halted)
1073
1315
  return this.pendingRun(runStart, lastModel, halted, messages, toolCalls, turns, usage);
1316
+ await this.roundEnd(messages, turn, false, "");
1074
1317
  }
1075
1318
  return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true);
1076
1319
  }
@@ -1099,6 +1342,8 @@ export class Client {
1099
1342
  let lastModel = this.opts.model;
1100
1343
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1101
1344
  const done = new Map();
1345
+ const rs = this.newRecovery();
1346
+ const overflow = { okSince: 0, streak: 0 };
1102
1347
  try {
1103
1348
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1104
1349
  turns++;
@@ -1116,14 +1361,23 @@ export class Client {
1116
1361
  const t0 = Date.now();
1117
1362
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
1118
1363
  let content = "";
1364
+ let finish = "";
1119
1365
  const acc = new Map();
1120
1366
  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)
1367
+ const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self) {
1368
+ const r = yield* self.llmFetchChainEvents(turnModel, (m) => [`${self.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1369
+ method: "POST",
1370
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...self.opts.headers },
1371
+ body: JSON.stringify(self.openaiBody(msgs, tools, true, m)),
1372
+ }], signal);
1373
+ if (!r.res.ok || !r.res.body)
1374
+ throw await llmHttpError(r.res);
1375
+ return r;
1376
+ })(this));
1377
+ messages = sent.messages;
1378
+ const res = sent.value.res;
1379
+ turnModel = lastModel = sent.value.model;
1380
+ if (!res.body)
1127
1381
  throw await llmHttpError(res);
1128
1382
  for await (const line of sseLines(res.body)) {
1129
1383
  if (!line.startsWith("data:"))
@@ -1143,6 +1397,8 @@ export class Client {
1143
1397
  addUsage(usage, j.usage, "openai");
1144
1398
  const choice = j?.choices?.[0];
1145
1399
  const d = choice?.delta;
1400
+ if (choice?.finish_reason)
1401
+ finish = choice.finish_reason;
1146
1402
  if (d?.content) {
1147
1403
  content += d.content;
1148
1404
  yield { type: "text", delta: d.content };
@@ -1167,13 +1423,23 @@ export class Client {
1167
1423
  if (this.opts.hooks?.afterLLM)
1168
1424
  await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn });
1169
1425
  const calls = [...acc.values()];
1426
+ messages.push(calls.length === 0
1427
+ ? { role: "assistant", content }
1428
+ : { role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
1429
+ if (this.recover(rs, turn, messages, calls.length > 0, calls.some((c) => malformedArgs(c.args)), finish === "length", content))
1430
+ continue;
1170
1431
  if (calls.length === 0) {
1171
- messages.push({ role: "assistant", content });
1432
+ if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + content)) {
1433
+ if (rs)
1434
+ rs.carry = "";
1435
+ continue;
1436
+ }
1172
1437
  yield { type: "usage", usage };
1173
- yield { type: "done", result: this.endRun(runStart, lastModel, content, messages, toolCalls, turns, usage) };
1438
+ yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + content, messages, toolCalls, turns, usage) };
1174
1439
  return;
1175
1440
  }
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 } })) });
1441
+ if (rs)
1442
+ rs.carry = "";
1177
1443
  for (const c of calls)
1178
1444
  yield this.callEvent(c.id, c.name, safeJson(c.args));
1179
1445
  done.clear();
@@ -1202,6 +1468,7 @@ export class Client {
1202
1468
  messages.push(toolMessage(c.id, c.name, result));
1203
1469
  yield this.resultEvent(c.id, c.name, safeJson(c.args), result);
1204
1470
  }
1471
+ await this.roundEnd(messages, turn, false, "");
1205
1472
  }
1206
1473
  yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) };
1207
1474
  }
@@ -1227,6 +1494,8 @@ export class Client {
1227
1494
  let lastModel = this.opts.model;
1228
1495
  // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1229
1496
  const done = new Map();
1497
+ const rs = this.newRecovery();
1498
+ const overflow = { okSince: 0, streak: 0 };
1230
1499
  try {
1231
1500
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1232
1501
  turns++;
@@ -1246,12 +1515,20 @@ export class Client {
1246
1515
  const blocks = new Map();
1247
1516
  let stopReason = "";
1248
1517
  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)
1518
+ const sent = yield* this.callWithOverflowEvents(overflow, { messages, tools, model: turnModel, turn }, (msgs) => (async function* (self) {
1519
+ const r = yield* self.llmFetchChainEvents(turnModel, (m) => [endpoint, {
1520
+ method: "POST",
1521
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...self.opts.headers },
1522
+ body: JSON.stringify(self.anthropicBody(system, msgs, tools, true, m)),
1523
+ }], signal);
1524
+ if (!r.res.ok || !r.res.body)
1525
+ throw await llmHttpError(r.res);
1526
+ return r;
1527
+ })(this));
1528
+ messages = sent.messages;
1529
+ const res = sent.value.res;
1530
+ turnModel = lastModel = sent.value.model;
1531
+ if (!res.body)
1255
1532
  throw await llmHttpError(res);
1256
1533
  for await (const line of sseLines(res.body)) {
1257
1534
  if (!line.startsWith("data:"))
@@ -1288,12 +1565,22 @@ export class Client {
1288
1565
  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
1566
  messages.push({ role: "assistant", content });
1290
1567
  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("");
1568
+ const hasCalls = stopReason === "tool_use" && uses.length > 0;
1569
+ const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
1570
+ if (this.recover(rs, turn, messages, hasCalls, [...blocks.values()].some((b) => b.type === "tool_use" && malformedArgs(b.json)), stopReason === "max_tokens", text))
1571
+ continue;
1572
+ if (!hasCalls) {
1573
+ if (await this.roundEnd(messages, turn, true, (rs?.carry ?? "") + text)) {
1574
+ if (rs)
1575
+ rs.carry = "";
1576
+ continue;
1577
+ }
1293
1578
  yield { type: "usage", usage };
1294
- yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) };
1579
+ yield { type: "done", result: this.endRun(runStart, lastModel, (rs?.carry ?? "") + text, messages, toolCalls, turns, usage) };
1295
1580
  return;
1296
1581
  }
1582
+ if (rs)
1583
+ rs.carry = "";
1297
1584
  for (const u of uses)
1298
1585
  yield this.callEvent(u.id, u.name, u.input);
1299
1586
  done.clear();
@@ -1325,6 +1612,7 @@ export class Client {
1325
1612
  yield this.resultEvent(u.id, u.name, u.input, result);
1326
1613
  }
1327
1614
  messages.push({ role: "user", content: results });
1615
+ await this.roundEnd(messages, turn, false, "");
1328
1616
  }
1329
1617
  yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) };
1330
1618
  }
@@ -1554,13 +1842,22 @@ function safeParse(s) {
1554
1842
  return undefined;
1555
1843
  }
1556
1844
  }
1845
+ const NUDGE = "Your previous response contained no answer. Continue and give your answer.";
1846
+ const CONTINUE = "Your previous response was cut off at the output token limit. Continue exactly where it stopped, without repeating anything.";
1847
+ 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.";
1848
+ function isObj(v) {
1849
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1850
+ }
1851
+ /** A raw argument string that is non-empty (trimmed) and not a JSON object (§8). */
1852
+ function malformedArgs(s) {
1853
+ return typeof s === "string" && s.trim() !== "" && !isObj(safeParse(s));
1854
+ }
1855
+ /** Tool-call arguments as an object: anything that is not a JSON object becomes `{}` (§8). */
1557
1856
  function safeJson(s) {
1558
- try {
1559
- return JSON.parse(s || "{}");
1560
- }
1561
- catch {
1562
- return {};
1563
- }
1857
+ return objArgs(safeParse(s || "{}"));
1858
+ }
1859
+ function objArgs(v) {
1860
+ return isObj(v) ? v : {};
1564
1861
  }
1565
1862
  /**
1566
1863
  * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
@@ -1569,17 +1866,19 @@ function safeJson(s) {
1569
1866
  */
1570
1867
  function toolMessage(id, name, result) {
1571
1868
  const m = { role: "tool", tool_call_id: id, content: result.output };
1572
- if (result.parts?.length) {
1869
+ const parts = modelParts(result);
1870
+ if (parts.length) {
1573
1871
  m.name = name;
1574
- m.parts = result.parts;
1872
+ m.parts = parts;
1575
1873
  }
1576
1874
  return m;
1577
1875
  }
1578
1876
  /** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
1579
1877
  function toolResultBlock(id, result) {
1580
1878
  const b = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError };
1581
- if (result.parts?.length)
1582
- b.parts = result.parts;
1879
+ const parts = modelParts(result);
1880
+ if (parts.length)
1881
+ b.parts = parts;
1583
1882
  return b;
1584
1883
  }
1585
1884
  const INTERRUPTED = { output: "interrupted: the turn ended before this tool returned. Whether its side effects happened is unknown. Do not re-run it automatically; check first.", isError: true };
@@ -1639,3 +1938,19 @@ function lastText(messages) {
1639
1938
  }
1640
1939
  return "";
1641
1940
  }
1941
+ /** The default LLM transport. On Node it is the keep-alive `node:http` transport in
1942
+ * `nodefetch.ts` (loaded lazily so this module stays free of `node:` imports for browser
1943
+ * bundles); on Bun, Deno, browsers, or if loading fails, it is the platform `fetch`. A
1944
+ * caller-supplied `fetch` option always wins and never reaches here. */
1945
+ const platformFetch = globalThis.fetch;
1946
+ let defaultFetchP;
1947
+ function defaultFetch() {
1948
+ // A host that replaced the global fetch (a stub, a proxy wrapper) keeps it.
1949
+ if (globalThis.fetch !== platformFetch)
1950
+ return Promise.resolve(globalThis.fetch);
1951
+ const g = globalThis;
1952
+ const onNode = !!g.process?.versions?.node && !g.Bun && !g.Deno;
1953
+ if (!onNode)
1954
+ return Promise.resolve((input, init) => globalThis.fetch(input, init));
1955
+ return (defaultFetchP ??= import("./nodefetch.js").then((m) => m.nodeFetch, () => (input, init) => globalThis.fetch(input, init)));
1956
+ }