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