toolnexus 0.16.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/README.md CHANGED
@@ -235,6 +235,52 @@ For `httpTool`: URL `{placeholders}` are filled from args; `query` arg names go
235
235
  querystring (all args on GET); the rest become the JSON body (`body: "json" | "form" | "raw"`).
236
236
  Non-2xx → `{ isError: true, output: "HTTP <status>: <body>" }`.
237
237
 
238
+ ## Images, PDFs and audio (content parts)
239
+
240
+ Parts go in the **first** argument, next to your text, because the order of text and image is
241
+ semantic to a model:
242
+
243
+ ```ts
244
+ import { attach, text } from "toolnexus"
245
+
246
+ const { text: answer } = await agent.run(
247
+ ["What is broken in this screenshot?", await attach("./shot.png")],
248
+ { toolkit: tk },
249
+ )
250
+ ```
251
+
252
+ `attach(source, opts?)` takes what you already hold: a filesystem path, a `data:` URL, an
253
+ `https:` URL, native bytes (`Uint8Array`, `Buffer`, `ArrayBuffer`, any `ArrayBufferView` —
254
+ `mimeType` required, bytes carry no extension), or a `Blob`/`File` straight off an upload.
255
+ `fromPath`, `fromBytes`, `fromBlob`, `fromDataUrl`, `fromUrl` are the same thing spelled
256
+ explicitly. Mime types come from a fixed extension table (`png jpg jpeg gif webp pdf mp3 wav`) —
257
+ never sniffed, never read from the machine's mime database, so every port agrees. A plain string
258
+ prompt is untouched and byte-identical to before.
259
+
260
+ A tool can answer with media too — `parts` alongside the still-required `output`, which is what
261
+ the transcript, compaction and any text-only provider keep seeing:
262
+
263
+ ```ts
264
+ defineTool({
265
+ name: "screenshot",
266
+ description: "Grab the screen",
267
+ inputSchema: { type: "object", properties: {} },
268
+ run: async () => ({
269
+ output: "screenshot, 1280x720 png",
270
+ parts: [await attach("./shot.png")],
271
+ }),
272
+ })
273
+ ```
274
+
275
+ **MCP tools' images now survive.** Non-text content from an MCP server — image, audio, embedded
276
+ resource, `resource_link` — used to be filtered out silently; it is preserved as `parts` and
277
+ emitted per provider style (Anthropic inside `tool_result`; OpenAI, whose `tool` message rejects
278
+ an image, in a synthetic user message that never enters your transcript).
279
+
280
+ Whatever you pass in, a part stores **bytes plus a `mimeType`** — never a handle, never a path —
281
+ so a persisted transcript replays without the original file. A `Blob` is read **eagerly** at
282
+ construction, and a handle you supply is read, not closed.
283
+
238
284
  ## Built-in tools
239
285
 
240
286
  A fifth source ships **10 built-in tools** — `bash`, `read`, `write`, `edit`, `grep`, `glob`,
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Level-1 subagent surface — SPEC.md §7D.
3
+ *
4
+ * One new noun: `agent(name, spec)`. An Agent is (identity/system prompt × a
5
+ * filtered toolkit view × the shipped client loop), and — the §7A/§7B axiom
6
+ * completed locally — an Agent IS a Tool: `asTool()` bridges it into the classic
7
+ * API's `extraTools`; `run()` executes one-shot against a private runtime.
8
+ * Everything compiles down to the six host verbs in runtime.ts.
9
+ */
10
+ import type { PromptInput } from "../content.js";
1
11
  import type { Answer, Request, Tool } from "../types.js";
2
12
  import { AgentRuntime, type AgentDef, type Budget, type RuntimeOptions, type TaskResult } from "./runtime.js";
3
13
  import { Loop, type Completion, type Guardrail } from "./loop.js";
