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/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,
@@ -27,6 +13,16 @@ export function guardedHooks(guardrails, hooks) {
27
13
  beforeTool: async (ev) => {
28
14
  for (const g of guardrails) {
29
15
  const verdict = g(ev);
16
+ // A guardrail is SYNCHRONOUS by contract. An async one returns a Promise,
17
+ // which is truthy and !== "allow", so it would silently DENY every call
18
+ // with `denied: [object Promise]`. Fail loudly instead, and say where
19
+ // asynchronous work belongs.
20
+ if (verdict != null && typeof verdict !== "string") {
21
+ const what = typeof verdict.then === "function" ? "a Promise" : typeof verdict;
22
+ throw new TypeError(`guardrail returned ${what}: a guardrail must return a string synchronously ` +
23
+ `("" or "allow" to permit, any other string to deny). ` +
24
+ `Do asynchronous work in hooks.beforeTool, which is awaited.`);
25
+ }
30
26
  if (verdict && verdict !== "allow") {
31
27
  return { result: { output: `denied: ${verdict}`, isError: true } };
32
28
  }
@@ -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");