toolnexus 0.21.0 → 0.23.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 +50 -4
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/client.d.ts +77 -2
- package/dist/client.js +304 -61
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/toolkit.d.ts +2 -2
- package/dist/toolkit.js +2 -2
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +4 -3
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +330 -66
- package/src/index.ts +5 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/toolkit.ts +2 -2
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/src/client.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Toolkit. See ../../SPEC.md §8.
|
|
5
5
|
*/
|
|
6
6
|
import type { Toolkit } from "./toolkit.js"
|
|
7
|
-
import type { ToolResult, Request, Answer } from "./types.js"
|
|
7
|
+
import type { Tool, ToolHints, ToolResult, Request, Answer } from "./types.js"
|
|
8
8
|
import { pendingOf } from "./types.js"
|
|
9
|
+
import { toOpenAI, toAnthropic } from "./adapters.js"
|
|
10
|
+
import { applyPromptCache, type PromptCacheOptions } from "./promptcache.js"
|
|
11
|
+
import { buildToolPlan, toolSearch, unwrapCallTool, type ToolPlan, type ToolTier, type ToolTierOptions } from "./tooltiers.js"
|
|
9
12
|
import { isRetryableStatus, retryAfterMs } from "./retry.js"
|
|
10
13
|
import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
|
|
11
14
|
import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
|
|
@@ -26,7 +29,15 @@ export interface ClientOptions {
|
|
|
26
29
|
baseUrl: string
|
|
27
30
|
style: ClientStyle
|
|
28
31
|
model: string
|
|
32
|
+
/** The key, used as-is. Absent => `apiKeyEnv`, else ONLY the env var that belongs to the
|
|
33
|
+
* `baseUrl` host over https: api.openai.com -> OPENAI_API_KEY, api.anthropic.com ->
|
|
34
|
+
* ANTHROPIC_API_KEY, openrouter.ai (and subdomains) -> OPENROUTER_API_KEY. Any other host (or
|
|
35
|
+
* plain http) needs `apiKey` or `apiKeyEnv` - a key is never sent to a host it does not belong
|
|
36
|
+
* to (SPEC 4C). Never logged. */
|
|
29
37
|
apiKey?: string
|
|
38
|
+
/** The NAME of the env var to read the key from when `apiKey` is absent - for a host not in
|
|
39
|
+
* the table above. Read at call time; the value is never logged. */
|
|
40
|
+
apiKeyEnv?: string
|
|
30
41
|
headers?: Record<string, string>
|
|
31
42
|
systemPrompt?: string
|
|
32
43
|
maxTurns?: number
|
|
@@ -68,7 +79,17 @@ export interface ClientOptions {
|
|
|
68
79
|
* Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
|
|
69
80
|
* remove this requirement anyway.
|
|
70
81
|
*/
|
|
71
|
-
|
|
82
|
+
/** The second argument carries the turn's abort signal (SPEC 4C), so a pending ask can end
|
|
83
|
+
* when the turn is cancelled: settle once `ctx.signal` aborts. */
|
|
84
|
+
waitFor?: (request: Request, ctx: { signal: AbortSignal }) => Promise<Answer>
|
|
85
|
+
/** Prompt caching (SPEC 8): anthropic `cache_control` breakpoints on the last tool, the system
|
|
86
|
+
* prompt and the latest message; openai `prompt_cache_key`. Applied before `requestParams`
|
|
87
|
+
* and `bodyTransform`, so both can still override it. Absent => body byte-identical. */
|
|
88
|
+
promptCache?: PromptCacheOptions
|
|
89
|
+
/** Tool tiers (SPEC 8): send only core tool schemas plus a `tool_search` tool that loads the
|
|
90
|
+
* rest on demand (and `call_tool`). The plan is frozen per toolkit until `resetToolTiers`.
|
|
91
|
+
* Absent => every tool is sent, as before. */
|
|
92
|
+
toolTiers?: ToolTierOptions
|
|
72
93
|
/** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
|
|
73
94
|
* its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
|
|
74
95
|
* default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
|
|
@@ -220,13 +241,35 @@ export interface ConversationStore {
|
|
|
220
241
|
/** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
|
|
221
242
|
export class InMemoryConversationStore implements ConversationStore {
|
|
222
243
|
private readonly map = new Map<string, any[]>()
|
|
244
|
+
private readonly updated = new Map<string, Date>()
|
|
223
245
|
async get(id: string): Promise<any[] | undefined> {
|
|
224
246
|
const m = this.map.get(id)
|
|
225
247
|
return m ? [...m] : undefined
|
|
226
248
|
}
|
|
227
249
|
async save(id: string, messages: any[]): Promise<void> {
|
|
228
250
|
this.map.set(id, [...messages])
|
|
251
|
+
this.updated.set(id, new Date())
|
|
229
252
|
}
|
|
253
|
+
/** Every stored conversation, newest first (ties by id ascending). */
|
|
254
|
+
async list(): Promise<ConversationEntry[]> {
|
|
255
|
+
return sortEntries([...this.updated].map(([id, updatedAt]) => ({ id, updatedAt })))
|
|
256
|
+
}
|
|
257
|
+
/** Remove a conversation; absent ids are not an error. */
|
|
258
|
+
async delete(id: string): Promise<void> {
|
|
259
|
+
this.map.delete(id)
|
|
260
|
+
this.updated.delete(id)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One `list()` row of a shipped conversation store. */
|
|
265
|
+
export interface ConversationEntry {
|
|
266
|
+
id: string
|
|
267
|
+
updatedAt: Date
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @internal Newest first, ties by id ascending (SPEC.md "Conversation memory"). */
|
|
271
|
+
export function sortEntries(entries: ConversationEntry[]): ConversationEntry[] {
|
|
272
|
+
return entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
230
273
|
}
|
|
231
274
|
|
|
232
275
|
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
@@ -263,7 +306,7 @@ export interface Hooks {
|
|
|
263
306
|
/** After each model call (observe: logging, cost, tracing). */
|
|
264
307
|
afterLLM?(ev: { response: any; model: string; turn: number }): void | Promise<void>
|
|
265
308
|
/** Before a tool runs. Return { result } to SHORT-CIRCUIT (deny/cache), { args } to rewrite. */
|
|
266
|
-
beforeTool?(ev: { name: string; args: Record<string, unknown>; id?: string; turn: number }): void | { args?: Record<string, unknown>; result?: ToolResult } | Promise<void | { args?: Record<string, unknown>; result?: ToolResult }>
|
|
309
|
+
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 }>
|
|
267
310
|
/** After a tool runs. Return { result } to replace the result. */
|
|
268
311
|
afterTool?(ev: { name: string; args: Record<string, unknown>; result: ToolResult; id?: string; turn: number }): void | { result?: ToolResult } | Promise<void | { result?: ToolResult }>
|
|
269
312
|
}
|
|
@@ -282,6 +325,14 @@ export interface Usage {
|
|
|
282
325
|
promptTokens: number
|
|
283
326
|
completionTokens: number
|
|
284
327
|
totalTokens: number
|
|
328
|
+
/** Prompt tokens served from the provider's prompt cache (anthropic cache_read_input_tokens;
|
|
329
|
+
* openai prompt_tokens_details.cached_tokens). Present once a provider reports any.
|
|
330
|
+
* Under anthropic `promptTokens` EXCLUDES cached tokens: the context sent is
|
|
331
|
+
* promptTokens + cacheReadTokens + cacheWriteTokens. Under openai `promptTokens` already
|
|
332
|
+
* includes cacheReadTokens. */
|
|
333
|
+
cacheReadTokens?: number
|
|
334
|
+
/** Tokens written to the prompt cache (anthropic cache_creation_input_tokens). */
|
|
335
|
+
cacheWriteTokens?: number
|
|
285
336
|
}
|
|
286
337
|
|
|
287
338
|
export interface RunResult {
|
|
@@ -310,19 +361,25 @@ export interface RunResult {
|
|
|
310
361
|
/** Events yielded by client.stream(). */
|
|
311
362
|
export type StreamEvent =
|
|
312
363
|
| { type: "text"; delta: string }
|
|
313
|
-
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown
|
|
314
|
-
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean }
|
|
364
|
+
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown>; via?: "call_tool" }
|
|
365
|
+
| { type: "tool_result"; id: string; name: string; output: string; isError: boolean; metadata?: Record<string, unknown>; via?: "call_tool" }
|
|
315
366
|
| { type: "usage"; usage: Usage }
|
|
316
367
|
| { type: "pending"; request: Request }
|
|
317
368
|
| { type: "done"; result: RunResult }
|
|
318
369
|
|
|
319
370
|
function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
|
|
320
371
|
if (!raw) return
|
|
372
|
+
const cache = (key: "cacheReadTokens" | "cacheWriteTokens", n: unknown) => {
|
|
373
|
+
if (typeof n === "number" && n > 0) acc[key] = (acc[key] ?? 0) + n
|
|
374
|
+
}
|
|
321
375
|
if (style === "anthropic") {
|
|
322
376
|
acc.promptTokens += raw.input_tokens ?? 0
|
|
323
377
|
acc.completionTokens += raw.output_tokens ?? 0
|
|
324
378
|
acc.totalTokens += (raw.input_tokens ?? 0) + (raw.output_tokens ?? 0)
|
|
379
|
+
cache("cacheReadTokens", raw.cache_read_input_tokens)
|
|
380
|
+
cache("cacheWriteTokens", raw.cache_creation_input_tokens)
|
|
325
381
|
} else {
|
|
382
|
+
cache("cacheReadTokens", raw.prompt_tokens_details?.cached_tokens)
|
|
326
383
|
acc.promptTokens += raw.prompt_tokens ?? 0
|
|
327
384
|
acc.completionTokens += raw.completion_tokens ?? 0
|
|
328
385
|
acc.totalTokens += raw.total_tokens ?? (raw.prompt_tokens ?? 0) + (raw.completion_tokens ?? 0)
|
|
@@ -336,18 +393,43 @@ function perCall(raw: any, style: ClientStyle): { prompt: number; completion: nu
|
|
|
336
393
|
return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 }
|
|
337
394
|
}
|
|
338
395
|
|
|
339
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Pick the API key (SPEC 4C, security). An explicit `apiKey` is used as-is; else `apiKeyEnv`;
|
|
398
|
+
* else ONLY the env var that belongs to the `baseUrl` host. There is no cross-provider fallback:
|
|
399
|
+
* any other host needs `apiKey` or `apiKeyEnv`. The value is never logged.
|
|
400
|
+
*/
|
|
401
|
+
export function resolveKey(opts: Pick<ClientOptions, "apiKey" | "apiKeyEnv" | "baseUrl">): string {
|
|
340
402
|
if (opts.apiKey) return opts.apiKey
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
403
|
+
const envName = opts.apiKeyEnv || keyEnvForBaseUrl(opts.baseUrl)
|
|
404
|
+
if (!envName) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`No API key: set apiKey or apiKeyEnv (provider env vars are only used for https://api.openai.com, https://api.anthropic.com and https://openrouter.ai, never for ${JSON.stringify(opts.baseUrl)})`,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
const key = process.env[envName]
|
|
410
|
+
if (!key) throw new Error(`No API key: set apiKey or ${envName}`)
|
|
348
411
|
return key
|
|
349
412
|
}
|
|
350
413
|
|
|
414
|
+
/** The env var whose key may be sent to `baseUrl`'s host, or "" when none may. */
|
|
415
|
+
export function keyEnvForBaseUrl(baseUrl: string): string {
|
|
416
|
+
const h = hostOf(baseUrl)
|
|
417
|
+
if (h === "api.openai.com") return "OPENAI_API_KEY"
|
|
418
|
+
if (h === "api.anthropic.com") return "ANTHROPIC_API_KEY"
|
|
419
|
+
if (h === "openrouter.ai" || h.endsWith(".openrouter.ai")) return "OPENROUTER_API_KEY"
|
|
420
|
+
return ""
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function hostOf(baseUrl: string): string {
|
|
424
|
+
try {
|
|
425
|
+
const u = new URL(baseUrl)
|
|
426
|
+
if (u.protocol !== "https:") return "" // a provider key is never sent over plain http
|
|
427
|
+
return u.hostname.toLowerCase()
|
|
428
|
+
} catch {
|
|
429
|
+
return ""
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
351
433
|
// ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
|
|
352
434
|
|
|
353
435
|
/** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
|
|
@@ -455,6 +537,8 @@ export class Client {
|
|
|
455
537
|
private readonly store: ConversationStore
|
|
456
538
|
/** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
|
|
457
539
|
private readonly registry = new MetricsRegistry()
|
|
540
|
+
/** The frozen ToolTiers plan per toolkit (SPEC 8 Tool tiers). */
|
|
541
|
+
private tierPlans = new WeakMap<Toolkit, ToolPlan>()
|
|
458
542
|
constructor(private readonly opts: ClientOptions) {
|
|
459
543
|
this.store = opts.store ?? new InMemoryConversationStore()
|
|
460
544
|
}
|
|
@@ -474,6 +558,7 @@ export class Client {
|
|
|
474
558
|
* every LLM body-build site so the ordering contract holds identically on all four paths.
|
|
475
559
|
*/
|
|
476
560
|
private finalizeBody(body: Record<string, any>): Record<string, any> {
|
|
561
|
+
if (this.opts.promptCache) applyPromptCache(body, this.opts.style, this.opts.promptCache)
|
|
477
562
|
const rp = this.opts.requestParams
|
|
478
563
|
if (rp) {
|
|
479
564
|
for (const [k, v] of Object.entries(rp)) {
|
|
@@ -736,9 +821,22 @@ export class Client {
|
|
|
736
821
|
return result!
|
|
737
822
|
}
|
|
738
823
|
if (!ctx?.id) return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal })
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
await this.store.
|
|
824
|
+
checkPromptParts(prompt, this.wireOpts())
|
|
825
|
+
const id = ctx.id
|
|
826
|
+
const history = (await this.store.get(id)) ?? []
|
|
827
|
+
// §8: an interrupted turn is saved too — the loop hands back its transcript, repaired.
|
|
828
|
+
let partial: any[] | undefined
|
|
829
|
+
const onInterrupt: OnInterrupt = (m) => { partial = m }
|
|
830
|
+
let result: RunResult
|
|
831
|
+
try {
|
|
832
|
+
result = this.opts.style === "anthropic"
|
|
833
|
+
? await this.runAnthropic(prompt, ctx.toolkit, ctx.signal, history, onInterrupt)
|
|
834
|
+
: await this.runOpenAI(prompt, ctx.toolkit, ctx.signal, history, onInterrupt)
|
|
835
|
+
} catch (e) {
|
|
836
|
+
if (partial) await this.store.save(id, partial)
|
|
837
|
+
throw e
|
|
838
|
+
}
|
|
839
|
+
await this.store.save(id, result.messages)
|
|
742
840
|
return result
|
|
743
841
|
}
|
|
744
842
|
|
|
@@ -755,12 +853,20 @@ export class Client {
|
|
|
755
853
|
async *stream(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
|
|
756
854
|
checkPromptParts(prompt, this.wireOpts())
|
|
757
855
|
const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined
|
|
856
|
+
// §8: with an id, an interrupted stream saves its repaired transcript before the error surfaces.
|
|
857
|
+
let partial: any[] | undefined
|
|
858
|
+
const onInterrupt: OnInterrupt | undefined = ctx?.id ? (m) => { partial = m } : undefined
|
|
758
859
|
const gen = this.opts.style === "anthropic"
|
|
759
|
-
? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
|
|
760
|
-
: this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history)
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
860
|
+
? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt)
|
|
861
|
+
: this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt)
|
|
862
|
+
try {
|
|
863
|
+
for await (const ev of gen) {
|
|
864
|
+
if (ev.type === "done" && ctx?.id) await this.store.save(ctx.id, ev.result.messages)
|
|
865
|
+
yield ev
|
|
866
|
+
}
|
|
867
|
+
} catch (e) {
|
|
868
|
+
if (partial && ctx?.id) await this.store.save(ctx.id, partial)
|
|
869
|
+
throw e
|
|
764
870
|
}
|
|
765
871
|
}
|
|
766
872
|
|
|
@@ -834,13 +940,33 @@ export class Client {
|
|
|
834
940
|
}
|
|
835
941
|
|
|
836
942
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
837
|
-
private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
|
|
943
|
+
private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number, signal?: AbortSignal) {
|
|
944
|
+
if (this.opts.toolTiers && name === "tool_search") {
|
|
945
|
+
// Engine-internal and read-only: no hooks (SPEC 8 Tool tiers).
|
|
946
|
+
const t0 = Date.now()
|
|
947
|
+
const result = toolSearch(toolkit?.tools() ?? [], (n) => toolkit?.get(n), args ?? {})
|
|
948
|
+
this.emitTool(name, "builtin", result, t0)
|
|
949
|
+
return { args, result }
|
|
950
|
+
}
|
|
951
|
+
// call_tool is unwrapped HERE, so hooks, hints and permission gates see the real tool. The
|
|
952
|
+
// returned args keep the wrapper shape (with any beforeTool rewrite inside), so a 10
|
|
953
|
+
// re-run unwraps the same way.
|
|
954
|
+
const inner = this.unwrapCall(name, args)
|
|
955
|
+
if (inner) {
|
|
956
|
+
const r = await this.runToolDirect(toolkit, inner.name, inner.args, id, turn, signal)
|
|
957
|
+
return { args: { name: inner.name, arguments: r.args }, result: r.result }
|
|
958
|
+
}
|
|
959
|
+
return this.runToolDirect(toolkit, name, args, id, turn, signal)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private async runToolDirect(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number, signal?: AbortSignal) {
|
|
838
963
|
const h = this.opts.hooks
|
|
839
|
-
const
|
|
964
|
+
const tool = toolkit?.get(name)
|
|
965
|
+
const source = tool?.source ?? "custom"
|
|
840
966
|
const t0 = Date.now()
|
|
841
967
|
let a = args
|
|
842
968
|
if (h?.beforeTool) {
|
|
843
|
-
const ov = await h.beforeTool({ name, args: a, id, turn })
|
|
969
|
+
const ov = await h.beforeTool({ name, args: a, id, turn, ...(tool?.hints ? { hints: tool.hints } : {}) })
|
|
844
970
|
if (ov?.result) {
|
|
845
971
|
// short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
|
|
846
972
|
// call for metrics, but a suspension is classified pending, never an error.
|
|
@@ -851,7 +977,7 @@ export class Client {
|
|
|
851
977
|
}
|
|
852
978
|
// No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
|
|
853
979
|
let result: ToolResult = toolkit
|
|
854
|
-
? await toolkit.execute(name, a)
|
|
980
|
+
? await toolkit.execute(name, a, { ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
|
|
855
981
|
: { output: `unknown tool: ${name}`, isError: true }
|
|
856
982
|
// A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
|
|
857
983
|
// result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
|
|
@@ -864,6 +990,70 @@ export class Client {
|
|
|
864
990
|
return { args: a, result }
|
|
865
991
|
}
|
|
866
992
|
|
|
993
|
+
/** ToolTiers `call_tool {name, arguments}` -> the real call; undefined when tiers are off. */
|
|
994
|
+
private unwrapCall(name: string, args: Record<string, unknown>): { name: string; args: Record<string, unknown> } | undefined {
|
|
995
|
+
return this.opts.toolTiers ? unwrapCallTool(name, args) : undefined
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** The RunResult.toolCalls entry: a `call_tool` call is recorded as the real tool with its
|
|
999
|
+
* inner arguments, the name hooks and stream events see (the transcript keeps the wrapper). */
|
|
1000
|
+
private toolCallRecord(name: string, args: Record<string, unknown>, result: ToolResult): ToolCallRecord {
|
|
1001
|
+
const inner = this.unwrapCall(name, args)
|
|
1002
|
+
return { name: inner ? inner.name : name, args: inner ? inner.args : args, output: result.output, isError: result.isError, metadata: result.metadata }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The stream's tool_call event, naming the real tool for a `call_tool` call. */
|
|
1006
|
+
private callEvent(id: string, name: string, args: Record<string, unknown>): StreamEvent {
|
|
1007
|
+
const inner = this.unwrapCall(name, args)
|
|
1008
|
+
return inner ? { type: "tool_call", id, name: inner.name, args: inner.args, via: "call_tool" } : { type: "tool_call", id, name, args }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private resultEvent(id: string, name: string, args: Record<string, unknown>, result: ToolResult): StreamEvent {
|
|
1012
|
+
const inner = this.unwrapCall(name, args)
|
|
1013
|
+
return {
|
|
1014
|
+
type: "tool_result", id, name: inner ? inner.name : name, output: result.output, isError: result.isError,
|
|
1015
|
+
...(result.metadata ? { metadata: result.metadata } : {}),
|
|
1016
|
+
...(inner ? { via: "call_tool" as const } : {}),
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private plan(toolkit: Toolkit | undefined): ToolPlan | undefined {
|
|
1021
|
+
if (!this.opts.toolTiers || !toolkit) return undefined
|
|
1022
|
+
let p = this.tierPlans.get(toolkit)
|
|
1023
|
+
if (!p) {
|
|
1024
|
+
p = buildToolPlan(this.opts.toolTiers, toolkit.tools())
|
|
1025
|
+
this.tierPlans.set(toolkit, p)
|
|
1026
|
+
}
|
|
1027
|
+
return p
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** The tools array for a request: core tools (sorted) then tool_search and call_tool. Without
|
|
1031
|
+
* tiers it is every tool, as before. */
|
|
1032
|
+
private requestTools(toolkit: Toolkit | undefined): Tool[] {
|
|
1033
|
+
if (!toolkit) return []
|
|
1034
|
+
const p = this.plan(toolkit)
|
|
1035
|
+
if (!p) return toolkit.tools()
|
|
1036
|
+
const out: Tool[] = []
|
|
1037
|
+
for (const n of p.core) {
|
|
1038
|
+
const t = toolkit.get(n)
|
|
1039
|
+
if (t) out.push(t)
|
|
1040
|
+
}
|
|
1041
|
+
return [...out, p.search, p.call]
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** The frozen tier plan for `toolkit` (built on first use): tool name -> tier. Undefined when
|
|
1045
|
+
* `toolTiers` is not set. For a host UI or log. */
|
|
1046
|
+
toolTierPlan(toolkit: Toolkit): Record<string, ToolTier> | undefined {
|
|
1047
|
+
const p = this.plan(toolkit)
|
|
1048
|
+
return p ? Object.fromEntries(p.tier) : undefined
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Forget `toolkit`'s plan so the next request rebuilds it - call at a new session start.
|
|
1052
|
+
* Mid-session it breaks the cached prefix. */
|
|
1053
|
+
resetToolTiers(toolkit: Toolkit): void {
|
|
1054
|
+
this.tierPlans.delete(toolkit)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
867
1057
|
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
868
1058
|
private emitTool(name: string, source: string, result: ToolResult, t0: number) {
|
|
869
1059
|
const req = pendingOf(result)
|
|
@@ -888,44 +1078,44 @@ export class Client {
|
|
|
888
1078
|
|
|
889
1079
|
// The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
|
|
890
1080
|
// run leaves: return, throw, or a consumer abandoning the stream half-way.
|
|
891
|
-
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
1081
|
+
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
892
1082
|
const signal = this.makeSignal(external)
|
|
893
1083
|
try {
|
|
894
|
-
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
1084
|
+
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
895
1085
|
} finally {
|
|
896
1086
|
this.releaseSignal(signal)
|
|
897
1087
|
}
|
|
898
1088
|
}
|
|
899
1089
|
|
|
900
|
-
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
1090
|
+
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
901
1091
|
const signal = this.makeSignal(external)
|
|
902
1092
|
try {
|
|
903
|
-
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
1093
|
+
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
904
1094
|
} finally {
|
|
905
1095
|
this.releaseSignal(signal)
|
|
906
1096
|
}
|
|
907
1097
|
}
|
|
908
1098
|
|
|
909
|
-
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1099
|
+
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
910
1100
|
const signal = this.makeSignal(external)
|
|
911
1101
|
try {
|
|
912
|
-
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
1102
|
+
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
913
1103
|
} finally {
|
|
914
1104
|
this.releaseSignal(signal)
|
|
915
1105
|
}
|
|
916
1106
|
}
|
|
917
1107
|
|
|
918
|
-
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1108
|
+
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
919
1109
|
const signal = this.makeSignal(external)
|
|
920
1110
|
try {
|
|
921
|
-
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
1111
|
+
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
922
1112
|
} finally {
|
|
923
1113
|
this.releaseSignal(signal)
|
|
924
1114
|
}
|
|
925
1115
|
}
|
|
926
1116
|
|
|
927
1117
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
928
|
-
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
1118
|
+
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
929
1119
|
const key = resolveKey(this.opts)
|
|
930
1120
|
const runStart = Date.now()
|
|
931
1121
|
let messages: any[] = history && history.length ? [...history] : []
|
|
@@ -934,12 +1124,14 @@ export class Client {
|
|
|
934
1124
|
if (system) messages.push({ role: "system", content: system })
|
|
935
1125
|
}
|
|
936
1126
|
messages.push({ role: "user", content: prompt })
|
|
937
|
-
let tools =
|
|
1127
|
+
let tools = toOpenAI(this.requestTools(toolkit))
|
|
938
1128
|
const toolCalls: ToolCallRecord[] = []
|
|
939
1129
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
940
1130
|
let turns = 0
|
|
941
1131
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
942
1132
|
let lastModel = this.opts.model
|
|
1133
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1134
|
+
const done = new Map<string, ToolResult>()
|
|
943
1135
|
|
|
944
1136
|
try {
|
|
945
1137
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -964,24 +1156,26 @@ export class Client {
|
|
|
964
1156
|
const calls = msg.tool_calls ?? []
|
|
965
1157
|
if (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
966
1158
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
967
|
-
|
|
1159
|
+
done.clear()
|
|
1160
|
+
const settled = await untilAborted(signal, Promise.all(
|
|
968
1161
|
calls.map(async (call: any) => {
|
|
969
|
-
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
1162
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal)
|
|
970
1163
|
let halted: Request | undefined
|
|
971
1164
|
const req = pendingOf(result)
|
|
972
1165
|
if (req) {
|
|
973
|
-
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
|
|
1166
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn, signal)
|
|
974
1167
|
result = r.result
|
|
975
1168
|
halted = r.halted
|
|
976
1169
|
}
|
|
1170
|
+
if (!halted && call.id) done.set(call.id, result)
|
|
977
1171
|
return { call, args, result, halted }
|
|
978
1172
|
}),
|
|
979
|
-
)
|
|
1173
|
+
))
|
|
980
1174
|
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
981
1175
|
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
982
1176
|
// on resume). Mirrors the streaming path.
|
|
983
1177
|
for (const s of settled) {
|
|
984
|
-
toolCalls.push(
|
|
1178
|
+
toolCalls.push(this.toolCallRecord(s.call.function.name, s.args, s.result))
|
|
985
1179
|
messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
|
|
986
1180
|
if (s.halted) return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage)
|
|
987
1181
|
}
|
|
@@ -989,6 +1183,7 @@ export class Client {
|
|
|
989
1183
|
return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true)
|
|
990
1184
|
} catch (e) {
|
|
991
1185
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1186
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
992
1187
|
throw e
|
|
993
1188
|
}
|
|
994
1189
|
}
|
|
@@ -1012,14 +1207,16 @@ export class Client {
|
|
|
1012
1207
|
* no `waitFor` is configured (the run should stop and surface the request).
|
|
1013
1208
|
*/
|
|
1014
1209
|
private async resolvePending(
|
|
1015
|
-
toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
1210
|
+
toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number, signal?: AbortSignal,
|
|
1016
1211
|
): Promise<{ result: ToolResult; halted?: Request }> {
|
|
1212
|
+
const inner = this.unwrapCall(name, args)
|
|
1213
|
+
if (inner) ({ name, args } = inner)
|
|
1017
1214
|
if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
|
|
1018
|
-
const answer = await this.opts.waitFor(request)
|
|
1215
|
+
const answer = await this.opts.waitFor(request, { signal: signal ?? new AbortController().signal })
|
|
1019
1216
|
if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
|
|
1020
1217
|
const t0 = Date.now()
|
|
1021
1218
|
let result: ToolResult = toolkit
|
|
1022
|
-
? await toolkit.execute(name, args, { answer })
|
|
1219
|
+
? await toolkit.execute(name, args, { answer, ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
|
|
1023
1220
|
: { output: `unknown tool: ${name}`, isError: true }
|
|
1024
1221
|
if (this.opts.hooks?.afterTool) {
|
|
1025
1222
|
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
@@ -1031,19 +1228,21 @@ export class Client {
|
|
|
1031
1228
|
}
|
|
1032
1229
|
|
|
1033
1230
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
1034
|
-
private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
1231
|
+
private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
1035
1232
|
const key = resolveKey(this.opts)
|
|
1036
1233
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
1037
1234
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
1038
1235
|
const system = this.system(toolkit)
|
|
1039
1236
|
const runStart = Date.now()
|
|
1040
1237
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
1041
|
-
let tools =
|
|
1238
|
+
let tools = toAnthropic(this.requestTools(toolkit))
|
|
1042
1239
|
const toolCalls: ToolCallRecord[] = []
|
|
1043
1240
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1044
1241
|
let turns = 0
|
|
1045
1242
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1046
1243
|
let lastModel = this.opts.model
|
|
1244
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1245
|
+
const done = new Map<string, ToolResult>()
|
|
1047
1246
|
|
|
1048
1247
|
try {
|
|
1049
1248
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1075,26 +1274,28 @@ export class Client {
|
|
|
1075
1274
|
return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage)
|
|
1076
1275
|
}
|
|
1077
1276
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
1078
|
-
|
|
1277
|
+
done.clear()
|
|
1278
|
+
const settled = await untilAborted(signal, Promise.all(
|
|
1079
1279
|
uses.map(async (use: any) => {
|
|
1080
|
-
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
1280
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal)
|
|
1081
1281
|
let halted: Request | undefined
|
|
1082
1282
|
const req = pendingOf(result)
|
|
1083
1283
|
if (req) {
|
|
1084
|
-
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
|
|
1284
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn, signal)
|
|
1085
1285
|
result = r.result
|
|
1086
1286
|
halted = r.halted
|
|
1087
1287
|
}
|
|
1288
|
+
if (!halted && use.id) done.set(use.id, result)
|
|
1088
1289
|
return { use, args, result, halted }
|
|
1089
1290
|
}),
|
|
1090
|
-
)
|
|
1291
|
+
))
|
|
1091
1292
|
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
1092
1293
|
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
1093
1294
|
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
1094
1295
|
const content: any[] = []
|
|
1095
1296
|
let halted: Request | undefined
|
|
1096
1297
|
for (const s of settled) {
|
|
1097
|
-
toolCalls.push(
|
|
1298
|
+
toolCalls.push(this.toolCallRecord(s.use.name, s.args, s.result))
|
|
1098
1299
|
content.push(toolResultBlock(s.use.id, s.result))
|
|
1099
1300
|
if (s.halted) { halted = s.halted; break }
|
|
1100
1301
|
}
|
|
@@ -1104,12 +1305,13 @@ export class Client {
|
|
|
1104
1305
|
return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true)
|
|
1105
1306
|
} catch (e) {
|
|
1106
1307
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1308
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1107
1309
|
throw e
|
|
1108
1310
|
}
|
|
1109
1311
|
}
|
|
1110
1312
|
|
|
1111
1313
|
// ---- Streaming: OpenAI-style ----
|
|
1112
|
-
private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1314
|
+
private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1113
1315
|
const key = resolveKey(this.opts)
|
|
1114
1316
|
const runStart = Date.now()
|
|
1115
1317
|
let messages: any[] = history && history.length ? [...history] : []
|
|
@@ -1118,12 +1320,14 @@ export class Client {
|
|
|
1118
1320
|
if (system) messages.push({ role: "system", content: system })
|
|
1119
1321
|
}
|
|
1120
1322
|
messages.push({ role: "user", content: prompt })
|
|
1121
|
-
let tools =
|
|
1323
|
+
let tools = toOpenAI(this.requestTools(toolkit))
|
|
1122
1324
|
const toolCalls: ToolCallRecord[] = []
|
|
1123
1325
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1124
1326
|
let turns = 0
|
|
1125
1327
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1126
1328
|
let lastModel = this.opts.model
|
|
1329
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1330
|
+
const done = new Map<string, ToolResult>()
|
|
1127
1331
|
|
|
1128
1332
|
try {
|
|
1129
1333
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1179,49 +1383,57 @@ export class Client {
|
|
|
1179
1383
|
return
|
|
1180
1384
|
}
|
|
1181
1385
|
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
1182
|
-
for (const c of calls) yield
|
|
1183
|
-
|
|
1386
|
+
for (const c of calls) yield this.callEvent(c.id, c.name, safeJson(c.args))
|
|
1387
|
+
done.clear()
|
|
1388
|
+
const settled = await untilAborted(signal, Promise.all(calls.map(async (c) => {
|
|
1389
|
+
const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal)
|
|
1390
|
+
if (!pendingOf(r.result) && c.id) done.set(c.id, r.result)
|
|
1391
|
+
return r
|
|
1392
|
+
})))
|
|
1184
1393
|
for (let i = 0; i < calls.length; i++) {
|
|
1185
1394
|
const c = calls[i]
|
|
1186
1395
|
let { args, result } = settled[i]
|
|
1187
1396
|
const req = pendingOf(result)
|
|
1188
1397
|
if (req) {
|
|
1189
1398
|
yield { type: "pending", request: req } // surface BEFORE waitFor so a channel can push the link
|
|
1190
|
-
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn)
|
|
1399
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal)
|
|
1191
1400
|
result = r.result
|
|
1192
1401
|
if (r.halted) {
|
|
1193
|
-
toolCalls.push(
|
|
1402
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result))
|
|
1194
1403
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1195
1404
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
|
|
1196
1405
|
return
|
|
1197
1406
|
}
|
|
1198
1407
|
}
|
|
1199
|
-
toolCalls.push(
|
|
1408
|
+
toolCalls.push(this.toolCallRecord(c.name, args, result))
|
|
1200
1409
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1201
|
-
yield
|
|
1410
|
+
yield this.resultEvent(c.id, c.name, safeJson(c.args), result)
|
|
1202
1411
|
}
|
|
1203
1412
|
}
|
|
1204
1413
|
yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) }
|
|
1205
1414
|
} catch (e) {
|
|
1206
1415
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1416
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1207
1417
|
throw e
|
|
1208
1418
|
}
|
|
1209
1419
|
}
|
|
1210
1420
|
|
|
1211
1421
|
// ---- Streaming: Anthropic-style ----
|
|
1212
|
-
private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1422
|
+
private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1213
1423
|
const key = resolveKey(this.opts)
|
|
1214
1424
|
const runStart = Date.now()
|
|
1215
1425
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
1216
1426
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
1217
1427
|
const system = this.system(toolkit)
|
|
1218
1428
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
1219
|
-
let tools =
|
|
1429
|
+
let tools = toAnthropic(this.requestTools(toolkit))
|
|
1220
1430
|
const toolCalls: ToolCallRecord[] = []
|
|
1221
1431
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1222
1432
|
let turns = 0
|
|
1223
1433
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1224
1434
|
let lastModel = this.opts.model
|
|
1435
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1436
|
+
const done = new Map<string, ToolResult>()
|
|
1225
1437
|
|
|
1226
1438
|
try {
|
|
1227
1439
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1273,8 +1485,13 @@ export class Client {
|
|
|
1273
1485
|
yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) }
|
|
1274
1486
|
return
|
|
1275
1487
|
}
|
|
1276
|
-
for (const u of uses as any[]) yield
|
|
1277
|
-
|
|
1488
|
+
for (const u of uses as any[]) yield this.callEvent(u.id, u.name, u.input)
|
|
1489
|
+
done.clear()
|
|
1490
|
+
const settled = await untilAborted(signal, Promise.all((uses as any[]).map(async (u) => {
|
|
1491
|
+
const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal)
|
|
1492
|
+
if (!pendingOf(r.result) && u.id) done.set(u.id, r.result)
|
|
1493
|
+
return r
|
|
1494
|
+
})))
|
|
1278
1495
|
const results: any[] = []
|
|
1279
1496
|
for (let i = 0; i < uses.length; i++) {
|
|
1280
1497
|
const u = uses[i] as any
|
|
@@ -1282,25 +1499,26 @@ export class Client {
|
|
|
1282
1499
|
const req = pendingOf(result)
|
|
1283
1500
|
if (req) {
|
|
1284
1501
|
yield { type: "pending", request: req }
|
|
1285
|
-
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
|
|
1502
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal)
|
|
1286
1503
|
result = r.result
|
|
1287
1504
|
if (r.halted) {
|
|
1288
|
-
toolCalls.push(
|
|
1505
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result))
|
|
1289
1506
|
results.push(toolResultBlock(u.id, result))
|
|
1290
1507
|
messages.push({ role: "user", content: results })
|
|
1291
1508
|
yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
|
|
1292
1509
|
return
|
|
1293
1510
|
}
|
|
1294
1511
|
}
|
|
1295
|
-
toolCalls.push(
|
|
1512
|
+
toolCalls.push(this.toolCallRecord(u.name, args, result))
|
|
1296
1513
|
results.push(toolResultBlock(u.id, result))
|
|
1297
|
-
yield
|
|
1514
|
+
yield this.resultEvent(u.id, u.name, u.input, result)
|
|
1298
1515
|
}
|
|
1299
1516
|
messages.push({ role: "user", content: results })
|
|
1300
1517
|
}
|
|
1301
1518
|
yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) }
|
|
1302
1519
|
} catch (e) {
|
|
1303
1520
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1521
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1304
1522
|
throw e
|
|
1305
1523
|
}
|
|
1306
1524
|
}
|
|
@@ -1531,6 +1749,52 @@ function toolResultBlock(id: string | undefined, result: ToolResult): any {
|
|
|
1531
1749
|
return b
|
|
1532
1750
|
}
|
|
1533
1751
|
|
|
1752
|
+
/** Receives the repaired transcript of a run that ended in an error or cancellation. */
|
|
1753
|
+
type OnInterrupt = (messages: any[]) => void
|
|
1754
|
+
|
|
1755
|
+
const INTERRUPTED: ToolResult = { 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 }
|
|
1756
|
+
|
|
1757
|
+
/** Settle with `p`, or reject with the abort reason as soon as `signal` aborts. */
|
|
1758
|
+
function untilAborted<T>(signal: AbortSignal, p: Promise<T>): Promise<T> {
|
|
1759
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"))
|
|
1760
|
+
return new Promise<T>((resolve, reject) => {
|
|
1761
|
+
const onAbort = () => reject(signal.reason ?? new Error("aborted"))
|
|
1762
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
1763
|
+
p.then(
|
|
1764
|
+
(v) => { signal.removeEventListener("abort", onAbort); resolve(v) },
|
|
1765
|
+
(e) => { signal.removeEventListener("abort", onAbort); reject(e) },
|
|
1766
|
+
)
|
|
1767
|
+
})
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* §8 "Interrupted turns are saved": a copy of `messages` in which every tool call of the LAST
|
|
1772
|
+
* assistant message has a result — the finished one from `done`, else `interrupted` (isError) —
|
|
1773
|
+
* in the dialect's own shape, so the saved transcript replays on either API.
|
|
1774
|
+
*/
|
|
1775
|
+
function interruptedTranscript(messages: any[], anthropic: boolean, done: Map<string, ToolResult>): any[] {
|
|
1776
|
+
const out = [...messages]
|
|
1777
|
+
let ai = -1
|
|
1778
|
+
for (let i = out.length - 1; i >= 0; i--) if (out[i]?.role === "assistant") { ai = i; break }
|
|
1779
|
+
if (ai < 0) return out
|
|
1780
|
+
const resultFor = (id: string) => done.get(id) ?? INTERRUPTED
|
|
1781
|
+
if (!anthropic) {
|
|
1782
|
+
const calls: any[] = out[ai].tool_calls ?? []
|
|
1783
|
+
const have = new Set(out.slice(ai + 1).filter((m) => m?.role === "tool").map((m) => m.tool_call_id))
|
|
1784
|
+
for (const c of calls) if (!have.has(c.id)) out.push(toolMessage(c.id, c.function?.name ?? "", resultFor(c.id)))
|
|
1785
|
+
return out
|
|
1786
|
+
}
|
|
1787
|
+
const uses = Array.isArray(out[ai].content) ? out[ai].content.filter((b: any) => b?.type === "tool_use") : []
|
|
1788
|
+
if (!uses.length) return out
|
|
1789
|
+
const next = out[ai + 1]
|
|
1790
|
+
const blocks: any[] = next?.role === "user" && Array.isArray(next.content) ? [...next.content] : []
|
|
1791
|
+
const have = new Set(blocks.filter((b) => b?.type === "tool_result").map((b) => b.tool_use_id))
|
|
1792
|
+
for (const u of uses) if (!have.has(u.id)) blocks.push(toolResultBlock(u.id, resultFor(u.id)))
|
|
1793
|
+
if (next?.role === "user" && Array.isArray(next.content)) out[ai + 1] = { ...next, content: blocks }
|
|
1794
|
+
else out.splice(ai + 1, 0, { role: "user", content: blocks })
|
|
1795
|
+
return out
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1534
1798
|
function lastText(messages: any[]): string {
|
|
1535
1799
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1536
1800
|
if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content
|