toolnexus 0.15.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 +59 -4
- package/dist/client.js +113 -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 +182 -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/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.
|
|
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",
|
|
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": [
|
package/src/agents/agent.ts
CHANGED
|
@@ -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:
|
|
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)) {
|
package/src/agents/compaction.ts
CHANGED
|
@@ -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)
|
|
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. */
|
package/src/agents/loop.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 { 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:
|
|
118
|
-
prompt:
|
|
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:
|
|
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:
|
|
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
|
package/src/agents/runtime.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 { 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:
|
|
180
|
+
input: PromptInput
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
interface QueuedWake {
|
|
183
184
|
h: Handle
|
|
184
|
-
prompt?:
|
|
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?:
|
|
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?:
|
|
754
|
-
|
|
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
|
|
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
|
|
202
|
+
let bytes: Buffer
|
|
202
203
|
try {
|
|
203
|
-
|
|
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
|
package/src/client.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import type { Toolkit } from "./toolkit.js"
|
|
7
7
|
import type { ToolResult, Request, Answer } from "./types.js"
|
|
8
8
|
import { pendingOf } from "./types.js"
|
|
9
|
+
import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
|
|
10
|
+
import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
|
|
9
11
|
import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
|
|
10
12
|
import {
|
|
11
13
|
finishReasonFor,
|
|
@@ -62,6 +64,13 @@ export interface ClientOptions {
|
|
|
62
64
|
* today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
|
|
63
65
|
* There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
|
|
64
66
|
onError?: (info: ErrorInfo) => ErrorTier
|
|
67
|
+
/** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
|
|
68
|
+
* +33% base64 string). Unset ⇒ no limit, as today. */
|
|
69
|
+
maxPartBytes?: number
|
|
70
|
+
/** §8A. Overrides the provenance rule uniformly: `"error"` fails on any part the style
|
|
71
|
+
* cannot represent (attached or tool-derived), `"text"` degrades every one of them to a
|
|
72
|
+
* text placeholder. Unset ⇒ attached parts error, derived parts degrade. */
|
|
73
|
+
onUnsupportedPart?: UnsupportedPartMode
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/** §8 Resilience. What to do with a failed LLM call. */
|
|
@@ -384,7 +393,7 @@ export class Client {
|
|
|
384
393
|
/** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
|
|
385
394
|
* matches the pre-change body so a no-options call is byte-identical. */
|
|
386
395
|
private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
|
|
387
|
-
const b: Record<string, any> = { model: this.opts.model, messages }
|
|
396
|
+
const b: Record<string, any> = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) }
|
|
388
397
|
if (tools.length) {
|
|
389
398
|
b.tools = tools
|
|
390
399
|
b.tool_choice = "auto"
|
|
@@ -398,12 +407,17 @@ export class Client {
|
|
|
398
407
|
|
|
399
408
|
/** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
|
|
400
409
|
private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
|
|
401
|
-
const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
|
|
410
|
+
const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) }
|
|
402
411
|
if (tools.length) b.tools = tools
|
|
403
412
|
if (stream) b.stream = true
|
|
404
413
|
return this.finalizeBody(b)
|
|
405
414
|
}
|
|
406
415
|
|
|
416
|
+
/** The §8A/§1B shaping options every wire build shares. */
|
|
417
|
+
private wireOpts(): WireOptions {
|
|
418
|
+
return { onUnsupportedPart: this.opts.onUnsupportedPart, maxPartBytes: this.opts.maxPartBytes }
|
|
419
|
+
}
|
|
420
|
+
|
|
407
421
|
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
408
422
|
metrics(): string {
|
|
409
423
|
return this.registry.render()
|
|
@@ -427,7 +441,8 @@ export class Client {
|
|
|
427
441
|
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) })
|
|
428
442
|
}
|
|
429
443
|
|
|
430
|
-
async run(prompt:
|
|
444
|
+
async run(prompt: PromptInput, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
|
|
445
|
+
checkPromptParts(prompt, this.wireOpts())
|
|
431
446
|
return this.opts.style === "anthropic"
|
|
432
447
|
? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
433
448
|
: this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
@@ -592,7 +607,7 @@ export class Client {
|
|
|
592
607
|
* the answer — so the next `ask` with the same `id` continues it. Without an
|
|
593
608
|
* `id` it is a stateless one-shot (identical to `run`).
|
|
594
609
|
*/
|
|
595
|
-
async ask(prompt:
|
|
610
|
+
async ask(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
|
|
596
611
|
// Block-style streaming: run the streaming loop, forward text deltas, still return the
|
|
597
612
|
// final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
|
|
598
613
|
if (ctx.on_text) {
|
|
@@ -620,7 +635,8 @@ export class Client {
|
|
|
620
635
|
* With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
|
|
621
636
|
* streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
|
|
622
637
|
*/
|
|
623
|
-
async *stream(prompt:
|
|
638
|
+
async *stream(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
|
|
639
|
+
checkPromptParts(prompt, this.wireOpts())
|
|
624
640
|
const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
|
|
625
641
|
const gen = this.opts.style === "anthropic"
|
|
626
642
|
? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
|
|
@@ -730,7 +746,7 @@ export class Client {
|
|
|
730
746
|
}
|
|
731
747
|
|
|
732
748
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
733
|
-
private async runOpenAI(prompt:
|
|
749
|
+
private async runOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
734
750
|
const key = resolveKey(this.opts)
|
|
735
751
|
const signal = this.makeSignal(external)
|
|
736
752
|
const runStart = Date.now()
|
|
@@ -783,7 +799,7 @@ export class Client {
|
|
|
783
799
|
// on resume). Mirrors the streaming path.
|
|
784
800
|
for (const s of settled) {
|
|
785
801
|
toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
786
|
-
messages.push(
|
|
802
|
+
messages.push(toolMessage(s.call.id, s.call.function.name, s.result))
|
|
787
803
|
if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
|
|
788
804
|
}
|
|
789
805
|
}
|
|
@@ -830,7 +846,7 @@ export class Client {
|
|
|
830
846
|
}
|
|
831
847
|
|
|
832
848
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
833
|
-
private async runAnthropic(prompt:
|
|
849
|
+
private async runAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
834
850
|
const key = resolveKey(this.opts)
|
|
835
851
|
const signal = this.makeSignal(external)
|
|
836
852
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
@@ -890,7 +906,7 @@ export class Client {
|
|
|
890
906
|
let halted: Request | undefined
|
|
891
907
|
for (const s of settled) {
|
|
892
908
|
toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
|
|
893
|
-
content.push(
|
|
909
|
+
content.push(toolResultBlock(s.use.id, s.result))
|
|
894
910
|
if (s.halted) { halted = s.halted; break }
|
|
895
911
|
}
|
|
896
912
|
messages.push({ role: "user", content })
|
|
@@ -904,7 +920,7 @@ export class Client {
|
|
|
904
920
|
}
|
|
905
921
|
|
|
906
922
|
// ---- Streaming: OpenAI-style ----
|
|
907
|
-
private async *streamOpenAI(prompt:
|
|
923
|
+
private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
908
924
|
const key = resolveKey(this.opts)
|
|
909
925
|
const signal = this.makeSignal(external)
|
|
910
926
|
const runStart = Date.now()
|
|
@@ -982,13 +998,13 @@ export class Client {
|
|
|
982
998
|
result = r.result
|
|
983
999
|
if (r.halted) {
|
|
984
1000
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
985
|
-
messages.push(
|
|
1001
|
+
messages.push(toolMessage(c.id, c.name, result))
|
|
986
1002
|
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
987
1003
|
return
|
|
988
1004
|
}
|
|
989
1005
|
}
|
|
990
1006
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
991
|
-
messages.push(
|
|
1007
|
+
messages.push(toolMessage(c.id, c.name, result))
|
|
992
1008
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
993
1009
|
}
|
|
994
1010
|
}
|
|
@@ -1000,7 +1016,7 @@ export class Client {
|
|
|
1000
1016
|
}
|
|
1001
1017
|
|
|
1002
1018
|
// ---- Streaming: Anthropic-style ----
|
|
1003
|
-
private async *streamAnthropic(prompt:
|
|
1019
|
+
private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
1004
1020
|
const key = resolveKey(this.opts)
|
|
1005
1021
|
const signal = this.makeSignal(external)
|
|
1006
1022
|
const runStart = Date.now()
|
|
@@ -1073,14 +1089,14 @@ export class Client {
|
|
|
1073
1089
|
result = r.result
|
|
1074
1090
|
if (r.halted) {
|
|
1075
1091
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
1076
|
-
results.push(
|
|
1092
|
+
results.push(toolResultBlock(u.id, result))
|
|
1077
1093
|
messages.push({ role: "user", content: results })
|
|
1078
1094
|
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
1079
1095
|
return
|
|
1080
1096
|
}
|
|
1081
1097
|
}
|
|
1082
1098
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
1083
|
-
results.push(
|
|
1099
|
+
results.push(toolResultBlock(u.id, result))
|
|
1084
1100
|
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
|
|
1085
1101
|
}
|
|
1086
1102
|
messages.push({ role: "user", content: results })
|
|
@@ -1097,6 +1113,135 @@ export function createClient(opts: ClientOptions): Client {
|
|
|
1097
1113
|
return new Client(opts)
|
|
1098
1114
|
}
|
|
1099
1115
|
|
|
1116
|
+
|
|
1117
|
+
// --------------------------------------------------------------------------- //
|
|
1118
|
+
// In-process models (SPEC §8 Gap 2, semantic form).
|
|
1119
|
+
// --------------------------------------------------------------------------- //
|
|
1120
|
+
|
|
1121
|
+
/** One tool call an in-process model asks for. Flat on purpose: the nested
|
|
1122
|
+
* `function: {}` wrapper is a wire detail, not something a model author should type.
|
|
1123
|
+
* `arguments` may be a structured value (encoded for you) or an encoded string. */
|
|
1124
|
+
export interface InProcessToolCall {
|
|
1125
|
+
id?: string
|
|
1126
|
+
name: string
|
|
1127
|
+
arguments?: unknown
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** What an in-process model is handed: the assembled request for THIS call. */
|
|
1131
|
+
export interface InProcessRequest {
|
|
1132
|
+
messages: any[]
|
|
1133
|
+
tools?: any[]
|
|
1134
|
+
model: string
|
|
1135
|
+
/** Every other key the client assembled, for a model that wants them. */
|
|
1136
|
+
body: Record<string, any>
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** What an in-process model returns: exactly one assistant message. Return `content`
|
|
1140
|
+
* to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
|
|
1141
|
+
* omitted the run reports zero tokens rather than failing. */
|
|
1142
|
+
export interface InProcessResponse {
|
|
1143
|
+
content?: string
|
|
1144
|
+
toolCalls?: InProcessToolCall[]
|
|
1145
|
+
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Options for {@link createInProcessClient}: every client option EXCEPT the three
|
|
1149
|
+
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
|
|
1150
|
+
export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
|
|
1151
|
+
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
1152
|
+
generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** The sentinel base URL. Never dialled — the transport below answers every request
|
|
1156
|
+
* before the network is reached — but the client builds a URL string internally, so
|
|
1157
|
+
* it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
|
|
1158
|
+
* name can never resolve. */
|
|
1159
|
+
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
1163
|
+
* HTTP types to construct.
|
|
1164
|
+
*
|
|
1165
|
+
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
1166
|
+
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
1167
|
+
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
1168
|
+
*
|
|
1169
|
+
* Streaming is refused rather than faked — see the thrown error.
|
|
1170
|
+
*/
|
|
1171
|
+
export function createInProcessClient(opts: InProcessOptions): Client {
|
|
1172
|
+
const { generate, ...rest } = opts
|
|
1173
|
+
if (typeof generate !== "function") {
|
|
1174
|
+
throw new Error("toolnexus: createInProcessClient requires a `generate` function")
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const inProcessFetch: typeof fetch = async (_url, init) => {
|
|
1178
|
+
const body = JSON.parse(String((init as any)?.body ?? "{}"))
|
|
1179
|
+
|
|
1180
|
+
// A generate() returns a whole answer, so a stream would be one chunk pretending
|
|
1181
|
+
// to be many. Content and delta-count assertions cannot tell that from a real
|
|
1182
|
+
// stream, so refuse instead of degrading silently.
|
|
1183
|
+
if (body.stream) {
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
"toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
|
|
1186
|
+
"complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.",
|
|
1187
|
+
)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const answer = await generate({
|
|
1191
|
+
messages: body.messages ?? [],
|
|
1192
|
+
tools: body.tools,
|
|
1193
|
+
model: body.model,
|
|
1194
|
+
body,
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
const message: Record<string, any> = { role: "assistant" }
|
|
1198
|
+
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
1199
|
+
message.tool_calls = answer.toolCalls.map((c, i) => ({
|
|
1200
|
+
id: c.id ?? `call_${i}`,
|
|
1201
|
+
type: "function",
|
|
1202
|
+
function: {
|
|
1203
|
+
name: c.name,
|
|
1204
|
+
// A string is already encoded; anything else is encoded here so a model
|
|
1205
|
+
// author never has to think about the wire.
|
|
1206
|
+
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
1207
|
+
},
|
|
1208
|
+
}))
|
|
1209
|
+
} else {
|
|
1210
|
+
message.content = answer.content ?? ""
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const u = answer.usage ?? {}
|
|
1214
|
+
const prompt = u.promptTokens ?? 0
|
|
1215
|
+
const completion = u.completionTokens ?? 0
|
|
1216
|
+
return new Response(
|
|
1217
|
+
JSON.stringify({
|
|
1218
|
+
choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
|
|
1219
|
+
usage: {
|
|
1220
|
+
prompt_tokens: prompt,
|
|
1221
|
+
completion_tokens: completion,
|
|
1222
|
+
total_tokens: u.totalTokens ?? prompt + completion,
|
|
1223
|
+
},
|
|
1224
|
+
}),
|
|
1225
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1226
|
+
)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
return new Client({
|
|
1230
|
+
// Retries default to ZERO here, unlike a network client. There is no wire, so
|
|
1231
|
+
// there is no transient failure to ride out: whatever `generate` throws will
|
|
1232
|
+
// throw again. Retrying it only buys backoff before the user sees their own bug —
|
|
1233
|
+
// measured at 3.7s for the streaming refusal before this default. A host whose
|
|
1234
|
+
// model really is flaky (a GPU that OOMs, say) can still set `retries`.
|
|
1235
|
+
retries: 0,
|
|
1236
|
+
...(rest as ClientOptions),
|
|
1237
|
+
baseUrl: IN_PROCESS_BASE_URL,
|
|
1238
|
+
style: "openai",
|
|
1239
|
+
// An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
|
|
1240
|
+
apiKey: "in-process",
|
|
1241
|
+
fetch: inProcessFetch,
|
|
1242
|
+
})
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1100
1245
|
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
1101
1246
|
export class Conversation {
|
|
1102
1247
|
/** Full running transcript (system + user + assistant + tool messages). */
|
|
@@ -1104,7 +1249,7 @@ export class Conversation {
|
|
|
1104
1249
|
constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
|
|
1105
1250
|
|
|
1106
1251
|
/** Send the next user turn; prior history is retained automatically. */
|
|
1107
|
-
async send(prompt:
|
|
1252
|
+
async send(prompt: PromptInput): Promise<RunResult> {
|
|
1108
1253
|
const result = await this.client.run(prompt, { toolkit: this.toolkit, signal: this.signal, history: this.messages })
|
|
1109
1254
|
this.messages = result.messages
|
|
1110
1255
|
return result
|
|
@@ -1150,6 +1295,27 @@ function safeJson(s: string): Record<string, unknown> {
|
|
|
1150
1295
|
}
|
|
1151
1296
|
}
|
|
1152
1297
|
|
|
1298
|
+
/**
|
|
1299
|
+
* A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
|
|
1300
|
+
* with parts it also carries them (and the tool `name` the §8A relocation header needs) —
|
|
1301
|
+
* both consumed and stripped by the wire build, so neither reaches OpenAI.
|
|
1302
|
+
*/
|
|
1303
|
+
function toolMessage(id: string | undefined, name: string, result: ToolResult): any {
|
|
1304
|
+
const m: any = { role: "tool", tool_call_id: id, content: result.output }
|
|
1305
|
+
if (result.parts?.length) {
|
|
1306
|
+
m.name = name
|
|
1307
|
+
m.parts = result.parts
|
|
1308
|
+
}
|
|
1309
|
+
return m
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
|
|
1313
|
+
function toolResultBlock(id: string | undefined, result: ToolResult): any {
|
|
1314
|
+
const b: any = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError }
|
|
1315
|
+
if (result.parts?.length) b.parts = result.parts
|
|
1316
|
+
return b
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1153
1319
|
function lastText(messages: any[]): string {
|
|
1154
1320
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1155
1321
|
if (messages[i].role === "assistant" && typeof messages[i].content === "string") return messages[i].content
|