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/README.md +61 -17
- 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 +112 -5
- package/dist/client.js +397 -82
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +13 -1
- package/dist/media.d.ts +465 -54
- package/dist/media.js +1819 -230
- 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/dist/wire.d.ts +11 -1
- package/dist/wire.js +20 -3
- 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 +380 -64
- package/src/index.ts +1 -0
- package/src/mcp.ts +18 -1
- package/src/media.ts +2034 -271
- package/src/nodefetch.ts +143 -0
- package/src/transcript_search.ts +172 -0
- package/src/wire.ts +19 -2
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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
933
|
+
async llmCallJson(build, signal, style, turnModel) {
|
|
822
934
|
const t0 = Date.now();
|
|
935
|
+
let model = turnModel;
|
|
823
936
|
try {
|
|
824
|
-
const
|
|
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
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
920
|
-
|
|
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
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
1041
|
-
|
|
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
|
|
1046
|
-
let { args, result } = await this.runTool(toolkit, use.name, use.input
|
|
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
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
-
|
|
1869
|
+
const parts = modelParts(result);
|
|
1870
|
+
if (parts.length) {
|
|
1573
1871
|
m.name = name;
|
|
1574
|
-
m.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
|
-
|
|
1582
|
-
|
|
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
|
+
}
|