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/dist/mcp.js CHANGED
@@ -10,6 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
10
10
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
11
11
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { sanitize } from "./types.js";
13
+ import { summarizePart } from "./content.js";
13
14
  // ---------------------------------------------------------------------------
14
15
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
15
16
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -78,6 +79,48 @@ export function expandEnvHeaders(headers) {
78
79
  }
79
80
  return out;
80
81
  }
82
+ /**
83
+ * §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
84
+ * server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
85
+ * `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
86
+ * carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
87
+ * takes it there.
88
+ */
89
+ export function mcpContentToParts(content) {
90
+ if (!Array.isArray(content))
91
+ return undefined;
92
+ const parts = [];
93
+ for (const item of content) {
94
+ const it = item;
95
+ if (!it || typeof it !== "object")
96
+ continue;
97
+ // `data` / `blob` are base64 STRINGS in the SDK, never bytes — passed through verbatim
98
+ // so no encoder of ours can drift them away from what the server sent.
99
+ if (it.type === "image" && typeof it.data === "string") {
100
+ parts.push({ type: "image", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data });
101
+ }
102
+ else if (it.type === "audio" && typeof it.data === "string") {
103
+ parts.push({ type: "audio", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data });
104
+ }
105
+ else if (it.type === "resource_link" && it.uri) {
106
+ const part = { type: "file", mimeType: String(it.mimeType ?? "application/octet-stream"), url: String(it.uri) };
107
+ if (it.name)
108
+ part.name = String(it.name);
109
+ parts.push(part);
110
+ }
111
+ else if (it.type === "resource" && typeof it.resource?.blob === "string") {
112
+ const part = { type: "file", mimeType: String(it.resource.mimeType ?? "application/octet-stream"), data: it.resource.blob };
113
+ if (it.resource.uri)
114
+ part.name = String(it.resource.uri);
115
+ parts.push(part);
116
+ }
117
+ }
118
+ return parts.length ? parts : undefined;
119
+ }
120
+ /** An image-only result must not answer the model with "". Name what came back instead. */
121
+ function describeMcpParts(parts) {
122
+ return parts ? parts.map(summarizePart).join("\n") : "";
123
+ }
81
124
  function formatToolErrorContent(content) {
82
125
  if (!Array.isArray(content))
83
126
  return "MCP tool returned an error";
@@ -92,11 +135,17 @@ function isTextContent(value) {
92
135
  value.type === "text" &&
93
136
  typeof value.text === "string");
94
137
  }
138
+ /** An embedded resource carrying text is text: it joins `output` in place, keeping the
139
+ * server's ordering, rather than being discarded with the other non-text entries. */
140
+ function isResourceText(value) {
141
+ const v = value;
142
+ return v?.type === "resource" && typeof v?.resource?.text === "string";
143
+ }
95
144
  function joinTextContent(content) {
96
145
  if (!Array.isArray(content))
97
146
  return "";
98
147
  return content
99
- .flatMap((item) => (isTextContent(item) ? [item.text] : []))
148
+ .flatMap((item) => (isTextContent(item) ? [item.text] : isResourceText(item) ? [item.resource.text] : []))
100
149
  .join("\n");
101
150
  }
