toolnexus 0.15.0 → 0.17.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/wire.js ADDED
@@ -0,0 +1,104 @@
1
+ import { encodeParts, isContentPart, validatePart } from "./content.js";
2
+ /** The text preceding each relocated part, so the model can attribute it to its call. */
3
+ export const relocationHeader = (name, id) => `Output of tool ${name} (${id}):`;
4
+ /** Split parts into the text ones (which ride along in place) and the rest. */
5
+ function split(parts) {
6
+ const texts = [];
7
+ const others = [];
8
+ for (const p of parts)
9
+ (p.type === "text" ? texts : others).push(p);
10
+ return { texts, others };
11
+ }
12
+ function partsOf(m) {
13
+ const p = m?.parts;
14
+ return Array.isArray(p) && p.length ? p : undefined;
15
+ }
16
+ /** True when a message's `content` is a canonical `ContentPart[]` rather than a wire array. */
17
+ function isPartArray(content) {
18
+ return Array.isArray(content) && content.length > 0 && content.every(isContentPart);
19
+ }
20
+ /**
21
+ * Build the OpenAI-style wire messages. Tool messages carry `output` plus text parts only;
22
+ * their non-text parts are collected and flushed as one synthetic user message the moment
23
+ * the run of tool messages ends.
24
+ */
25
+ export function toOpenAIWire(messages, opts = {}) {
26
+ const out = [];
27
+ let relocated = [];
28
+ const flush = () => {
29
+ if (!relocated.length)
30
+ return;
31
+ const blocks = [];
32
+ for (const r of relocated) {
33
+ blocks.push({ type: "text", text: relocationHeader(r.name, r.id) });
34
+ blocks.push(...encodeParts(r.parts, { style: "openai", provenance: "derived", ...opts }));
35
+ }
36
+ relocated = [];
37
+ out.push({ role: "user", content: blocks });
38
+ };
39
+ for (const m of messages ?? []) {
40
+ if (m?.role === "tool") {
41
+ const parts = partsOf(m);
42
+ const { parts: _drop, ...rest } = m ?? {};
43
+ if (!parts) {
44
+ out.push(m);
45
+ continue;
46
+ }
47
+ const { texts, others } = split(parts);
48
+ const msg = { ...rest };
49
+ if (texts.length) {
50
+ msg.content = [{ type: "text", text: String(rest.content ?? "") }, ...encodeParts(texts, { style: "openai", provenance: "derived", ...opts })];
51
+ }
52
+ out.push(msg);
53
+ if (others.length)
54
+ relocated.push({ name: String(rest.name ?? ""), id: String(rest.tool_call_id ?? ""), parts: others });
55
+ continue;
56
+ }
57
+ flush();
58
+ if (isPartArray(m?.content)) {
59
+ out.push({ ...m, content: encodeParts(m.content, { style: "openai", provenance: "attached", ...opts }) });
60
+ continue;
61
+ }
62
+ out.push(m);
63
+ }
64
+ flush();
65
+ return out;
66
+ }
67
+ /**
68
+ * Build the Anthropic-style wire messages. Nothing is relocated: a `tool_result` block that
69
+ * carries parts gets them natively inside its own `content`, keyed to its `tool_use_id`.
70
+ */
71
+ export function toAnthropicWire(messages, opts = {}) {
72
+ return (messages ?? []).map((m) => {
73
+ if (isPartArray(m?.content)) {
74
+ return { ...m, content: encodeParts(m.content, { style: "anthropic", provenance: "attached", ...opts }) };
75
+ }
76
+ if (!Array.isArray(m?.content))
77
+ return m;
78
+ let touched = false;
79
+ const content = m.content.map((block) => {
80
+ if (block?.type !== "tool_result")
81
+ return block;
82
+ const parts = partsOf(block);
83
+ if (!parts)
84
+ return block;
85
+ touched = true;
86
+ const { parts: _drop, ...rest } = block;
87
+ return {
88
+ ...rest,
89
+ content: [
90
+ { type: "text", text: String(rest.content ?? "") },
91
+ ...encodeParts(parts, { style: "anthropic", provenance: "derived", ...opts }),
92
+ ],
93
+ };
94
+ });
95
+ return touched ? { ...m, content } : m;
96
+ });
97
+ }
98
+ /** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
99
+ export function checkPromptParts(prompt, opts) {
100
+ if (!Array.isArray(prompt))
101
+ return;
102
+ for (const p of prompt)
103
+ validatePart(p, opts.maxPartBytes);
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.15.0",
3
+ "version": "0.17.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/home.test.ts test/compaction.test.ts test/loop.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/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts"
22
22
  },
23
23
  "keywords": [
@@ -7,6 +7,7 @@
7
7
  * API's `extraTools`; `run()` executes one-shot against a private runtime.
8
8
  * Everything compiles down to the six host verbs in runtime.ts.
9
9
  */
