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/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
- waitFor?: (request: Request) => Promise<Answer>
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
- function resolveKey(opts: ClientOptions): string {
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 env = process.env
342
- const key =
343
- env.OPENROUTER_API_KEY ||
344
- (opts.style === "anthropic" ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY) ||
345
- env.OPENAI_API_KEY ||
346
- env.ANTHROPIC_API_KEY
347
- if (!key) throw new Error("No API key: set apiKey or OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY")
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
- const history = (await this.store.get(ctx.id)) ?? []
740
- const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
741
- await this.store.save(ctx.id, result.messages)
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
- for await (const ev of gen) {
762
- if (ev.type === "done" && ctx?.id) await this.store.save(ctx.id, ev.result.messages)
763
- yield ev
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 source = toolkit?.get(name)?.source ?? "custom"
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 = toolkit?.toOpenAI() ?? []
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
- const settled = await Promise.all(
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 = toolkit?.toAnthropic() ?? []
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
- const settled = await Promise.all(
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 = toolkit?.toOpenAI() ?? []
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 { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
1183
- const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
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 { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
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 = toolkit?.toAnthropic() ?? []
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 { type: "tool_call", id: u.id, name: u.name, args: u.input }
1277
- const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
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 { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
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