toolnexus 0.23.0 → 0.24.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 +23 -0
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +23 -1
- package/dist/client.js +52 -18
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +3 -0
- package/dist/toolkit.js +1 -1
- package/dist/types.d.ts +4 -1
- package/package.json +3 -2
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +72 -21
- package/src/index.ts +2 -0
- package/src/media.ts +604 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +4 -1
- package/src/types.ts +4 -1
package/dist/media.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media generation (SPEC §7G, ADR 0036) — one engine, two faces: typed verbs a host calls and
|
|
3
|
+
* tools (source "media") an LLM calls. Providers are styles: JSON data, never code.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
/** Canonical verbs — `conformance/media/verbs.json`, embedded verbatim (byte contract). */
|
|
7
|
+
export const MEDIA_VERBS = [
|
|
8
|
+
{
|
|
9
|
+
"verb": "image",
|
|
10
|
+
"tool": "generate_image",
|
|
11
|
+
"description": "Generate an image from a text prompt.",
|
|
12
|
+
"args": [
|
|
13
|
+
{
|
|
14
|
+
"name": "prompt",
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "What to draw.",
|
|
17
|
+
"required": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "width",
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"description": "Width in pixels."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "height",
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"description": "Height in pixels."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "aspectRatio",
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Aspect ratio, e.g. 16:9."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "seed",
|
|
36
|
+
"type": "integer",
|
|
37
|
+
"description": "Seed for reproducible output."
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"verb": "video",
|
|
43
|
+
"tool": "generate_video",
|
|
44
|
+
"description": "Generate a short video from a prompt, optionally starting from an image URL. Can take minutes.",
|
|
45
|
+
"args": [
|
|
46
|
+
{
|
|
47
|
+
"name": "prompt",
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "What should happen in the video.",
|
|
50
|
+
"required": true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "image",
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "URL of the starting image."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "seconds",
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"description": "Video length in seconds."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "aspectRatio",
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "Aspect ratio, e.g. 9:16."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"verb": "speech",
|
|
71
|
+
"tool": "generate_speech",
|
|
72
|
+
"description": "Speak text aloud and return the audio.",
|
|
73
|
+
"args": [
|
|
74
|
+
{
|
|
75
|
+
"name": "text",
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "The text to speak.",
|
|
78
|
+
"required": true
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "voice",
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Voice or speaker id."
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"verb": "transcribe",
|
|
89
|
+
"tool": "transcribe_audio",
|
|
90
|
+
"description": "Transcribe speech in an audio URL to text.",
|
|
91
|
+
"args": [
|
|
92
|
+
{
|
|
93
|
+
"name": "audio",
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "URL of the audio.",
|
|
96
|
+
"required": true
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"verb": "cloneVoice",
|
|
102
|
+
"tool": "clone_voice",
|
|
103
|
+
"description": "Create a cloned voice from audio sample URLs and return its voice id.",
|
|
104
|
+
"args": [
|
|
105
|
+
{
|
|
106
|
+
"name": "name",
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Name for the new voice.",
|
|
109
|
+
"required": true
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "samples",
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": "string",
|
|
115
|
+
"description": "URLs of audio samples of the voice.",
|
|
116
|
+
"required": true
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"verb": "talking",
|
|
122
|
+
"tool": "talking_video",
|
|
123
|
+
"description": "Make the person in an image speak the given audio (lip-synced video).",
|
|
124
|
+
"args": [
|
|
125
|
+
{
|
|
126
|
+
"name": "image",
|
|
127
|
+
"type": "string",
|
|
128
|
+
"description": "URL of the face image.",
|
|
129
|
+
"required": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "audio",
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "URL of the speech audio.",
|
|
135
|
+
"required": true
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
];
|
|
140
|
+
/** Read one style file (JSON). */
|
|
141
|
+
export function loadMediaStyle(path) {
|
|
142
|
+
const style = JSON.parse(readFileSync(path, "utf8"));
|
|
143
|
+
validateStyle(style);
|
|
144
|
+
return style;
|
|
145
|
+
}
|
|
146
|
+
/** §7G Empty dotPath: an args entry mapped to "" is a style error (sorted, so deterministic). */
|
|
147
|
+
function validateStyle(style) {
|
|
148
|
+
for (const verb of Object.keys(style.verbs ?? {}).sort()) {
|
|
149
|
+
const args = style.verbs[verb]?.args ?? {};
|
|
150
|
+
for (const key of Object.keys(args).sort()) {
|
|
151
|
+
if (args[key] === "")
|
|
152
|
+
throw new Error(`style: empty path for argument "${key}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// ---- dotPath ---------------------------------------------------------------------------------
|
|
157
|
+
const isIndex = (seg) => /^[0-9]+$/.test(seg);
|
|
158
|
+
function readPath(obj, path) {
|
|
159
|
+
if (path === "")
|
|
160
|
+
return obj; // §7G: empty dotPath is the whole value
|
|
161
|
+
let cur = obj;
|
|
162
|
+
for (const seg of path.split(".")) {
|
|
163
|
+
if (cur === null || cur === undefined || typeof cur !== "object")
|
|
164
|
+
return undefined;
|
|
165
|
+
if (Array.isArray(cur)) {
|
|
166
|
+
if (!isIndex(seg))
|
|
167
|
+
return undefined;
|
|
168
|
+
cur = cur[Number(seg)];
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
if (!Object.prototype.hasOwnProperty.call(cur, seg))
|
|
172
|
+
return undefined;
|
|
173
|
+
cur = cur[seg];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return cur;
|
|
177
|
+
}
|
|
178
|
+
function writePath(obj, path, value) {
|
|
179
|
+
const segs = path.split(".");
|
|
180
|
+
let cur = obj;
|
|
181
|
+
for (let i = 0; i < segs.length; i++) {
|
|
182
|
+
const seg = segs[i];
|
|
183
|
+
const key = Array.isArray(cur) && isIndex(seg) ? Number(seg) : seg;
|
|
184
|
+
if (Array.isArray(cur) && typeof key === "number")
|
|
185
|
+
while (cur.length < key)
|
|
186
|
+
cur.push(null);
|
|
187
|
+
if (i === segs.length - 1) {
|
|
188
|
+
cur[key] = value;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let next = cur[key];
|
|
192
|
+
// A scalar — or an array followed by a non-numeric segment — is replaced (§7G).
|
|
193
|
+
if (next === null || next === undefined || typeof next !== "object" || (Array.isArray(next) && !isIndex(segs[i + 1]))) {
|
|
194
|
+
next = isIndex(segs[i + 1]) ? [] : {};
|
|
195
|
+
cur[key] = next;
|
|
196
|
+
}
|
|
197
|
+
cur = next;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ---- helpers ---------------------------------------------------------------------------------
|
|
201
|
+
function expandEnv(v) {
|
|
202
|
+
return v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, name) => process.env[name] ?? "");
|
|
203
|
+
}
|
|
204
|
+
const MIME = {
|
|
205
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
|
|
206
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
|
|
207
|
+
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac",
|
|
208
|
+
};
|
|
209
|
+
const KIND_DEFAULT_MIME = { image: "image/png", video: "video/mp4", audio: "audio/mpeg" };
|
|
210
|
+
const KIND_FILE_TYPE = { image: "image", audio: "audio", video: "file" };
|
|
211
|
+
function mimeFor(url, kind) {
|
|
212
|
+
let path = url.split("#")[0].split("?")[0];
|
|
213
|
+
const scheme = path.indexOf("://");
|
|
214
|
+
if (scheme >= 0) {
|
|
215
|
+
const slash = path.indexOf("/", scheme + 3);
|
|
216
|
+
path = slash >= 0 ? path.slice(slash) : "";
|
|
217
|
+
}
|
|
218
|
+
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
219
|
+
const dot = last.lastIndexOf(".");
|
|
220
|
+
const ext = dot >= 0 ? last.slice(dot + 1).toLowerCase() : "";
|
|
221
|
+
return MIME[ext] ?? KIND_DEFAULT_MIME[kind];
|
|
222
|
+
}
|
|
223
|
+
function jsonEqual(a, b) {
|
|
224
|
+
if (a === b)
|
|
225
|
+
return true;
|
|
226
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
|
|
227
|
+
return false;
|
|
228
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
229
|
+
return false;
|
|
230
|
+
if (Array.isArray(a)) {
|
|
231
|
+
const bb = b;
|
|
232
|
+
return a.length === bb.length && a.every((x, i) => jsonEqual(x, bb[i]));
|
|
233
|
+
}
|
|
234
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
235
|
+
return ka.length === kb.length && ka.every((k) => jsonEqual(a[k], b[k]));
|
|
236
|
+
}
|
|
237
|
+
function renderScalar(v) {
|
|
238
|
+
if (typeof v === "string")
|
|
239
|
+
return v;
|
|
240
|
+
if (typeof v === "number" && Number.isInteger(v))
|
|
241
|
+
return String(v);
|
|
242
|
+
return JSON.stringify(v);
|
|
243
|
+
}
|
|
244
|
+
/** Poll status text (§7G): string as-is, numbers like the envelope code, anything else "". */
|
|
245
|
+
function statusText(v) {
|
|
246
|
+
if (typeof v === "string")
|
|
247
|
+
return v;
|
|
248
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
249
|
+
return String(v);
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
/** RFC 3986: keep unreserved A-Z a-z 0-9 - . _ ~, %XX (uppercase) every other UTF-8 byte. */
|
|
253
|
+
function percentEncode(s) {
|
|
254
|
+
return encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
255
|
+
}
|
|
256
|
+
/** §7G Redirects: the followed statuses; anything else (or no Location) is an HTTP error. */
|
|
257
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
258
|
+
class TooManyRedirects extends Error {
|
|
259
|
+
}
|
|
260
|
+
/** Echoed error bodies keep their first 1000 code points, then "…" (§7G). */
|
|
261
|
+
function capBody(text) {
|
|
262
|
+
const cps = Array.from(text);
|
|
263
|
+
return cps.length <= 1000 ? text : cps.slice(0, 1000).join("") + "\u2026";
|
|
264
|
+
}
|
|
265
|
+
/** §7G: an interval/timeout that is absent, not a number or <= 0 takes the default. */
|
|
266
|
+
const positive = (v, dflt) => (typeof v === "number" && v > 0 ? v : dflt);
|
|
267
|
+
const originOf = (u) => new URL(u).origin;
|
|
268
|
+
function abortError(signal) {
|
|
269
|
+
return signal.reason ?? Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
|
|
270
|
+
}
|
|
271
|
+
function sleep(ms, signal) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
if (signal?.aborted)
|
|
274
|
+
return reject(abortError(signal));
|
|
275
|
+
const onAbort = () => {
|
|
276
|
+
clearTimeout(t);
|
|
277
|
+
reject(abortError(signal));
|
|
278
|
+
};
|
|
279
|
+
const t = setTimeout(() => {
|
|
280
|
+
signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
resolve();
|
|
282
|
+
}, ms);
|
|
283
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function flattenStrings(v, out) {
|
|
287
|
+
if (typeof v === "string") {
|
|
288
|
+
if (v !== "")
|
|
289
|
+
out.push(v);
|
|
290
|
+
}
|
|
291
|
+
else if (Array.isArray(v)) {
|
|
292
|
+
for (const x of v)
|
|
293
|
+
flattenStrings(x, out);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ---- engine ----------------------------------------------------------------------------------
|
|
297
|
+
export class Media {
|
|
298
|
+
opts;
|
|
299
|
+
styles;
|
|
300
|
+
constructor(styles, opts = {}) {
|
|
301
|
+
this.opts = opts;
|
|
302
|
+
this.styles = Array.isArray(styles) ? styles : [styles];
|
|
303
|
+
for (const s of this.styles)
|
|
304
|
+
validateStyle(s);
|
|
305
|
+
}
|
|
306
|
+
resolve(verb) {
|
|
307
|
+
for (const style of this.styles) {
|
|
308
|
+
const spec = style.verbs[verb];
|
|
309
|
+
if (spec)
|
|
310
|
+
return { style, spec };
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
/** Generic entry point: run `verb` with normalised `args` (§7G engine). */
|
|
315
|
+
async generate(verb, args, callOpts = {}) {
|
|
316
|
+
const resolved = this.resolve(verb);
|
|
317
|
+
if (!resolved)
|
|
318
|
+
throw new Error(`media: no style supports "${verb}"`);
|
|
319
|
+
const { style, spec } = resolved;
|
|
320
|
+
const present = Object.entries(args ?? {}).filter(([, v]) => v !== undefined && v !== null);
|
|
321
|
+
for (const key of present.map(([k]) => k).sort()) {
|
|
322
|
+
if (!Object.prototype.hasOwnProperty.call(spec.args, key)) {
|
|
323
|
+
throw new Error(`${verb}: argument "${key}" not supported by this style`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const body = spec.defaults ? structuredClone(spec.defaults) : {};
|
|
327
|
+
for (const [key, value] of present)
|
|
328
|
+
writePath(body, spec.args[key], value);
|
|
329
|
+
for (const [k, v] of Object.entries(callOpts.providerOptions ?? {}))
|
|
330
|
+
body[k] = v;
|
|
331
|
+
const base = style.baseUrl.replace(/\/+$/, "");
|
|
332
|
+
const signal = callOpts.signal;
|
|
333
|
+
const styleHeaders = () => {
|
|
334
|
+
const h = {};
|
|
335
|
+
for (const [k, v] of Object.entries(style.headers ?? {}))
|
|
336
|
+
h[k] = expandEnv(v);
|
|
337
|
+
return h;
|
|
338
|
+
};
|
|
339
|
+
const doFetch = this.opts.fetch ?? fetch;
|
|
340
|
+
// Every request is bounded by the time left until the job deadline (§7G Per-request timeout).
|
|
341
|
+
const timeout = positive(spec.poll?.timeoutMs, 600000);
|
|
342
|
+
const deadline = Date.now() + timeout;
|
|
343
|
+
let jobId;
|
|
344
|
+
const timedOut = () => new Error(jobId !== undefined ? `job ${jobId} timed out after ${timeout}ms` : `${verb}: timed out after ${timeout}ms`);
|
|
345
|
+
// Redirects are followed by hand so style headers never reach another origin (§7G Redirects).
|
|
346
|
+
const send = async (url, method, json, reqSignal) => {
|
|
347
|
+
const origin = originOf(url);
|
|
348
|
+
let body = json !== undefined ? JSON.stringify(json) : undefined;
|
|
349
|
+
for (let hops = 0;; hops++) {
|
|
350
|
+
const sameOrigin = originOf(url) === origin;
|
|
351
|
+
const headers = { ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(sameOrigin ? styleHeaders() : {}) };
|
|
352
|
+
const res = await doFetch(url, { method, headers, body, signal: reqSignal, redirect: "manual" });
|
|
353
|
+
const loc = res.headers?.get?.("location");
|
|
354
|
+
if (!REDIRECTS.has(res.status) || !loc)
|
|
355
|
+
return res;
|
|
356
|
+
await res.body?.cancel().catch(() => { });
|
|
357
|
+
if (hops >= 10)
|
|
358
|
+
throw new TooManyRedirects();
|
|
359
|
+
url = new URL(loc, url).toString();
|
|
360
|
+
if (res.status <= 303 && method !== "GET" && method !== "HEAD") {
|
|
361
|
+
method = "GET";
|
|
362
|
+
body = undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const call = async (method, path, json) => {
|
|
367
|
+
if (signal?.aborted)
|
|
368
|
+
throw abortError(signal);
|
|
369
|
+
const remaining = deadline - Date.now();
|
|
370
|
+
if (remaining <= 0)
|
|
371
|
+
throw timedOut();
|
|
372
|
+
const ctl = new AbortController();
|
|
373
|
+
const timer = setTimeout(() => ctl.abort(), remaining);
|
|
374
|
+
const forward = () => ctl.abort();
|
|
375
|
+
signal?.addEventListener("abort", forward, { once: true });
|
|
376
|
+
let res, text;
|
|
377
|
+
try {
|
|
378
|
+
res = await send(base + path, method, json, ctl.signal);
|
|
379
|
+
text = await res.text();
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
if (signal?.aborted)
|
|
383
|
+
throw abortError(signal);
|
|
384
|
+
if (ctl.signal.aborted)
|
|
385
|
+
throw timedOut();
|
|
386
|
+
if (e instanceof TooManyRedirects)
|
|
387
|
+
throw new Error(`${verb}: too many redirects`);
|
|
388
|
+
throw new Error(`${verb}: ${e instanceof Error ? e.message : String(e)}`);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
signal?.removeEventListener("abort", forward);
|
|
393
|
+
}
|
|
394
|
+
if (res.status < 200 || res.status > 299)
|
|
395
|
+
throw new Error(`HTTP ${res.status}: ${capBody(text)}`);
|
|
396
|
+
let data;
|
|
397
|
+
try {
|
|
398
|
+
data = JSON.parse(text);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
throw new Error(`${verb}: response is not JSON`);
|
|
402
|
+
}
|
|
403
|
+
const env = style.errorEnvelope;
|
|
404
|
+
if (env) {
|
|
405
|
+
const code = readPath(data, env.codePath);
|
|
406
|
+
if (code !== undefined && !env.okValues.some((ok) => jsonEqual(ok, code))) {
|
|
407
|
+
const msg = env.messagePath ? readPath(data, env.messagePath) : undefined;
|
|
408
|
+
throw new Error(`API error ${renderScalar(code)}: ${typeof msg === "string" ? msg : ""}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return data;
|
|
412
|
+
};
|
|
413
|
+
let final;
|
|
414
|
+
let status = "completed";
|
|
415
|
+
const startResp = await call((spec.start.method ?? "POST").toUpperCase(), spec.start.path, body);
|
|
416
|
+
if (spec.mode === "async") {
|
|
417
|
+
const poll = spec.poll;
|
|
418
|
+
const idPath = spec.idPath ?? "";
|
|
419
|
+
const rawId = readPath(startResp, idPath);
|
|
420
|
+
if (typeof rawId === "string" && rawId !== "")
|
|
421
|
+
jobId = rawId;
|
|
422
|
+
else if (typeof rawId === "number" && Number.isInteger(rawId))
|
|
423
|
+
jobId = String(rawId);
|
|
424
|
+
else
|
|
425
|
+
throw new Error(`${verb}: no job id at ${idPath}`);
|
|
426
|
+
const interval = positive(poll.intervalMs, 5000);
|
|
427
|
+
const pollPath = poll.path.split("{id}").join(percentEncode(jobId));
|
|
428
|
+
for (;;) {
|
|
429
|
+
await sleep(interval, signal);
|
|
430
|
+
const resp = await call((poll.method ?? "GET").toUpperCase(), pollPath);
|
|
431
|
+
const s = statusText(readPath(resp, poll.statusPath));
|
|
432
|
+
this.opts.onProgress?.({ verb: verb, jobId, status: s });
|
|
433
|
+
if ((poll.done ?? []).includes(s)) {
|
|
434
|
+
final = resp;
|
|
435
|
+
status = s;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
if ((poll.failed ?? []).includes(s)) {
|
|
439
|
+
const m = poll.errorPath ? readPath(resp, poll.errorPath) : undefined;
|
|
440
|
+
throw new Error(typeof m === "string" && m !== "" ? `job ${jobId} ${s}: ${m}` : `job ${jobId} ${s}`);
|
|
441
|
+
}
|
|
442
|
+
if (Date.now() >= deadline)
|
|
443
|
+
throw timedOut();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
final = startResp;
|
|
448
|
+
}
|
|
449
|
+
const value = readPath(final, spec.resultPath);
|
|
450
|
+
const result = { files: [], status };
|
|
451
|
+
if (spec.kind === "text") {
|
|
452
|
+
if (typeof value === "string")
|
|
453
|
+
result.text = value;
|
|
454
|
+
else if (typeof value === "number")
|
|
455
|
+
result.text = renderScalar(value);
|
|
456
|
+
else
|
|
457
|
+
throw new Error(`${verb}: no result at ${spec.resultPath}`);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const urls = [];
|
|
461
|
+
flattenStrings(value, urls);
|
|
462
|
+
if (urls.length === 0)
|
|
463
|
+
throw new Error(`${verb}: no result at ${spec.resultPath}`);
|
|
464
|
+
result.files = urls.map((url) => ({ type: KIND_FILE_TYPE[spec.kind], mimeType: mimeFor(url, spec.kind), url }));
|
|
465
|
+
}
|
|
466
|
+
if (jobId !== undefined)
|
|
467
|
+
result.jobId = jobId;
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
typed(verb, input) {
|
|
471
|
+
const { providerOptions, signal, ...args } = input;
|
|
472
|
+
return this.generate(verb, args, { providerOptions, signal });
|
|
473
|
+
}
|
|
474
|
+
image(input) {
|
|
475
|
+
return this.typed("image", input);
|
|
476
|
+
}
|
|
477
|
+
video(input) {
|
|
478
|
+
return this.typed("video", input);
|
|
479
|
+
}
|
|
480
|
+
speech(input) {
|
|
481
|
+
return this.typed("speech", input);
|
|
482
|
+
}
|
|
483
|
+
transcribe(input) {
|
|
484
|
+
return this.typed("transcribe", input);
|
|
485
|
+
}
|
|
486
|
+
cloneVoice(input) {
|
|
487
|
+
return this.typed("cloneVoice", input);
|
|
488
|
+
}
|
|
489
|
+
talking(input) {
|
|
490
|
+
return this.typed("talking", input);
|
|
491
|
+
}
|
|
492
|
+
/** One Tool (source "media") per supported verb, in canonical order (§7G tool face). */
|
|
493
|
+
tools() {
|
|
494
|
+
const out = [];
|
|
495
|
+
for (const v of MEDIA_VERBS) {
|
|
496
|
+
const resolved = this.resolve(v.verb);
|
|
497
|
+
if (!resolved)
|
|
498
|
+
continue;
|
|
499
|
+
const mapped = v.args.filter((a) => Object.prototype.hasOwnProperty.call(resolved.spec.args, a.name));
|
|
500
|
+
const properties = {};
|
|
501
|
+
for (const a of mapped) {
|
|
502
|
+
properties[a.name] = a.type === "array"
|
|
503
|
+
? { type: "array", items: { type: a.items }, description: a.description }
|
|
504
|
+
: { type: a.type, description: a.description };
|
|
505
|
+
}
|
|
506
|
+
const inputSchema = { type: "object", properties, required: mapped.filter((a) => a.required).map((a) => a.name) };
|
|
507
|
+
const kind = resolved.spec.kind;
|
|
508
|
+
const mode = resolved.spec.mode;
|
|
509
|
+
out.push({
|
|
510
|
+
name: v.tool,
|
|
511
|
+
description: v.description,
|
|
512
|
+
inputSchema,
|
|
513
|
+
source: "media",
|
|
514
|
+
// Each call is an independent remote job: always concurrency-safe (§7G, §8 Tool scheduling).
|
|
515
|
+
concurrencySafe: () => true,
|
|
516
|
+
execute: async (args, ctx) => {
|
|
517
|
+
try {
|
|
518
|
+
const r = await this.generate(v.verb, args ?? {}, { signal: ctx?.signal });
|
|
519
|
+
const metadata = { source: "media", verb: v.verb };
|
|
520
|
+
if (mode === "async" && r.jobId !== undefined)
|
|
521
|
+
metadata.jobId = r.jobId;
|
|
522
|
+
metadata.status = r.status;
|
|
523
|
+
if (kind === "text")
|
|
524
|
+
return { isError: false, output: r.text ?? "", metadata };
|
|
525
|
+
const parts = r.files.map((f) => ({ type: f.type, mimeType: f.mimeType, url: f.url }));
|
|
526
|
+
return { isError: false, output: `${kind} ready: ` + r.files.map((f) => f.url).join("\n"), parts, metadata };
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
return { isError: true, output: e instanceof Error ? e.message : String(e) };
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** Build a media engine over one style or a list (first style declaring a verb wins). */
|
|
538
|
+
export function createMedia(styles, opts = {}) {
|
|
539
|
+
return new Media(styles, opts);
|
|
540
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool scheduling (SPEC §8 "Tool scheduling"): the calls of one model response run in emitted
|
|
3
|
+
* order as consecutive runs — safe calls overlap under a cap, each unsafe call runs alone.
|
|
4
|
+
*/
|
|
5
|
+
import type { Tool } from "./types.js";
|
|
6
|
+
/** The cap when neither the option nor TOOLNEXUS_MAX_TOOL_CONCURRENCY sets one. */
|
|
7
|
+
export declare const DEFAULT_MAX_TOOL_CONCURRENCY = 10;
|
|
8
|
+
/** option > env TOOLNEXUS_MAX_TOOL_CONCURRENCY > 10. Non-positive or non-numeric values fall back. */
|
|
9
|
+
export declare function resolveMaxToolConcurrency(option?: number): number;
|
|
10
|
+
/** Whether a call to `tool` with `args` may run alongside other safe calls. The tool's own
|
|
11
|
+
* predicate wins; otherwise only a tool whose hints say readOnly is safe. */
|
|
12
|
+
export declare function isConcurrencySafe(tool: Tool, args: Record<string, unknown>): boolean;
|
|
13
|
+
/** Runs `fn` over `items` in emitted order: safe items overlap (at most `cap`), an unsafe item
|
|
14
|
+
* waits for everything before it and runs alone. Results keep item order. The first failure
|
|
15
|
+
* stops new items from starting and is rethrown once the running ones settle. */
|
|
16
|
+
export declare function scheduleCalls<T, R>(items: T[], safe: (item: T) => boolean, cap: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The cap when neither the option nor TOOLNEXUS_MAX_TOOL_CONCURRENCY sets one. */
|
|
2
|
+
export const DEFAULT_MAX_TOOL_CONCURRENCY = 10;
|
|
3
|
+
/** option > env TOOLNEXUS_MAX_TOOL_CONCURRENCY > 10. Non-positive or non-numeric values fall back. */
|
|
4
|
+
export function resolveMaxToolConcurrency(option) {
|
|
5
|
+
if (typeof option === "number" && Number.isInteger(option) && option > 0)
|
|
6
|
+
return option;
|
|
7
|
+
const raw = typeof process !== "undefined" ? process.env.TOOLNEXUS_MAX_TOOL_CONCURRENCY : undefined;
|
|
8
|
+
if (raw !== undefined && /^\s*[+]?\d+\s*$/.test(raw)) {
|
|
9
|
+
const n = Number.parseInt(raw, 10);
|
|
10
|
+
if (n > 0)
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
return DEFAULT_MAX_TOOL_CONCURRENCY;
|
|
14
|
+
}
|
|
15
|
+
/** Whether a call to `tool` with `args` may run alongside other safe calls. The tool's own
|
|
16
|
+
* predicate wins; otherwise only a tool whose hints say readOnly is safe. */
|
|
17
|
+
export function isConcurrencySafe(tool, args) {
|
|
18
|
+
if (tool.concurrencySafe)
|
|
19
|
+
return tool.concurrencySafe(args);
|
|
20
|
+
return tool.hints?.readOnly === true;
|
|
21
|
+
}
|
|
22
|
+
/** Runs `fn` over `items` in emitted order: safe items overlap (at most `cap`), an unsafe item
|
|
23
|
+
* waits for everything before it and runs alone. Results keep item order. The first failure
|
|
24
|
+
* stops new items from starting and is rethrown once the running ones settle. */
|
|
25
|
+
export async function scheduleCalls(items, safe, cap, fn) {
|
|
26
|
+
const out = new Array(items.length);
|
|
27
|
+
const running = new Set();
|
|
28
|
+
let failed;
|
|
29
|
+
for (let i = 0; i < items.length && !failed; i++) {
|
|
30
|
+
const item = items[i];
|
|
31
|
+
if (!safe(item)) {
|
|
32
|
+
await Promise.all(running);
|
|
33
|
+
if (failed)
|
|
34
|
+
break;
|
|
35
|
+
try {
|
|
36
|
+
out[i] = await fn(item);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
failed = { e };
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
while (running.size >= cap)
|
|
44
|
+
await Promise.race(running);
|
|
45
|
+
if (failed)
|
|
46
|
+
break;
|
|
47
|
+
const p = fn(item).then((r) => {
|
|
48
|
+
out[i] = r;
|
|
49
|
+
}, (e) => {
|
|
50
|
+
if (!failed)
|
|
51
|
+
failed = { e };
|
|
52
|
+
}).finally(() => running.delete(p));
|
|
53
|
+
running.add(p);
|
|
54
|
+
}
|
|
55
|
+
await Promise.all(running);
|
|
56
|
+
if (failed)
|
|
57
|
+
throw failed.e;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type Agent } from "./a2a.js";
|
|
|
9
9
|
import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
|
|
10
10
|
import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
11
11
|
import type { Client } from "./client.js";
|
|
12
|
+
import type { Media } from "./media.js";
|
|
12
13
|
export interface ToolkitOptions {
|
|
13
14
|
mcpConfig?: string | object;
|
|
14
15
|
skillsDir?: string | string[];
|
|
@@ -28,6 +29,8 @@ export interface ToolkitOptions {
|
|
|
28
29
|
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
|
|
29
30
|
skillSampleLimit?: number;
|
|
30
31
|
extraTools?: Tool[];
|
|
32
|
+
/** Media generation (§7G): registers `media.tools()` (source "media"). */
|
|
33
|
+
media?: Media;
|
|
31
34
|
/** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
|
|
32
35
|
builtins?: BuiltinsConfig;
|
|
33
36
|
/** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
|
package/dist/toolkit.js
CHANGED
|
@@ -89,7 +89,7 @@ export class Toolkit {
|
|
|
89
89
|
console.warn(`[toolnexus] A2A agent "${a.card}" failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
90
90
|
return [];
|
|
91
91
|
})));
|
|
92
|
-
const agentToolsFlat = agentToolLists.flat();
|
|
92
|
+
const agentToolsFlat = [...agentToolLists.flat(), ...(opts.media ? opts.media.tools() : [])];
|
|
93
93
|
// A2A inbound profile: a top-level `a2a` block on a parsed config object
|
|
94
94
|
// (mirrors builtins/agents). serve() prefers its inline `a2a` over this.
|
|
95
95
|
let a2aConfig;
|
package/dist/types.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type JSONSchema = {
|
|
|
9
9
|
[k: string]: unknown;
|
|
10
10
|
};
|
|
11
11
|
import type { ContentPart } from "./content.js";
|
|
12
|
-
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom";
|
|
12
|
+
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "media" | "custom";
|
|
13
13
|
export interface ToolResult {
|
|
14
14
|
output: string;
|
|
15
15
|
isError: boolean;
|
|
@@ -96,6 +96,9 @@ export interface Tool {
|
|
|
96
96
|
/** What the tool may do (§4C). Builtins always carry hints; MCP tools carry them when the
|
|
97
97
|
* server sends annotations. Advisory: a server can mislabel its tools. */
|
|
98
98
|
hints?: ToolHints;
|
|
99
|
+
/** Decides per call whether this tool may run alongside other safe calls of the same model
|
|
100
|
+
* response (SPEC §8 "Tool scheduling"). Absent ⇒ safe only when `hints.readOnly`. */
|
|
101
|
+
concurrencySafe?: (args: Record<string, unknown>) => boolean;
|
|
99
102
|
}
|
|
100
103
|
export type McpStatus = "connected" | "disabled" | "failed";
|
|
101
104
|
/** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
|