index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. /**
  2. * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
  3. * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
  4. * parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
  5. * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model
  6. * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool
  7. * errors, and background collection remains deferred. Presentation is an args-only generic card
  8. * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
  9. * section rather than deployment persona prose.
  10. * @module @deepseek-ai/dsh-tool-workflow
  11. */
  12. import type { Context } from '@deepseek-ai/cordis'
  13. import z from '@deepseek-ai/schemastery'
  14. import { defineTool } from '@deepseek-ai/dsh-tools'
  15. import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
  16. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  17. import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session'
  18. import type {
  19. WorkflowResult, WorkflowRun, WorkflowRunId, WorkflowStopReason,
  20. } from '@deepseek-ai/dsh-workflow'
  21. import type {
  22. ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
  23. ToolWorkflowRunEndData, ToolWorkflowRunStartData,
  24. } from './types.ts'
  25. // Declaration merge only: makes ctx.systemPrompt visible for the section registration.
  26. import type {} from '@deepseek-ai/dsh-system-prompt'
  27. export const name = 'tool-workflow'
  28. export const inject = ['tools', 'workflowEngine', 'systemPrompt']
  29. /** Config: the model-facing tool name plus result rendering caps. */
  30. export interface Config {
  31. /** The model-facing tool name to register (default `workflow`). */
  32. toolName?: string
  33. /** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
  34. maxResultChars?: number
  35. }
  36. export const Config: z<Config> = z.object({
  37. toolName: z.string().default('workflow'),
  38. maxResultChars: z.natural().min(1).default(50_000),
  39. })
  40. type ResolvedConfig = Required<Config>
  41. interface WorkflowRecorder {
  42. start(session: Session, run: WorkflowRun): void
  43. finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void
  44. abandon(runId: WorkflowRunId): void
  45. }
  46. interface ToolWorkflowRecordEventMap {
  47. 'tool-workflow/run-start': ToolWorkflowRunStartData
  48. 'tool-workflow/agent-start': ToolWorkflowAgentStartData
  49. 'tool-workflow/agent-end': ToolWorkflowAgentEndData
  50. 'tool-workflow/run-end': ToolWorkflowRunEndData
  51. }
  52. /** Render a contained recording failure without trusting the thrown value. */
  53. function renderRecordingError(error: unknown): string {
  54. try {
  55. return String(error)
  56. } catch {
  57. return '[unrenderable thrown value]'
  58. }
  59. }
  60. /**
  61. * Project active top-level workflow runs into their parent Sessions without
  62. * letting recording failure affect tool execution.
  63. */
  64. function createWorkflowRecorder(ctx: Context): WorkflowRecorder {
  65. const active = new Map<WorkflowRunId, Session>()
  66. const append = <Type extends keyof ToolWorkflowRecordEventMap>(
  67. session: Session,
  68. type: Type,
  69. data: SessionEventMap[Type],
  70. ): boolean => {
  71. // These four package-owned events are all log-only. Narrowing the generic
  72. // append face here discharges Session.append's conditional options tuple.
  73. const appendRecord = session.append.bind(session) as <Event extends keyof ToolWorkflowRecordEventMap>(
  74. event: Event,
  75. value: SessionEventMap[Event],
  76. ) => void
  77. try {
  78. appendRecord(type, data)
  79. return true
  80. } catch (error: unknown) {
  81. ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`)
  82. return false
  83. }
  84. }
  85. ctx.on('workflow/agent-start', (info, agent) => {
  86. const session = active.get(info.id)
  87. if (session === undefined) return
  88. const data: ToolWorkflowAgentStartData = {
  89. runId: info.id,
  90. seq: agent.seq,
  91. label: agent.label,
  92. ...agent.phase === undefined ? {} : { phase: agent.phase },
  93. childId: agent.childId,
  94. }
  95. if (!append(session, 'tool-workflow/agent-start', data)) active.delete(info.id)
  96. })
  97. ctx.on('workflow/agent-end', (info, agent) => {
  98. const session = active.get(info.id)
  99. if (session === undefined) return
  100. const data: ToolWorkflowAgentEndData = {
  101. runId: info.id,
  102. seq: agent.seq,
  103. outcome: agent.outcome,
  104. }
  105. if (!append(session, 'tool-workflow/agent-end', data)) active.delete(info.id)
  106. })
  107. return {
  108. start(session, run) {
  109. if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) {
  110. active.set(run.id, session)
  111. }
  112. },
  113. finish(runId, stopReason) {
  114. const session = active.get(runId)
  115. if (session !== undefined) append(session, 'tool-workflow/run-end', { runId, stopReason })
  116. active.delete(runId)
  117. },
  118. abandon: (runId) => { active.delete(runId) },
  119. }
  120. }
  121. /**
  122. * The script-authoring contract, embedded in the tool description. This IS the
  123. * model-facing spec: the meta block, the hooks and their exact semantics, and
  124. * the supported schema subset.
  125. */
  126. 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.
  127. 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 <value>\` — the value must be JSON-serializable and is this tool's result.
  128. Script-body hooks:
  129. - \`agent(prompt, opts?): Promise<any>\` — 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.
  130. - \`pipeline(items, ...stages): Promise<any[]>\` — 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.
  131. - \`parallel(thunks): Promise<any[]>\` — 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\`.
  132. - \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
  133. 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\`.
  134. 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.`
  135. type WorkflowCallArgs = {
  136. script: string
  137. meta: {
  138. name: string
  139. description: string
  140. whenToUse?: string
  141. phases?: { title: string; detail?: string; provider?: string; model?: string }[]
  142. }
  143. args?: Record<string, unknown>
  144. }
  145. /** The pending-state card: a generic card titled by the workflow's meta name. */
  146. function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
  147. return {
  148. card: 'generic',
  149. title: `workflow: ${args.meta.name}`,
  150. rawInput: args.script,
  151. }
  152. }
  153. /** The completed-state card: keep the pending title; render the result content as-is. */
  154. function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
  155. void args
  156. void result
  157. return { card: 'generic' }
  158. }
  159. /** A non-`completed` stop reason means the script did not finish cleanly. */
  160. function stopReasonError(result: WorkflowResult): string | undefined {
  161. switch (result.stopReason) {
  162. case 'completed':
  163. return undefined
  164. case 'cancelled':
  165. return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}`
  166. case 'error':
  167. return `workflow run failed: ${result.error ?? 'unknown error'}`
  168. /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
  169. default:
  170. return `workflow run ended abnormally (${String(result.stopReason satisfies never)})`
  171. /* v8 ignore stop */
  172. }
  173. }
  174. /** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
  175. function renderResult(name: string, agentsStarted: number, value: JsonValue, maxChars: number): string {
  176. // The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
  177. const rendered = JSON.stringify(value, null, 2)
  178. const clipped = rendered.length > maxChars
  179. ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
  180. : rendered
  181. return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
  182. }
  183. export function apply(ctx: Context, config: Config): void {
  184. // schemastery (the exported Config schema) has already filled the defaulted
  185. // fields; the assertion records that resolution, not a hidden fallback.
  186. const { toolName, maxResultChars } = config as ResolvedConfig
  187. const recorder = createWorkflowRecorder(ctx)
  188. // Usage policy ships with the tool (the master convention: tool guidance
  189. // lives in tool plugins as prompt sections, not in the deployment persona).
  190. ctx.systemPrompt.section({
  191. name: `tool:${toolName}`,
  192. order: 115,
  193. 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.`,
  194. })
  195. ctx.tools.register(defineTool({
  196. name: toolName,
  197. description: DESCRIPTION,
  198. parameters: {
  199. script: {
  200. type: 'string',
  201. required: true,
  202. description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`).',
  203. },
  204. meta: {
  205. type: 'object',
  206. additionalProperties: true,
  207. required: true,
  208. description: 'The workflow identity block (plain JSON — never code).',
  209. properties: {
  210. name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' },
  211. description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' },
  212. whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' },
  213. phases: {
  214. type: 'array',
  215. description: 'Optional phase declarations matched by phase() calls.',
  216. items: {
  217. type: 'object',
  218. additionalProperties: true,
  219. properties: {
  220. title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
  221. detail: { type: 'string', description: 'Optional one-line description of the phase.' },
  222. provider: { type: 'string', description: 'Optional provider override this phase is expected to use.' },
  223. model: { type: 'string', description: 'Optional model override this phase is expected to use.' },
  224. },
  225. },
  226. },
  227. },
  228. },
  229. args: {
  230. type: 'object',
  231. additionalProperties: true,
  232. description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
  233. },
  234. },
  235. output: {
  236. schema: {
  237. type: 'object',
  238. additionalProperties: false,
  239. properties: {
  240. runId: { type: 'string', required: true },
  241. agentsStarted: { type: 'integer', required: true },
  242. result: { type: 'json', required: true },
  243. },
  244. },
  245. render: (args, value) => [{
  246. type: 'text',
  247. text: renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars),
  248. }],
  249. },
  250. async execute(args, exec) {
  251. const parent = exec.agent
  252. if (!parent) {
  253. // The loop sets `exec.agent` for every model-driven call; its absence
  254. // means a non-agent caller invoked the tool directly, which has no
  255. // parent to attribute the children to. Fail loud rather than guess.
  256. throw new Error('workflow tool requires a calling agent (exec.agent was undefined)')
  257. }
  258. // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
  259. // synchronously here and become isError results via the registry — the
  260. // model sees the violation list and can correct the call.
  261. const run = ctx.workflowEngine.start({
  262. script: args.script,
  263. meta: args.meta,
  264. ...args.args !== undefined ? { args: args.args } : {},
  265. parent,
  266. signal: exec.signal,
  267. })
  268. const recordsRun = exec.parent === undefined
  269. // The shipped worker-thread engine publishes member events from later
  270. // worker messages, after start() returns and this run record is active.
  271. if (recordsRun) recorder.start(parent.session, run)
  272. // Bridge the tool's abort signal to the run: if the parent step is aborted while the
  273. // script is in flight, cancel the whole run. The signal also enters the engine directly, but
  274. // this local bridge preserves the tool contract even if an implementation ignores it.
  275. const onAbort = (): void => { run.cancel('parent step aborted') }
  276. exec.signal.addEventListener('abort', onAbort, { once: true })
  277. let result: WorkflowResult | undefined
  278. try {
  279. result = await run.result
  280. const error = stopReasonError(result)
  281. if (error !== undefined) {
  282. // Map a non-clean finish to an isError result (the registry turns a
  283. // throw into an isError). Report the reason, not partial output.
  284. throw new Error(error)
  285. }
  286. return {
  287. runId: run.id,
  288. agentsStarted: result.agentsStarted,
  289. result: result.value as JsonValue,
  290. }
  291. } finally {
  292. exec.signal.removeEventListener('abort', onAbort)
  293. try {
  294. // Keep member listeners alive through disposal: an engine may
  295. // synthesize cancelled member endings while reaching quiescence.
  296. await run.dispose()
  297. if (recordsRun) {
  298. /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
  299. if (result === undefined) throw new Error('workflow run settled without a result')
  300. recorder.finish(run.id, result.stopReason)
  301. }
  302. } finally {
  303. if (recordsRun) recorder.abandon(run.id)
  304. }
  305. }
  306. },
  307. presentCall: args => presentWorkflowCall(args),
  308. presentResult: (args, result) => presentWorkflowResult(args, result),
  309. }))
  310. }