toolnexus 0.19.0 → 0.21.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
@@ -240,9 +240,26 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
240
240
  * Lifecycle hooks around the agent loop. Each may observe, and (where noted)
241
241
  * mutate or short-circuit. All may be async.
242
242
  */
243
+ /** The `beforeLLM` hook event. */
244
+ export interface BeforeLLMEvent {
245
+ messages: any[]
246
+ tools: any[]
247
+ model: string
248
+ turn: number
249
+ }
250
+
251
+ /** What a `beforeLLM` hook may return. `model` (non-empty) applies to that turn only. */
252
+ export interface LLMOverride {
253
+ messages?: any[]
254
+ tools?: any[]
255
+ model?: string | null
256
+ }
257
+
243
258
  export interface Hooks {
244
- /** Before each model call. Return { messages?, tools? } to replace them. */
245
- beforeLLM?(ev: { messages: any[]; tools: any[]; model: string; turn: number }): void | { messages?: any[]; tools?: any[] } | Promise<void | { messages?: any[]; tools?: any[] }>
259
+ /** Before each model call. Return { messages?, tools? } to replace them, and { model } to send a
260
+ * different model for THIS turn only (request body + afterLLM event); absent/null/empty keeps the
261
+ * configured model verbatim (SPEC §8, e.g. a ModelRouterClassifier). */
262
+ beforeLLM?(ev: BeforeLLMEvent): void | LLMOverride | Promise<void | LLMOverride>
246
263
  /** After each model call (observe: logging, cost, tracing). */
247
264
  afterLLM?(ev: { response: any; model: string; turn: number }): void | Promise<void>
248
265
  /** Before a tool runs. Return { result } to SHORT-CIRCUIT (deny/cache), { args } to rewrite. */
@@ -476,8 +493,8 @@ export class Client {
476
493
 
477
494
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
478
495
  * matches the pre-change body so a no-options call is byte-identical. */
479
- private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
480
- const b: Record<string, any> = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) }
496
+ private openaiBody(messages: any[], tools: any[], stream: boolean, model: string = this.opts.model): Record<string, any> {
497
+ const b: Record<string, any> = { model, messages: toOpenAIWire(messages, this.wireOpts()) }
481
498
  if (tools.length) {
482
499
  b.tools = tools
483
500
  b.tool_choice = "auto"
@@ -490,8 +507,8 @@ export class Client {
490
507
  }
491
508
 
492
509
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
493
- private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
494
- const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) }
510
+ private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean, model: string = this.opts.model): Record<string, any> {
511
+ const b: Record<string, any> = { model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) }
495
512
  if (tools.length) b.tools = tools
496
513
  if (stream) b.stream = true
497
514
  return this.finalizeBody(b)