10
+ import type { PromptInput } from "../content.js"
10
11
  import fs from "node:fs"
11
12
  import { defineTool } from "../native.js"
12
13
  import type { Answer, Request, Tool } from "../types.js"
@@ -113,7 +114,7 @@ export class Agent {
113
114
  * the tree down — unless the run suspends durably (`status:"pending"`), in which
114
115
  * case the tree stays parked and `result.runtime.resume(answer)` continues it.
115
116
  */
116
- async run(prompt: string, opts: AgentRunOptions = {}): Promise<AgentRunResult> {
117
+ async run(prompt: PromptInput, opts: AgentRunOptions = {}): Promise<AgentRunResult> {
117
118
  const runtime = this.createRuntime(opts)
118
119
  const h = runtime.spawn(runtime.root, this.name)
119
120
  if (isVerbError(h)) {
@@ -23,6 +23,7 @@
23
23
  * hooks: { beforeLLM: agents.compactor({ maxTokens: 120_000, summarize }) },
24
24
  * })
25
25
  */
26
+ import { partTokens } from "../content.js"
26
27
 
27
28
  /**
28
29
  * A chat message in the working transcript. Structural and provider-neutral — only the
@@ -44,10 +45,47 @@ export interface Message {
44
45
  */
45
46
  export function estimateTokens(messages: Message[]): number {
46
47
  let total = 0
47
- for (const m of messages) total += Math.ceil(JSON.stringify(m).length / 4)
48
+ for (const m of messages) {
49
+ const { text, partBytes } = stripPartPayloads(m)
50
+ total += Math.ceil(text.length / 4)
51
+ // §1B: a media part is charged from its BYTE LENGTH. Scoring it by its `mimeType`
52
+ // string would price a 5 MB image at ~3 tokens, so the compactor would evict text and
53
+ // keep every image forever — the exact opposite of what a budget is for. Charging the
54
+ // base64 verbatim is the other failure: it prices an 82-byte PNG like 30 lines of prose.
55
+ for (const bytes of partBytes) total += partTokens(bytes)
56
+ }
48
57
  return total
49
58
  }
50
59
 
60
+ /** Keys whose string value is a media payload rather than prose. */
61
+ const PAYLOAD_KEYS = new Set(["data", "file_data", "blob"])
62
+
63
+ /**
64
+ * Serialize a message for estimation with its media payloads replaced by "", returning the
65
+ * decoded byte length of each one so the caller can charge it properly. Recognises both the
66
+ * canonical `ContentPart` (`data`) and every provider block shape it encodes to
67
+ * (`image_url.url` as a data: URL, `file.file_data`, `input_audio.data`).
68
+ */
69
+ function stripPartPayloads(m: Message): { text: string; partBytes: number[] } {
70
+ const partBytes: number[] = []
71
+ const walk = (v: unknown, key?: string): unknown => {
72
+ if (typeof v === "string") {
73
+ const b64 = PAYLOAD_KEYS.has(key ?? "") ? v : v.startsWith("data:") ? v.slice(v.indexOf(",") + 1) : undefined
74
+ if (b64 === undefined) return v
75
+ partBytes.push(Math.max(0, Math.floor((b64.length * 3) / 4)))
76
+ return ""
77
+ }
78
+ if (Array.isArray(v)) return v.map((x) => walk(x))
79
+ if (v && typeof v === "object") {
80
+ const out: Record<string, unknown> = {}
81
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = walk(val, k)
82
+ return out
83
+ }
84
+ return v
85
+ }
86
+ return { text: JSON.stringify(walk(m)) ?? "", partBytes }
87
+ }
88
+
51
89
  /** Options for {@link compactor}. */
52
90
  export interface CompactorOptions {
53
91
  /** Compact only when the transcript estimate exceeds this; at/below ⇒ no-op. */
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * So Loop takes no options: it is read, not configured.
14
14
  */
15
+ import type { PromptInput } from "../content.js"
15
16
  import { createClient, type ClientOptions, type Hooks, type RunResult } from "../client.js"
16
17
  import type { Toolkit } from "../toolkit.js"
17
18
  import type { Agent } from "./agent.js"
@@ -114,8 +115,8 @@ export function allTodosDone(result: RunResult): Verdict {
114
115
  * it owes an Answer or a fix.
115
116
  */
116
117
  export async function runGated(
117
- ask: (prompt: string) => Promise<RunResult>,
118
- prompt: string,
118
+ ask: (prompt: PromptInput) => Promise<RunResult>,
119
+ prompt: PromptInput,
119
120
  completion: Completion | undefined,
120
121
  ): Promise<RunResult> {
121
122
  if (!completion) return ask(prompt)
@@ -187,14 +188,14 @@ export class Loop {
187
188
  return this.#turns
188
189
  }
189
190
 
190
- async run(prompt: string, opts: LoopRunOptions = {}): Promise<Outcome> {
191
+ async run(prompt: PromptInput, opts: LoopRunOptions = {}): Promise<Outcome> {
191
192
  const completion = this.agent.spec.completion
192
193
  const client = createClient(this.#clientOptions(opts))
193
194
  this.#status = "running"
194
195
 
195
196
  let attempts = 0
196
197
  let history: any[] = []
197
- const ask = async (p: string): Promise<RunResult> => {
198
+ const ask = async (p: PromptInput): Promise<RunResult> => {
198
199
  attempts++
199
200
  const r = await client.run(p, { toolkit: this.toolkit, history })
200
201
  this.#turns += r.turns
@@ -14,6 +14,7 @@
14
14
  * the ConversationStore. SPEC pins transitions, never scheduling — conformance is
15
15
  * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
16
  */
17
+ import type { PromptInput } from "../content.js"
17
18
  import { createClient, InMemoryConversationStore, type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js"
18
19
  import { runGated, type Completion } from "./loop.js"
19
20
  import { createToolkit, type Toolkit } from "../toolkit.js"
@@ -176,12 +177,12 @@ interface Pools {
176
177
  */
177
178
  interface Checkpoint {
178
179
  /** The exact turn input (prompt + rendered inbox drain) to replay. */
179
- input: string
180
+ input: PromptInput
180
181
  }
181
182
 
182
183
  interface QueuedWake {
183
184
  h: Handle
184
- prompt?: string
185
+ prompt?: PromptInput
185
186
  }
186
187
 
187
188
  /**
@@ -387,7 +388,7 @@ export class AgentRuntime {
387
388
  * the wake queues FIFO and a completing sibling transfers its slot. Waking a
388
389
  * suspended handle is a no-op (items buffer; only the Answer wakes it).
389
390
  */
390
- wake(h: Handle, prompt?: string): { ok: true } | ({ ok: false } & VerbError) {
391
+ wake(h: Handle, prompt?: PromptInput): { ok: true } | ({ ok: false } & VerbError) {
391
392
  if (h.state === "closed") return { ok: false, error: `closed: ${h.id}` }
392
393
  if (h.state === "suspended") return { ok: true } // buffers; suspended→running ONLY via the Answer
393
394
  if (h.state === "running") return { ok: true } // already awake; inbox drains next turn
@@ -750,8 +751,15 @@ export class AgentRuntime {
750
751
  * When the Run halts pending, the stored transcript is REWOUND to the pre-turn
751
752
  * snapshot and the input is checkpointed — resume replays the whole turn.
752
753
  */
753
- private async executeTurn(h: Handle, prompt?: string, oneShotWaitFor?: (req: Request) => Promise<Answer>): Promise<TaskResult> {
754
- const input = (prompt ?? "") + this.drainInbox(h)
754
+ private async executeTurn(h: Handle, prompt?: PromptInput, oneShotWaitFor?: (req: Request) => Promise<Answer>): Promise<TaskResult> {
755
+ // A parts prompt keeps its ordering; the drained inbox joins it as a trailing text part,
756
+ // which is the same append the string path does.
757
+ const drained = this.drainInbox(h)
758
+ const input: PromptInput = typeof prompt === "string" || prompt === undefined
759
+ ? (prompt ?? "") + drained
760
+ : drained
761
+ ? [...prompt, { type: "text" as const, text: drained }]
762
+ : prompt
755
763
  let result: TaskResult
756
764
  try {
757
765
  await h.ready
package/src/builtin.ts CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readdirSync } from "node:fs"
13
13
  import path from "node:path"
14
14
  import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
15
15
  import { pending } from "./types.js"
16
+ import { mediaTypeFor } from "./content.js"
16
17
 
17
18
  /** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
18
19
  export type BuiltinsConfig =
@@ -184,7 +185,7 @@ function bashTool(): Tool {
184
185
  function readTool(): Tool {
185
186
  return builtin(
186
187
  "read",
187
- "Read a UTF-8 text file. With offset/limit, return only that line window.",
188
+ "Read a file. Recognised media (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a content part; anything else is read as UTF-8 text, with offset/limit returning that line window.",
188
189
  {
189
190
  type: "object",
190
191
  properties: {
@@ -198,12 +199,31 @@ function readTool(): Tool {
198
199
  async (args) => {
199
200
  const p = String(args.path ?? "")
200
201
  if (!p) return err("read: path is required")
201
- let content: string
202
+ let bytes: Buffer
202
203
  try {
203
- content = await fs.readFile(p, "utf8")
204
+ bytes = await fs.readFile(p)
204
205
  } catch (e) {
205
206
  return err(`read: ${e instanceof Error ? e.message : String(e)}`)
206
207
  }
208
+ // §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
209
+ // sniffing and no platform mime database, whose contents vary per machine.
210
+ const media = mediaTypeFor(p)
211
+ if (media) {
212
+ return {
213
+ output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
214
+ isError: false,
215
+ parts: [{ type: media.type, mimeType: media.mimeType, data: bytes.toString("base64"), ...(media.type === "file" ? { name: path.basename(p) } : {}) } as any],
216
+ }
217
+ }
218
+ let content: string
219
+ try {
220
+ // `fatal` so undecodable bytes are an isError RESULT, not U+FFFD soup — and never a
221
+ // raised exception escaping execute() into the loop. `ignoreBOM` keeps a leading BOM,
222
+ // as readFile(…, "utf8") does, so text reads stay byte-identical.
223
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes)
224
+ } catch {
225
+ return err(`read: ${p} is not valid UTF-8 text and has no recognised media extension`)
226
+ }
207
227
  if (args.offset === undefined && args.limit === undefined) return ok(content)
208
228
  const lines = content.split("\n")
209
229
  const offset = typeof args.offset === "number" ? Math.max(1, Math.trunc(args.offset)) : 1
package/src/client.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import type { Toolkit } from "./toolkit.js"
7
7
  import type { ToolResult, Request, Answer } from "./types.js"
8
8
  import { pendingOf } from "./types.js"
9
+ import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
10
+ import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
9
11
  import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
10
12
  import {
11
13
  finishReasonFor,
@@ -62,6 +64,13 @@ export interface ClientOptions {
62
64
  * today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
63
65
  * There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
64
66
  onError?: (info: ErrorInfo) => ErrorTier
67
+ /** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
68
+ * +33% base64 string). Unset ⇒ no limit, as today. */
69
+ maxPartBytes?: number
70
+ /** §8A. Overrides the provenance rule uniformly: `"error"` fails on any part the style
71
+ * cannot represent (attached or tool-derived), `"text"` degrades every one of them to a
72
+ * text placeholder. Unset ⇒ attached parts error, derived parts degrade. */
73
+ onUnsupportedPart?: UnsupportedPartMode
65
74
  }
66
75
 
67
76
  /** §8 Resilience. What to do with a failed LLM call. */
@@ -384,7 +393,7 @@ export class Client {
384
393
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
385
394
  * matches the pre-change body so a no-options call is byte-identical. */
386
395
  private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
387
- const b: Record<string, any> = { model: this.opts.model, messages }
396
+ const b: Record<string, any> = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) }
388
397
  if (tools.length) {
389
398
  b.tools = tools
390
399
  b.tool_choice = "auto"
@@ -398,12 +407,17 @@ export class Client {
398
407
 
399
408
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
400
409
  private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
401
- const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
410
+ const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) }
402
411
  if (tools.length) b.tools = tools
403
412
  if (stream) b.stream = true
404
413
  return this.finalizeBody(b)
405
414
  }
406
415
 
416
+ /** The §8A/§1B shaping options every wire build shares. */
417
+ private wireOpts(): WireOptions {
418
+ return { onUnsupportedPart: this.opts.onUnsupportedPart, maxPartBytes: this.opts.maxPartBytes }
419
+ }
420
+
407
421
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
408
422
  metrics(): string {
409
423
  return this.registry.render()
@@ -427,7 +441,8 @@ export class Client {
427
441
  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) })
428
442
  }
429
443
 
430
- async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
444
+ async run(prompt: PromptInput, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
445
+ checkPromptParts(prompt, this.wireOpts())
431
446
  return this.opts.style === "anthropic"
432
447
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
433
448
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
@@ -592,7 +607,7 @@ export class Client {
592
607
  * the answer — so the next `ask` with the same `id` continues it. Without an
593
608
  * `id` it is a stateless one-shot (identical to `run`).
594
609
  */
595
- async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
610
+ async ask(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
596
611
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
597
612
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
598
613
  if (ctx.on_text) {
@@ -620,7 +635,8 @@ export class Client {
620
635
  * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
621
636
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
622
637
  */
623
- async *stream(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
638
+ async *stream(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
639
+ checkPromptParts(prompt, this.wireOpts())
624
640
  const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
625
641
  const gen = this.opts.style === "anthropic"
626
642
  ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
@@ -730,7 +746,7 @@ export class Client {
730
746
  }
731
747
 
732
748
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
733
- private async runOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
749
+ private async runOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
734
750
  const key = resolveKey(this.opts)
735
751
  const signal = this.makeSignal(external)
736
752
  const runStart = Date.now()
@@ -783,7 +799,7 @@ export class Client {
783
799
  // on resume). Mirrors the streaming path.
784
800
  for (const s of settled) {
785
801
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
786
- messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output })
802
+ messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
787
803
  if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
788
804
  }
789
805
  }
@@ -830,7 +846,7 @@ export class Client {
830
846
  }
831
847
 
832
848
  // ---- Anthropic-style: POST {baseUrl}/messages ----
833
- private async runAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
849
+ private async runAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
834
850
  const key = resolveKey(this.opts)
835
851
  const signal = this.makeSignal(external)
836
852
  const base = this.opts.baseUrl.replace(/\/$/, "")
@@ -890,7 +906,7 @@ export class Client {
890
906
  let halted: Request | undefined
891
907
  for (const s of settled) {
892
908
  toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
893
- content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError })
909
+ content.push(toolResultBlock(s.use.id, s.result))
894
910
  if (s.halted) { halted = s.halted; break }
895
911
  }
896
912
  messages.push({ role: "user", content })
@@ -904,7 +920,7 @@ export class Client {
904
920
  }
905
921
 
906
922
  // ---- Streaming: OpenAI-style ----
907
- private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
923
+ private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
908
924
  const key = resolveKey(this.opts)
909
925
  const signal = this.makeSignal(external)
910
926
  const runStart = Date.now()
@@ -982,13 +998,13 @@ export class Client {
982
998
  result = r.result
983
999
  if (r.halted) {
984
1000
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
985
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
1001
+ messages.push(toolMessage(c.id, c.name, result))
986
1002
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
987
1003
  return
988
1004
  }
989
1005
  }
990
1006
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
991
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
1007
+ messages.push(toolMessage(c.id, c.name, result))
992
1008
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
993
1009
  }
994
1010
  }
@@ -1000,7 +1016,7 @@ export class Client {
1000
1016
  }
1001
1017
 
1002
1018
  // ---- Streaming: Anthropic-style ----
1003
- private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1019
+ private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1004
1020
  const key = resolveKey(this.opts)
1005
1021
  const signal = this.makeSignal(external)
1006
1022
  const runStart = Date.now()
@@ -1073,14 +1089,14 @@ export class Client {
1073
1089
  result = r.result
1074
1090
  if (r.halted) {
1075
1091
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1076
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
1092
+ results.push(toolResultBlock(u.id, result))
1077
1093
  messages.push({ role: "user", content: results })
1078
1094
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
1079
1095
  return
1080
1096
  }
1081
1097
  }
1082
1098
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
1083
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
1099
+ results.push(toolResultBlock(u.id, result))
1084
1100
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
1085
1101
  }
1086
1102
  messages.push({ role: "user", content: results })
@@ -1097,6 +1113,135 @@ export function createClient(opts: ClientOptions): Client {
1097
1113
  return new Client(opts)
1098
1114
  }
1099
1115
 
1116
+
1117
+ // --------------------------------------------------------------------------- //
1118
+ // In-process models (SPEC §8 Gap 2, semantic form).
1119
+ // --------------------------------------------------------------------------- //
1120
+
1121
+ /** One tool call an in-process model asks for. Flat on purpose: the nested
1122
+ * `function: {}` wrapper is a wire detail, not something a model author should type.
1123
+ * `arguments` may be a structured value (encoded for you) or an encoded string. */
1124
+ export interface InProcessToolCall {
1125
+ id?: string
1126
+ name: string
1127
+ arguments?: unknown
1128
+ }
1129
+
1130
+ /** What an in-process model is handed: the assembled request for THIS call. */
1131
+ export interface InProcessRequest {
1132
+ messages: any[]
1133
+ tools?: any[]
1134
+ model: string
1135
+ /** Every other key the client assembled, for a model that wants them. */
1136
+ body: Record<string, any>
1137
+ }
1138
+
1139
+ /** What an in-process model returns: exactly one assistant message. Return `content`
1140
+ * to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
1141
+ * omitted the run reports zero tokens rather than failing. */
1142
+ export interface InProcessResponse {
1143
+ content?: string
1144
+ toolCalls?: InProcessToolCall[]
1145
+ usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
1146
+ }
1147
+
1148
+ /** Options for {@link createInProcessClient}: every client option EXCEPT the three
1149
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
1150
+ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
1151
+ /** Your model. Receives the assembled request, returns one assistant message. */
1152
+ generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
1153
+ }
1154
+
1155
+ /** The sentinel base URL. Never dialled — the transport below answers every request
1156
+ * before the network is reached — but the client builds a URL string internally, so
1157
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
1158
+ * name can never resolve. */
1159
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
1160
+
1161
+ /**
1162
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1163
+ * HTTP types to construct.
1164
+ *
1165
+ * This is a second constructor, not a second seam: it builds on the same injectable
1166
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1167
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1168
+ *
1169
+ * Streaming is refused rather than faked — see the thrown error.
1170
+ */
1171
+ export function createInProcessClient(opts: InProcessOptions): Client {
1172
+ const { generate, ...rest } = opts
1173
+ if (typeof generate !== "function") {
1174
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function")
1175
+ }
1176
+
1177
+ const inProcessFetch: typeof fetch = async (_url, init) => {
1178
+ const body = JSON.parse(String((init as any)?.body ?? "{}"))
1179
+
1180
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
1181
+ // to be many. Content and delta-count assertions cannot tell that from a real
1182
+ // stream, so refuse instead of degrading silently.
1183
+ if (body.stream) {
1184
+ throw new Error(
1185
+ "toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
1186
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.",
1187
+ )
1188
+ }
1189
+
1190
+ const answer = await generate({
1191
+ messages: body.messages ?? [],
1192
+ tools: body.tools,
1193
+ model: body.model,
1194
+ body,
1195
+ })
1196
+
1197
+ const message: Record<string, any> = { role: "assistant" }
1198
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
1199
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
1200
+ id: c.id ?? `call_${i}`,
1201
+ type: "function",
1202
+ function: {
1203
+ name: c.name,
1204
+ // A string is already encoded; anything else is encoded here so a model
1205
+ // author never has to think about the wire.
1206
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1207
+ },
1208
+ }))
1209
+ } else {
1210
+ message.content = answer.content ?? ""
1211
+ }
1212
+
1213
+ const u = answer.usage ?? {}
1214
+ const prompt = u.promptTokens ?? 0
1215
+ const completion = u.completionTokens ?? 0
1216
+ return new Response(
1217
+ JSON.stringify({
1218
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1219
+ usage: {
1220
+ prompt_tokens: prompt,
1221
+ completion_tokens: completion,
1222
+ total_tokens: u.totalTokens ?? prompt + completion,
1223
+ },
1224
+ }),
1225
+ { status: 200, headers: { "content-type": "application/json" } },
1226
+ )
1227
+ }
1228
+
1229
+ return new Client({
1230
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1231
+ // there is no transient failure to ride out: whatever `generate` throws will
1232
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1233
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1234
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1235
+ retries: 0,
1236
+ ...(rest as ClientOptions),
1237
+ baseUrl: IN_PROCESS_BASE_URL,
1238
+ style: "openai",
1239
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1240
+ apiKey: "in-process",
1241
+ fetch: inProcessFetch,
1242
+ })
1243
+ }
1244
+
1100
1245
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
1101
1246
  export class Conversation {
1102
1247
  /** Full running transcript (system + user + assistant + tool messages). */
@@ -1104,7 +1249,7 @@ export class Conversation {
1104
1249
  constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
1105
1250
 
1106
1251
  /** Send the next user turn; prior history is retained automatically. */
1107
- async send(prompt: string): Promise<RunResult> {
1252
+ async send(prompt: PromptInput): Promise<RunResult> {
1108
1253
  const result = await this.client.run(prompt, { toolkit: this.toolkit, signal: this.signal, history: this.messages })
1109
1254
  this.messages = result.messages
1110
1255
  return result
@@ -1150,6 +1295,27 @@ function safeJson(s: string): Record<string, unknown> {
1150
1295
  }
1151
1296
  }
1152
1297
 
1298
+ /**
1299
+ * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
1300
+ * with parts it also carries them (and the tool `name` the §8A relocation header needs) —
1301
+ * both consumed and stripped by the wire build, so neither reaches OpenAI.
1302
+ */
1303
+ function toolMessage(id: string | undefined, name: string, result: ToolResult): any {
1304
+ const m: any = { role: "tool", tool_call_id: id, content: result.output }
1305
+ if (result.parts?.length) {
1306
+ m.name = name
1307
+ m.parts = result.parts
1308
+ }
1309
+ return m
1310
+ }
1311
+
1312
+ /** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
1313
+ function toolResultBlock(id: string | undefined, result: ToolResult): any {
1314
+ const b: any = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError }
1315
+ if (result.parts?.length) b.parts = result.parts
1316
+ return b
1317
+ }
1318
+
1153
1319
  function lastText(messages: any[]): string {
1154
1320
  for (let i = messages.length - 1; i >= 0; i--) {
1155
1321
  if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content