chore: isolate shared dsh plugins into independent monorepo
Some checks failed
build-and-publish / build-test (push) Failing after 1m6s
build-and-publish / publish (push) Has been skipped

This commit is contained in:
2026-08-26 22:44:31 +07:00
parent 0e0b68fed5
commit 81159a22e8
37 changed files with 3456 additions and 2 deletions

View File

@@ -0,0 +1,203 @@
/**
* ComfyUI image-generation tool. Submits an SDXL-turbo workflow to a shared
* ComfyUI instance, polls `/history/{prompt_id}` until the SaveImage node
* produced a file, and returns the served PNG URL. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/comfy
*/
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** Default SDXL-turbo checkpoint on the lab server. */
export const DEFAULT_MODEL = 'sdxl_turbo.safetensors'
/** Default negative prompt applied unless the caller overrides it. */
export const DEFAULT_NEGATIVE = 'negative prompt'
/** Default output filename prefix for SaveImage. */
export const DEFAULT_FILENAME_PREFIX = 'dsh'
/** Default step count for the SDXL-turbo Euler-normal pass. */
export const DEFAULT_STEPS = 4
/** Default generation dimensions when neither axis is supplied. */
export const DEFAULT_SIZE = 512
/** One history poll interval (ms) while awaiting the rendered image. */
export const POLL_INTERVAL_MS = 1_000
/** Schema-validated arguments for `lab_generate_image`. */
export interface GenerateImageArgs {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
filename_prefix?: string
}
/**
* Validate and resolve optional image arguments against defaults. Positive
* integer constraints the schema DSL cannot express are enforced here.
*/
export function resolveImageArgs(args: GenerateImageArgs): Required<GenerateImageArgs> {
const resolved = {
prompt: args.prompt,
negative: args.negative ?? DEFAULT_NEGATIVE,
width: args.width ?? DEFAULT_SIZE,
height: args.height ?? DEFAULT_SIZE,
steps: args.steps ?? DEFAULT_STEPS,
seed: args.seed ?? 123,
model: args.model ?? DEFAULT_MODEL,
filename_prefix: args.filename_prefix ?? DEFAULT_FILENAME_PREFIX,
}
for (const [name, value] of [
['width', resolved.width],
['height', resolved.height],
['steps', resolved.steps],
['seed', resolved.seed],
] as const) {
if (!Number.isInteger(value) || value < 1) throw new Error(`lab_generate_image: ${name} must be a positive integer`)
}
return resolved
}
/**
* Build an SDXL-turbo ComfyUI workflow for a 4-step Euler-normal pass. The
* node graph is the lab's validated shape; only the seed, dimensions, step
* count, prompts, and output prefix vary per call.
*/
export function buildWorkflow(opts: {
prompt: string
negative: string
width: number
height: number
steps: number
seed: number
model: string
filenamePrefix: string
}): Record<string, unknown> {
const { prompt, negative, width, height, steps, seed, model, filenamePrefix } = opts
return {
'3': {
class_type: 'KSampler',
inputs: {
seed,
steps,
cfg: 1.0,
sampler_name: 'euler',
scheduler: 'normal',
denoise: 1.0,
model: ['4', 0],
positive: ['6', 0],
negative: ['7', 0],
latent_image: ['5', 0],
},
},
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
'6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['4', 1] } },
'7': { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
'9': { class_type: 'SaveImage', inputs: { filename_prefix: filenamePrefix, images: ['8', 0] } },
}
}
/**
* Execute one SDXL-turbo generation against a ComfyUI server: queue the
* workflow, poll history for the rendered image, and return its `GET /view` URL.
*
* @param baseUrl - the ComfyUI server base URL, e.g. `http://192.168.31.240:8188`.
* @param resolved - schema-validated, default-resolved generation arguments.
* @param signal - the executor's cancellation signal (fused into the deadline).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the served PNG URL.
*/
export async function runComfyImage(
baseUrl: string,
resolved: Required<GenerateImageArgs>,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
let response = await fetch(`${baseUrl}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: buildWorkflow({
prompt: resolved.prompt,
negative: resolved.negative,
width: resolved.width,
height: resolved.height,
steps: resolved.steps,
seed: resolved.seed,
model: resolved.model,
filenamePrefix: resolved.filename_prefix,
}),
client_id: 'dsh',
}),
signal: d.signal,
})
if (!response.ok) {
throw new HttpError(`lab_generate_image: ComfyUI prompt failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
}
const queued = await response.json() as { prompt_id?: string; node_errors?: Record<string, unknown> }
const nodeErrors = queued.node_errors ?? {}
if (Object.keys(nodeErrors).length > 0) throw new Error(`lab_generate_image: workflow rejected: ${JSON.stringify(nodeErrors)}`)
const promptId = queued.prompt_id
if (promptId === undefined) throw new Error('lab_generate_image: /prompt returned no prompt_id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/history/${encodeURIComponent(promptId)}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_generate_image: ComfyUI history failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
}
const history = await response.json() as Record<string, { outputs?: Record<string, { images?: { filename?: string }[] }> }>
const images = history[promptId]?.outputs?.['9']?.images ?? []
const filename = images[0]?.filename
if (filename !== undefined) {
return `${baseUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=&type=output`
}
await sleep(POLL_INTERVAL_MS, d.signal)
}
}
/**
* Register the `lab_generate_image` tool.
*
* @param ctx - context whose `tools` registry receives the definition
* (effect-scoped; unregistered on plugin dispose).
* @param baseUrl - the ComfyUI server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxOutputChars - output cap; image generation returns a URL and ignores this.
*/
export function registerLabComfyTool(ctx: Context, baseUrl: string, timeoutMs: number, maxOutputChars: number): void {
void maxOutputChars
ctx.tools.register(defineTool({
name: 'lab_generate_image',
description:
'Generate an image with the shared ComfyUI lab server (SDXL-turbo, 4 steps, euler/normal, no auth). '
+ 'Returns the served PNG URL. Polls the render up to the tool timeout; use the default size for fastest results.',
parameters: {
prompt: { type: 'string', required: true, description: 'The positive text prompt describing the image.' },
negative: { type: 'string', description: `Negative prompt; defaults to "${DEFAULT_NEGATIVE}".` },
width: { type: 'integer', description: `Output width; defaults to ${DEFAULT_SIZE}.` },
height: { type: 'integer', description: `Output height; defaults to ${DEFAULT_SIZE}.` },
steps: { type: 'integer', description: `Sampling steps; defaults to ${DEFAULT_STEPS} (SDXL-turbo).` },
seed: { type: 'integer', description: 'Sampling seed; defaults to 123.' },
model: { type: 'string', description: `Checkpoint name on the server; defaults to "${DEFAULT_MODEL}".` },
filename_prefix: { type: 'string', description: `Output filename prefix; defaults to "${DEFAULT_FILENAME_PREFIX}".` },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
// A shared GPU render is idempotent from the caller's perspective: sibling
// calls mutate no parent-owned state beyond the lab queue.
isConcurrencySafe: () => true,
async execute(args, exec) {
return runComfyImage(baseUrl, resolveImageArgs(args as GenerateImageArgs), exec.signal, timeoutMs)
},
}))
}

View File

@@ -0,0 +1,150 @@
/**
* Docling PDF-OCR tool. Uploads a PDF through Docling's multipart async
* conversion endpoint, polls the task status, and returns the extracted
* markdown. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/docling
*/
import type { Context } from '@deepseek-ai/cordis'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** Docling `/v1/status/poll` wait hint (seconds) per poll. */
export const DOCLING_POLL_WAIT_S = 2
/** One transport retry interval (ms) between status polls. */
export const DOCLING_POLL_INTERVAL_MS = 2_000
/** Schema-validated arguments for `lab_ocr_pdf`. */
export interface OcrPdfArgs {
file_path: string
}
/**
* Upload a PDF blob and poll the Docling task until it succeeds, returning
* the extracted markdown (`document.md_content`).
*
* @param baseUrl - the Docling server base URL, e.g. `http://192.168.31.159:5001`.
* @param bytes - the PDF file bytes read through `ctx.fs.readBytes`.
* @param filename - the file name sent in the multipart upload.
* @param signal - the executor's cancellation signal (deadline-fused).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the extracted markdown text.
*/
export async function runDoclingOcr(
baseUrl: string,
bytes: Uint8Array,
filename: string,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
const form = new FormData()
form.append('files', new Blob([bytes as BlobPart], { type: 'application/pdf' }), filename)
let response = await fetch(`${baseUrl}/v1/convert/file/async`, { method: 'POST', body: form, signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling convert failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const queued = await response.json() as { task_id?: string; task_status?: string }
const taskId = queued.task_id
if (taskId === undefined) throw new Error('lab_ocr_pdf: /v1/convert/file/async returned no task_id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/v1/status/poll/${encodeURIComponent(taskId)}?wait=${DOCLING_POLL_WAIT_S}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling status failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const status = await response.json() as { task_status?: string }
if (status.task_status === 'success') break
if (status.task_status === 'failure' || status.task_status === 'cancelled' || status.task_status === 'error') {
throw new Error(`lab_ocr_pdf: Docling task ended with status "${status.task_status}"`)
}
await sleep(DOCLING_POLL_INTERVAL_MS, d.signal)
}
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/v1/result/${encodeURIComponent(taskId)}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling result failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const result = await response.json() as { status?: string; errors?: unknown[]; document?: { md_content?: string } }
if (result.status !== 'success') throw new Error(`lab_ocr_pdf: Docling result status "${result.status ?? 'unknown'}"`)
const markdown = result.document?.md_content
if (markdown === undefined) throw new Error('lab_ocr_pdf: Docling result carried no document.md_content')
return markdown
}
/**
* Register the `lab_ocr_pdf` tool. Reads the file as bytes via `ctx.fs`
* (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
*
* @param ctx - context whose `tools` registry receives the definition and whose
* `fs` provides file reads.
* @param baseUrl - the Docling server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxBytes - inclusive cap on the PDF bytes read from disk.
* @param maxOutputChars - cap on the returned markdown; longer output is truncated.
*/
export function registerLabDoclingTool(
ctx: Context,
baseUrl: string,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): void {
ctx.tools.register(defineTool({
name: 'lab_ocr_pdf',
description:
'OCR a PDF file and return the extracted markdown text via the shared Docling server (no auth). '
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until OCR succeeds or times out.`,
parameters: {
file_path: { type: 'string', required: true, description: 'Path to the PDF file to OCR.' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
// Reads and an upload to the shared OCR queue: no parent-owned mutation.
isConcurrencySafe: () => true,
async execute(args, exec) {
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
return runDoclingOcrFromTarget(
baseUrl,
ctx,
target,
args.file_path,
exec.signal,
timeoutMs,
maxBytes,
maxOutputChars,
)
},
}))
}
/** Read the resolved target bytes and run the Docling OCR flow. */
async function runDoclingOcrFromTarget(
baseUrl: string,
ctx: Context,
target: FsTarget,
displayPath: string,
signal: AbortSignal | undefined,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): Promise<string> {
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
const filename = displayPath.split(/[\\/]/).pop() ?? 'input.pdf'
const markdown = await runDoclingOcr(baseUrl, bytes, filename, signal, timeoutMs)
if (markdown.length <= maxOutputChars) return markdown
return `${markdown.slice(0, maxOutputChars)}\n\n(OCR output truncated.)`
}
/** Human-readable byte bound for the tool description. */
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
}

View File

@@ -0,0 +1,68 @@
/**
* Shared cooperative utilities for the lab tools: a signal-fused deadline, an
* abortable sleep, and a structured HTTP/upstream error carrier. Deadline and
* sleep cooperate with the caller's cancellation signal so a timed-out or
* cancelled call unwinds promptly instead of blocking on a hung server.
* @module @deepseek-ai/dsh-tool-lab/helpers
*/
import { deadline as makeDeadline } from '@deepseek-ai/dsh-timeout'
import type { Deadline } from '@deepseek-ai/dsh-timeout'
/**
* Cooperative classification error for lab-service HTTP failures. Carries the
* HTTP status (or `undefined` for transport failures) and a stable code so
* callers and diagnostics can distinguish a rejected workflow, an unreachable
* server, and a timed-out request without reparsing the message.
*/
export class HttpError extends Error {
override name = 'HttpError'
constructor(
message: string,
readonly status: number | undefined,
readonly code: string,
) {
super(message)
}
}
/**
* Fuse the executor's cancellation signal with a per-call timeout budget.
* The returned deadline aborts on upstream cancellation OR on timeout (the
* timeout carries a {@link TimeoutReason} with the given code). `using` clears
* the timer at scope exit.
*
* @param signal - the executor's cancellation signal, if any.
* @param timeoutMs - cooperative timeout budget in milliseconds.
* @param code - capability-owned code stamped onto the timeout reason.
* @returns the fused deadline (signal + timer cleanup).
*/
export function deadline(signal: AbortSignal | undefined, timeoutMs: number, code: string): Deadline {
return makeDeadline(signal, timeoutMs, code)
}
/**
* Suspend the current coroutine for `ms` milliseconds, returning early when
* the signal aborts. Throws the abort once registered; cooperative callers
* check `signal.aborted` or let the next upstream call raise.
*
* @param ms - sleep duration in milliseconds.
* @param signal - optional signal to observe.
*/
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
if (ms <= 0) return
if (signal !== undefined && signal.aborted) signal.throwIfAborted()
await new Promise<void>((resolve, reject) => {
if (signal === undefined) {
setTimeout(resolve, ms)
return
}
const onAbort = (): void => reject(signal.reason)
signal.addEventListener('abort', onAbort, { once: true })
setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
})
}

View File

@@ -0,0 +1,90 @@
/**
* Model-facing lab tools over the LAN media lab: ComfyUI image generation,
* Docling PDF OCR, and Whishper speech-to-text. This package owns schemas,
* validation, defaults, and presentation; each service module owns its HTTP
* flow. Enablement is config-driven; the three tools register together.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { registerLabComfyTool } from './comfy.ts'
import { registerLabDoclingTool } from './docling.ts'
import { registerLabWhishTool } from './whish.ts'
export { DEFAULT_MODEL as COMFY_DEFAULT_MODEL, DEFAULT_NEGATIVE as COMFY_DEFAULT_NEGATIVE } from './comfy.ts'
export type { GenerateImageArgs as ComfyGenerateImageArgs } from './comfy.ts'
export type { OcrPdfArgs as DoclingOcrPdfArgs } from './docling.ts'
export type { TranscribeAudioArgs as WhishTranscribeAudioArgs } from './whish.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'lab'
/** Services required by the lab tool suite. */
export const inject = ['tools', 'fs']
/** Default ComfyUI server base URL. */
export const DEFAULT_COMFY_BASE_URL = 'http://192.168.31.240:8188'
/** Default Docling server base URL. */
export const DEFAULT_DOCLING_BASE_URL = 'http://192.168.31.159:5001'
/** Default Whishper server base URL. */
export const DEFAULT_WHISH_BASE_URL = 'http://192.168.31.159:8082'
/** Default per-call cooperative timeout budget. */
export const DEFAULT_TIMEOUT_MS = 120_000
/** Default cap on one uploaded file. */
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
/** Default cap on one tool's text output. */
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
/** Plugin config: lab service endpoints and the shared tool bounds. */
export interface Config {
/** ComfyUI server base URL. */
comfyBaseUrl?: string
/** Docling server base URL. */
doclingBaseUrl?: string
/** Whishper server base URL. */
whishBaseUrl?: string
/** Cooperative timeout budget (ms) for one lab tool call. */
timeoutMs?: number
/** Inclusive cap on one uploaded file (bytes). */
maxUploadBytes?: number
/** Cap on one tool's text output (characters). */
maxOutputChars?: number
}
export const Config: z<Config> = z.object({
comfyBaseUrl: z.string().default(DEFAULT_COMFY_BASE_URL),
doclingBaseUrl: z.string().default(DEFAULT_DOCLING_BASE_URL),
whishBaseUrl: z.string().default(DEFAULT_WHISH_BASE_URL),
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
})
/** Complete config after schemastery applies every field default. */
type ResolvedConfig = Required<Config>
/** Configured timeouts and caps must be positive integers. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-lab: ${name} must be a positive integer`)
}
/**
* Register the three lab tools. Each tool's cooperative timeout budget
* (`timeoutMs`) and file/output bounds come from config and are attached to
* the definition for `@deepseek-ai/dsh-tool-call-timeout-policy` to enforce.
* The effect-based registry cleanup means no manual teardown is needed.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
for (const [key, value] of [
['timeoutMs', resolved.timeoutMs],
['maxUploadBytes', resolved.maxUploadBytes],
['maxOutputChars', resolved.maxOutputChars],
] as const) {
assertPositiveInteger(key, value)
}
registerLabComfyTool(ctx, resolved.comfyBaseUrl, resolved.timeoutMs, resolved.maxOutputChars)
registerLabDoclingTool(ctx, resolved.doclingBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
registerLabWhishTool(ctx, resolved.whishBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
* @module @deepseek-ai/dsh-tool-lab/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
/** Cordis companion plugin name. */
export const name = 'tool-lab-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle
* stream; execution relations are owned by the capability seams it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,153 @@
/**
* Whishper speech-to-text tool. Uploads an audio file through Whishper's
* multipart `/api/transcriptions` endpoint, polls until a transcript is ready,
* and returns `result.text`. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/whish
*/
import type { Context } from '@deepseek-ai/cordis'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** One transcription status poll interval (ms). */
export const WHISH_POLL_INTERVAL_MS = 2_000
/** Default Whishper model size when the caller omits it. */
export const WHISH_DEFAULT_MODEL = 'base'
/** Schema-validated arguments for `lab_transcribe_audio`. */
export interface TranscribeAudioArgs {
file_path: string
model_size?: string
language?: string
}
/**
* Upload an audio blob and poll Whishper until the transcript text is ready,
* returning it. A queued item reports `status: -1`; once `status >= 0` AND
* `result.text` is non-empty the transcript is done.
*
* @param baseUrl - the Whishper server base URL, e.g. `http://192.168.31.159:8082`.
* @param bytes - the audio bytes read through `ctx.fs.readBytes`.
* @param filename - the file name sent in the multipart upload.
* @param modelSize - optional model size hint.
* @param language - optional spoken-language hint.
* @param signal - the executor's cancellation signal (deadline-fused).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the transcribed text.
*/
export async function runWhishTranscribe(
baseUrl: string,
bytes: Uint8Array,
filename: string,
modelSize: string | undefined,
language: string | undefined,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
const form = new FormData()
form.append('files', new Blob([bytes as BlobPart]), filename)
if (modelSize !== undefined) form.append('model_size', modelSize)
if (language !== undefined) form.append('language', language)
let response = await fetch(`${baseUrl}/api/transcriptions`, { method: 'POST', body: form, signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_transcribe_audio: Whishper upload failed (HTTP ${response.status})`, response.status, 'LAB_WHISH_HTTP')
}
const queued = await response.json() as { id?: string; status?: number }
const id = queued.id
if (id === undefined) throw new Error('lab_transcribe_audio: /api/transcriptions returned no id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/api/transcriptions/${encodeURIComponent(String(id))}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_transcribe_audio: Whishper status failed (HTTP ${response.status})`, response.status, 'LAB_WISH_HTTP')
}
const state = await response.json() as { status?: number; result?: { text?: string } }
const status = state.status ?? 0
const text = state.result?.text
if (status >= 0 && text !== undefined && text.trim().length > 0) return text
await sleep(WHISH_POLL_INTERVAL_MS, d.signal)
}
}
/**
* Register the `lab_transcribe_audio` tool. Reads the audio file as bytes via
* `ctx.fs` (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
*
* @param ctx - context whose `tools` registry receives the definition and whose
* `fs` provides file reads.
* @param baseUrl - the Whishper server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxBytes - inclusive cap on the audio bytes read from disk.
* @param maxOutputChars - cap on the returned transcript; longer text is truncated.
*/
export function registerLabWhishTool(
ctx: Context,
baseUrl: string,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): void {
ctx.tools.register(defineTool({
name: 'lab_transcribe_audio',
description:
'Transcribe speech to text from an audio file via the shared Whishper server (no auth). '
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until the transcript is ready or times out.`,
parameters: {
file_path: { type: 'string', required: true, description: 'Path to the audio file to transcribe.' },
model_size: { type: 'string', description: `Optional model size hint; defaults to "${WHISH_DEFAULT_MODEL}".` },
language: { type: 'string', description: 'Optional spoken-language hint (e.g. "en" or "ru").' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
isConcurrencySafe: () => true,
async execute(args, exec) {
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
return runWhishFromTarget(
baseUrl,
ctx,
target,
args.file_path,
args.model_size,
args.language,
exec.signal,
timeoutMs,
maxBytes,
maxOutputChars,
)
},
}))
}
/** Read the resolved target bytes and run the Whishper flow. */
async function runWhishFromTarget(
baseUrl: string,
ctx: Context,
target: FsTarget,
displayPath: string,
modelSize: string | undefined,
language: string | undefined,
signal: AbortSignal | undefined,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): Promise<string> {
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
const filename = displayPath.split(/[\\/]/).pop() ?? 'audio.bin'
const model = modelSize !== undefined && modelSize.trim().length > 0 ? modelSize.trim() : WHISH_DEFAULT_MODEL
const text = await runWhishTranscribe(baseUrl, bytes, filename, model, language, signal, timeoutMs)
if (text.length <= maxOutputChars) return text
return `${text.slice(0, maxOutputChars)}\n\n(Transcript truncated.)`
}
/** Human-readable byte bound for the tool description. */
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
}