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 +46 -0
- package/dist/agents/agent.d.ts +11 -1
- package/dist/agents/agent.js +0 -9
- package/dist/agents/compaction.d.ts +0 -25
- package/dist/agents/compaction.js +41 -2
- package/dist/agents/loop.d.ts +3 -2
- package/dist/agents/loop.js +0 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/client.d.ts +14 -4
- package/dist/client.js +35 -8
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- package/dist/translate.js +30 -2
- package/dist/types.d.ts +8 -0
- package/dist/wire.d.ts +40 -0
- package/dist/wire.js +104 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +5 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/client.ts +53 -16
- package/src/content.ts +448 -0
- package/src/index.ts +2 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- package/src/translate.ts +25 -1
- package/src/types.ts +9 -0
- package/src/wire.ts +131 -0
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`,
|
package/dist/agents/agent.d.ts
CHANGED
|
@@ -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:
|
|
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
|
package/dist/agents/agent.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/agents/loop.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
98
|
+
run(prompt: PromptInput, opts?: LoopRunOptions): Promise<Outcome>;
|
|
98
99
|
}
|
package/dist/agents/loop.js
CHANGED
|
@@ -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,
|
package/dist/agents/runtime.d.ts
CHANGED
|
@@ -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:
|
|
136
|
+
input: PromptInput;
|
|
136
137
|
}
|
|
137
138
|
interface QueuedWake {
|
|
138
139
|
h: Handle;
|
|
139
|
-
prompt?:
|
|
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?:
|
|
270
|
+
wake(h: Handle, prompt?: PromptInput): {
|
|
270
271
|
ok: true;
|
|
271
272
|
} | ({
|
|
272
273
|
ok: false;
|
package/dist/agents/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
188
|
+
let bytes;
|
|
188
189
|
try {
|
|
189
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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")
|