index.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * The model-facing `workflow` tool: run a JavaScript orchestration script that
  3. * fans out subagents, and return the script's final value. Pure schema +
  4. * lifecycle shaping — script parsing, execution, caps, and cancellation live
  5. * behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine
  6. * swaps in without touching what the model sees.
  7. *
  8. * Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute`
  9. * starts a run and awaits `run.result` inside a `try/finally` that always
  10. * disposes the run, so the script and its children are torn down on every
  11. * path. A non-`completed` stop reason maps to an `isError` tool result (by
  12. * throwing) rather than returning partial output as success. Background
  13. * collection is deferred to the cross-tool background redesign.
  14. *
  15. * Render intent (decided up front, per the render-intent RFC): a `generic`
  16. * card whose title carries the script's `meta.name`, sniffed textually from
  17. * the args — presentation must be a pure function of `args`, so it cannot ask
  18. * the engine to parse.
  19. *
  20. * @module @deepseek-ai/dsh-tool-workflow
  21. */
  22. import type { Context } from 'cordis'
  23. import z from 'schemastery'
  24. import { defineTool } from '@deepseek-ai/dsh-tools'
  25. import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
  26. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  27. import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
  28. export const name = 'tool-workflow'
  29. export const inject = ['tools', 'workflows']
  30. /** Config: the model-facing tool name plus result rendering caps. */
  31. export interface Config {
  32. /** The model-facing tool name to register (default `workflow`). */
  33. toolName?: string
  34. /** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
  35. maxResultChars?: number
  36. }
  37. export const Config: z<Config> = z.object({
  38. toolName: z.string().default('workflow'),
  39. maxResultChars: z.natural().min(1).default(50_000),
  40. })
  41. /**
  42. * The script-authoring contract, embedded in the tool description. This IS the
  43. * model-facing spec: the meta block, the hooks and their exact semantics, the
  44. * determinism bans, and the supported schema subset.
  45. */
  46. 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.
  47. The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
  48. Script-body hooks:
  49. - \`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 — no oneOf/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), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
  50. - \`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.
  51. - \`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\`.
  52. - \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
  53. 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\`.
  54. Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`
  55. type WorkflowCallArgs = { script: string; args?: Record<string, unknown> }
  56. /** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */
  57. function sniffMetaName(script: string): string | undefined {
  58. const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script)
  59. return match?.[2]
  60. }
  61. /** The pending-state card: a generic card titled by the script's meta name. */
  62. function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
  63. const name = sniffMetaName(args.script)
  64. return {
  65. card: 'generic',
  66. title: name !== undefined ? `workflow: ${name}` : 'workflow',
  67. rawInput: args.script,
  68. }
  69. }
  70. /** The completed-state card: keep the pending title; render the result content as-is. */
  71. function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
  72. void args
  73. void result
  74. return { card: 'generic' }
  75. }
  76. /** A non-`completed` stop reason means the script did not finish cleanly. */
  77. function stopReasonError(result: WorkflowResult): string | undefined {
  78. switch (result.stopReason) {
  79. case 'completed':
  80. return undefined
  81. case 'cancelled':
  82. return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}`
  83. case 'error':
  84. return `workflow run failed: ${result.error ?? 'unknown error'}`
  85. /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
  86. default:
  87. return `workflow run ended abnormally (${String(result.stopReason satisfies never)})`
  88. /* v8 ignore stop */
  89. }
  90. }
  91. /** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
  92. function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number): string {
  93. // The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
  94. const rendered = JSON.stringify(result.value, null, 2)
  95. const clipped = rendered.length > maxChars
  96. ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
  97. : rendered
  98. return `workflow "${run.meta.name}" completed (${result.agentsStarted} agent${result.agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
  99. }
  100. export function apply(ctx: Context, config: Config): void {
  101. const maxResultChars = config.maxResultChars ?? 50_000
  102. ctx.tools.register(defineTool({
  103. name: config.toolName ?? 'workflow',
  104. description: DESCRIPTION,
  105. parameters: {
  106. script: {
  107. type: 'string',
  108. required: true,
  109. description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`).',
  110. },
  111. args: {
  112. type: 'object',
  113. description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
  114. },
  115. },
  116. async execute(args, exec): Promise<ContentBlock[]> {
  117. const parent = exec.agent
  118. if (!parent) {
  119. // The loop sets `exec.agent` for every model-driven call; its absence
  120. // means a non-agent caller invoked the tool directly, which has no
  121. // parent to attribute the children to. Fail loud rather than guess.
  122. throw new Error('workflow tool requires a calling agent (exec.agent was undefined)')
  123. }
  124. // Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here
  125. // and become isError results via the registry — the model sees the
  126. // violation list and can correct the script.
  127. const run: WorkflowRun = ctx.workflows.start({
  128. script: args.script,
  129. ...args.args !== undefined ? { args: args.args } : {},
  130. parent,
  131. ...exec.signal ? { signal: exec.signal } : {},
  132. })
  133. // Bridge the tool's abort signal to the run: if the parent step is
  134. // aborted while the script is in flight, cancel the whole run. The
  135. // engine also receives `signal` directly, but an explicit bridge keeps
  136. // the tool's contract local (and covers an engine that ignores it).
  137. const onAbort = (): void => { run.cancel('parent step aborted') }
  138. exec.signal?.addEventListener('abort', onAbort, { once: true })
  139. // `addEventListener` does NOT fire for a signal already aborted before
  140. // this line — cancel explicitly in that case.
  141. if (exec.signal?.aborted) run.cancel('parent step aborted')
  142. try {
  143. const result = await run.result
  144. const error = stopReasonError(result)
  145. if (error !== undefined) {
  146. // Map a non-clean finish to an isError result (the registry turns a
  147. // throw into an isError). Report the reason, not partial output.
  148. throw new Error(error)
  149. }
  150. return [{ type: 'text', text: renderResult(run, result, maxResultChars) }]
  151. } finally {
  152. exec.signal?.removeEventListener('abort', onAbort)
  153. // Always reach run quiescence — never leak a live script or children.
  154. await run.dispose()
  155. }
  156. },
  157. presentCall: args => presentWorkflowCall(args),
  158. presentResult: (args, result) => presentWorkflowResult(args, result),
  159. }))
  160. }