@@ -515,14 +532,14 @@ export class Client {
515
532
 
516
533
  /** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
517
534
  * maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
518
- private endRun(runStart: number, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
519
- this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
520
- return this.result(text, messages, toolCalls, turns, usage, exhausted)
535
+ private endRun(runStart: number, model: string, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
536
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
537
+ return this.result(model, text, messages, toolCalls, turns, usage, exhausted)
521
538
  }
522
539
 
523
540
  /** Emit a `run` error metric event (once, on a thrown run). */
524
- private emitRunError(runStart: number, toolCalls: ToolCallRecord[], turns: number, usage: Usage, e: unknown): void {
525
- this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
541
+ private emitRunError(runStart: number, model: string, toolCalls: ToolCallRecord[], turns: number, usage: Usage, e: unknown): void {
542
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
526
543
  }
527
544
 
528
545
  /**
@@ -563,19 +580,21 @@ export class Client {
563
580
  if (sys && !hasSystemMessage(messages)) messages = [{ role: "system", content: sys }, ...messages]
564
581
 
565
582
  let tools = declared
583
+ let turnModel = this.opts.model
566
584
  if (this.opts.hooks?.beforeLLM) {
567
585
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 })
568
586
  if (ov?.messages) messages = ov.messages
569
587
  if (ov?.tools) tools = ov.tools
588
+ if (ov?.model) turnModel = ov.model
570
589
  }
571
590
 
572
- const body: Record<string, any> = { model: this.opts.model, messages }
591
+ const body: Record<string, any> = { model: turnModel, messages }
573
592
  if (tools.length) body.tools = tools
574
593
  if (req.toolChoice !== undefined) body.tool_choice = req.toolChoice
575
594
  if (req.maxTokens) body.max_tokens = req.maxTokens
576
595
 
577
596
  const signal = this.makeSignal(req.signal)
578
- {
597
+ try {
579
598
  const data = await this.llmCallJson(
580
599
  `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
581
600
  {
@@ -585,8 +604,9 @@ export class Client {
585
604
  },
586
605
  signal,
587
606
  "openai",
607
+ turnModel,
588
608
  )
589
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 })
609
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 })
590
610
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
591
611
  addUsage(usage, data.usage, "openai")
592
612
  const choice = data.choices?.[0]
@@ -600,9 +620,11 @@ export class Client {
600
620
  toolCalls,
601
621
  finishReason: choice?.finish_reason ?? finishReasonFor(toolCalls.length > 0),
602
622
  usage,
603
- model: this.opts.model,
623
+ model: turnModel,
604
624
  raw: data,
605
625
  }
626
+ } finally {
627
+ this.releaseSignal(signal)
606
628
  }
607
629
  }
608
630
 
@@ -620,14 +642,16 @@ export class Client {
620
642
  ...openAIToolsToAnthropic(req.tools),
621
643
  ]
622
644
 
645
+ let turnModel = this.opts.model
623
646
  if (this.opts.hooks?.beforeLLM) {
624
647
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 })
625
648
  if (ov?.messages) messages = ov.messages
626
649
  if (ov?.tools) tools = ov.tools
650
+ if (ov?.model) turnModel = ov.model
627
651
  }
628
652
 
629
653
  const body: Record<string, any> = {
630
- model: this.opts.model,
654
+ model: turnModel,
631
655
  max_tokens: req.maxTokens && req.maxTokens > 0 ? req.maxTokens : 4096,
632
656
  messages,
633
657
  }
@@ -637,7 +661,7 @@ export class Client {
637
661
  if (tc) body.tool_choice = tc
638
662
 
639
663
  const signal = this.makeSignal(req.signal)
640
- {
664
+ try {
641
665
  const data = await this.llmCallJson(
642
666
  endpoint,
643
667
  {
@@ -652,8 +676,9 @@ export class Client {
652
676
  },
653
677
  signal,
654
678
  "anthropic",
679
+ turnModel,
655
680
  )
656
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 })
681
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 })
657
682
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
658
683
  addUsage(usage, data.usage, "anthropic")
659
684
  const text: string[] = []
@@ -673,9 +698,11 @@ export class Client {
673
698
  toolCalls,
674
699
  finishReason: finishReasonFor(toolCalls.length > 0, data.stop_reason),
675
700
  usage,
676
- model: this.opts.model,
701
+ model: turnModel,
677
702
  raw: data,
678
703
  }
704
+ } finally {
705
+ this.releaseSignal(signal)
679
706
  }
680
707
  }
681
708
 
@@ -743,12 +770,30 @@ export class Client {
743
770
  return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n")
744
771
  }
745
772
 
746
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
773
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
774
+ private readonly runTimers = new WeakMap<AbortSignal, ReturnType<typeof setTimeout>>()
775
+
776
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
777
+ private releaseSignal(signal: AbortSignal): void {
778
+ const t = this.runTimers.get(signal)
779
+ if (t !== undefined) {
780
+ clearTimeout(t)
781
+ this.runTimers.delete(signal)
782
+ }
783
+ }
784
+
785
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
786
+ *
787
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
788
+ * alive, so a run whose only pending work is a request that never settles — an injected
789
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
790
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
791
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
747
792
  private makeSignal(external?: AbortSignal): AbortSignal {
748
793
  const ctrl = new AbortController()
749
794
  if (this.opts.timeoutMs) {
750
795
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs)
751
- ;(t as any).unref?.()
796
+ this.runTimers.set(ctrl.signal, t)
752
797
  }
753
798
  if (external) {
754
799
  if (external.aborted) ctrl.abort(external.reason)
@@ -826,25 +871,62 @@ export class Client {
826
871
  }
827
872
 
828
873
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
829
- private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
874
+ private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle, model: string): Promise<any> {
830
875
  const t0 = Date.now()
831
876
  try {
832
877
  const res = await this.llmFetch(url, init, signal)
833
878
  if (!res.ok) throw await llmHttpError(res)
834
879
  const data: any = await res.json()
835
880
  const tok = perCall(data.usage, style)
836
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
881
+ this.emit({ event: "llm", model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
837
882
  return data
838
883
  } catch (e) {
839
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
884
+ this.emit({ event: "llm", model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
840
885
  throw e
841
886
  }
842
887
  }
843
888
 
844
- // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
889
+ // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
890
+ // run leaves: return, throw, or a consumer abandoning the stream half-way.
845
891
  private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
846
- const key = resolveKey(this.opts)
847
892
  const signal = this.makeSignal(external)
893
+ try {
894
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history)
895
+ } finally {
896
+ this.releaseSignal(signal)
897
+ }
898
+ }
899
+
900
+ private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
901
+ const signal = this.makeSignal(external)
902
+ try {
903
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history)
904
+ } finally {
905
+ this.releaseSignal(signal)
906
+ }
907
+ }
908
+
909
+ private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
910
+ const signal = this.makeSignal(external)
911
+ try {
912
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history)
913
+ } finally {
914
+ this.releaseSignal(signal)
915
+ }
916
+ }
917
+
918
+ private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
919
+ const signal = this.makeSignal(external)
920
+ try {
921
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history)
922
+ } finally {
923
+ this.releaseSignal(signal)
924
+ }
925
+ }
926
+
927
+ // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
928
+ private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
929
+ const key = resolveKey(this.opts)
848
930
  const runStart = Date.now()
849
931
  let messages: any[] = history && history.length ? [...history] : []
850
932
  if (!messages.length) {
@@ -856,26 +938,31 @@ export class Client {
856
938
  const toolCalls: ToolCallRecord[] = []
857
939
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
858
940
  let turns = 0
941
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
942
+ let lastModel = this.opts.model
859
943
 
860
944
  try {
861
945
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
862
946
  turns++
947
+ let turnModel = this.opts.model
863
948
  if (this.opts.hooks?.beforeLLM) {
864
949
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
865
950
  if (ov?.messages) messages = ov.messages
866
951
  if (ov?.tools) tools = ov.tools
952
+ if (ov?.model) turnModel = ov.model
867
953
  }
954
+ lastModel = turnModel
868
955
  const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
869
956
  method: "POST",
870
957
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
871
- body: JSON.stringify(this.openaiBody(messages, tools, false)),
872
- }, signal, "openai")
958
+ body: JSON.stringify(this.openaiBody(messages, tools, false, turnModel)),
959
+ }, signal, "openai", turnModel)
873
960
  addUsage(usage, data.usage, "openai")
874
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
961
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn })
875
962
  const msg = data.choices[0].message
876
963
  messages.push(msg)
877
964
  const calls = msg.tool_calls ?? []
878
- if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
965
+ if (calls.length === 0) return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage)
879
966
  // execute all tool calls in this turn concurrently (true parallel tool calling)
880
967
  const settled = await Promise.all(
881
968
  calls.map(async (call: any) => {
@@ -896,27 +983,27 @@ export class Client {
896
983
  for (const s of settled) {
897
984
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
898
985
  messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
899
- if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
986
+ if (s.halted) return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage)
900
987
  }
901
988
  }
902
- return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true)
989
+ return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true)
903
990
  } catch (e) {
904
- this.emitRunError(runStart, toolCalls, turns, usage, e)
991
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
905
992
  throw e
906
993
  }
907
994
  }
908
995
 
909
- private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
996
+ private result(model: string, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage, exhausted = false): RunResult {
910
997
  const incomplete = exhausted && text === ""
911
- const r: RunResult = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: incomplete ? "incomplete" : "done" }
998
+ const r: RunResult = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" }
912
999
  if (incomplete) r.limit = "maxTurns"
913
1000
  return r
914
1001
  }
915
1002
 
916
1003
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
917
- private pendingRun(runStart: number, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
918
- this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
919
- return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request }
1004
+ private pendingRun(runStart: number, model: string, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
1005
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
1006
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: "pending", pending: request }
920
1007
  }
921
1008
 
922
1009
  /**
@@ -944,9 +1031,8 @@ export class Client {
944
1031
  }
945
1032
 
946
1033
  // ---- Anthropic-style: POST {baseUrl}/messages ----
947
- private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
1034
+ private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
948
1035
  const key = resolveKey(this.opts)
949
- const signal = this.makeSignal(external)
950
1036
  const base = this.opts.baseUrl.replace(/\/$/, "")
951
1037
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
952
1038
  const system = this.system(toolkit)
@@ -956,15 +1042,20 @@ export class Client {
956
1042
  const toolCalls: ToolCallRecord[] = []
957
1043
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
958
1044
  let turns = 0
1045
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
1046
+ let lastModel = this.opts.model
959
1047
 
960
1048
  try {
961
1049
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
962
1050
  turns++
1051
+ let turnModel = this.opts.model
963
1052
  if (this.opts.hooks?.beforeLLM) {
964
1053
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
965
1054
  if (ov?.messages) messages = ov.messages
966
1055
  if (ov?.tools) tools = ov.tools
1056
+ if (ov?.model) turnModel = ov.model
967
1057
  }
1058
+ lastModel = turnModel
968
1059
  const data: any = await this.llmCallJson(endpoint, {
969
1060
  method: "POST",
970
1061
  headers: {
@@ -973,15 +1064,15 @@ export class Client {
973
1064
  "Content-Type": "application/json",
974
1065
  ...this.opts.headers,
975
1066
  },
976
- body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
977
- }, signal, "anthropic")
1067
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false, turnModel)),
1068
+ }, signal, "anthropic", turnModel)
978
1069
  addUsage(usage, data.usage, "anthropic")
979
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
1070
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn })
980
1071
  messages.push({ role: "assistant", content: data.content })
981
1072
  const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
982
1073
  if (uses.length === 0) {
983
1074
  const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
984
- return this.endRun(runStart, text, messages, toolCalls, turns, usage)
1075
+ return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage)
985
1076
  }
986
1077
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
987
1078
  const settled = await Promise.all(
@@ -1008,19 +1099,18 @@ export class Client {
1008
1099
  if (s.halted) { halted = s.halted; break }
1009
1100
  }
1010
1101
  messages.push({ role: "user", content })
1011
- if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
1102
+ if (halted) return this.pendingRun(runStart, lastModel, halted, messages, toolCalls, turns, usage)
1012
1103
  }
1013
- return this.endRun(runStart, "", messages, toolCalls, turns, usage, true)
1104
+ return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true)
1014
1105
  } catch (e) {
1015
- this.emitRunError(runStart, toolCalls, turns, usage, e)
1106
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
1016
1107
  throw e
1017
1108
  }
1018
1109
  }
1019
1110
 
1020
1111
  // ---- Streaming: OpenAI-style ----
1021
- private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1112
+ private async *streamOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1022
1113
  const key = resolveKey(this.opts)
1023
- const signal = this.makeSignal(external)
1024
1114
  const runStart = Date.now()
1025
1115
  let messages: any[] = history && history.length ? [...history] : []
1026
1116
  if (!messages.length) {
@@ -1032,15 +1122,20 @@ export class Client {
1032
1122
  const toolCalls: ToolCallRecord[] = []
1033
1123
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1034
1124
  let turns = 0
1125
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
1126
+ let lastModel = this.opts.model
1035
1127
 
1036
1128
  try {
1037
1129
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1038
1130
  turns++
1131
+ let turnModel = this.opts.model
1039
1132
  if (this.opts.hooks?.beforeLLM) {
1040
1133
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
1041
1134
  if (ov?.messages) messages = ov.messages
1042
1135
  if (ov?.tools) tools = ov.tools
1136
+ if (ov?.model) turnModel = ov.model
1043
1137
  }
1138
+ lastModel = turnModel
1044
1139
  const t0 = Date.now()
1045
1140
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens
1046
1141
  let content = ""
@@ -1049,7 +1144,7 @@ export class Client {
1049
1144
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1050
1145
  method: "POST",
1051
1146
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
1052
- body: JSON.stringify(this.openaiBody(messages, tools, true)),
1147
+ body: JSON.stringify(this.openaiBody(messages, tools, true, turnModel)),
1053
1148
  }, signal)
1054
1149
  if (!res.ok || !res.body) throw await llmHttpError(res)
1055
1150
  for await (const line of sseLines(res.body)) {
@@ -1070,17 +1165,17 @@ export class Client {
1070
1165
  }
1071
1166
  }
1072
1167
  } catch (e) {
1073
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
1168
+ this.emit({ event: "llm", model: turnModel, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
1074
1169
  throw e
1075
1170
  }
1076
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
1077
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
1171
+ this.emit({ event: "llm", model: turnModel, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
1172
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn })
1078
1173
 
1079
1174
  const calls = [...acc.values()]
1080
1175
  if (calls.length === 0) {
1081
1176
  messages.push({ role: "assistant", content })
1082
1177
  yield { type: "usage", usage }
1083
- yield { type: "done", result: this.endRun(runStart, content, messages, toolCalls, turns, usage) }
1178
+ yield { type: "done", result: this.endRun(runStart, lastModel, content, messages, toolCalls, turns, usage) }
1084
1179
  return
1085
1180
  }
1086
1181
  messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
@@ -1097,7 +1192,7 @@ export class Client {
1097
1192
  if (r.halted) {
1098
1193
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1099
1194
  messages.push(toolMessage(c.id, c.name, result))
1100
- yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
1195
+ yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
1101
1196
  return
1102
1197
  }
1103
1198
  }
@@ -1106,17 +1201,16 @@ export class Client {
1106
1201
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
1107
1202
  }
1108
1203
  }
1109
- yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true) }
1204
+ yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) }
1110
1205
  } catch (e) {
1111
- this.emitRunError(runStart, toolCalls, turns, usage, e)
1206
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
1112
1207
  throw e
1113
1208
  }
1114
1209
  }
1115
1210
 
1116
1211
  // ---- Streaming: Anthropic-style ----
1117
- private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1212
+ private async *streamAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1118
1213
  const key = resolveKey(this.opts)
1119
- const signal = this.makeSignal(external)
1120
1214
  const runStart = Date.now()
1121
1215
  const base = this.opts.baseUrl.replace(/\/$/, "")
1122
1216
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
@@ -1126,15 +1220,20 @@ export class Client {
1126
1220
  const toolCalls: ToolCallRecord[] = []
1127
1221
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1128
1222
  let turns = 0
1223
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
1224
+ let lastModel = this.opts.model
1129
1225
 
1130
1226
  try {
1131
1227
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
1132
1228
  turns++
1229
+ let turnModel = this.opts.model
1133
1230
  if (this.opts.hooks?.beforeLLM) {
1134
1231
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
1135
1232
  if (ov?.messages) messages = ov.messages
1136
1233
  if (ov?.tools) tools = ov.tools
1234
+ if (ov?.model) turnModel = ov.model
1137
1235
  }
1236
+ lastModel = turnModel
1138
1237
  const t0 = Date.now()
1139
1238
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens
1140
1239
  const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
@@ -1143,7 +1242,7 @@ export class Client {
1143
1242
  const res = await this.llmFetch(endpoint, {
1144
1243
  method: "POST",
1145
1244
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
1146
- body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
1245
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true, turnModel)),
1147
1246
  }, signal)
1148
1247
  if (!res.ok || !res.body) throw await llmHttpError(res)
1149
1248
  for await (const line of sseLines(res.body)) {
@@ -1159,11 +1258,11 @@ export class Client {
1159
1258
  } else if (j.type === "message_delta") { stopReason = j.delta?.stop_reason ?? stopReason; addUsage(usage, j.usage, "anthropic") }
1160
1259
  }
1161
1260
  } catch (e) {
1162
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
1261
+ this.emit({ event: "llm", model: turnModel, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
1163
1262
  throw e
1164
1263
  }
1165
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
1166
- if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
1264
+ this.emit({ event: "llm", model: turnModel, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
1265
+ if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn })
1167
1266
 
1168
1267
  const content = [...blocks.values()].map((b) => b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: safeJson(b.json || "{}") } : { type: "text", text: b.text })
1169
1268
  messages.push({ role: "assistant", content })
@@ -1171,7 +1270,7 @@ export class Client {
1171
1270
  if (stopReason !== "tool_use" || uses.length === 0) {
1172
1271
  const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
1173
1272
  yield { type: "usage", usage }
1174
- yield { type: "done", result: this.endRun(runStart, text, messages, toolCalls, turns, usage) }
1273
+ yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) }
1175
1274
  return
1176
1275
  }
1177
1276
  for (const u of uses as any[]) yield { type: "tool_call", id: u.id, name: u.name, args: u.input }
@@ -1189,7 +1288,7 @@ export class Client {
1189
1288
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1190
1289
  results.push(toolResultBlock(u.id, result))
1191
1290
  messages.push({ role: "user", content: results })
1192
- yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
1291
+ yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) }
1193
1292
  return
1194
1293
  }
1195
1294
  }
@@ -1199,9 +1298,9 @@ export class Client {
1199
1298
  }
1200
1299
  messages.push({ role: "user", content: results })
1201
1300
  }
1202
- yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) }
1301
+ yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) }
1203
1302
  } catch (e) {
1204
- this.emitRunError(runStart, toolCalls, turns, usage, e)
1303
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e)
1205
1304
  throw e
1206
1305
  }
1207
1306
  }
package/src/index.ts CHANGED
@@ -16,3 +16,29 @@ export * from "./toolkit.js"
16
16
  export * from "./client.js"
17
17
  export * from "./classifier.js"
18
18
  export * as agents from "./agents/index.js"
19
+ // Simple judgments (§8B, add-judge-adapters). `judge.noul/choice/score` are the NAMED builders
20
+ // (the bare `noul/choice/score` above stay the §8B wire builders); the rest is also top-level.
21
+ export * as judge from "./judge.js"
22
+ // Judge batteries (§8B "Batteries", add-judge-batteries): eight `*Classifier` values + latestUserText.
23
+ export * from "./batteries.js"
24
+ export {
25
+ State,
26
+ context,
27
+ questionMap,
28
+ ask,
29
+ gate,
30
+ decide,
31
+ applyGate,
32
+ applyPolicy,
33
+ answersOf,
34
+ staticClassifier,
35
+ Tape,
36
+ DEFAULT_BANDS,
37
+ type NamedQuestion,
38
+ type Band,
39
+ type Bands,
40
+ type Answers,
41
+ type Rule,
42
+ type GateOutcome,
43
+ type Policy,
44
+ } from "./judge.js"