toolnexus 0.18.1 → 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/classifier.ts CHANGED
@@ -10,9 +10,8 @@
10
10
  * here is a security control.
11
11
  */
12
12
  import type { Client } from "./client.js"
13
- import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js"
13
+ import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
14
14
  import { retryAfterMs, isRetryableStatus } from "./retry.js"
15
- import type { Toolkit } from "./toolkit.js"
16
15
 
17
16
  // ---------------------------------------------------------------- constants
18
17
 
@@ -25,6 +24,19 @@ export const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY"
25
24
  /** Bounds ONE request; a classifier has no loop to bound. */
26
25
  export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10_000
27
26
 
27
+ /**
28
+ * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
29
+ * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
30
+ * `typesafe/jev-1.13`, and mixing the two is issue #91.
31
+ */
32
+ export type ClassifierBackend = "typesafe" | "openrouter"
33
+
34
+ /** The two servable pairings, as configurations rather than six independently-choosable cells. */
35
+ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, { baseUrl: string; model: string; apiKeyEnv: string }>> = {
36
+ typesafe: { baseUrl: DEFAULT_CLASSIFIER_BASE_URL, model: DEFAULT_CLASSIFIER_MODEL, apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV },
37
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", apiKeyEnv: "OPENROUTER_API_KEY" },
38
+ }
39
+
28
40
  /** Client-side cap on a choice's named options (§8B). */
29
41
  export const MAX_CHOICE_OPTIONS = 255
30
42
  /** Bounds of a score rubric (§8B). */
