index.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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 type { Agent, 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 = SessionId(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. sessionId: childId,
  119. meta: {
  120. ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
  121. parentSession: parentHeader.id,
  122. ...seedLength > 0 ? { seedLength } : {},
  123. },
  124. ...options.seed !== undefined ? { seed: options.seed } : {},
  125. agentOptions,
  126. signal: request.signal,
  127. setup,
  128. })
  129. const child = handle.agent
  130. // Agent creation detaches its creation-only abort listener before returning.
  131. // Close the narrow handoff race before installing the live-run listener.
  132. // Static analysis does not model the abort that may land between the
  133. // factory's listener detachment and this continuation.
  134. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  135. if (request.signal.aborted) {
  136. flags.cancelled = true
  137. await handle.dispose()
  138. throw prePublicationAbort()
  139. }
  140. const onAbort = (): void => {
  141. flags.cancelled = true
  142. child.cancel('subagent request aborted')
  143. }
  144. request.signal.addEventListener('abort', onAbort, { once: true })
  145. const result: Promise<SubagentResult> = (async () => {
  146. try {
  147. child.send(request.prompt)
  148. await child.whenIdle()
  149. return readResult(
  150. child,
  151. seedLength,
  152. flags.cancelled,
  153. structured ? { captured: structured.captured() } : undefined,
  154. )
  155. } finally {
  156. request.signal.removeEventListener('abort', onAbort)
  157. }
  158. })()
  159. return {
  160. id: childId,
  161. result,
  162. dispose(): Promise<void> {
  163. request.signal.removeEventListener('abort', onAbort)
  164. flags.cancelled = true
  165. return handle.dispose()
  166. },
  167. }
  168. }
  169. /** Read one settled child's result from events after its optional fork seed. */
  170. function readResult(
  171. child: Agent,
  172. seedLength: number,
  173. cancelled: boolean,
  174. structured?: { captured?: { value: unknown } | undefined },
  175. ): SubagentResult {
  176. const own = child.session.events.slice(seedLength)
  177. const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
  178. const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
  179. const output: ContentBlock[] = lastMessage?.data.content ?? []
  180. const recorded = toStopReason(lastEnd?.data.reason)
  181. // Disposal can tear the owner down before the loop records its ordinary
  182. // `aborted` end, yielding `disposed` instead. A requested cancellation owns
  183. // every non-completed in-flight outcome; a turn already completed stays so.
  184. const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
  185. ? 'aborted'
  186. : recorded
  187. if (structured !== undefined) {
  188. if (structured.captured !== undefined) {
  189. return { output, structured: structured.captured.value, stopReason }
  190. }
  191. if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
  192. }
  193. return { output, stopReason }
  194. }