toolnexus 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +74 -2
- package/dist/client.js +292 -55
- 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/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
- 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 +317 -60
- 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/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,63 @@ 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 stream's tool_call event, naming the real tool for a `call_tool` call. */
|
|
999
|
+
private callEvent(id: string, name: string, args: Record<string, unknown>): StreamEvent {
|
|
1000
|
+
const inner = this.unwrapCall(name, args)
|
|
1001
|
+
return inner ? { type: "tool_call", id, name: inner.name, args: inner.args, via: "call_tool" } : { type: "tool_call", id, name, args }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private resultEvent(id: string, name: string, args: Record<string, unknown>, result: ToolResult): StreamEvent {
|
|
1005
|
+
const inner = this.unwrapCall(name, args)
|
|
1006
|
+
return {
|
|
1007
|
+
type: "tool_result", id, name: inner ? inner.name : name, output: result.output, isError: result.isError,
|
|
1008
|
+
...(result.metadata ? { metadata: result.metadata } : {}),
|
|
1009
|
+
...(inner ? { via: "call_tool" as const } : {}),
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private plan(toolkit: Toolkit | undefined): ToolPlan | undefined {
|
|
1014
|
+
if (!this.opts.toolTiers || !toolkit) return undefined
|
|
1015
|
+
let p = this.tierPlans.get(toolkit)
|
|
1016
|
+
if (!p) {
|
|
1017
|
+
p = buildToolPlan(this.opts.toolTiers, toolkit.tools())
|
|
1018
|
+
this.tierPlans.set(toolkit, p)
|
|
1019
|
+
}
|
|
1020
|
+
return p
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** The tools array for a request: core tools (sorted) then tool_search and call_tool. Without
|
|
1024
|
+
* tiers it is every tool, as before. */
|
|
1025
|
+
private requestTools(toolkit: Toolkit | undefined): Tool[] {
|
|
1026
|
+
if (!toolkit) return []
|
|
1027
|
+
const p = this.plan(toolkit)
|
|
1028
|
+
if (!p) return toolkit.tools()
|
|
1029
|
+
const out: Tool[] = []
|
|
1030
|
+
for (const n of p.core) {
|
|
1031
|
+
const t = toolkit.get(n)
|
|
1032
|
+
if (t) out.push(t)
|
|
1033
|
+
}
|
|
1034
|
+
return [...out, p.search, p.call]
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** The frozen tier plan for `toolkit` (built on first use): tool name -> tier. Undefined when
|
|
1038
|
+
* `toolTiers` is not set. For a host UI or log. */
|
|
1039
|
+
toolTierPlan(toolkit: Toolkit): Record<string, ToolTier> | undefined {
|
|
1040
|
+
const p = this.plan(toolkit)
|
|
1041
|
+
return p ? Object.fromEntries(p.tier) : undefined
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Forget `toolkit`'s plan so the next request rebuilds it - call at a new session start.
|
|
1045
|
+
* Mid-session it breaks the cached prefix. */
|
|
1046
|
+
resetToolTiers(toolkit: Toolkit): void {
|
|
1047
|
+
this.tierPlans.delete(toolkit)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
867
1050
|
/** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
|
|
868
1051
|
private emitTool(name: string, source: string, result: ToolResult, t0: number) {
|
|
869
1052
|
const req = pendingOf(result)
|
|
@@ -888,44 +1071,44 @@ export class Client {
|
|
|
888
1071
|
|
|
889
1072
|
// The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
|
|
890
1073
|
// 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> {
|
|
1074
|
+
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
892
1075
|
const signal = this.makeSignal(external)
|
|
893
1076
|
try {
|
|
894
|
-
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
1077
|
+
return await this.runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
895
1078
|
} finally {
|
|
896
1079
|
this.releaseSignal(signal)
|
|
897
1080
|
}
|
|
898
1081
|
}
|
|
899
1082
|
|
|
900
|
-
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
1083
|
+
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
901
1084
|
const signal = this.makeSignal(external)
|
|
902
1085
|
try {
|
|
903
|
-
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
1086
|
+
return await this.runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
904
1087
|
} finally {
|
|
905
1088
|
this.releaseSignal(signal)
|
|
906
1089
|
}
|
|
907
1090
|
}
|
|
908
1091
|
|
|
909
|
-
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1092
|
+
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
910
1093
|
const signal = this.makeSignal(external)
|
|
911
1094
|
try {
|
|
912
|
-
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history)
|
|
1095
|
+
yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
913
1096
|
} finally {
|
|
914
1097
|
this.releaseSignal(signal)
|
|
915
1098
|
}
|
|
916
1099
|
}
|
|
917
1100
|
|
|
918
|
-
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1101
|
+
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
919
1102
|
const signal = this.makeSignal(external)
|
|
920
1103
|
try {
|
|
921
|
-
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history)
|
|
1104
|
+
yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt)
|
|
922
1105
|
} finally {
|
|
923
1106
|
this.releaseSignal(signal)
|
|
924
1107
|
}
|
|
925
1108
|
}
|
|
926
1109
|
|
|
927
1110
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
928
|
-
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
1111
|
+
private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
929
1112
|
const key = resolveKey(this.opts)
|
|
930
1113
|
const runStart = Date.now()
|
|
931
1114
|
let messages: any[] = history && history.length ? [...history] : []
|
|
@@ -934,12 +1117,14 @@ export class Client {
|
|
|
934
1117
|
if (system) messages.push({ role: "system", content: system })
|
|
935
1118
|
}
|
|
936
1119
|
messages.push({ role: "user", content: prompt })
|
|
937
|
-
let tools =
|
|
1120
|
+
let tools = toOpenAI(this.requestTools(toolkit))
|
|
938
1121
|
const toolCalls: ToolCallRecord[] = []
|
|
939
1122
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
940
1123
|
let turns = 0
|
|
941
1124
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
942
1125
|
let lastModel = this.opts.model
|
|
1126
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1127
|
+
const done = new Map<string, ToolResult>()
|
|
943
1128
|
|
|
944
1129
|
try {
|
|
945
1130
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -964,19 +1149,21 @@ export class Client {
|
|
|
964
1149
|
const calls = msg.tool_calls ?? []
|
|
965
1150
|
if (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
966
1151
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
967
|
-
|
|
1152
|
+
done.clear()
|
|
1153
|
+
const settled = await untilAborted(signal, Promise.all(
|
|
968
1154
|
calls.map(async (call: any) => {
|
|
969
|
-
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
1155
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal)
|
|
970
1156
|
let halted: Request | undefined
|
|
971
1157
|
const req = pendingOf(result)
|
|
972
1158
|
if (req) {
|
|
973
|
-
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
|
|
1159
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn, signal)
|
|
974
1160
|
result = r.result
|
|
975
1161
|
halted = r.halted
|
|
976
1162
|
}
|
|
1163
|
+
if (!halted && call.id) done.set(call.id, result)
|
|
977
1164
|
return { call, args, result, halted }
|
|
978
1165
|
}),
|
|
979
|
-
)
|
|
1166
|
+
))
|
|
980
1167
|
// Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
|
|
981
1168
|
// and the later suspensions' placeholder results never enter the transcript (they re-suspend
|
|
982
1169
|
// on resume). Mirrors the streaming path.
|
|
@@ -989,6 +1176,7 @@ export class Client {
|
|
|
989
1176
|
return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true)
|
|
990
1177
|
} catch (e) {
|
|
991
1178
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1179
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
992
1180
|
throw e
|
|
993
1181
|
}
|
|
994
1182
|
}
|
|
@@ -1012,14 +1200,16 @@ export class Client {
|
|
|
1012
1200
|
* no `waitFor` is configured (the run should stop and surface the request).
|
|
1013
1201
|
*/
|
|
1014
1202
|
private async resolvePending(
|
|
1015
|
-
toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
1203
|
+
toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number, signal?: AbortSignal,
|
|
1016
1204
|
): Promise<{ result: ToolResult; halted?: Request }> {
|
|
1205
|
+
const inner = this.unwrapCall(name, args)
|
|
1206
|
+
if (inner) ({ name, args } = inner)
|
|
1017
1207
|
if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
|
|
1018
|
-
const answer = await this.opts.waitFor(request)
|
|
1208
|
+
const answer = await this.opts.waitFor(request, { signal: signal ?? new AbortController().signal })
|
|
1019
1209
|
if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
|
|
1020
1210
|
const t0 = Date.now()
|
|
1021
1211
|
let result: ToolResult = toolkit
|
|
1022
|
-
? await toolkit.execute(name, args, { answer })
|
|
1212
|
+
? await toolkit.execute(name, args, { answer, ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
|
|
1023
1213
|
: { output: `unknown tool: ${name}`, isError: true }
|
|
1024
1214
|
if (this.opts.hooks?.afterTool) {
|
|
1025
1215
|
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
@@ -1031,19 +1221,21 @@ export class Client {
|
|
|
1031
1221
|
}
|
|
1032
1222
|
|
|
1033
1223
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
1034
|
-
private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
|
|
1224
|
+
private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): Promise<RunResult> {
|
|
1035
1225
|
const key = resolveKey(this.opts)
|
|
1036
1226
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
1037
1227
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
1038
1228
|
const system = this.system(toolkit)
|
|
1039
1229
|
const runStart = Date.now()
|
|
1040
1230
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
1041
|
-
let tools =
|
|
1231
|
+
let tools = toAnthropic(this.requestTools(toolkit))
|
|
1042
1232
|
const toolCalls: ToolCallRecord[] = []
|
|
1043
1233
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1044
1234
|
let turns = 0
|
|
1045
1235
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1046
1236
|
let lastModel = this.opts.model
|
|
1237
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1238
|
+
const done = new Map<string, ToolResult>()
|
|
1047
1239
|
|
|
1048
1240
|
try {
|
|
1049
1241
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1075,19 +1267,21 @@ export class Client {
|
|
|
1075
1267
|
return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage)
|
|
1076
1268
|
}
|
|
1077
1269
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
1078
|
-
|
|
1270
|
+
done.clear()
|
|
1271
|
+
const settled = await untilAborted(signal, Promise.all(
|
|
1079
1272
|
uses.map(async (use: any) => {
|
|
1080
|
-
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
1273
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal)
|
|
1081
1274
|
let halted: Request | undefined
|
|
1082
1275
|
const req = pendingOf(result)
|
|
1083
1276
|
if (req) {
|
|
1084
|
-
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
|
|
1277
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn, signal)
|
|
1085
1278
|
result = r.result
|
|
1086
1279
|
halted = r.halted
|
|
1087
1280
|
}
|
|
1281
|
+
if (!halted && use.id) done.set(use.id, result)
|
|
1088
1282
|
return { use, args, result, halted }
|
|
1089
1283
|
}),
|
|
1090
|
-
)
|
|
1284
|
+
))
|
|
1091
1285
|
// Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
|
|
1092
1286
|
// building the content, and surface it — deterministic (G3), later suspensions re-suspend on
|
|
1093
1287
|
// resume. Mirrors the OpenAI path and the streaming loops.
|
|
@@ -1104,12 +1298,13 @@ export class Client {
|
|
|
1104
1298
|
return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true)
|
|
1105
1299
|
} catch (e) {
|
|
1106
1300
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1301
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1107
1302
|
throw e
|
|
1108
1303
|
}
|
|
1109
1304
|
}
|
|
1110
1305
|
|
|
1111
1306
|
// ---- Streaming: OpenAI-style ----
|
|
1112
|
-
private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1307
|
+
private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1113
1308
|
const key = resolveKey(this.opts)
|
|
1114
1309
|
const runStart = Date.now()
|
|
1115
1310
|
let messages: any[] = history && history.length ? [...history] : []
|
|
@@ -1118,12 +1313,14 @@ export class Client {
|
|
|
1118
1313
|
if (system) messages.push({ role: "system", content: system })
|
|
1119
1314
|
}
|
|
1120
1315
|
messages.push({ role: "user", content: prompt })
|
|
1121
|
-
let tools =
|
|
1316
|
+
let tools = toOpenAI(this.requestTools(toolkit))
|
|
1122
1317
|
const toolCalls: ToolCallRecord[] = []
|
|
1123
1318
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1124
1319
|
let turns = 0
|
|
1125
1320
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1126
1321
|
let lastModel = this.opts.model
|
|
1322
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1323
|
+
const done = new Map<string, ToolResult>()
|
|
1127
1324
|
|
|
1128
1325
|
try {
|
|
1129
1326
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1179,15 +1376,20 @@ export class Client {
|
|
|
1179
1376
|
return
|
|
1180
1377
|
}
|
|
1181
1378
|
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
|
-
|
|
1379
|
+
for (const c of calls) yield this.callEvent(c.id, c.name, safeJson(c.args))
|
|
1380
|
+
done.clear()
|
|
1381
|
+
const settled = await untilAborted(signal, Promise.all(calls.map(async (c) => {
|
|
1382
|
+
const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal)
|
|
1383
|
+
if (!pendingOf(r.result) && c.id) done.set(c.id, r.result)
|
|
1384
|
+
return r
|
|
1385
|
+
})))
|
|
1184
1386
|
for (let i = 0; i < calls.length; i++) {
|
|
1185
1387
|
const c = calls[i]
|
|
1186
1388
|
let { args, result } = settled[i]
|
|
1187
1389
|
const req = pendingOf(result)
|
|
1188
1390
|
if (req) {
|
|
1189
1391
|
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)
|
|
1392
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal)
|
|
1191
1393
|
result = r.result
|
|
1192
1394
|
if (r.halted) {
|
|
1193
1395
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
@@ -1198,30 +1400,33 @@ export class Client {
|
|
|
1198
1400
|
}
|
|
1199
1401
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
1200
1402
|
messages.push(toolMessage(c.id, c.name, result))
|
|
1201
|
-
yield
|
|
1403
|
+
yield this.resultEvent(c.id, c.name, safeJson(c.args), result)
|
|
1202
1404
|
}
|
|
1203
1405
|
}
|
|
1204
1406
|
yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) }
|
|
1205
1407
|
} catch (e) {
|
|
1206
1408
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1409
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1207
1410
|
throw e
|
|
1208
1411
|
}
|
|
1209
1412
|
}
|
|
1210
1413
|
|
|
1211
1414
|
// ---- Streaming: Anthropic-style ----
|
|
1212
|
-
private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1415
|
+
private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[], onInterrupt?: OnInterrupt): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1213
1416
|
const key = resolveKey(this.opts)
|
|
1214
1417
|
const runStart = Date.now()
|
|
1215
1418
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
1216
1419
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
1217
1420
|
const system = this.system(toolkit)
|
|
1218
1421
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
1219
|
-
let tools =
|
|
1422
|
+
let tools = toAnthropic(this.requestTools(toolkit))
|
|
1220
1423
|
const toolCalls: ToolCallRecord[] = []
|
|
1221
1424
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
1222
1425
|
let turns = 0
|
|
1223
1426
|
// The model of the last call this run made (a beforeLLM override when one applied) — §8.
|
|
1224
1427
|
let lastModel = this.opts.model
|
|
1428
|
+
// Results finished in the current tool phase, by call id — what an interrupted save keeps.
|
|
1429
|
+
const done = new Map<string, ToolResult>()
|
|
1225
1430
|
|
|
1226
1431
|
try {
|
|
1227
1432
|
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
@@ -1273,8 +1478,13 @@ export class Client {
|
|
|
1273
1478
|
yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) }
|
|
1274
1479
|
return
|
|
1275
1480
|
}
|
|
1276
|
-
for (const u of uses as any[]) yield
|
|
1277
|
-
|
|
1481
|
+
for (const u of uses as any[]) yield this.callEvent(u.id, u.name, u.input)
|
|
1482
|
+
done.clear()
|
|
1483
|
+
const settled = await untilAborted(signal, Promise.all((uses as any[]).map(async (u) => {
|
|
1484
|
+
const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal)
|
|
1485
|
+
if (!pendingOf(r.result) && u.id) done.set(u.id, r.result)
|
|
1486
|
+
return r
|
|
1487
|
+
})))
|
|
1278
1488
|
const results: any[] = []
|
|
1279
1489
|
for (let i = 0; i < uses.length; i++) {
|
|
1280
1490
|
const u = uses[i] as any
|
|
@@ -1282,7 +1492,7 @@ export class Client {
|
|
|
1282
1492
|
const req = pendingOf(result)
|
|
1283
1493
|
if (req) {
|
|
1284
1494
|
yield { type: "pending", request: req }
|
|
1285
|
-
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
|
|
1495
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal)
|
|
1286
1496
|
result = r.result
|
|
1287
1497
|
if (r.halted) {
|
|
1288
1498
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
@@ -1294,13 +1504,14 @@ export class Client {
|
|
|
1294
1504
|
}
|
|
1295
1505
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
1296
1506
|
results.push(toolResultBlock(u.id, result))
|
|
1297
|
-
yield
|
|
1507
|
+
yield this.resultEvent(u.id, u.name, u.input, result)
|
|
1298
1508
|
}
|
|
1299
1509
|
messages.push({ role: "user", content: results })
|
|
1300
1510
|
}
|
|
1301
1511
|
yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) }
|
|
1302
1512
|
} catch (e) {
|
|
1303
1513
|
this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
|
|
1514
|
+
onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done))
|
|
1304
1515
|
throw e
|
|
1305
1516
|
}
|
|
1306
1517
|
}
|
|
@@ -1531,6 +1742,52 @@ function toolResultBlock(id: string | undefined, result: ToolResult): any {
|
|
|
1531
1742
|
return b
|
|
1532
1743
|
}
|
|
1533
1744
|
|
|
1745
|
+
/** Receives the repaired transcript of a run that ended in an error or cancellation. */
|
|
1746
|
+
type OnInterrupt = (messages: any[]) => void
|
|
1747
|
+
|
|
1748
|
+
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 }
|
|
1749
|
+
|
|
1750
|
+
/** Settle with `p`, or reject with the abort reason as soon as `signal` aborts. */
|
|
1751
|
+
function untilAborted<T>(signal: AbortSignal, p: Promise<T>): Promise<T> {
|
|
1752
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"))
|
|
1753
|
+
return new Promise<T>((resolve, reject) => {
|
|
1754
|
+
const onAbort = () => reject(signal.reason ?? new Error("aborted"))
|
|
1755
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
1756
|
+
p.then(
|
|
1757
|
+
(v) => { signal.removeEventListener("abort", onAbort); resolve(v) },
|
|
1758
|
+
(e) => { signal.removeEventListener("abort", onAbort); reject(e) },
|
|
1759
|
+
)
|
|
1760
|
+
})
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* §8 "Interrupted turns are saved": a copy of `messages` in which every tool call of the LAST
|
|
1765
|
+
* assistant message has a result — the finished one from `done`, else `interrupted` (isError) —
|
|
1766
|
+
* in the dialect's own shape, so the saved transcript replays on either API.
|
|
1767
|
+
*/
|
|
1768
|
+
function interruptedTranscript(messages: any[], anthropic: boolean, done: Map<string, ToolResult>): any[] {
|
|
1769
|
+
const out = [...messages]
|
|
1770
|
+
let ai = -1
|
|
1771
|
+
for (let i = out.length - 1; i >= 0; i--) if (out[i]?.role === "assistant") { ai = i; break }
|
|
1772
|
+
if (ai < 0) return out
|
|
1773
|
+
const resultFor = (id: string) => done.get(id) ?? INTERRUPTED
|
|
1774
|
+
if (!anthropic) {
|
|
1775
|
+
const calls: any[] = out[ai].tool_calls ?? []
|
|
1776
|
+
const have = new Set(out.slice(ai + 1).filter((m) => m?.role === "tool").map((m) => m.tool_call_id))
|
|
1777
|
+
for (const c of calls) if (!have.has(c.id)) out.push(toolMessage(c.id, c.function?.name ?? "", resultFor(c.id)))
|
|
1778
|
+
return out
|
|
1779
|
+
}
|
|
1780
|
+
const uses = Array.isArray(out[ai].content) ? out[ai].content.filter((b: any) => b?.type === "tool_use") : []
|
|
1781
|
+
if (!uses.length) return out
|
|
1782
|
+
const next = out[ai + 1]
|
|
1783
|
+
const blocks: any[] = next?.role === "user" && Array.isArray(next.content) ? [...next.content] : []
|
|
1784
|
+
const have = new Set(blocks.filter((b) => b?.type === "tool_result").map((b) => b.tool_use_id))
|
|
1785
|
+
for (const u of uses) if (!have.has(u.id)) blocks.push(toolResultBlock(u.id, resultFor(u.id)))
|
|
1786
|
+
if (next?.role === "user" && Array.isArray(next.content)) out[ai + 1] = { ...next, content: blocks }
|
|
1787
|
+
else out.splice(ai + 1, 0, { role: "user", content: blocks })
|
|
1788
|
+
return out
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1534
1791
|
function lastText(messages: any[]): string {
|
|
1535
1792
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1536
1793
|
if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content
|