@@ -71,7 +81,7 @@ export declare class Agent {
71
81
  * the tree down — unless the run suspends durably (`status:"pending"`), in which
72
82
  * case the tree stays parked and `result.runtime.resume(answer)` continues it.
73
83
  */
74
- run(prompt: string, opts?: AgentRunOptions): Promise<AgentRunResult>;
84
+ run(prompt: PromptInput, opts?: AgentRunOptions): Promise<AgentRunResult>;
75
85
  /**
76
86
  * The bridge: an Agent IS a Tool. Drop the result into the classic API's
77
87
  * `extraTools`; the agent's own loop runs in isolation and the classic run
@@ -1,12 +1,3 @@
1
- /**
2
- * Level-1 subagent surface — SPEC.md §7D.
3
- *
4
- * One new noun: `agent(name, spec)`. An Agent is (identity/system prompt × a
5
- * filtered toolkit view × the shipped client loop), and — the §7A/§7B axiom
6
- * completed locally — an Agent IS a Tool: `asTool()` bridges it into the classic
7
- * API's `extraTools`; `run()` executes one-shot against a private runtime.
8
- * Everything compiles down to the six host verbs in runtime.ts.
9
- */
10
1
  import fs from "node:fs";
11
2
  import { defineTool } from "../native.js";
12
3
  import { AgentRuntime, isVerbError } from "./runtime.js";
@@ -1,28 +1,3 @@
1
- /**
2
- * Context compaction (SPEC.md §7F) — keep a long-lived agent under budget.
3
- *
4
- * A long-running agent grows its transcript until it overflows the model's window.
5
- * `compactor(opts)` returns a **`beforeLLM` hook** (§8) that summarizes the older
6
- * transcript and keeps a recent tail. It rides the existing seam: the client loop
7
- * applies a `beforeLLM` message rewrite by *replacing* the working transcript, and
8
- * that array flows on into `RunResult.messages` and the `ConversationStore`. So
9
- * compaction is a pure `messages → messages` helper and adds **no loop behavior** —
10
- * it is the canonical use of the §8 `beforeLLM` hook.
11
- *
12
- * Invariants (SPEC §7F):
13
- * 1. At/below `maxTokens` the hook is a **no-op** — it returns nothing and the run
14
- * is byte-identical to a run with no compactor.
15
- * 2. **Tool-pair safety** — the retained tail always begins at a `user` turn, so no
16
- * `tool` message is ever orphaned from the `assistant` carrying its `tool_call_id`.
17
- * 3. The **leading system prompt** (identity / soul / skills) is preserved verbatim;
18
- * only the body between it and the tail is summarized.
19
- *
20
- * import { agents } from "toolnexus"
21
- * const client = createClient({
22
- * …,
23
- * hooks: { beforeLLM: agents.compactor({ maxTokens: 120_000, summarize }) },
24
- * })
25
- */
26
1
  /**
27
2
  * A chat message in the working transcript. Structural and provider-neutral — only the
28
3
  * fields compaction reasons about are named; anything else on the message is carried
@@ -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
  * Cheap, deterministic token estimate: `ceil(chars/4)` over each message's JSON
28
29
  * serialization, summed. This is an *estimator*, not a tokenizer — exactness is the
@@ -30,10 +31,48 @@
30
31
  */
31
32
  export function estimateTokens(messages) {
32
33
  let total = 0;
33
- for (const m of messages)
34
- total += Math.ceil(JSON.stringify(m).length / 4);
34
+ for (const m of messages) {
35
+ const { text, partBytes } = stripPartPayloads(m);
36
+ total += Math.ceil(text.length / 4);
37
+ // §1B: a media part is charged from its BYTE LENGTH. Scoring it by its `mimeType`
38
+ // string would price a 5 MB image at ~3 tokens, so the compactor would evict text and
39
+ // keep every image forever — the exact opposite of what a budget is for. Charging the
40
+ // base64 verbatim is the other failure: it prices an 82-byte PNG like 30 lines of prose.
41
+ for (const bytes of partBytes)
42
+ total += partTokens(bytes);
43
+ }
35
44
  return total;
36
45
  }
46
+ /** Keys whose string value is a media payload rather than prose. */
47
+ const PAYLOAD_KEYS = new Set(["data", "file_data", "blob"]);
48
+ /**
49
+ * Serialize a message for estimation with its media payloads replaced by "", returning the
50
+ * decoded byte length of each one so the caller can charge it properly. Recognises both the
51
+ * canonical `ContentPart` (`data`) and every provider block shape it encodes to
52
+ * (`image_url.url` as a data: URL, `file.file_data`, `input_audio.data`).
53
+ */
54
+ function stripPartPayloads(m) {
55
+ const partBytes = [];
56
+ const walk = (v, key) => {
57
+ if (typeof v === "string") {
58
+ const b64 = PAYLOAD_KEYS.has(key ?? "") ? v : v.startsWith("data:") ? v.slice(v.indexOf(",") + 1) : undefined;
59
+ if (b64 === undefined)
60
+ return v;
61
+ partBytes.push(Math.max(0, Math.floor((b64.length * 3) / 4)));
62
+ return "";
63
+ }
64
+ if (Array.isArray(v))
65
+ return v.map((x) => walk(x));
66
+ if (v && typeof v === "object") {
67
+ const out = {};
68
+ for (const [k, val] of Object.entries(v))
69
+ out[k] = walk(val, k);
70
+ return out;
71
+ }
72
+ return v;
73
+ };
74
+ return { text: JSON.stringify(walk(m)) ?? "", partBytes };
75
+ }
37
76
  /** The system reminder injected when `flushToMemory` is set. */
38
77
  const FLUSH_TO_MEMORY_REMINDER = "Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized.";
39
78
  /** Prefix on the summary system message; hosts/tests key off this to detect a compaction. */
@@ -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 { 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";
@@ -77,7 +78,7 @@ export declare function allTodosDone(result: RunResult): Verdict;
77
78
  * keeps `pending` and `incomplete` distinct — the caller can always tell whether
78
79
  * it owes an Answer or a fix.
79
80
  */
80
- export declare function runGated(ask: (prompt: string) => Promise<RunResult>, prompt: string, completion: Completion | undefined): Promise<RunResult>;
81
+ export declare function runGated(ask: (prompt: PromptInput) => Promise<RunResult>, prompt: PromptInput, completion: Completion | undefined): Promise<RunResult>;
81
82
  /** A live execution of an Agent. Its only verbs are `run` and reading state. */
82
83
  export declare class Loop {
83
84
  #private;
@@ -94,5 +95,5 @@ export declare class Loop {
94
95
  get status(): string;
95
96
  /** Model round trips this loop has spent. */
96
97
  get turns(): number;
97
- run(prompt: string, opts?: LoopRunOptions): Promise<Outcome>;
98
+ run(prompt: PromptInput, opts?: LoopRunOptions): Promise<Outcome>;
98
99
  }
@@ -1,17 +1,3 @@
1
- /**
2
- * Loop — a live execution of an Agent, and the completion gate that stops it
3
- * claiming `done` too early. A layer over the shipped §8 client: nothing here
4
- * changes existing behaviour.
5
- *
6
- * The placement law this encodes:
7
- *
8
- * AgentSpec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
9
- * RunOptions answers "with WHAT?" — model for this call. Per call.
10
- * Loop answers "DID it?" — status, turns. Observed.
11
- * none of them answers "is it RIGHT?"— a tool, skill or agent.
12
- *
13
- * So Loop takes no options: it is read, not configured.
14
- */
15
1
  import { createClient } from "../client.js";
16
2
  /**
17
3
  * Compiles a spec's guardrails into one `beforeTool` with FIRST-DENY-WINS,
@@ -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 { type ClientStyle, type ConversationStore, type Hooks, type MetricEvent } from "../client.js";
18
19
  import { type Completion } from "./loop.js";
19
20
  import type { Answer, Request, Tool } from "../types.js";
@@ -132,11 +133,11 @@ interface Pools {
132
133
  */
133
134
  interface Checkpoint {
134
135
  /** The exact turn input (prompt + rendered inbox drain) to replay. */
135
- input: string;
136
+ input: PromptInput;
136
137
  }
137
138
  interface QueuedWake {
138
139
  h: Handle;
139
- prompt?: string;
140
+ prompt?: PromptInput;
140
141
  }
141
142
  /**
142
143
  * A live agent. Public surface is the observable state (§7D pins transitions, not
@@ -266,7 +267,7 @@ export declare class AgentRuntime {
266
267
  * the wake queues FIFO and a completing sibling transfers its slot. Waking a
267
268
  * suspended handle is a no-op (items buffer; only the Answer wakes it).
268
269
  */
269
- wake(h: Handle, prompt?: string): {
270
+ wake(h: Handle, prompt?: PromptInput): {
270
271
  ok: true;
271
272
  } | ({
272
273
  ok: false;
@@ -1,19 +1,3 @@
1
- /**
2
- * Agent runtime substrate — SPEC.md §7D.
3
- *
4
- * A Handle is a live agent: state machine (`idle → running → idle|suspended|closed`,
5
- * `suspended → running` only via the Answer to its pending Request), an inbox held as
6
- * agent state, hierarchical budgets, and a deterministic parent-scoped id
7
- * (`root/coordinator.1/explore.2`). The runtime exposes exactly six host verbs
8
- * (spawn/post/wake/wait/interrupt/close) plus read-only list/inspect views, and owns
9
- * the cross-cutting infrastructure: ONE ConversationStore for all handles
10
- * (conversation id = handle id, so transcripts genuinely survive turns), an
11
- * injectable clock (fixtures run virtual), and the handle table.
12
- *
13
- * Everything rides shipped machinery: the §8 client loop, §10 pending/waitFor, and
14
- * the ConversationStore. SPEC pins transitions, never scheduling — conformance is
15
- * identical per-handle transition traces against `examples/subagent-*` fixtures.
16
- */
17
1
  import { createClient, InMemoryConversationStore } from "../client.js";
18
2
  import { runGated } from "./loop.js";
19
3
  import { createToolkit } from "../toolkit.js";
@@ -563,7 +547,14 @@ export class AgentRuntime {
563
547
  * snapshot and the input is checkpointed — resume replays the whole turn.
564
548
  */
565
549
  async executeTurn(h, prompt, oneShotWaitFor) {
566
- const input = (prompt ?? "") + this.drainInbox(h);
550
+ // A parts prompt keeps its ordering; the drained inbox joins it as a trailing text part,
551
+ // which is the same append the string path does.
552
+ const drained = this.drainInbox(h);
553
+ const input = typeof prompt === "string" || prompt === undefined
554
+ ? (prompt ?? "") + drained
555
+ : drained
556
+ ? [...prompt, { type: "text", text: drained }]
557
+ : prompt;
567
558
  let result;
568
559
  try {
569
560
  await h.ready;
package/dist/builtin.js CHANGED
@@ -12,6 +12,7 @@ import fs from "node:fs/promises";
12
12
  import { existsSync, readdirSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { pending } from "./types.js";
15
+ import { mediaTypeFor } from "./content.js";
15
16
  /**
16
17
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
17
18
  * `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
@@ -171,7 +172,7 @@ function bashTool() {
171
172
  }));
172
173
  }
173
174
  function readTool() {
174
- return builtin("read", "Read a UTF-8 text file. With offset/limit, return only that line window.", {
175
+ return builtin("read", "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.", {
175
176
  type: "object",
176
177
  properties: {
177
178
  path: { type: "string", description: "Path to the file to read" },
@@ -184,13 +185,33 @@ function readTool() {
184
185
  const p = String(args.path ?? "");
185
186
  if (!p)
186
187
  return err("read: path is required");
187
- let content;
188
+ let bytes;
188
189
  try {
189
- content = await fs.readFile(p, "utf8");
190
+ bytes = await fs.readFile(p);
190
191
  }
191
192
  catch (e) {
192
193
  return err(`read: ${e instanceof Error ? e.message : String(e)}`);
193
194
  }
195
+ // §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
196
+ // sniffing and no platform mime database, whose contents vary per machine.
197
+ const media = mediaTypeFor(p);
198
+ if (media) {
199
+ return {
200
+ output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
201
+ isError: false,
202
+ parts: [{ type: media.type, mimeType: media.mimeType, data: bytes.toString("base64"), ...(media.type === "file" ? { name: path.basename(p) } : {}) }],
203
+ };
204
+ }
205
+ let content;
206
+ try {
207
+ // `fatal` so undecodable bytes are an isError RESULT, not U+FFFD soup — and never a
208
+ // raised exception escaping execute() into the loop. `ignoreBOM` keeps a leading BOM,
209
+ // as readFile(…, "utf8") does, so text reads stay byte-identical.
210
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
211
+ }
212
+ catch {
213
+ return err(`read: ${p} is not valid UTF-8 text and has no recognised media extension`);
214
+ }
194
215
  if (args.offset === undefined && args.limit === undefined)
195
216
  return ok(content);
196
217
  const lines = content.split("\n");
package/dist/client.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js";
7
7
  import type { ToolResult, Request, Answer } from "./types.js";
8
+ import type { PromptInput, UnsupportedPartMode } from "./content.js";
8
9
  import type { TranslateRequest, TranslateResult } from "./translate.js";
9
10
  export type ClientStyle = "openai" | "anthropic";
10
11
  export interface ClientOptions {
@@ -50,6 +51,13 @@ export interface ClientOptions {
50
51
  * today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
51
52
  * There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
52
53
  onError?: (info: ErrorInfo) => ErrorTier;
54
+ /** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
55
+ * +33% base64 string). Unset ⇒ no limit, as today. */
56
+ maxPartBytes?: number;
57
+ /** §8A. Overrides the provenance rule uniformly: `"error"` fails on any part the style
58
+ * cannot represent (attached or tool-derived), `"text"` degrades every one of them to a
59
+ * text placeholder. Unset ⇒ attached parts error, derived parts degrade. */
60
+ onUnsupportedPart?: UnsupportedPartMode;
53
61
  }
54
62
  /** §8 Resilience. What to do with a failed LLM call. */
55
63
  export type ErrorTier = "retry" | "fail";
@@ -243,6 +251,8 @@ export declare class Client {
243
251
  private openaiBody;
244
252
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
245
253
  private anthropicBody;
254
+ /** The §8A/§1B shaping options every wire build shares. */
255
+ private wireOpts;
246
256
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
247
257
  metrics(): string;
248
258
  /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
@@ -252,7 +262,7 @@ export declare class Client {
252
262
  private endRun;
253
263
  /** Emit a `run` error metric event (once, on a thrown run). */
254
264
  private emitRunError;
255
- run(prompt: string, ctx: {
265
+ run(prompt: PromptInput, ctx: {
256
266
  toolkit: Toolkit;
257
267
  signal?: AbortSignal;
258
268
  history?: any[];
@@ -285,7 +295,7 @@ export declare class Client {
285
295
  * the answer — so the next `ask` with the same `id` continues it. Without an
286
296
  * `id` it is a stateless one-shot (identical to `run`).
287
297
  */
288
- ask(prompt: string, ctx: {
298
+ ask(prompt: PromptInput, ctx: {
289
299
  toolkit: Toolkit;
290
300
  id?: string;
291
301
  on_text?: (delta: string) => void;
@@ -301,7 +311,7 @@ export declare class Client {
301
311
  * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
302
312
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
303
313
  */
304
- stream(prompt: string, ctx: {
314
+ stream(prompt: PromptInput, ctx: {
305
315
  toolkit: Toolkit;
306
316
  id?: string;
307
317
  signal?: AbortSignal;
@@ -387,7 +397,7 @@ export declare class Conversation {
387
397
  messages: any[];
388
398
  constructor(client: Client, toolkit: Toolkit, signal?: AbortSignal | undefined);
389
399
  /** Send the next user turn; prior history is retained automatically. */
390
- send(prompt: string): Promise<RunResult>;
400
+ send(prompt: PromptInput): Promise<RunResult>;
391
401
  /** Reset the conversation memory. */
392
402
  reset(): void;
393
403
  }
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { pendingOf } from "./types.js";
2
+ import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
2
3
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
3
4
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
4
5
  export class InMemoryConversationStore {
@@ -207,7 +208,7 @@ export class Client {
207
208
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
208
209
  * matches the pre-change body so a no-options call is byte-identical. */
209
210
  openaiBody(messages, tools, stream) {
210
- const b = { model: this.opts.model, messages };
211
+ const b = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) };
211
212
  if (tools.length) {
212
213
  b.tools = tools;
213
214
  b.tool_choice = "auto";
@@ -220,13 +221,17 @@ export class Client {
220
221
  }
221
222
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
222
223
  anthropicBody(system, messages, tools, stream) {
223
- const b = { model: this.opts.model, max_tokens: 4096, system, messages };
224
+ const b = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) };
224
225
  if (tools.length)
225
226
  b.tools = tools;
226
227
  if (stream)
227
228
  b.stream = true;
228
229
  return this.finalizeBody(b);
229
230
  }
231
+ /** The §8A/§1B shaping options every wire build shares. */
232
+ wireOpts() {
233
+ return { onUnsupportedPart: this.opts.onUnsupportedPart, maxPartBytes: this.opts.maxPartBytes };
234
+ }
230
235
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
231
236
  metrics() {
232
237
  return this.registry.render();
@@ -247,6 +252,7 @@ export class Client {
247
252
  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) });
248
253
  }
249
254
  async run(prompt, ctx) {
255
+ checkPromptParts(prompt, this.wireOpts());
250
256
  return this.opts.style === "anthropic"
251
257
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
252
258
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history);
@@ -433,6 +439,7 @@ export class Client {
433
439
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
434
440
  */
435
441
  async *stream(prompt, ctx) {
442
+ checkPromptParts(prompt, this.wireOpts());
436
443
  const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
437
444
  const gen = this.opts.style === "anthropic"
438
445
  ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
@@ -602,7 +609,7 @@ export class Client {
602
609
  // on resume). Mirrors the streaming path.
603
610
  for (const s of settled) {
604
611
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
605
- messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output });
612
+ messages.push(toolMessage(s.call.id, s.call.function.name, s.result));
606
613
  if (s.halted)
607
614
  return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
608
615
  }
@@ -710,7 +717,7 @@ export class Client {
710
717
  let halted;
711
718
  for (const s of settled) {
712
719
  toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
713
- content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError });
720
+ content.push(toolResultBlock(s.use.id, s.result));
714
721
  if (s.halted) {
715
722
  halted = s.halted;
716
723
  break;
@@ -820,13 +827,13 @@ export class Client {
820
827
  result = r.result;
821
828
  if (r.halted) {
822
829
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
823
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
830
+ messages.push(toolMessage(c.id, c.name, result));
824
831
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
825
832
  return;
826
833
  }
827
834
  }
828
835
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
829
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
836
+ messages.push(toolMessage(c.id, c.name, result));
830
837
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
831
838
  }
832
839
  }
@@ -927,14 +934,14 @@ export class Client {
927
934
  result = r.result;
928
935
  if (r.halted) {
929
936
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
930
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
937
+ results.push(toolResultBlock(u.id, result));
931
938
  messages.push({ role: "user", content: results });
932
939
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
933
940
  return;
934
941
  }
935
942
  }
936
943
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
937
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
944
+ results.push(toolResultBlock(u.id, result));
938
945
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
939
946
  }
940
947
  messages.push({ role: "user", content: results });
@@ -1086,6 +1093,26 @@ function safeJson(s) {
1086
1093
  return {};
1087
1094
  }
1088
1095
  }
1096
+ /**
1097
+ * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
1098
+ * with parts it also carries them (and the tool `name` the §8A relocation header needs) —
1099
+ * both consumed and stripped by the wire build, so neither reaches OpenAI.
1100
+ */
1101
+ function toolMessage(id, name, result) {
1102
+ const m = { role: "tool", tool_call_id: id, content: result.output };
1103
+ if (result.parts?.length) {
1104
+ m.name = name;
1105
+ m.parts = result.parts;
1106
+ }
1107
+ return m;
1108
+ }
1109
+ /** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
1110
+ function toolResultBlock(id, result) {
1111
+ const b = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError };
1112
+ if (result.parts?.length)
1113
+ b.parts = result.parts;
1114
+ return b;
1115
+ }
1089
1116
  function lastText(messages) {
1090
1117
  for (let i = messages.length - 1; i >= 0; i--) {
1091
1118
  if (messages[i].role === "assistant" && typeof messages[i].content === "string")