index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. /**
  2. * Model-facing delegation through one configured `ctx.subagents` provider.
  3. * Provider lifecycle controls tool registration and context-sensitive schema
  4. * wording. Foreground calls always dispose the run after collection; background
  5. * calls use an independent cancellation signal and settle a final-output task
  6. * only after child disposal.
  7. * @module @deepseek-ai/dsh-tool-subagent
  8. */
  9. import type { Context } from 'cordis'
  10. import z from 'schemastery'
  11. import { defineTool } from '@deepseek-ai/dsh-tools'
  12. import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
  13. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  14. import type { JsonValue } from '@deepseek-ai/dsh-session'
  15. import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
  16. import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
  17. import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
  18. export const name = 'tool-subagent'
  19. export const inject = ['tools', 'subagents']
  20. /** Config: which registered provider this tool delegates to, plus child defaults. */
  21. export interface Config {
  22. /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
  23. provider: string
  24. /**
  25. * Model-facing tool name (default `subagent`). Each loaded instance must use
  26. * a distinct name.
  27. */
  28. toolName?: string
  29. /**
  30. * Expose `run_in_background` (default true). Disabled instances omit the
  31. * parameter and reject forced background calls.
  32. */
  33. enableRunInBackground?: boolean
  34. /**
  35. * Agent options applied to every child; omitted fields use child-loop defaults.
  36. */
  37. agentOptions?: AgentOptions
  38. /**
  39. * Per-child persona that shadows `deployment:persona`. Requires the
  40. * provider's `persona` capability; omission preserves the deployment persona.
  41. */
  42. persona?: string
  43. /**
  44. * Tool filter applied to every child. Filtered tools disappear from its
  45. * prompt and reject execution. Requires the provider's `toolFilter`
  46. * capability; unknown names fail startup.
  47. */
  48. toolFilter?: {
  49. /** Global tool names the child keeps; everything else is removed. */
  50. allow?: string[]
  51. /** Global tool names removed from the child. */
  52. deny?: string[]
  53. }
  54. /**
  55. * Maximum child depth: a non-negative safe integer (default `3`; `0` forbids
  56. * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap
  57. * requires the provider's `depthLimit` capability (mount fails loud
  58. * otherwise). The provider checks the calling agent's current depth at every
  59. * start; the tool remains model-visible so runtime policy owns rejection.
  60. * `'provider-managed'` is for an out-of-process provider (ACP) whose
  61. * recursion budget belongs to the child harness's own deployment.
  62. */
  63. maxDepth?: number | 'provider-managed'
  64. }
  65. export const Config: z<Config> = z.object({
  66. provider: z.string().required(),
  67. toolName: z.string().default('subagent'),
  68. enableRunInBackground: z.boolean().default(true),
  69. // Prevent Schemastery from materializing omitted agentOptions as `{}`.
  70. agentOptions: z.object({
  71. provider: z.string(),
  72. model: z.string(),
  73. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
  74. }).default(undefined as unknown as { provider: string; model: string; maxTokens: number }),
  75. persona: z.string(),
  76. // Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool.
  77. toolFilter: z.object({
  78. allow: z.array(z.string()).default(undefined as unknown as string[]),
  79. deny: z.array(z.string()).default(undefined as unknown as string[]),
  80. }).default(undefined as unknown as { allow: string[]; deny: string[] }),
  81. maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3),
  82. })
  83. /**
  84. * Flatten a child's final output blocks to text for the tool result. The child
  85. * may return non-text blocks; this path returns only text. Structured results
  86. * use `outputSchema`.
  87. */
  88. function outputText(blocks: ContentBlock[]): string {
  89. return blocks
  90. .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
  91. .map(b => b.text)
  92. .join('')
  93. }
  94. /** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
  95. function outputValueText(values: JsonValue[]): string {
  96. return values
  97. .filter((value): value is { type: 'text'; text: string } =>
  98. typeof value === 'object' && value !== null && !Array.isArray(value)
  99. && value.type === 'text' && typeof value.text === 'string')
  100. .map(value => value.text)
  101. .join('')
  102. }
  103. /** A non-`completed` stop reason means the child did not finish cleanly. */
  104. function stopReasonError(result: SubagentResult): string | undefined {
  105. switch (result.stopReason) {
  106. case 'completed':
  107. return undefined
  108. case 'aborted':
  109. return 'subagent run was cancelled'
  110. case 'error':
  111. return 'subagent run failed'
  112. case 'max-tokens':
  113. return 'subagent run hit its token limit before finishing'
  114. case 'refusal':
  115. return 'subagent declined the task'
  116. // Merge-extensible union: a backend may add stop reasons. Treat an unknown
  117. // terminal reason as a failure rather than reporting partial output as success.
  118. default:
  119. return `subagent run ended abnormally (${String(result.stopReason)})`
  120. }
  121. }
  122. /**
  123. * Map a child result to the task outcome: completed carries final text,
  124. * aborted is killed, and every other reason is failed without partial output.
  125. * @param result - child terminal result.
  126. * @returns outcome for the `ctx.tasks` registration.
  127. */
  128. export function runOutcome(result: SubagentResult): TaskOutcome {
  129. switch (result.stopReason) {
  130. case 'completed':
  131. return { status: 'completed', output: outputText(result.output) }
  132. case 'aborted':
  133. return { status: 'killed' }
  134. case 'error':
  135. case 'max-tokens':
  136. case 'refusal':
  137. return { status: 'failed', detail: result.stopReason }
  138. // Merge-extensible reasons remain failures with their raw detail.
  139. default:
  140. return { status: 'failed', detail: String(result.stopReason) }
  141. }
  142. }
  143. /**
  144. * Await the child result, dispose the run, then return its task outcome. Result
  145. * and disposal failures become `failed`; when both fail, both details survive.
  146. * @param run - live run to settle and release.
  147. * @returns outcome after child resources are released.
  148. */
  149. export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
  150. let outcome: TaskOutcome
  151. try {
  152. outcome = runOutcome(await run.result)
  153. } catch (error: unknown) {
  154. outcome = { status: 'failed', detail: String(error) }
  155. }
  156. try {
  157. await run.dispose()
  158. } catch (error: unknown) {
  159. const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
  160. return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
  161. }
  162. return outcome
  163. }
  164. /**
  165. * Model-facing wording from the provider's conversation-history descriptor
  166. * ({@link SubagentProvider.inheritsParentContext}).
  167. * A fresh child needs a standalone prompt; a forked child already sees the
  168. * conversation's completed turns — telling the model to restate everything
  169. * (or, worse, that the child "does not see this conversation") would be false
  170. * for a fork.
  171. * @param inheritsConversation - whether the child's conversation is seeded
  172. * with the parent's completed turns; this says nothing about tool, service,
  173. * scope, or authority inheritance.
  174. * @returns the tool `description` and the `prompt` parameter description.
  175. */
  176. function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
  177. if (inheritsConversation) {
  178. return {
  179. description:
  180. 'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
  181. + 'completed turns so far (it does not see the current in-flight turn), returning only its final '
  182. + 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
  183. + 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
  184. + 'You receive only its final answer, not its intermediate steps.',
  185. promptDescription:
  186. 'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '
  187. + 'freely and state only what is new.',
  188. }
  189. }
  190. return {
  191. description:
  192. 'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
  193. + 'and return its final result. Use this to offload focused, independent work — research, a scoped '
  194. + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
  195. + 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
  196. + 'complete, standalone prompt: it does not see this conversation.',
  197. promptDescription:
  198. 'The complete, self-contained task for the subagent. It does not share this '
  199. + 'conversation\'s context, so include everything it needs.',
  200. }
  201. }
  202. function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
  203. const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
  204. return {
  205. prompt: [{ type: 'text', text: prompt }],
  206. parent,
  207. signal,
  208. ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
  209. ...config.persona !== undefined ? { persona: config.persona } : {},
  210. ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
  211. ...maxDepth !== undefined ? { maxDepth } : {},
  212. }
  213. }
  214. /** Settle pending startup without rejecting the task producer contract. */
  215. async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
  216. try {
  217. return await settleRun(await start)
  218. } catch (error: unknown) {
  219. return signal.aborted
  220. ? { status: 'killed' }
  221. : { status: 'failed', detail: String(error) }
  222. }
  223. }
  224. export function apply(ctx: Context, config: Config): void {
  225. // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply
  226. // omission stays capless (the schema default only runs through the loader).
  227. if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth)
  228. // Reject an empty explicit filter at load instead of failing every delegation.
  229. if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
  230. throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
  231. }
  232. // Mirror provider lifecycle because sibling load order and HMR replacement
  233. // can change provider availability while this fiber remains active.
  234. let disposeTool: (() => void) | undefined
  235. const mount = (provider: SubagentProvider): void => {
  236. // A numeric cap the provider cannot enforce is a misconfiguration — fail at
  237. // mount (the earliest point the provider's capabilities are known), not on
  238. // the first delegation.
  239. if (typeof config.maxDepth === 'number' && !provider.capabilities.depthLimit) {
  240. throw new Error(
  241. `tool-subagent: provider "${provider.name}" cannot enforce maxDepth (no depthLimit capability) — `
  242. + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider',
  243. )
  244. }
  245. const wording = providerWording(provider.inheritsParentContext)
  246. const backgroundEnabled = config.enableRunInBackground !== false
  247. disposeTool = ctx.tools.register(defineTool({
  248. name: config.toolName ?? 'subagent',
  249. description: wording.description + (backgroundEnabled
  250. ? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
  251. : ''),
  252. parameters: {
  253. description: {
  254. type: 'string',
  255. required: true,
  256. description: 'A short (3-5 word) description of the delegated task, for display.',
  257. },
  258. prompt: {
  259. type: 'string',
  260. required: true,
  261. description: wording.promptDescription,
  262. },
  263. ...backgroundEnabled ? {
  264. run_in_background: {
  265. type: 'boolean' as const,
  266. description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
  267. },
  268. } : {},
  269. },
  270. output: {
  271. schema: {
  272. oneOf: [
  273. {
  274. type: 'object',
  275. additionalProperties: false,
  276. properties: {
  277. kind: { type: 'string', required: true, const: 'background' },
  278. taskId: { type: 'string', required: true },
  279. },
  280. },
  281. {
  282. type: 'object',
  283. additionalProperties: false,
  284. properties: {
  285. kind: { type: 'string', required: true, const: 'foreground' },
  286. runId: { type: 'string', required: true },
  287. output: { type: 'array', required: true, items: { type: 'json' } },
  288. },
  289. },
  290. ],
  291. },
  292. render: (_args, value) => [{
  293. type: 'text',
  294. text: value.kind === 'background'
  295. ? `started background subagent task ${value.taskId}`
  296. : outputValueText(value.output),
  297. }],
  298. },
  299. async execute(args, exec) {
  300. const parent = exec.agent
  301. if (!parent) {
  302. // Non-agent callers provide no parent for delegation ownership.
  303. throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
  304. }
  305. if (args.run_in_background === true) {
  306. // The validator permits undeclared keys, so schema omission also needs
  307. // execution-time enforcement.
  308. if (!backgroundEnabled) {
  309. throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
  310. }
  311. const tasks = ctx.get('tasks')
  312. if (tasks === undefined) {
  313. throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
  314. }
  315. // Task preflight finishes before the starter can spawn a child.
  316. const id = tasks.start({
  317. kind: 'subagent',
  318. label: args.description,
  319. owner: parent,
  320. run: () => {
  321. const controller = new AbortController()
  322. const start = ctx.subagents.start(
  323. config.provider,
  324. startRequest(config, args.prompt, parent, controller.signal),
  325. )
  326. return {
  327. cancel: (reason?: string) => {
  328. controller.abort(reason ?? 'background subagent task killed')
  329. },
  330. done: settleStart(start, controller.signal),
  331. // No readOutput: the child session owns intermediate detail.
  332. }
  333. },
  334. })
  335. return { kind: 'background' as const, taskId: id }
  336. }
  337. const request = startRequest(
  338. config,
  339. args.prompt,
  340. parent,
  341. exec.signal,
  342. )
  343. const run: SubagentRun = await ctx.subagents.start(config.provider, request)
  344. try {
  345. const result = await run.result
  346. const error = stopReasonError(result)
  347. if (error !== undefined) {
  348. // The registry converts this throw to isError; partial output is not success.
  349. throw new Error(error)
  350. }
  351. return {
  352. kind: 'foreground' as const,
  353. runId: run.id,
  354. // Content blocks already cross durable JSON boundaries elsewhere;
  355. // the registry performs the authoritative lossless snapshot here.
  356. output: result.output as unknown as JsonValue[],
  357. }
  358. } finally {
  359. // Dispose before returning so no child session outlives the call.
  360. await run.dispose()
  361. }
  362. },
  363. }))
  364. }
  365. // Register listeners before checking presence so no synchronous change is missed.
  366. // TODO(subagent-dup-toolname): two WAITING fibers configured with the same
  367. // toolName collide when their provider appears, and the duplicate-name throw
  368. // rolls back the provider registration. Add an intent registry if this occurs.
  369. ctx.on('subagent/provider-added', (provider) => {
  370. if (provider.name === config.provider && disposeTool === undefined) mount(provider)
  371. })
  372. ctx.on('subagent/provider-removed', (name) => {
  373. if (name !== config.provider || disposeTool === undefined) return
  374. disposeTool()
  375. disposeTool = undefined
  376. })
  377. const present = ctx.subagents.getProvider(config.provider)
  378. if (present !== undefined) {
  379. mount(present)
  380. } else {
  381. // A backend fiber may activate later; a misspelled provider remains visible in this log.
  382. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
  383. }
  384. }