index.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * Shared driver for in-process subagent providers. The agent factory's
  3. * creation transaction owns unpublished setup and rollback; after publication
  4. * the returned AgentHandle is the one quiescent lifecycle owner held by the
  5. * provider's caller.
  6. *
  7. * @module @deepseek-ai/dsh-subagent-inprocess
  8. */
  9. import { randomUUID } from 'node:crypto'
  10. import type { Context } from 'cordis'
  11. import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
  12. import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
  13. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  14. import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
  15. import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
  16. import {
  17. attachStructuredRuntime,
  18. type StructuredAttachment,
  19. } from './structured.ts'
  20. export {
  21. STRUCTURED_OUTPUT_TOOL,
  22. STRUCTURED_OUTPUT_INSTRUCTION,
  23. } from './structured.ts'
  24. declare module '@deepseek-ai/dsh-agent' {
  25. interface AgentOptions {
  26. /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
  27. subagentDepth?: number
  28. }
  29. }
  30. /**
  31. * Read an agent's delegation depth, treating absence as top-level depth zero.
  32. * @param agent - the agent whose options carry the depth.
  33. * @returns its non-negative safe-integer depth.
  34. */
  35. export function depthOf(agent: Agent): number {
  36. const depth = agent.options.subagentDepth
  37. if (depth === undefined) return 0
  38. if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) {
  39. throw new TypeError('agent subagentDepth must be a non-negative safe integer')
  40. }
  41. return depth
  42. }
  43. /** Thrown when starting a child would exceed the requested depth cap. */
  44. export class SubagentDepthError extends Error {
  45. constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
  46. super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
  47. this.name = 'SubagentDepthError'
  48. }
  49. }
  50. /** Map a session turn outcome to the subagent seam's terminal vocabulary. */
  51. function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
  52. switch (reason?.kind) {
  53. case 'completed':
  54. return 'completed'
  55. case 'max-tokens':
  56. return 'max-tokens'
  57. case 'aborted':
  58. return 'aborted'
  59. case 'error':
  60. case 'disposed':
  61. case 'interrupted':
  62. default:
  63. return 'error'
  64. }
  65. }
  66. /** Extra inputs the spawn and fork providers supply to the shared driver. */
  67. export interface InProcessRunOptions {
  68. /** Completed-turn seed for fork, or undefined for a fresh spawn. */
  69. readonly seed?: SessionEvent[]
  70. }
  71. /** Error used when cancellation wins before the child publication boundary. */
  72. function prePublicationAbort(): Error {
  73. return new Error('subagent request was aborted before child publication')
  74. }
  75. /**
  76. * Establish and drive one in-process child. Fulfillment means the agent is
  77. * already published in the registry; rejection means the agent factory's
  78. * creation transaction and any partially-created child have reached quiescence.
  79. * @param request - the trusted typed start request, including its required signal.
  80. * @param options - the optional fork seed.
  81. * @returns a ready holder-owned run.
  82. */
  83. export async function startInProcessRun(
  84. request: SubagentStartRequest,
  85. options: InProcessRunOptions,
  86. ): Promise<SubagentRun> {
  87. assertSubagentMaxDepth(request.maxDepth)
  88. if (request.signal.aborted) throw prePublicationAbort()
  89. const parent = request.parent
  90. const childDepth = depthOf(parent) + 1
  91. if (!Number.isSafeInteger(childDepth)) {
  92. throw new RangeError('subagent child depth exceeds the safe-integer range')
  93. }
  94. if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
  95. throw new SubagentDepthError(childDepth, request.maxDepth)
  96. }
  97. const childId = AgentId(randomUUID())
  98. const seedLength = options.seed?.length ?? 0
  99. const parentHeader = parent.session.header
  100. const parentModel = parent.options.model
  101. const agentOptions: AgentOptions = {
  102. ...parentModel !== undefined ? { model: parentModel } : {},
  103. ...request.agentOptions,
  104. subagentDepth: childDepth,
  105. }
  106. let structured: StructuredAttachment | undefined
  107. const setup = (childCtx: Context): void => {
  108. if (request.persona !== undefined) {
  109. childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
  110. }
  111. if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
  112. if (request.outputSchema !== undefined) {
  113. structured = attachStructuredRuntime(childCtx, request.outputSchema)
  114. }
  115. }
  116. const flags = { cancelled: false }
  117. const handle = await parent.ctx.agents.create({
  118. agentId: childId,
  119. sessionId: SessionId(randomUUID()),
  120. meta: {
  121. ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
  122. parentSession: parentHeader.id,
  123. ...seedLength > 0 ? { seedLength } : {},
  124. },
  125. ...options.seed !== undefined ? { seed: options.seed } : {},
  126. agentOptions,
  127. signal: request.signal,
  128. setup,
  129. })
  130. const child = handle.agent
  131. // Agent creation detaches its creation-only abort listener before returning.
  132. // Close the narrow handoff race before installing the live-run listener.
  133. // Static analysis does not model the abort that may land between the
  134. // factory's listener detachment and this continuation.
  135. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  136. if (request.signal.aborted) {
  137. flags.cancelled = true
  138. await handle.dispose()
  139. throw prePublicationAbort()
  140. }
  141. const onAbort = (): void => {
  142. flags.cancelled = true
  143. child.cancel('subagent request aborted')
  144. }
  145. request.signal.addEventListener('abort', onAbort, { once: true })
  146. const result: Promise<SubagentResult> = (async () => {
  147. try {
  148. child.send(request.prompt)
  149. await child.whenIdle()
  150. return readResult(
  151. child,
  152. seedLength,
  153. flags.cancelled,
  154. structured ? { captured: structured.captured() } : undefined,
  155. )
  156. } finally {
  157. request.signal.removeEventListener('abort', onAbort)
  158. }
  159. })()
  160. return {
  161. id: childId,
  162. result,
  163. dispose(): Promise<void> {
  164. request.signal.removeEventListener('abort', onAbort)
  165. flags.cancelled = true
  166. return handle.dispose()
  167. },
  168. }
  169. }
  170. /** Read one settled child's result from events after its optional fork seed. */
  171. function readResult(
  172. child: Agent,
  173. seedLength: number,
  174. cancelled: boolean,
  175. structured?: { captured?: { value: unknown } | undefined },
  176. ): SubagentResult {
  177. const own = child.session.events.slice(seedLength)
  178. const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
  179. const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
  180. const output: ContentBlock[] = lastMessage?.data.content ?? []
  181. const recorded = toStopReason(lastEnd?.data.reason)
  182. // Disposal can tear the owner down before the loop records its ordinary
  183. // `aborted` end, yielding `disposed` instead. A requested cancellation owns
  184. // every non-completed in-flight outcome; a turn already completed stays so.
  185. const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
  186. ? 'aborted'
  187. : recorded
  188. if (structured !== undefined) {
  189. if (structured.captured !== undefined) {
  190. return { output, structured: structured.captured.value, stopReason }
  191. }
  192. if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
  193. }
  194. return { output, stopReason }
  195. }