toolnexus 0.16.0 → 0.18.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/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.18.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,8 +17,10 @@
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",
21
- "example": "node --experimental-strip-types examples/basic.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 test/classifier.test.ts",
21
+ "example": "node --experimental-strip-types examples/basic.ts",
22
+ "example:judge": "node --experimental-strip-types examples/judge.ts",
23
+ "example:multimodal": "node --experimental-strip-types examples/multimodal.ts"
22
24
  },
23
25
  "keywords": [
24
26
  "mcp",
@@ -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"
@@ -71,6 +72,18 @@ export function guardedHooks(guardrails: Guardrail[] | undefined, hooks: Hooks |
71
72
  beforeTool: async (ev) => {
72
73
  for (const g of guardrails) {
73
74
  const verdict = g(ev)
75
+ // A guardrail is SYNCHRONOUS by contract. An async one returns a Promise,
76
+ // which is truthy and !== "allow", so it would silently DENY every call
77
+ // with `denied: [object Promise]`. Fail loudly instead, and say where
78
+ // asynchronous work belongs.
79
+ if (verdict != null && typeof verdict !== "string") {
80
+ const what = typeof (verdict as { then?: unknown }).then === "function" ? "a Promise" : typeof verdict
81
+ throw new TypeError(
82
+ `guardrail returned ${what}: a guardrail must return a string synchronously ` +
83
+ `("" or "allow" to permit, any other string to deny). ` +
84
+ `Do asynchronous work in hooks.beforeTool, which is awaited.`,
85
+ )
86
+ }
74
87
  if (verdict && verdict !== "allow") {
75
88
  return { result: { output: `denied: ${verdict}`, isError: true } }
76
89
  }
@@ -114,8 +127,8 @@ export function allTodosDone(result: RunResult): Verdict {
114
127
  * it owes an Answer or a fix.
115
128
  */
116
129
  export async function runGated(
117
- ask: (prompt: string) => Promise<RunResult>,
118
- prompt: string,
130
+ ask: (prompt: PromptInput) => Promise<RunResult>,
131
+ prompt: PromptInput,
119
132
  completion: Completion | undefined,
120
133
  ): Promise<RunResult> {
121
134
  if (!completion) return ask(prompt)
@@ -187,14 +200,14 @@ export class Loop {
187
200
  return this.#turns
188
201
  }
189
202
 
190
- async run(prompt: string, opts: LoopRunOptions = {}): Promise<Outcome> {
203
+ async run(prompt: PromptInput, opts: LoopRunOptions = {}): Promise<Outcome> {
191
204
  const completion = this.agent.spec.completion
192
205
  const client = createClient(this.#clientOptions(opts))
193
206
  this.#status = "running"
194
207
 
195
208
  let attempts = 0
196
209
  let history: any[] = []
197
- const ask = async (p: string): Promise<RunResult> => {
210
+ const ask = async (p: PromptInput): Promise<RunResult> => {
198
211
  attempts++
199
212
  const r = await client.run(p, { toolkit: this.toolkit, history })
200
213
  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