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