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/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,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 = toolkit?.toOpenAI() ?? []
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
- const settled = await Promise.all(
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({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
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 = toolkit?.toAnthropic() ?? []
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
- const settled = await Promise.all(
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({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
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 = toolkit?.toOpenAI() ?? []
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 { 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)))
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({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
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({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1408
+ toolCalls.push(this.toolCallRecord(c.name, args, result))
1200
1409
  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 }
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 = toolkit?.toAnthropic() ?? []
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 { 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)))
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({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
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({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1512
+ toolCalls.push(this.toolCallRecord(u.name, args, result))
1296
1513
  results.push(toolResultBlock(u.id, result))
1297
- yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
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