@@ -392,6 +404,11 @@ export type EvaluateFn = (
392
404
  export interface ClassifierOptions {
393
405
  /** Default `"systemone"`. */
394
406
  style?: ClassifierStyle
407
+ /** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
408
+ * UNIT. Default `"typesafe"`. The three individual options still win where given, for a
409
+ * self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
410
+ * as an "Unknown model" 400. */
411
+ backend?: ClassifierBackend
395
412
  /** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
396
413
  baseUrl?: string
397
414
  /** Default `"jev-latest"`. Pin it once thresholds are tuned. */
@@ -456,9 +473,12 @@ export class Classifier {
456
473
 
457
474
  constructor(private readonly opts: ClassifierOptions) {
458
475
  this.style = opts.style ?? "systemone"
459
- this.baseUrl = opts.baseUrl ?? DEFAULT_CLASSIFIER_BASE_URL
460
- this.model = opts.model ?? DEFAULT_CLASSIFIER_MODEL
461
- this.apiKeyEnv = opts.apiKeyEnv ?? DEFAULT_CLASSIFIER_API_KEY_ENV
476
+ const preset = CLASSIFIER_BACKENDS[opts.backend ?? "typesafe"]
477
+ this.baseUrl = opts.baseUrl ?? preset.baseUrl
478
+ this.model = opts.model ?? preset.model
479
+ this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
480
+ const mismatch = backendMismatch(this.baseUrl, this.model)
481
+ if (mismatch) throw new Error(mismatch)
462
482
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
463
483
  switch (this.style) {
464
484
  case "systemone":
@@ -630,8 +650,10 @@ export class Classifier {
630
650
  let lastErr: unknown
631
651
  for (let attempt = 0; ; attempt++) {
632
652
  const timer = new AbortController()
653
+ // NOT unref'd: this watchdog is the only thing that will end a request an
654
+ // injected `fetch` never settles, and an unref'd timer cannot keep the
655
+ // loop alive long enough to fire. It is cleared on every exit path below.
633
656
  const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs)
634
- ;(t as any).unref?.()
635
657
  const onOuter = () => timer.abort(signal!.reason)
636
658
  if (signal) {
637
659
  if (signal.aborted) {
@@ -706,7 +728,8 @@ export class Classifier {
706
728
  '{"type":"choice","choice":"<one offered option id>","probabilities":{"<every offered option id>":<0..1>},"confidence":<0..1>}. ' +
707
729
  'A "score" answer is {"type":"score","score":<a number within the rubric bounds, fractional allowed>,' +
708
730
  '"legend":{"0":"<level 0>",…},"probabilities":{"0":<0..1>,…},"confidence":<0..1>}.'
709
- const run = await this.opts.client!.run(prompt, { toolkit: EMPTY_TOOLKIT, signal })
731
+ // No toolkit at all: a classifier has no loop and executes nothing (SPEC §0.10, ADR 0023).
732
+ const run = await this.opts.client!.run(prompt, { signal })
710
733
  const payload = firstJsonObject(run.text)
711
734
  let parsed: unknown
712
735
  try {
@@ -778,9 +801,21 @@ export function degenerateCriteria(criteria: Record<string, string>): string {
778
801
  */
779
802
  function cause(status: number, body: string): string {
780
803
  if (status === 401 || status === 403) return ""
781
- const s = body.trim()
782
- if (!s) return ""
783
- return ": " + (s.length > 200 ? s.slice(0, 200) + "…" : s)
804
+ // ONE policy, now shared with the §8 client path: redact account identifiers, then cap.
805
+ const s = capErrorBody(redactErrorBody(body))
806
+ return s ? ": " + s : ""
807
+ }
808
+
809
+ /**
810
+ * The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
811
+ * pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
812
+ * with, or `""` when the pairing is fine.
813
+ */
814
+ export function backendMismatch(baseUrl: string, model: string): string {
815
+ const host = baseUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0].toLowerCase()
816
+ if (host !== "openrouter.ai" && !host.endsWith(".openrouter.ai")) return ""
817
+ if (!/^jev-/.test(model)) return ""
818
+ return `classifier: model "${model}" is TypeSafe's spelling; on openrouter.ai use "${CLASSIFIER_BACKENDS.openrouter.model}"`
784
819
  }
785
820
 
786
821
  /** `${ENV_VAR}` expands at call time; an unset var expands to the empty string, as §2. */
@@ -813,20 +848,3 @@ function sleep(ms: number): Promise<void> {
813
848
  })
814
849
  }
815
850
 
816
- /**
817
- * The `llm` style declares no tools — a classifier has no loop and executes nothing. The §8
818
- * `run` takes a Toolkit, so this is the empty one; the cast is the price of not exposing a
819
- * public "no tools" Toolkit constructor just for this path.
820
- */
821
- const EMPTY_TOOLKIT = {
822
- skillsPrompt: () => "",
823
- toOpenAI: () => [],
824
- toAnthropic: () => [],
825
- toGemini: () => [],
826
- get: () => undefined,
827
- list: () => [],
828
- execute: async () => {
829
- throw new Error("classifier: the llm style declares no tools")
830
- },
831
- close: async () => {},
832
- } as unknown as Toolkit
package/src/client.ts CHANGED
@@ -56,7 +56,18 @@ export interface ClientOptions {
56
56
  /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
57
57
  * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
58
58
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
59
- * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
59
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
60
+ *
61
+ * IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
62
+ * turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
63
+ * REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
64
+ * itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
65
+ * bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
66
+ * side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
67
+ * responsibility. Any tool reachable in a suspendable turn must be idempotent.
68
+ * Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
69
+ * remove this requirement anyway.
70
+ */
60
71
  waitFor?: (request: Request) => Promise<Answer>
61
72
  /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
62
73
  * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
@@ -84,6 +95,77 @@ export interface ClientOptions {
84
95
  onUnsupportedPart?: UnsupportedPartMode
85
96
  }
86
97
 
98
+ /**
99
+ * The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
100
+ * §7D AGENT vocabulary (`agents.TASK_STATUSES`), which also contains `"timeout"`, `"closed"`,
101
+ * `"interrupted"` and `"error"`. Two fields are called `status`; they are not interchangeable.
102
+ * A run that fails at a deadline THROWS here — it never comes back as a `RunResult`.
103
+ */
104
+ export const RUN_STATUSES = ["done", "pending", "incomplete"] as const
105
+ export type RunStatus = (typeof RUN_STATUSES)[number]
106
+
107
+ /** Account-identifier keys blanked out of a provider error body before it reaches a message,
108
+ * a log or a metric. The shape survives; the value does not (ADR 0027 D3.2). */
109
+ export const REDACTED_BODY_KEYS = ["user_id", "account_id", "org_id", "organization"] as const
110
+ /** What a redacted value is replaced with — identical in all seven ports. */
111
+ export const REDACTION_PLACEHOLDER = "\u00abredacted\u00bb"
112
+ /** A redacted body is capped at this many characters IN THE MESSAGE ONLY. The typed `body`
113
+ * field carries the full redacted body — a host that opted into a typed error asked for the
114
+ * whole thing (ADR 0027 D3 / addendum A5). */
115
+ export const ERROR_BODY_CAP = 200
116
+
117
+ /**
118
+ * Redact known account identifiers, then cap. A CAP IS NOT REDACTION: the body that leaked
119
+ * `user_2…` in issue #92 was 96 bytes, well inside the cap, so both steps are needed and
120
+ * redaction runs FIRST (capping first could split a key/value pair and hide it from the regex).
121
+ */
122
+ export function redactErrorBody(body: string): string {
123
+ let out = body
124
+ for (const key of REDACTED_BODY_KEYS) {
125
+ // JSON ("key": "value" / "key": 123) and form/query (key=value) spellings alike.
126
+ out = out.replace(new RegExp(`("${key}"\\s*:\\s*)("(?:[^"\\\\]|\\\\.)*"|-?\\d+(?:\\.\\d+)?|true|false|null)`, "g"), `$1"${REDACTION_PLACEHOLDER}"`)
127
+ out = out.replace(new RegExp(`\\b${key}=([^&\\s]+)`, "g"), `${key}=${REDACTION_PLACEHOLDER}`)
128
+ }
129
+ return out.trim()
130
+ }
131
+
132
+ /** Cap a already-redacted body for interpolation into a MESSAGE. Redaction is not a cap and a
133
+ * cap is not redaction: they are independent and both are needed. */
134
+ export function capErrorBody(body: string): string {
135
+ return body.length > ERROR_BODY_CAP ? body.slice(0, ERROR_BODY_CAP) + "\u2026" : body
136
+ }
137
+
138
+ /**
139
+ * A provider failure carried as a VALUE, not a sentence (ADR 0027 D3.1). A host that must
140
+ * decide what to log can read `status` / `retryAfter` instead of parsing a message.
141
+ *
142
+ * `body` is ALREADY redacted and capped, and is `""` on 401/403 — an auth body routinely
143
+ * reflects the credential or the header that was sent. `rawBody` is never populated by the
144
+ * library; the SPEC credentials guarantee now covers error messages too.
145
+ */
146
+ export class LlmHttpError extends Error {
147
+ readonly name = "LlmHttpError"
148
+ constructor(
149
+ /** The HTTP status the provider answered with. */
150
+ readonly status: number,
151
+ /** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
152
+ * applies to `message` only. */
153
+ readonly body: string,
154
+ /** The `Retry-After` header verbatim, when the provider sent one. */
155
+ readonly retryAfter?: string,
156
+ ) {
157
+ super(`LLM ${status}${body ? ": " + capErrorBody(body) : ""}`)
158
+ }
159
+ }
160
+
161
+ /** Build the typed error from a non-2xx provider response (reads the body once). */
162
+ export async function llmHttpError(res: Response): Promise<LlmHttpError> {
163
+ const raw = await res.text().catch(() => "")
164
+ // 401/403: never echo the body — see `Classifier.cause`, now one policy, not two.
165
+ const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw)
166
+ return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined)
167
+ }
168
+
87
169
  /** §8 Resilience. What to do with a failed LLM call. */
88
170
  export type ErrorTier = "retry" | "fail"
89
171
 
@@ -443,11 +525,17 @@ export class Client {
443
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) })
444
526
  }
445
527
 
446
- async run(prompt: PromptInput, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
528
+ /**
529
+ * Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
530
+ * COMPLETION (SPEC §0.10). The system message is then the system prompt alone, and the
531
+ * request body carries no `tools` and no `tool_choice` key (not an empty array). An empty
532
+ * toolkit and no toolkit are observably identical on the wire.
533
+ */
534
+ async run(prompt: PromptInput, ctx?: { toolkit?: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
447
535
  checkPromptParts(prompt, this.wireOpts())
448
536
  return this.opts.style === "anthropic"
449
- ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
450
- : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
537
+ ? this.runAnthropic(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
538
+ : this.runOpenAI(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
451
539
  }
452
540
 
453
541
  /**
@@ -487,7 +575,7 @@ export class Client {
487
575
  if (req.maxTokens) body.max_tokens = req.maxTokens
488
576
 
489
577
  const signal = this.makeSignal(req.signal)
490
- {
578
+ try {
491
579
  const data = await this.llmCallJson(
492
580
  `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`,
493
581
  {
@@ -515,6 +603,8 @@ export class Client {
515
603
  model: this.opts.model,
516
604
  raw: data,
517
605
  }
606
+ } finally {
607
+ this.releaseSignal(signal)
518
608
  }
519
609
  }
520
610
 
@@ -549,7 +639,7 @@ export class Client {
549
639
  if (tc) body.tool_choice = tc
550
640
 
551
641
  const signal = this.makeSignal(req.signal)
552
- {
642
+ try {
553
643
  const data = await this.llmCallJson(
554
644
  endpoint,
555
645
  {
@@ -588,6 +678,8 @@ export class Client {
588
678
  model: this.opts.model,
589
679
  raw: data,
590
680
  }
681
+ } finally {
682
+ this.releaseSignal(signal)
591
683
  }
592
684
  }
593
685
 
@@ -609,10 +701,10 @@ export class Client {
609
701
  * the answer — so the next `ask` with the same `id` continues it. Without an
610
702
  * `id` it is a stateless one-shot (identical to `run`).
611
703
  */
612
- async ask(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
704
+ async ask(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
613
705
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
614
706
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
615
- if (ctx.on_text) {
707
+ if (ctx?.on_text) {
616
708
  let result: RunResult | undefined
617
709
  for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
618
710
  if (ev.type === "text") ctx.on_text(ev.delta)
@@ -620,7 +712,7 @@ export class Client {
620
712
  }
621
713
  return result!
622
714
  }
623
- if (!ctx.id) return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal })
715
+ if (!ctx?.id) return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal })
624
716
  const history = (await this.store.get(ctx.id)) ?? []
625
717
  const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
626
718
  await this.store.save(ctx.id, result.messages)
@@ -628,8 +720,8 @@ export class Client {
628
720
  }
629
721
 
630
722
  /** A stateful multi-turn conversation that retains history across sends. */
631
- conversation(ctx: { toolkit: Toolkit; signal?: AbortSignal }): Conversation {
632
- return new Conversation(this, ctx.toolkit, ctx.signal)
723
+ conversation(ctx?: { toolkit?: Toolkit; signal?: AbortSignal }): Conversation {
724
+ return new Conversation(this, ctx?.toolkit, ctx?.signal)
633
725
  }
634
726
 
635
727
  /**
@@ -637,28 +729,48 @@ export class Client {
637
729
  * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
638
730
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
639
731
  */
640
- async *stream(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
732
+ async *stream(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
641
733
  checkPromptParts(prompt, this.wireOpts())
642
- const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
734
+ const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined
643
735
  const gen = this.opts.style === "anthropic"
644
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
645
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history)
736
+ ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
737
+ : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history)
646
738
  for await (const ev of gen) {
647
- if (ev.type === "done" && ctx.id) await this.store.save(ctx.id, ev.result.messages)
739
+ if (ev.type === "done" && ctx?.id) await this.store.save(ctx.id, ev.result.messages)
648
740
  yield ev
649
741
  }
650
742
  }
651
743
 
652
- private system(toolkit: Toolkit): string {
653
- return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n")
744
+ /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
745
+ * the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
746
+ private system(toolkit?: Toolkit): string {
747
+ return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n")
654
748
  }
655
749
 
656
- /** 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. */
657
769
  private makeSignal(external?: AbortSignal): AbortSignal {
658
770
  const ctrl = new AbortController()
659
771
  if (this.opts.timeoutMs) {
660
772
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs)
661
- ;(t as any).unref?.()
773
+ this.runTimers.set(ctrl.signal, t)
662
774
  }
663
775
  if (external) {
664
776
  if (external.aborted) ctrl.abort(external.reason)
@@ -699,9 +811,9 @@ export class Client {
699
811
  }
700
812
 
701
813
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
702
- private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
814
+ private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
703
815
  const h = this.opts.hooks
704
- const source = toolkit.get(name)?.source ?? "custom"
816
+ const source = toolkit?.get(name)?.source ?? "custom"
705
817
  const t0 = Date.now()
706
818
  let a = args
707
819
  if (h?.beforeTool) {
@@ -714,7 +826,10 @@ export class Client {
714
826
  }
715
827
  if (ov?.args) a = ov.args
716
828
  }
717
- let result = await toolkit.execute(name, a)
829
+ // No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
830
+ let result: ToolResult = toolkit
831
+ ? await toolkit.execute(name, a)
832
+ : { output: `unknown tool: ${name}`, isError: true }
718
833
  // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
719
834
  // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
720
835
  // a tool error.
@@ -737,7 +852,7 @@ export class Client {
737
852
  const t0 = Date.now()
738
853
  try {
739
854
  const res = await this.llmFetch(url, init, signal)
740
- if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
855
+ if (!res.ok) throw await llmHttpError(res)
741
856
  const data: any = await res.json()
742
857
  const tok = perCall(data.usage, style)
743
858
  this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
@@ -748,10 +863,47 @@ export class Client {
748
863
  }
749
864
  }
750
865
 
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.
868
+ private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
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
+
751
904
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
752
- private async runOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
905
+ private async runOpenAIWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
753
906
  const key = resolveKey(this.opts)
754
- const signal = this.makeSignal(external)
755
907
  const runStart = Date.now()
756
908
  let messages: any[] = history && history.length ? [...history] : []
757
909
  if (!messages.length) {
@@ -759,7 +911,7 @@ export class Client {
759
911
  if (system) messages.push({ role: "system", content: system })
760
912
  }
761
913
  messages.push({ role: "user", content: prompt })
762
- let tools = toolkit.toOpenAI()
914
+ let tools = toolkit?.toOpenAI() ?? []
763
915
  const toolCalls: ToolCallRecord[] = []
764
916
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
765
917
  let turns = 0
@@ -832,32 +984,33 @@ export class Client {
832
984
  * no `waitFor` is configured (the run should stop and surface the request).
833
985
  */
834
986
  private async resolvePending(
835
- toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
987
+ toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
836
988
  ): Promise<{ result: ToolResult; halted?: Request }> {
837
989
  if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
838
990
  const answer = await this.opts.waitFor(request)
839
991
  if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
840
992
  const t0 = Date.now()
841
- let result = await toolkit.execute(name, args, { answer })
993
+ let result: ToolResult = toolkit
994
+ ? await toolkit.execute(name, args, { answer })
995
+ : { output: `unknown tool: ${name}`, isError: true }
842
996
  if (this.opts.hooks?.afterTool) {
843
997
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
844
998
  if (ov?.result) result = ov.result
845
999
  }
846
- this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
1000
+ this.emitTool(name, toolkit?.get(name)?.source ?? "custom", result, t0)
847
1001
  if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
848
1002
  return { result }
849
1003
  }
850
1004
 
851
1005
  // ---- Anthropic-style: POST {baseUrl}/messages ----
852
- private async runAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
1006
+ private async runAnthropicWithSignal(signal: AbortSignal, prompt: PromptInput, toolkit: Toolkit | undefined, history?: any[]): Promise<RunResult> {
853
1007
  const key = resolveKey(this.opts)
854
- const signal = this.makeSignal(external)
855
1008
  const base = this.opts.baseUrl.replace(/\/$/, "")
856
1009
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
857
1010
  const system = this.system(toolkit)
858
1011
  const runStart = Date.now()
859
1012
  let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
860
- let tools = toolkit.toAnthropic()
1013
+ let tools = toolkit?.toAnthropic() ?? []
861
1014
  const toolCalls: ToolCallRecord[] = []
862
1015
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
863
1016
  let turns = 0
@@ -923,9 +1076,8 @@ export class Client {
923
1076
  }
924
1077
 
925
1078
  // ---- Streaming: OpenAI-style ----
926
- private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit, 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> {
927
1080
  const key = resolveKey(this.opts)
928
- const signal = this.makeSignal(external)
929
1081
  const runStart = Date.now()
930
1082
  let messages: any[] = history && history.length ? [...history] : []
931
1083
  if (!messages.length) {
@@ -933,7 +1085,7 @@ export class Client {
933
1085
  if (system) messages.push({ role: "system", content: system })
934
1086
  }
935
1087
  messages.push({ role: "user", content: prompt })
936
- let tools = toolkit.toOpenAI()
1088
+ let tools = toolkit?.toOpenAI() ?? []
937
1089
  const toolCalls: ToolCallRecord[] = []
938
1090
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
939
1091
  let turns = 0
@@ -956,7 +1108,7 @@ export class Client {
956
1108
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
957
1109
  body: JSON.stringify(this.openaiBody(messages, tools, true)),
958
1110
  }, signal)
959
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
1111
+ if (!res.ok || !res.body) throw await llmHttpError(res)
960
1112
  for await (const line of sseLines(res.body)) {
961
1113
  if (!line.startsWith("data:")) continue
962
1114
  const payload = line.slice(5).trim()
@@ -1019,15 +1171,14 @@ export class Client {
1019
1171
  }
1020
1172
 
1021
1173
  // ---- Streaming: Anthropic-style ----
1022
- private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit, 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> {
1023
1175
  const key = resolveKey(this.opts)
1024
- const signal = this.makeSignal(external)
1025
1176
  const runStart = Date.now()
1026
1177
  const base = this.opts.baseUrl.replace(/\/$/, "")
1027
1178
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
1028
1179
  const system = this.system(toolkit)
1029
1180
  let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
1030
- let tools = toolkit.toAnthropic()
1181
+ let tools = toolkit?.toAnthropic() ?? []
1031
1182
  const toolCalls: ToolCallRecord[] = []
1032
1183
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1033
1184
  let turns = 0
@@ -1050,7 +1201,7 @@ export class Client {
1050
1201
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
1051
1202
  body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
1052
1203
  }, signal)
1053
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
1204
+ if (!res.ok || !res.body) throw await llmHttpError(res)
1054
1205
  for await (const line of sseLines(res.body)) {
1055
1206
  if (!line.startsWith("data:")) continue
1056
1207
  const j = safeParse(line.slice(5).trim())
@@ -1162,19 +1313,18 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
1162
1313
  const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
1163
1314
 
1164
1315
  /**
1165
- * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1166
- * HTTP types to construct.
1167
- *
1168
- * This is a second constructor, not a second seam: it builds on the same injectable
1169
- * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1170
- * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1316
+ * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
1317
+ * the reusable piece of {@link createInProcessClient} — assembles the wire request,
1318
+ * calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
1319
+ * caller that wants an in-process model behind a `fetch` slot (the top-level client,
1320
+ * or the agent runtime's `inProcess` option) goes through this ONE function, so there
1321
+ * is no second copy of the request/response assembly to drift.
1171
1322
  *
1172
1323
  * Streaming is refused rather than faked — see the thrown error.
1173
1324
  */
1174
- export function createInProcessClient(opts: InProcessOptions): Client {
1175
- const { generate, ...rest } = opts
1325
+ export function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch {
1176
1326
  if (typeof generate !== "function") {
1177
- throw new Error("toolnexus: createInProcessClient requires a `generate` function")
1327
+ throw new Error("toolnexus: createInProcessFetch requires a `generate` function")
1178
1328
  }
1179
1329
 
1180
1330
  const inProcessFetch: typeof fetch = async (_url, init) => {
@@ -1229,6 +1379,25 @@ export function createInProcessClient(opts: InProcessOptions): Client {
1229
1379
  )
1230
1380
  }
1231
1381
 
1382
+ return inProcessFetch
1383
+ }
1384
+
1385
+ /**
1386
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1387
+ * HTTP types to construct.
1388
+ *
1389
+ * This is a second constructor, not a second seam: it builds on the same injectable
1390
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1391
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1392
+ * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
1393
+ * is just that adapter wired into a `Client`.
1394
+ *
1395
+ * Streaming is refused rather than faked — see {@link createInProcessFetch}.
1396
+ */
1397
+ export function createInProcessClient(opts: InProcessOptions): Client {
1398
+ const { generate, ...rest } = opts
1399
+ const inProcessFetch = createInProcessFetch(generate)
1400
+
1232
1401
  return new Client({
1233
1402
  // Retries default to ZERO here, unlike a network client. There is no wire, so
1234
1403
  // there is no transient failure to ride out: whatever `generate` throws will
@@ -1249,7 +1418,7 @@ export function createInProcessClient(opts: InProcessOptions): Client {
1249
1418
  export class Conversation {
1250
1419
  /** Full running transcript (system + user + assistant + tool messages). */
1251
1420
  messages: any[] = []
1252
- constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
1421
+ constructor(private readonly client: Client, private readonly toolkit?: Toolkit, private readonly signal?: AbortSignal) {}
1253
1422
 
1254
1423
  /** Send the next user turn; prior history is retained automatically. */
1255
1424
  async send(prompt: PromptInput): Promise<RunResult> {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./builtin.js"
7
7
  export * from "./native.js"
8
8
  export * from "./http.js"
9
9
  export * from "./a2a.js"
10
+ export * from "./acp.js"
10
11
  export * from "./serve.js"
11
12
  export * from "./mcpserve.js"
12
13
  export * from "./adapters.js"