102
151
  /**
@@ -166,13 +215,19 @@ function convertTool(server, def, client, timeout) {
166
215
  async execute(args, ctx) {
167
216
  try {
168
217
  const result = await client.callTool({ name: def.name, arguments: args ?? {} }, undefined, { resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout });
218
+ // Parts are collected BEFORE any branch returns: the isError and structuredContent
219
+ // short-circuits both read `content[]` on the way past, so neither reintroduces the
220
+ // silent drop this mapping exists to remove (§2).
221
+ const parts = mcpContentToParts(result.content);
222
+ const withParts = (r) => (parts ? { ...r, parts } : r);
169
223
  if (result.isError) {
170
- return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } };
224
+ return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } });
171
225
  }
172
226
  if (result.structuredContent !== undefined && result.structuredContent !== null) {
173
- return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } };
227
+ return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } });
174
228
  }
175
- return { output: joinTextContent(result.content), isError: false, metadata: { server } };
229
+ const text = joinTextContent(result.content);
230
+ return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } });
176
231
  }
177
232
  catch (e) {
178
233
  return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
package/dist/mcpserve.js CHANGED
@@ -63,7 +63,33 @@ export function buildMcpServer(tools, cfg, onCall) {
63
63
  // Host callback errors are isolated.
64
64
  }
65
65
  }
66
- return { content: [{ type: "text", text: result.output }], isError: result.isError };
66
+ return { content: [{ type: "text", text: result.output }, ...partBlocks(result.parts)], isError: result.isError };
67
67
  });
68
68
  return server;
69
69
  }
70
+ /**
71
+ * §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
72
+ * text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
73
+ * resource (a url-backed file keeps its uri, a byte-backed one carries the blob).
74
+ */
75
+ function partBlocks(parts) {
76
+ if (!parts?.length)
77
+ return [];
78
+ const out = [];
79
+ for (const p of parts) {
80
+ if (p.type === "text")
81
+ out.push({ type: "text", text: p.text });
82
+ else if (p.type === "image" && p.data)
83
+ out.push({ type: "image", data: p.data, mimeType: p.mimeType });
84
+ else if (p.type === "audio" && p.data)
85
+ out.push({ type: "audio", data: p.data, mimeType: p.mimeType });
86
+ else {
87
+ const uri = p.url ?? `file:///${p.name ?? "resource"}`;
88
+ const resource = { uri, mimeType: p.mimeType };
89
+ if (p.data)
90
+ resource.blob = p.data;
91
+ out.push({ type: "resource", resource });
92
+ }
93
+ }
94
+ return out;
95
+ }
package/dist/translate.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * OpenAI function-calling posture). When toolnexus owns the conversation, use the agent
13
13
  * loop with relay tools + `runWithAnswer` (§10) instead.
14
14
  */
15
+ import { encodeParts, inboundPart } from "./content.js";
15
16
  /**
16
17
  * Maps a provider stop reason onto an OpenAI finish reason. Tool calls win: a turn that
17
18
  * emitted any tool call is always `"tool_calls"` to a conforming client.
@@ -134,17 +135,44 @@ export function openAIMessagesToAnthropic(messages) {
134
135
  }
135
136
  default: {
136
137
  flush();
138
+ // A `content` array has its text parts concatenated and its NON-text parts translated
139
+ // into Anthropic's block shape (§11). Six ports used to pass a text-empty array through
140
+ // raw and undocumented; one specified mapping replaces that, so nothing is flattened
141
+ // away and nothing is dropped.
142
+ const blocks = contentPartBlocks(m.content);
143
+ if (blocks) {
144
+ out.push({ role: "user", content: blocks });
145
+ break;
146
+ }
137
147
  const s = contentText(m.content);
138
148
  if (s)
139
149
  out.push({ role: "user", content: s });
140
- else if (Array.isArray(m.content) && m.content.length)
141
- out.push({ role: "user", content: m.content });
142
150
  }
143
151
  }
144
152
  }
145
153
  flush();
146
154
  return { messages: out, system: systemParts.join("\n\n") };
147
155
  }
156
+ /**
157
+ * Translate an OpenAI-shaped `content` array into Anthropic blocks — but ONLY when it
158
+ * actually carries something non-text; an all-text array still concatenates, so the
159
+ * common case is byte-identical. Both spellings are accepted: a literal `ContentPart`
160
+ * and the provider-native block that same part encodes to.
161
+ */
162
+ function contentPartBlocks(content) {
163
+ if (!Array.isArray(content) || content.length === 0)
164
+ return undefined;
165
+ const parts = [];
166
+ for (const raw of content) {
167
+ const p = inboundPart(raw);
168
+ if (!p)
169
+ return undefined; // an array we do not understand is left to contentText, as before
170
+ parts.push(p);
171
+ }
172
+ if (parts.every((p) => p.type === "text"))
173
+ return undefined;
174
+ return encodeParts(parts, { style: "anthropic", provenance: "attached" });
175
+ }
148
176
  /**
149
177
  * Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
150
178
  * already provider-native pass through; anything unrecognized is skipped.
package/dist/types.d.ts CHANGED
@@ -8,10 +8,18 @@ export type JSONSchema = {
8
8
  additionalProperties?: boolean;
9
9
  [k: string]: unknown;
10
10
  };
11
+ import type { ContentPart } from "./content.js";
11
12
  export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
12
13
  export interface ToolResult {
13
14
  output: string;
14
15
  isError: boolean;
16
+ /**
17
+ * §1B non-text output. `output` stays required and remains what the transcript,
18
+ * compaction, token estimation and any text-only provider see; `parts` is the extra
19
+ * channel a screenshot / PDF / audio clip travels on. Absent ⇒ byte-identical to a
20
+ * pre-0.17 result, which is why it is optional rather than a fourth required field.
21
+ */
22
+ parts?: ContentPart[];
15
23
  metadata?: Record<string, unknown>;
