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/dist/client.js CHANGED
@@ -349,7 +349,7 @@ export class Client {
349
349
  if (req.maxTokens)
350
350
  body.max_tokens = req.maxTokens;
351
351
  const signal = this.makeSignal(req.signal);
352
- {
352
+ try {
353
353
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
354
354
  method: "POST",
355
355
  headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
@@ -374,6 +374,9 @@ export class Client {
374
374
  raw: data,
375
375
  };
376
376
  }
377
+ finally {
378
+ this.releaseSignal(signal);
379
+ }
377
380
  }
378
381
  /** Anthropic-style upstream: the real translation. */
379
382
  async translateAnthropic(req) {
@@ -407,7 +410,7 @@ export class Client {
407
410
  if (tc)
408
411
  body.tool_choice = tc;
409
412
  const signal = this.makeSignal(req.signal);
410
- {
413
+ try {
411
414
  const data = await this.llmCallJson(endpoint, {
412
415
  method: "POST",
413
416
  headers: {
@@ -444,6 +447,9 @@ export class Client {
444
447
  raw: data,
445
448
  };
446
449
  }
450
+ finally {
451
+ this.releaseSignal(signal);
452
+ }
447
453
  }
448
454
  /**
449
455
  * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
@@ -508,12 +514,28 @@ export class Client {
508
514
  system(toolkit) {
509
515
  return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n");
510
516
  }
511
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
517
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
518
+ runTimers = new WeakMap();
519
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
520
+ releaseSignal(signal) {
521
+ const t = this.runTimers.get(signal);
522
+ if (t !== undefined) {
523
+ clearTimeout(t);
524
+ this.runTimers.delete(signal);
525
+ }
526
+ }
527
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
528
+ *
529
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
530
+ * alive, so a run whose only pending work is a request that never settles — an injected
531
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
532
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
533
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
512
534
  makeSignal(external) {
513
535
  const ctrl = new AbortController();
514
536
  if (this.opts.timeoutMs) {
515
537
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs);
516
- t.unref?.();
538
+ this.runTimers.set(ctrl.signal, t);
517
539
  }
518
540
  if (external) {
519
541
  if (external.aborted)
@@ -612,10 +634,47 @@ export class Client {
612
634
  throw e;
613
635
  }
614
636
  }
615
- // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
637
+ // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
638
+ // run leaves: return, throw, or a consumer abandoning the stream half-way.
616
639
  async runOpenAI(prompt, toolkit, external, history) {
617
- const key = resolveKey(this.opts);
618
640
  const signal = this.makeSignal(external);
641
+ try {
642
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history);
643
+ }
644
+ finally {
645
+ this.releaseSignal(signal);
646
+ }
647
+ }
648
+ async runAnthropic(prompt, toolkit, external, history) {
649
+ const signal = this.makeSignal(external);
650
+ try {
651
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history);
652
+ }
653
+ finally {
654
+ this.releaseSignal(signal);
655
+ }
656
+ }
657
+ async *streamOpenAI(prompt, toolkit, external, history) {
658
+ const signal = this.makeSignal(external);
659
+ try {
660
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history);
661
+ }
662
+ finally {
663
+ this.releaseSignal(signal);
664
+ }
665
+ }
666
+ async *streamAnthropic(prompt, toolkit, external, history) {
667
+ const signal = this.makeSignal(external);
668
+ try {
669
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history);
670
+ }
671
+ finally {
672
+ this.releaseSignal(signal);
673
+ }
674
+ }
675
+ // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
676
+ async runOpenAIWithSignal(signal, prompt, toolkit, history) {
677
+ const key = resolveKey(this.opts);
619
678
  const runStart = Date.now();
620
679
  let messages = history && history.length ? [...history] : [];
621
680
  if (!messages.length) {
@@ -718,9 +777,8 @@ export class Client {
718
777
  return { result };
719
778
  }
720
779
  // ---- Anthropic-style: POST {baseUrl}/messages ----
721
- async runAnthropic(prompt, toolkit, external, history) {
780
+ async runAnthropicWithSignal(signal, prompt, toolkit, history) {
722
781
  const key = resolveKey(this.opts);
723
- const signal = this.makeSignal(external);
724
782
  const base = this.opts.baseUrl.replace(/\/$/, "");
725
783
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
726
784
  const system = this.system(toolkit);
@@ -796,9 +854,8 @@ export class Client {
796
854
  }
797
855
  }
798
856
  // ---- Streaming: OpenAI-style ----
799
- async *streamOpenAI(prompt, toolkit, external, history) {
857
+ async *streamOpenAIWithSignal(signal, prompt, toolkit, history) {
800
858
  const key = resolveKey(this.opts);
801
- const signal = this.makeSignal(external);
802
859
  const runStart = Date.now();
803
860
  let messages = history && history.length ? [...history] : [];
804
861
  if (!messages.length) {
@@ -906,9 +963,8 @@ export class Client {
906
963
  }
907
964
  }
908
965
  // ---- Streaming: Anthropic-style ----
909
- async *streamAnthropic(prompt, toolkit, external, history) {
966
+ async *streamAnthropicWithSignal(signal, prompt, toolkit, history) {
910
967
  const key = resolveKey(this.opts);
911
- const signal = this.makeSignal(external);
912
968
  const runStart = Date.now();
913
969
  const base = this.opts.baseUrl.replace(/\/$/, "");
914
970
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/issues.test.ts test/host-boundary.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts",
22
22
  "example:judge": "node --experimental-strip-types examples/judge.ts",
23
23
  "example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
@@ -234,9 +234,11 @@ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions)
234
234
  })
235
235
  }
236
236
  }
237
- cancel = runtime.clock.setTimeout(beat, hb.everyMs) // self-reschedule → one live timer
237
+ // keepAlive:false — a heartbeat is housekeeping and must never be the reason
238
+ // a host process stays up.
239
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }) // self-reschedule → one live timer
238
240
  }
239
- cancel = runtime.clock.setTimeout(beat, hb.everyMs)
241
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false })
240
242
 
241
243
  return {
242
244
  runtime,
@@ -39,16 +39,23 @@ import type { Answer, Request, Tool, ToolResult } from "../types.js"
39
39
  export interface Clock {
40
40
  /** Current time in ms (epoch or virtual). */
41
41
  now(): number
42
- /** Schedule `fn` after `ms`; returns a cancel function. */
43
- setTimeout(fn: () => void, ms: number): () => void
42
+ /** Schedule `fn` after `ms`; returns a cancel function.
43
+ *
44
+ * `keepAlive` defaults to TRUE, and the default is the important half: a timer
45
+ * that cannot hold the event loop open never fires when it is the only thing
46
+ * left to do — which is precisely a deadline whose promise nothing else will
47
+ * settle. Background housekeeping that should never hold a process open (a
48
+ * heartbeat) passes `false` explicitly. A host's own Clock may ignore the
49
+ * option; it then behaves as that host's timers always have. */
50
+ setTimeout(fn: () => void, ms: number, opts?: { keepAlive?: boolean }): () => void
44
51
  }
45
52
 
46
- /** The real clock (default). Timers are unref'd so they never hold the process open. */
53
+ /** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
47
54
  export const systemClock: Clock = {
48
55
  now: () => Date.now(),
49
- setTimeout(fn, ms) {
56
+ setTimeout(fn, ms, opts) {
50
57
  const t = setTimeout(fn, ms)
51
- ;(t as { unref?: () => void }).unref?.()
58
+ if (opts?.keepAlive === false) (t as { unref?: () => void }).unref?.()
52
59
  return () => clearTimeout(t)
53
60
  },
54
61
  }