toolnexus 0.19.0 → 0.20.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
@@ -575,7 +575,7 @@ export class Client {
575
575
  if (req.maxTokens) body.max_tokens = req.maxTokens
576
576
 
577
577
  const signal = this.makeSignal(req.signal)
578
- {
578
+ try {
579
579
  const data = await this.llmCallJson(
580
580
  `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
581
581
  {
@@ -603,6 +603,8 @@ export class Client {
603
603
  model: this.opts.model,
604
604
  raw: data,
605
605
  }
606
+ } finally {
607
+ this.releaseSignal(signal)
606
608
  }
607
609
  }
608
610
 
@@ -637,7 +639,7 @@ export class Client {
637
639
  if (tc) body.tool_choice = tc
638
640
 
639
641
  const signal = this.makeSignal(req.signal)
640
- {
642
+ try {
641
643
  const data = await this.llmCallJson(
642
644
  endpoint,
643
645
  {
@@ -676,6 +678,8 @@ export class Client {
676
678
  model: this.opts.model,
677
679
  raw: data,
678
680
  }
681
+ } finally {
682
+ this.releaseSignal(signal)
679
683
  }
680
684
  }
681
685
 
@@ -743,12 +747,30 @@ export class Client {
743
747
  return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n")
744
748
  }
745
749
 
746
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
750
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
751
+ private readonly runTimers = new WeakMap<AbortSignal, ReturnType<typeof setTimeout>>()
752
+
753
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
754
+ private releaseSignal(signal: AbortSignal): void {
755
+ const t = this.runTimers.get(signal)
756
+ if (t !== undefined) {
757
+ clearTimeout(t)
758
+ this.runTimers.delete(signal)
759
+ }
760
+ }
761
+
762
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
763
+ *
764
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
765
+ * alive, so a run whose only pending work is a request that never settles — an injected
766
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
767
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
768
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
747
769
  private makeSignal(external?: AbortSignal): AbortSignal {
748
770
  const ctrl = new AbortController()
749
771
  if (this.opts.timeoutMs) {
750
772
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs)
751
- ;(t as any).unref?.()
773
+ this.runTimers.set(ctrl.signal, t)
752
774
  }
753
775
  if (external) {
754
776
  if (external.aborted) ctrl.abort(external.reason)
@@ -841,10 +863,47 @@ export class Client {
841
863
  }
842
864
  }
843
865
 
844
- // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
866
+ // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
867
+ // run leaves: return, throw, or a consumer abandoning the stream half-way.
845
868
  private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
846
- const key = resolveKey(this.opts)
847
869
  const signal = this.makeSignal(external)
870
+ try {
871
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history)
872
+ } finally {
873
+ this.releaseSignal(signal)
874
+ }
875
+ }
876
+
877
+ private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
878
+ const signal = this.makeSignal(external)
879
+ try {
880
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history)
881
+ } finally {
882
+ this.releaseSignal(signal)
883
+ }
884
+ }
885
+
886
+ private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
887
+ const signal = this.makeSignal(external)
888
+ try {
889
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history)
890
+ } finally {
891
+ this.releaseSignal(signal)
892
+ }
893
+ }
894
+
895
+ private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
896
+ const signal = this.makeSignal(external)
897
+ try {
898
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history)
899
+ } finally {
900
+ this.releaseSignal(signal)
901
+ }
902
+ }
903
+
904
+ // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
905
+ private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
906
+ const key = resolveKey(this.opts)
848
907
  const runStart = Date.now()
849
908
  let messages: any[] = history && history.length ? [...history] : []
850
909
  if (!messages.length) {
@@ -944,9 +1003,8 @@ export class Client {
944
1003
  }
945
1004
 
946
1005
  // ---- Anthropic-style: POST {baseUrl}/messages ----
947
- private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
1006
+ private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
948
1007
  const key = resolveKey(this.opts)
949
- const signal = this.makeSignal(external)
950
1008
  const base = this.opts.baseUrl.replace(/\/$/, "")
951
1009
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
952
1010
  const system = this.system(toolkit)
@@ -1018,9 +1076,8 @@ export class Client {
1018
1076
  }
1019
1077
 
1020
1078
  // ---- Streaming: OpenAI-style ----
1021
- private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1079
+ private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1022
1080
  const key = resolveKey(this.opts)
1023
- const signal = this.makeSignal(external)
1024
1081
  const runStart = Date.now()
1025
1082
  let messages: any[] = history && history.length ? [...history] : []
1026
1083
  if (!messages.length) {
@@ -1114,9 +1171,8 @@ export class Client {
1114
1171
  }
1115
1172
 
1116
1173
  // ---- Streaming: Anthropic-style ----
1117
- private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1174
+ private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1118
1175
  const key = resolveKey(this.opts)
1119
- const signal = this.makeSignal(external)
1120
1176
  const runStart = Date.now()
1121
1177
  const base = this.opts.baseUrl.replace(/\/$/, "")
1122
1178
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`