16
24
  }
17
25
  export interface ToolContext {
package/dist/wire.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Canonical transcript → provider wire messages (SPEC.md §8A).
3
+ *
4
+ * The loop's `messages` array is the **canonical transcript**: a user turn carrying parts
5
+ * holds `ContentPart[]`, and a tool turn carrying parts holds them under a `parts` key.
6
+ * Neither shape is a wire shape. This module is the single place that turns the canonical
7
+ * transcript into what each style actually accepts, so the four loop sites (run/stream ×
8
+ * openai/anthropic) only ever have to *record* parts, never encode them.
9
+ *
10
+ * It is also where the **tool-result relocation rule** lives. Anthropic defines blocks
11
+ * inside `tool_result.content`, so its parts are emitted natively, keyed to their
12
+ * `tool_use_id`. OpenAI's `tool` message rejects an image outright (a hard 400 — "Image URLs
13
+ * are only allowed for messages with role 'user'"), so there every non-text part from every
14
+ * tool result answering one assistant turn is relocated, in tool-call order, into ONE
15
+ * synthetic `user` message emitted immediately after the last tool message. That synthetic
16
+ * message is an adapter artifact: it is built here, on the way out, and never written back
17
+ * to the transcript, the `ConversationStore` or `translate` output — which is what keeps a
18
+ * mid-conversation provider switch free of OpenAI-shaped residue.
19
+ */
20
+ import type { UnsupportedPartMode } from "./content.js";
21
+ /** Shared shaping options for a wire build. */
22
+ export interface WireOptions {
23
+ onUnsupportedPart?: UnsupportedPartMode;
24
+ maxPartBytes?: number;
25
+ }
26
+ /** The text preceding each relocated part, so the model can attribute it to its call. */
27
+ export declare const relocationHeader: (name: string, id: string) => string;
28
+ /**
29
+ * Build the OpenAI-style wire messages. Tool messages carry `output` plus text parts only;
30
+ * their non-text parts are collected and flushed as one synthetic user message the moment
31
+ * the run of tool messages ends.
32
+ */
33
+ export declare function toOpenAIWire(messages: any[], opts?: WireOptions): any[];
34
+ /**
35
+ * Build the Anthropic-style wire messages. Nothing is relocated: a `tool_result` block that
36
+ * carries parts gets them natively inside its own `content`, keyed to its `tool_use_id`.
37
+ */
38
+ export declare function toAnthropicWire(messages: any[], opts?: WireOptions): any[];
39
+ /** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
40
+ export declare function checkPromptParts(prompt: unknown, opts: WireOptions): void;
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.16.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 test/in-process.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