/** * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script * parsing, execution, caps, and cancellation live behind `ctx.workflowEngine` * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool * errors, and background collection remains deferred. Presentation is an args-only generic card * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt * section rather than deployment persona prose. * @module @deepseek-ai/dsh-tool-workflow */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session' import type { WorkflowResult, WorkflowRun, WorkflowRunId, WorkflowStopReason, } from '@deepseek-ai/dsh-workflow' import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, ToolWorkflowRunEndData, ToolWorkflowRunStartData, } from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] /** Config: the model-facing tool name plus result rendering caps. */ export interface Config { /** The model-facing tool name to register (default `workflow`). */ toolName?: string /** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */ maxResultChars?: number } export const Config: z = z.object({ toolName: z.string().default('workflow'), maxResultChars: z.natural().min(1).default(50_000), }) type ResolvedConfig = Required interface WorkflowRecorder { start(session: Session, run: WorkflowRun): void finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void abandon(runId: WorkflowRunId): void } interface ToolWorkflowRecordEventMap { 'tool-workflow/run-start': ToolWorkflowRunStartData 'tool-workflow/agent-start': ToolWorkflowAgentStartData 'tool-workflow/agent-end': ToolWorkflowAgentEndData 'tool-workflow/run-end': ToolWorkflowRunEndData } /** Render a contained recording failure without trusting the thrown value. */ function renderRecordingError(error: unknown): string { try { return String(error) } catch { return '[unrenderable thrown value]' } } /** * Project active top-level workflow runs into their parent Sessions without * letting recording failure affect tool execution. */ function createWorkflowRecorder(ctx: Context): WorkflowRecorder { const active = new Map() const append = ( session: Session, type: Type, data: SessionEventMap[Type], ): boolean => { // These four package-owned events are all log-only. Narrowing the generic // append face here discharges Session.append's conditional options tuple. const appendRecord = session.append.bind(session) as ( event: Event, value: SessionEventMap[Event], ) => void try { appendRecord(type, data) return true } catch (error: unknown) { ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`) return false } } ctx.on('workflow/agent-start', (info, agent) => { const session = active.get(info.id) if (session === undefined) return const data: ToolWorkflowAgentStartData = { runId: info.id, seq: agent.seq, label: agent.label, ...agent.phase === undefined ? {} : { phase: agent.phase }, childId: agent.childId, } if (!append(session, 'tool-workflow/agent-start', data)) active.delete(info.id) }) ctx.on('workflow/agent-end', (info, agent) => { const session = active.get(info.id) if (session === undefined) return const data: ToolWorkflowAgentEndData = { runId: info.id, seq: agent.seq, outcome: agent.outcome, } if (!append(session, 'tool-workflow/agent-end', data)) active.delete(info.id) }) return { start(session, run) { if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) { active.set(run.id, session) } }, finish(runId, stopReason) { const session = active.get(runId) if (session !== undefined) append(session, 'tool-workflow/run-end', { runId, stopReason }) active.delete(runId) }, abandon: (runId) => { active.delete(runId) }, } } /** * The script-authoring contract, embedded in the tool description. This IS the * model-facing spec: the meta block, the hooks and their exact semantics, and * the supported schema subset. */ const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return \` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - \`agent(prompt, opts?): Promise\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly. - \`pipeline(items, ...stages): Promise\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages. - \`parallel(thunks): Promise\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`. - \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.` type WorkflowCallArgs = { script: string meta: { name: string description: string whenToUse?: string phases?: { title: string; detail?: string; provider?: string; model?: string }[] } args?: Record } /** The pending-state card: a generic card titled by the workflow's meta name. */ function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView { return { card: 'generic', title: `workflow: ${args.meta.name}`, rawInput: args.script, } } /** The completed-state card: keep the pending title; render the result content as-is. */ function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView { void args void result return { card: 'generic' } } /** A non-`completed` stop reason means the script did not finish cleanly. */ function stopReasonError(result: WorkflowResult): string | undefined { switch (result.stopReason) { case 'completed': return undefined case 'cancelled': return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}` case 'error': return `workflow run failed: ${result.error ?? 'unknown error'}` /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */ default: return `workflow run ended abnormally (${String(result.stopReason satisfies never)})` /* v8 ignore stop */ } } /** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */ function renderResult(name: string, agentsStarted: number, value: JsonValue, maxChars: number): string { // The engine returns JSON data (null for a valueless script), so stringify never yields undefined. const rendered = JSON.stringify(value, null, 2) const clipped = rendered.length > maxChars ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]` : rendered return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}` } export function apply(ctx: Context, config: Config): void { // schemastery (the exported Config schema) has already filled the defaulted // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ name: `tool:${toolName}`, order: 115, text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`, }) ctx.tools.register(defineTool({ name: toolName, description: DESCRIPTION, parameters: { script: { type: 'string', required: true, description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `).', }, meta: { type: 'object', additionalProperties: true, required: true, description: 'The workflow identity block (plain JSON — never code).', properties: { name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' }, description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' }, whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' }, phases: { type: 'array', description: 'Optional phase declarations matched by phase() calls.', items: { type: 'object', additionalProperties: true, properties: { title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' }, detail: { type: 'string', description: 'Optional one-line description of the phase.' }, provider: { type: 'string', description: 'Optional provider override this phase is expected to use.' }, model: { type: 'string', description: 'Optional model override this phase is expected to use.' }, }, }, }, }, }, args: { type: 'object', additionalProperties: true, description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).', }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { runId: { type: 'string', required: true }, agentsStarted: { type: 'integer', required: true }, result: { type: 'json', required: true }, }, }, render: (args, value) => [{ type: 'text', text: renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars), }], }, async execute(args, exec) { const parent = exec.agent if (!parent) { // The loop sets `exec.agent` for every model-driven call; its absence // means a non-agent caller invoked the tool directly, which has no // parent to attribute the children to. Fail loud rather than guess. throw new Error('workflow tool requires a calling agent (exec.agent was undefined)') } // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw // synchronously here and become isError results via the registry — the // model sees the violation list and can correct the call. const run = ctx.workflowEngine.start({ script: args.script, meta: args.meta, ...args.args !== undefined ? { args: args.args } : {}, parent, signal: exec.signal, }) const recordsRun = exec.parent === undefined // The shipped worker-thread engine publishes member events from later // worker messages, after start() returns and this run record is active. if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but // this local bridge preserves the tool contract even if an implementation ignores it. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal.addEventListener('abort', onAbort, { once: true }) let result: WorkflowResult | undefined try { result = await run.result const error = stopReasonError(result) if (error !== undefined) { // Map a non-clean finish to an isError result (the registry turns a // throw into an isError). Report the reason, not partial output. throw new Error(error) } return { runId: run.id, agentsStarted: result.agentsStarted, result: result.value as JsonValue, } } finally { exec.signal.removeEventListener('abort', onAbort) try { // Keep member listeners alive through disposal: an engine may // synthesize cancelled member endings while reaching quiescence. await run.dispose() if (recordsRun) { /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ if (result === undefined) throw new Error('workflow run settled without a result') recorder.finish(run.id, result.stopReason) } } finally { if (recordsRun) recorder.abandon(run.id) } } }, presentCall: args => presentWorkflowCall(args), presentResult: (args, result) => presentWorkflowResult(args, result), })) }