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