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/client.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { pendingOf } from "./types.js";
2
+ import { toOpenAI, toAnthropic } from "./adapters.js";
3
+ import { applyPromptCache } from "./promptcache.js";
4
+ import { buildToolPlan, toolSearch, unwrapCallTool } from "./tooltiers.js";
2
5
  import { isRetryableStatus, retryAfterMs } from "./retry.js";
3
6
  import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
4
7
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
@@ -74,13 +77,28 @@ export async function llmHttpError(res) {
74
77
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
75
78
  export class InMemoryConversationStore {
76
79
  map = new Map();
80
+ updated = new Map();
77
81
  async get(id) {
78
82
  const m = this.map.get(id);
79
83
  return m ? [...m] : undefined;
80
84
  }
81
85
  async save(id, messages) {
82
86
  this.map.set(id, [...messages]);
87
+ this.updated.set(id, new Date());
83
88
  }
89
+ /** Every stored conversation, newest first (ties by id ascending). */
90
+ async list() {
91
+ return sortEntries([...this.updated].map(([id, updatedAt]) => ({ id, updatedAt })));
92
+ }
93
+ /** Remove a conversation; absent ids are not an error. */
94
+ async delete(id) {
95
+ this.map.delete(id);
96
+ this.updated.delete(id);
97
+ }
98
+ }
99
+ /** @internal Newest first, ties by id ascending (SPEC.md "Conversation memory"). */
100
+ export function sortEntries(entries) {
101
+ return entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
84
102
  }
85
103
  function delay(ms, signal) {
86
104
  return new Promise((resolve, reject) => {
@@ -91,12 +109,19 @@ function delay(ms, signal) {
91
109
  function addUsage(acc, raw, style) {
92
110
  if (!raw)
93
111
  return;
112
+ const cache = (key, n) => {
113
+ if (typeof n === "number" && n > 0)
114
+ acc[key] = (acc[key] ?? 0) + n;
115
+ };
94
116
  if (style === "anthropic") {
95
117
  acc.promptTokens += raw.input_tokens ?? 0;
96
118
  acc.completionTokens += raw.output_tokens ?? 0;
97
119
  acc.totalTokens += (raw.input_tokens ?? 0) + (raw.output_tokens ?? 0);
120
+ cache("cacheReadTokens", raw.cache_read_input_tokens);
121
+ cache("cacheWriteTokens", raw.cache_creation_input_tokens);
98
122
  }
99
123
  else {
124
+ cache("cacheReadTokens", raw.prompt_tokens_details?.cached_tokens);
100
125
  acc.promptTokens += raw.prompt_tokens ?? 0;
101
126
  acc.completionTokens += raw.completion_tokens ?? 0;
102
127
  acc.totalTokens += raw.total_tokens ?? (raw.prompt_tokens ?? 0) + (raw.completion_tokens ?? 0);
@@ -110,18 +135,45 @@ function perCall(raw, style) {
110
135
  return { prompt: raw.input_tokens ?? 0, completion: raw.output_tokens ?? 0 };
111
136
  return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 };
112
137
  }
113
- function resolveKey(opts) {
138
+ /**
139
+ * Pick the API key (SPEC 4C, security). An explicit `apiKey` is used as-is; else `apiKeyEnv`;
140
+ * else ONLY the env var that belongs to the `baseUrl` host. There is no cross-provider fallback:
141
+ * any other host needs `apiKey` or `apiKeyEnv`. The value is never logged.
142
+ */
143
+ export function resolveKey(opts) {
114
144
  if (opts.apiKey)
115
145
  return opts.apiKey;
116
- const env = process.env;
117
- const key = env.OPENROUTER_API_KEY ||
118
- (opts.style === "anthropic" ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY) ||
119
- env.OPENAI_API_KEY ||
120
- env.ANTHROPIC_API_KEY;
146
+ const envName = opts.apiKeyEnv || keyEnvForBaseUrl(opts.baseUrl);
147
+ if (!envName) {
148
+ throw new Error(`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)})`);
149
+ }
150
+ const key = process.env[envName];
121
151
  if (!key)
122
- throw new Error("No API key: set apiKey or OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY");
152
+ throw new Error(`No API key: set apiKey or ${envName}`);
123
153
  return key;
124
154
  }
155
+ /** The env var whose key may be sent to `baseUrl`'s host, or "" when none may. */
156
+ export function keyEnvForBaseUrl(baseUrl) {
157
+ const h = hostOf(baseUrl);
158
+ if (h === "api.openai.com")
159
+ return "OPENAI_API_KEY";
160
+ if (h === "api.anthropic.com")
161
+ return "ANTHROPIC_API_KEY";
162
+ if (h === "openrouter.ai" || h.endsWith(".openrouter.ai"))
163
+ return "OPENROUTER_API_KEY";
164
+ return "";
165
+ }
166
+ function hostOf(baseUrl) {
167
+ try {
168
+ const u = new URL(baseUrl);
169
+ if (u.protocol !== "https:")
170
+ return ""; // a provider key is never sent over plain http
171
+ return u.hostname.toLowerCase();
172
+ }
173
+ catch {
174
+ return "";
175
+ }
176
+ }
125
177
  // ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
126
178
  /** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
127
179
  const DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
@@ -217,6 +269,8 @@ export class Client {
217
269
  store;
218
270
  /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
219
271
  registry = new MetricsRegistry();
272
+ /** The frozen ToolTiers plan per toolkit (SPEC 8 Tool tiers). */
273
+ tierPlans = new WeakMap();
220
274
  constructor(opts) {
221
275
  this.opts = opts;
222
276
  this.store = opts.store ?? new InMemoryConversationStore();
@@ -235,6 +289,8 @@ export class Client {
235
289
  * every LLM body-build site so the ordering contract holds identically on all four paths.
236
290
  */
237
291
  finalizeBody(body) {
292
+ if (this.opts.promptCache)
293
+ applyPromptCache(body, this.opts.style, this.opts.promptCache);
238
294
  const rp = this.opts.requestParams;
239
295
  if (rp) {
240
296
  for (const [k, v] of Object.entries(rp)) {
@@ -489,9 +545,24 @@ export class Client {
489
545
  }
490
546
  if (!ctx?.id)
491
547
  return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal });
492
- const history = (await this.store.get(ctx.id)) ?? [];
493
- const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history });
494
- await this.store.save(ctx.id, result.messages);
548
+ checkPromptParts(prompt, this.wireOpts());
549
+ const id = ctx.id;
550
+ const history = (await this.store.get(id)) ?? [];
551
+ // §8: an interrupted turn is saved too — the loop hands back its transcript, repaired.
552
+ let partial;
553
+ const onInterrupt = (m) => { partial = m; };
554
+ let result;
555
+ try {
556
+ result = this.opts.style === "anthropic"
557
+ ? await this.runAnthropic(prompt, ctx.toolkit, ctx.signal, history, onInterrupt)
558
+ : await this.runOpenAI(prompt, ctx.toolkit, ctx.signal, history, onInterrupt);
559
+ }
560
+ catch (e) {
561
+ if (partial)
562
+ await this.store.save(id, partial);
563
+ throw e;
564
+ }
565
+ await this.store.save(id, result.messages);
495
566
  return result;
496
567
  }
497
568
  /** A stateful multi-turn conversation that retains history across sends. */
@@ -506,13 +577,23 @@ export class Client {
506
577
  async *stream(prompt, ctx) {
507
578
  checkPromptParts(prompt, this.wireOpts());
508
579
  const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
580
+ // §8: with an id, an interrupted stream saves its repaired transcript before the error surfaces.
581
+ let partial;
582
+ const onInterrupt = ctx?.id ? (m) => { partial = m; } : undefined;
509
583
  const gen = this.opts.style === "anthropic"
510
- ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
511
- : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history);
512
- for await (const ev of gen) {
513
- if (ev.type === "done" && ctx?.id)
514
- await this.store.save(ctx.id, ev.result.messages);
515
- yield ev;
584
+ ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt)
585
+ : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history, onInterrupt);
586
+ try {
587
+ for await (const ev of gen) {
588
+ if (ev.type === "done" && ctx?.id)
589
+ await this.store.save(ctx.id, ev.result.messages);
590
+ yield ev;
591
+ }
592
+ }
593
+ catch (e) {
594
+ if (partial && ctx?.id)
595
+ await this.store.save(ctx.id, partial);
596
+ throw e;
516
597
  }
517
598
  }
518
599
  /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
@@ -587,13 +668,32 @@ export class Client {
587
668
  throw lastErr;
588
669
  }
589
670
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
590
- async runTool(toolkit, name, args, id, turn) {
671
+ async runTool(toolkit, name, args, id, turn, signal) {
672
+ if (this.opts.toolTiers && name === "tool_search") {
673
+ // Engine-internal and read-only: no hooks (SPEC 8 Tool tiers).
674
+ const t0 = Date.now();
675
+ const result = toolSearch(toolkit?.tools() ?? [], (n) => toolkit?.get(n), args ?? {});
676
+ this.emitTool(name, "builtin", result, t0);
677
+ return { args, result };
678
+ }
679
+ // call_tool is unwrapped HERE, so hooks, hints and permission gates see the real tool. The
680
+ // returned args keep the wrapper shape (with any beforeTool rewrite inside), so a 10
681
+ // re-run unwraps the same way.
682
+ const inner = this.unwrapCall(name, args);
683
+ if (inner) {
684
+ const r = await this.runToolDirect(toolkit, inner.name, inner.args, id, turn, signal);
685
+ return { args: { name: inner.name, arguments: r.args }, result: r.result };
686
+ }
687
+ return this.runToolDirect(toolkit, name, args, id, turn, signal);
688
+ }
689
+ async runToolDirect(toolkit, name, args, id, turn, signal) {
591
690
  const h = this.opts.hooks;
592
- const source = toolkit?.get(name)?.source ?? "custom";
691
+ const tool = toolkit?.get(name);
692
+ const source = tool?.source ?? "custom";
593
693
  const t0 = Date.now();
594
694
  let a = args;
595
695
  if (h?.beforeTool) {
596
- const ov = await h.beforeTool({ name, args: a, id, turn });
696
+ const ov = await h.beforeTool({ name, args: a, id, turn, ...(tool?.hints ? { hints: tool.hints } : {}) });
597
697
  if (ov?.result) {
598
698
  // short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
599
699
  // call for metrics, but a suspension is classified pending, never an error.
@@ -605,7 +705,7 @@ export class Client {
605
705
  }
606
706
  // No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
607
707
  let result = toolkit
608
- ? await toolkit.execute(name, a)
708
+ ? await toolkit.execute(name, a, { ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
609
709
  : { output: `unknown tool: ${name}`, isError: true };
610
710
  // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
611
711
  // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
@@ -618,6 +718,60 @@ export class Client {
618
718
  this.emitTool(name, source, result, t0);
619
719
  return { args: a, result };
620
720
  }
721
+ /** ToolTiers `call_tool {name, arguments}` -> the real call; undefined when tiers are off. */
722
+ unwrapCall(name, args) {
723
+ return this.opts.toolTiers ? unwrapCallTool(name, args) : undefined;
724
+ }
725
+ /** The stream's tool_call event, naming the real tool for a `call_tool` call. */
726
+ callEvent(id, name, args) {
727
+ const inner = this.unwrapCall(name, args);
728
+ return inner ? { type: "tool_call", id, name: inner.name, args: inner.args, via: "call_tool" } : { type: "tool_call", id, name, args };
729
+ }
730
+ resultEvent(id, name, args, result) {
731
+ const inner = this.unwrapCall(name, args);
732
+ return {
733
+ type: "tool_result", id, name: inner ? inner.name : name, output: result.output, isError: result.isError,
734
+ ...(result.metadata ? { metadata: result.metadata } : {}),
735
+ ...(inner ? { via: "call_tool" } : {}),
736
+ };
737
+ }
738
+ plan(toolkit) {
739
+ if (!this.opts.toolTiers || !toolkit)
740
+ return undefined;
741
+ let p = this.tierPlans.get(toolkit);
742
+ if (!p) {
743
+ p = buildToolPlan(this.opts.toolTiers, toolkit.tools());
744
+ this.tierPlans.set(toolkit, p);
745
+ }
746
+ return p;
747
+ }
748
+ /** The tools array for a request: core tools (sorted) then tool_search and call_tool. Without
749
+ * tiers it is every tool, as before. */
750
+ requestTools(toolkit) {
751
+ if (!toolkit)
752
+ return [];
753
+ const p = this.plan(toolkit);
754
+ if (!p)
755
+ return toolkit.tools();
756
+ const out = [];
757
+ for (const n of p.core) {
758
+ const t = toolkit.get(n);
759
+ if (t)
760
+ out.push(t);
761
+ }
762
+ return [...out, p.search, p.call];
763
+ }
764
+ /** The frozen tier plan for `toolkit` (built on first use): tool name -> tier. Undefined when
765
+ * `toolTiers` is not set. For a host UI or log. */
766
+ toolTierPlan(toolkit) {
767
+ const p = this.plan(toolkit);
768
+ return p ? Object.fromEntries(p.tier) : undefined;
769
+ }
770
+ /** Forget `toolkit`'s plan so the next request rebuilds it - call at a new session start.
771
+ * Mid-session it breaks the cached prefix. */
772
+ resetToolTiers(toolkit) {
773
+ this.tierPlans.delete(toolkit);
774
+ }
621
775
  /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
622
776
  emitTool(name, source, result, t0) {
623
777
  const req = pendingOf(result);
@@ -642,44 +796,44 @@ export class Client {
642
796
  }
643
797
  // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
644
798
  // run leaves: return, throw, or a consumer abandoning the stream half-way.
645
- async runOpenAI(prompt, toolkit, external, history) {
799
+ async runOpenAI(prompt, toolkit, external, history, onInterrupt) {
646
800
  const signal = this.makeSignal(external);
647
801
  try {
648
- return await this.runOpenAIWithSignal(signal, prompt, toolkit, history);
802
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt);
649
803
  }
650
804
  finally {
651
805
  this.releaseSignal(signal);
652
806
  }
653
807
  }
654
- async runAnthropic(prompt, toolkit, external, history) {
808
+ async runAnthropic(prompt, toolkit, external, history, onInterrupt) {
655
809
  const signal = this.makeSignal(external);
656
810
  try {
657
- return await this.runAnthropicWithSignal(signal, prompt, toolkit, history);
811
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt);
658
812
  }
659
813
  finally {
660
814
  this.releaseSignal(signal);
661
815
  }
662
816
  }
663
- async *streamOpenAI(prompt, toolkit, external, history) {
817
+ async *streamOpenAI(prompt, toolkit, external, history, onInterrupt) {
664
818
  const signal = this.makeSignal(external);
665
819
  try {
666
- yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history);
820
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt);
667
821
  }
668
822
  finally {
669
823
  this.releaseSignal(signal);
670
824
  }
671
825
  }
672
- async *streamAnthropic(prompt, toolkit, external, history) {
826
+ async *streamAnthropic(prompt, toolkit, external, history, onInterrupt) {
673
827
  const signal = this.makeSignal(external);
674
828
  try {
675
- yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history);
829
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt);
676
830
  }
677
831
  finally {
678
832
  this.releaseSignal(signal);
679
833
  }
680
834
  }
681
835
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
682
- async runOpenAIWithSignal(signal, prompt, toolkit, history) {
836
+ async runOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
683
837
  const key = resolveKey(this.opts);
684
838
  const runStart = Date.now();
685
839
  let messages = history && history.length ? [...history] : [];
@@ -689,12 +843,14 @@ export class Client {
689
843
  messages.push({ role: "system", content: system });
690
844
  }
691
845
  messages.push({ role: "user", content: prompt });
692
- let tools = toolkit?.toOpenAI() ?? [];
846
+ let tools = toOpenAI(this.requestTools(toolkit));
693
847
  const toolCalls = [];
694
848
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
695
849
  let turns = 0;
696
850
  // The model of the last call this run made (a beforeLLM override when one applied) — §8.
697
851
  let lastModel = this.opts.model;
852
+ // Results finished in the current tool phase, by call id — what an interrupted save keeps.
853
+ const done = new Map();
698
854
  try {
699
855
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
700
856
  turns++;
@@ -723,17 +879,20 @@ export class Client {
723
879
  if (calls.length === 0)
724
880
  return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage);
725
881
  // execute all tool calls in this turn concurrently (true parallel tool calling)
726
- const settled = await Promise.all(calls.map(async (call) => {
727
- let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
882
+ done.clear();
883
+ const settled = await untilAborted(signal, Promise.all(calls.map(async (call) => {
884
+ let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn, signal);
728
885
  let halted;
729
886
  const req = pendingOf(result);
730
887
  if (req) {
731
- const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
888
+ const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn, signal);
732
889
  result = r.result;
733
890
  halted = r.halted;
734
891
  }
892
+ if (!halted && call.id)
893
+ done.set(call.id, result);
735
894
  return { call, args, result, halted };
736
- }));
895
+ })));
737
896
  // Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
738
897
  // and the later suspensions' placeholder results never enter the transcript (they re-suspend
739
898
  // on resume). Mirrors the streaming path.
@@ -748,6 +907,7 @@ export class Client {
748
907
  }
749
908
  catch (e) {
750
909
  this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
910
+ onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
751
911
  throw e;
752
912
  }
753
913
  }
@@ -768,15 +928,18 @@ export class Client {
768
928
  * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
769
929
  * no `waitFor` is configured (the run should stop and surface the request).
770
930
  */
771
- async resolvePending(toolkit, name, args, request, id, turn) {
931
+ async resolvePending(toolkit, name, args, request, id, turn, signal) {
932
+ const inner = this.unwrapCall(name, args);
933
+ if (inner)
934
+ ({ name, args } = inner);
772
935
  if (!this.opts.waitFor)
773
936
  return { result: { output: request.prompt, isError: true }, halted: request };
774
- const answer = await this.opts.waitFor(request);
937
+ const answer = await this.opts.waitFor(request, { signal: signal ?? new AbortController().signal });
775
938
  if (!answer?.ok)
776
939
  return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
777
940
  const t0 = Date.now();
778
941
  let result = toolkit
779
- ? await toolkit.execute(name, args, { answer })
942
+ ? await toolkit.execute(name, args, { answer, ...(signal ? { signal } : {}), ...(id ? { callId: id } : {}) })
780
943
  : { output: `unknown tool: ${name}`, isError: true };
781
944
  if (this.opts.hooks?.afterTool) {
782
945
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
@@ -789,19 +952,21 @@ export class Client {
789
952
  return { result };
790
953
  }
791
954
  // ---- Anthropic-style: POST {baseUrl}/messages ----
792
- async runAnthropicWithSignal(signal, prompt, toolkit, history) {
955
+ async runAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt) {
793
956
  const key = resolveKey(this.opts);
794
957
  const base = this.opts.baseUrl.replace(/\/$/, "");
795
958
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
796
959
  const system = this.system(toolkit);
797
960
  const runStart = Date.now();
798
961
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
799
- let tools = toolkit?.toAnthropic() ?? [];
962
+ let tools = toAnthropic(this.requestTools(toolkit));
800
963
  const toolCalls = [];
801
964
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
802
965
  let turns = 0;
803
966
  // The model of the last call this run made (a beforeLLM override when one applied) — §8.
804
967
  let lastModel = this.opts.model;
968
+ // Results finished in the current tool phase, by call id — what an interrupted save keeps.
969
+ const done = new Map();
805
970
  try {
806
971
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
807
972
  turns++;
@@ -836,17 +1001,20 @@ export class Client {
836
1001
  return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
837
1002
  }
838
1003
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
839
- const settled = await Promise.all(uses.map(async (use) => {
840
- let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
1004
+ done.clear();
1005
+ const settled = await untilAborted(signal, Promise.all(uses.map(async (use) => {
1006
+ let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn, signal);
841
1007
  let halted;
842
1008
  const req = pendingOf(result);
843
1009
  if (req) {
844
- const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
1010
+ const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn, signal);
845
1011
  result = r.result;
846
1012
  halted = r.halted;
847
1013
  }
1014
+ if (!halted && use.id)
1015
+ done.set(use.id, result);
848
1016
  return { use, args, result, halted };
849
- }));
1017
+ })));
850
1018
  // Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
851
1019
  // building the content, and surface it — deterministic (G3), later suspensions re-suspend on
852
1020
  // resume. Mirrors the OpenAI path and the streaming loops.
@@ -868,11 +1036,12 @@ export class Client {
868
1036
  }
869
1037
  catch (e) {
870
1038
  this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
1039
+ onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
871
1040
  throw e;
872
1041
  }
873
1042
  }
874
1043
  // ---- Streaming: OpenAI-style ----
875
- async *streamOpenAIWithSignal(signal, prompt, toolkit, history) {
1044
+ async *streamOpenAIWithSignal(signal, prompt, toolkit, history, onInterrupt) {
876
1045
  const key = resolveKey(this.opts);
877
1046
  const runStart = Date.now();
878
1047
  let messages = history && history.length ? [...history] : [];
@@ -882,12 +1051,14 @@ export class Client {
882
1051
  messages.push({ role: "system", content: system });
883
1052
  }
884
1053
  messages.push({ role: "user", content: prompt });
885
- let tools = toolkit?.toOpenAI() ?? [];
1054
+ let tools = toOpenAI(this.requestTools(toolkit));
886
1055
  const toolCalls = [];
887
1056
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
888
1057
  let turns = 0;
889
1058
  // The model of the last call this run made (a beforeLLM override when one applied) — §8.
890
1059
  let lastModel = this.opts.model;
1060
+ // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1061
+ const done = new Map();
891
1062
  try {
892
1063
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
893
1064
  turns++;
@@ -957,15 +1128,21 @@ export class Client {
957
1128
  }
958
1129
  messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
959
1130
  for (const c of calls)
960
- yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
961
- const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
1131
+ yield this.callEvent(c.id, c.name, safeJson(c.args));
1132
+ done.clear();
1133
+ const settled = await untilAborted(signal, Promise.all(calls.map(async (c) => {
1134
+ const r = await this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn, signal);
1135
+ if (!pendingOf(r.result) && c.id)
1136
+ done.set(c.id, r.result);
1137
+ return r;
1138
+ })));
962
1139
  for (let i = 0; i < calls.length; i++) {
963
1140
  const c = calls[i];
964
1141
  let { args, result } = settled[i];
965
1142
  const req = pendingOf(result);
966
1143
  if (req) {
967
1144
  yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
968
- const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
1145
+ const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn, signal);
969
1146
  result = r.result;
970
1147
  if (r.halted) {
971
1148
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
@@ -976,30 +1153,33 @@ export class Client {
976
1153
  }
977
1154
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
978
1155
  messages.push(toolMessage(c.id, c.name, result));
979
- yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
1156
+ yield this.resultEvent(c.id, c.name, safeJson(c.args), result);
980
1157
  }
981
1158
  }
982
1159
  yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) };
983
1160
  }
984
1161
  catch (e) {
985
1162
  this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
1163
+ onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
986
1164
  throw e;
987
1165
  }
988
1166
  }
989
1167
  // ---- Streaming: Anthropic-style ----
990
- async *streamAnthropicWithSignal(signal, prompt, toolkit, history) {
1168
+ async *streamAnthropicWithSignal(signal, prompt, toolkit, history, onInterrupt) {
991
1169
  const key = resolveKey(this.opts);
992
1170
  const runStart = Date.now();
993
1171
  const base = this.opts.baseUrl.replace(/\/$/, "");
994
1172
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
995
1173
  const system = this.system(toolkit);
996
1174
  let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
997
- let tools = toolkit?.toAnthropic() ?? [];
1175
+ let tools = toAnthropic(this.requestTools(toolkit));
998
1176
  const toolCalls = [];
999
1177
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1000
1178
  let turns = 0;
1001
1179
  // The model of the last call this run made (a beforeLLM override when one applied) — §8.
1002
1180
  let lastModel = this.opts.model;
1181
+ // Results finished in the current tool phase, by call id — what an interrupted save keeps.
1182
+ const done = new Map();
1003
1183
  try {
1004
1184
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1005
1185
  turns++;
@@ -1068,8 +1248,14 @@ export class Client {
1068
1248
  return;
1069
1249
  }
1070
1250
  for (const u of uses)
1071
- yield { type: "tool_call", id: u.id, name: u.name, args: u.input };
1072
- const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
1251
+ yield this.callEvent(u.id, u.name, u.input);
1252
+ done.clear();
1253
+ const settled = await untilAborted(signal, Promise.all(uses.map(async (u) => {
1254
+ const r = await this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn, signal);
1255
+ if (!pendingOf(r.result) && u.id)
1256
+ done.set(u.id, r.result);
1257
+ return r;
1258
+ })));
1073
1259
  const results = [];
1074
1260
  for (let i = 0; i < uses.length; i++) {
1075
1261
  const u = uses[i];
@@ -1077,7 +1263,7 @@ export class Client {
1077
1263
  const req = pendingOf(result);
1078
1264
  if (req) {
1079
1265
  yield { type: "pending", request: req };
1080
- const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
1266
+ const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn, signal);
1081
1267
  result = r.result;
1082
1268
  if (r.halted) {
1083
1269
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
@@ -1089,7 +1275,7 @@ export class Client {
1089
1275
  }
1090
1276
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
1091
1277
  results.push(toolResultBlock(u.id, result));
1092
- yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
1278
+ yield this.resultEvent(u.id, u.name, u.input, result);
1093
1279
  }
1094
1280
  messages.push({ role: "user", content: results });
1095
1281
  }
@@ -1097,6 +1283,7 @@ export class Client {
1097
1283
  }
1098
1284
  catch (e) {
1099
1285
  this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
1286
+ onInterrupt?.(interruptedTranscript(messages, this.opts.style === "anthropic", done));
1100
1287
  throw e;
1101
1288
  }
1102
1289
  }
@@ -1276,6 +1463,56 @@ function toolResultBlock(id, result) {
1276
1463
  b.parts = result.parts;
1277
1464
  return b;
1278
1465
  }
1466
+ const INTERRUPTED = { 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 };
1467
+ /** Settle with `p`, or reject with the abort reason as soon as `signal` aborts. */
1468
+ function untilAborted(signal, p) {
1469
+ if (signal.aborted)
1470
+ return Promise.reject(signal.reason ?? new Error("aborted"));
1471
+ return new Promise((resolve, reject) => {
1472
+ const onAbort = () => reject(signal.reason ?? new Error("aborted"));
1473
+ signal.addEventListener("abort", onAbort, { once: true });
1474
+ p.then((v) => { signal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbort); reject(e); });
1475
+ });
1476
+ }
1477
+ /**
1478
+ * §8 "Interrupted turns are saved": a copy of `messages` in which every tool call of the LAST
1479
+ * assistant message has a result — the finished one from `done`, else `interrupted` (isError) —
1480
+ * in the dialect's own shape, so the saved transcript replays on either API.
1481
+ */
1482
+ function interruptedTranscript(messages, anthropic, done) {
1483
+ const out = [...messages];
1484
+ let ai = -1;
1485
+ for (let i = out.length - 1; i >= 0; i--)
1486
+ if (out[i]?.role === "assistant") {
1487
+ ai = i;
1488
+ break;
1489
+ }
1490
+ if (ai < 0)
1491
+ return out;
1492
+ const resultFor = (id) => done.get(id) ?? INTERRUPTED;
1493
+ if (!anthropic) {
1494
+ const calls = out[ai].tool_calls ?? [];
1495
+ const have = new Set(out.slice(ai + 1).filter((m) => m?.role === "tool").map((m) => m.tool_call_id));
1496
+ for (const c of calls)
1497
+ if (!have.has(c.id))
1498
+ out.push(toolMessage(c.id, c.function?.name ?? "", resultFor(c.id)));
1499
+ return out;
1500
+ }
1501
+ const uses = Array.isArray(out[ai].content) ? out[ai].content.filter((b) => b?.type === "tool_use") : [];
1502
+ if (!uses.length)
1503
+ return out;
1504
+ const next = out[ai + 1];
1505
+ const blocks = next?.role === "user" && Array.isArray(next.content) ? [...next.content] : [];
1506
+ const have = new Set(blocks.filter((b) => b?.type === "tool_result").map((b) => b.tool_use_id));
1507
+ for (const u of uses)
1508
+ if (!have.has(u.id))
1509
+ blocks.push(toolResultBlock(u.id, resultFor(u.id)));
1510
+ if (next?.role === "user" && Array.isArray(next.content))
1511
+ out[ai + 1] = { ...next, content: blocks };
1512
+ else
1513
+ out.splice(ai + 1, 0, { role: "user", content: blocks });
1514
+ return out;
1515
+ }
1279
1516
  function lastText(messages) {
1280
1517
  for (let i = messages.length - 1; i >= 0; i--) {
1281
1518
  if (messages[i].role === "assistant" && typeof messages[i].content === "string")
package/dist/index.d.ts CHANGED
@@ -14,8 +14,13 @@ export * from "./adapters.js";
14
14
  export * from "./translate.js";
15
15
  export * from "./toolkit.js";
16
16
  export * from "./client.js";
17
+ export * from "./store.js";
17
18
  export * from "./classifier.js";
18
19
  export * as agents from "./agents/index.js";
19
20
  export * as judge from "./judge.js";
20
21
  export * from "./batteries.js";
21
22
  export { State, context, questionMap, ask, gate, decide, applyGate, applyPolicy, answersOf, staticClassifier, Tape, DEFAULT_BANDS, type NamedQuestion, type Band, type Bands, type Answers, type Rule, type GateOutcome, type Policy, } from "./judge.js";
23
+ export * from "./promptcache.js";
24
+ export * from "./tooltiers.js";
25
+ export * from "./models.js";
26
+ export * from "./builtin_extras.js";