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/README.md +6 -2
- package/dist/acp.d.ts +6 -0
- package/dist/acp.js +38 -16
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +11 -3
- package/dist/classifier.js +120 -3
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +13 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +42 -15
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +117 -6
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +18 -1
- package/src/nodefetch.ts +143 -0
- package/src/transcript_search.ts +172 -0
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
925
|
+
async llmCallJson(build, signal, style, turnModel) {
|
|
822
926
|
const t0 = Date.now();
|
|
927
|
+
let model = turnModel;
|
|
823
928
|
try {
|
|
824
|
-
const
|
|
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
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
920
|
-
|
|
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
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
1041
|
-
|
|
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
|
|
1046
|
-
let { args, result } = await this.runTool(toolkit, use.name, use.input
|
|
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
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
+
}
|