index.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /**
  2. * Shared driver for in-process ONE-SHOT 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. * Continuable children never come through here: the continuation manager
  8. * composes and drives them directly, so this driver owns exactly one turn with
  9. * one result.
  10. *
  11. * @module @deepseek-ai/dsh-subagent-in-process-driver
  12. */
  13. import { randomUUID } from 'node:crypto'
  14. import type { Context } from '@deepseek-ai/cordis'
  15. import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
  16. import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
  17. import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
  18. import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
  19. import {
  20. appendDelegatedPolicyOverrides,
  21. applyChildComposition,
  22. assertSubagentMaxDepth,
  23. captureDelegatedPolicyOverrides,
  24. childSessionMeta,
  25. finalAssistantOutput,
  26. resolveChildAgentOptions,
  27. resolveChildDepth,
  28. } from '@deepseek-ai/dsh-subagent'
  29. import type {
  30. ResolvedSubagentStartRequest,
  31. SubagentDescriptorData,
  32. SubagentResult,
  33. SubagentRun,
  34. SubagentStopReason,
  35. } from '@deepseek-ai/dsh-subagent'
  36. import {
  37. attachStructuredRuntime,
  38. type StructuredAttachment,
  39. } from './structured.ts'
  40. export {
  41. STRUCTURED_OUTPUT_TOOL,
  42. STRUCTURED_OUTPUT_INSTRUCTION,
  43. } from './structured.ts'
  44. /** Map a session turn outcome to the subagent seam's terminal vocabulary. */
  45. function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
  46. switch (reason?.kind) {
  47. case 'completed':
  48. return 'completed'
  49. case 'max-tokens':
  50. return 'max-tokens'
  51. case 'aborted':
  52. return 'aborted'
  53. // A pre-step rejection discarded the claimed prompt: the task was
  54. // declined, and the caller must not read the run as done.
  55. case 'blocked':
  56. return 'refusal'
  57. case 'error':
  58. case 'interrupted':
  59. default:
  60. return 'error'
  61. }
  62. }
  63. /** Extra inputs the spawn and fork providers supply to the shared driver. */
  64. export interface InProcessRunOptions {
  65. /** Completed-turn seed for fork, or undefined for a fresh spawn. */
  66. readonly seed?: SessionEvent[]
  67. }
  68. /** Error used when cancellation wins before the child publication boundary. */
  69. function prePublicationAbort(): Error {
  70. return new Error('subagent request was aborted before child publication')
  71. }
  72. /** Append one one-shot descriptor inside the child's initial turn before its first request. */
  73. function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
  74. let appended = false
  75. childCtx.on('agent/pre-step', async ({ agent }, next) => {
  76. const decision = await next()
  77. if (!appended && decision.kind === 'enter') {
  78. appended = true
  79. agent.session.append('subagent/descriptor', descriptor)
  80. }
  81. return decision
  82. })
  83. }
  84. /**
  85. * Establish and drive one in-process one-shot child. Fulfillment means the agent
  86. * is already published in the registry and transfers its turn, cancellation,
  87. * and disposal work through the returned run. Rejection means the agent
  88. * factory's unpublished creation transaction reached quiescence without
  89. * publishing a child. Every start appends its resolved descriptor inside the
  90. * child's initial turn.
  91. * @param request - the trusted typed start request, including its required signal.
  92. * @param options - the optional fork seed.
  93. * @returns a published holder-owned run.
  94. */
  95. export async function startInProcessRun(
  96. request: ResolvedSubagentStartRequest,
  97. options: InProcessRunOptions,
  98. ): Promise<SubagentRun> {
  99. assertSubagentMaxDepth(request.maxDepth)
  100. if (request.signal.aborted) throw prePublicationAbort()
  101. const parent = request.parent
  102. const childDepth = resolveChildDepth(parent, request.maxDepth)
  103. const childId = SessionId(randomUUID())
  104. const seed = options.seed
  105. const activationBoundary = seed?.length ?? 0
  106. // Capture before the first await: a later parent switch belongs to the
  107. // parent's future.
  108. const inherited = captureDelegatedPolicyOverrides(parent)
  109. let structured: StructuredAttachment | undefined
  110. const setup = (childCtx: Context): void => {
  111. appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
  112. applyChildComposition(childCtx, parent, {
  113. persona: request.persona,
  114. toolFilter: request.toolFilter,
  115. })
  116. if (request.outputSchema !== undefined) {
  117. structured = attachStructuredRuntime(childCtx, request.outputSchema)
  118. }
  119. attachDescriptorAppend(childCtx, request.descriptor)
  120. }
  121. const handle = await parent.ctx.agents.create({
  122. sessionId: childId,
  123. meta: childSessionMeta(parent, childDepth, activationBoundary),
  124. ...seed !== undefined ? { seed } : {},
  125. agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
  126. signal: request.signal,
  127. setup,
  128. })
  129. return drivePublishedRun(
  130. handle,
  131. request.signal,
  132. request.prompt,
  133. childId,
  134. activationBoundary,
  135. structured,
  136. )
  137. }
  138. /**
  139. * Wrap a published child in the single run lifecycle that owns signal handoff,
  140. * one turn, result settlement, and quiescent disposal.
  141. */
  142. function drivePublishedRun(
  143. handle: AgentHandle,
  144. signal: AbortSignal,
  145. prompt: ContentBlock[],
  146. childId: SessionId,
  147. boundary: number,
  148. structured: StructuredAttachment | undefined,
  149. ): SubagentRun {
  150. const child = handle.agent
  151. const flags = { cancelled: false }
  152. const onAbort = (): void => {
  153. flags.cancelled = true
  154. child.cancel({ kind: 'parent' })
  155. }
  156. signal.addEventListener('abort', onAbort, { once: true })
  157. // Agent creation detaches its creation-only listener before returning. The
  158. // post-registration check closes that handoff without treating an already
  159. // published child as a failed start.
  160. if (signal.aborted) onAbort()
  161. const result: Promise<SubagentResult> = (async () => {
  162. try {
  163. if (!flags.cancelled) {
  164. child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
  165. await child.whenIdle()
  166. }
  167. return readResult(
  168. child,
  169. boundary,
  170. flags.cancelled,
  171. structured ? { captured: structured.captured() } : undefined,
  172. )
  173. } finally {
  174. signal.removeEventListener('abort', onAbort)
  175. }
  176. })()
  177. return {
  178. id: childId,
  179. localAgent: child,
  180. result,
  181. async dispose(): Promise<void> {
  182. signal.removeEventListener('abort', onAbort)
  183. flags.cancelled = true
  184. const settlements = await Promise.allSettled([handle.dispose(), result])
  185. const disposal = settlements[0]
  186. // The result channel owns run faults; disposal reports only failure to
  187. // release the published handle after both operations settle.
  188. if (disposal.status === 'rejected') throw disposal.reason
  189. },
  190. }
  191. }
  192. /** Read one settled child's result from events after its activation boundary. */
  193. function readResult(
  194. child: Agent,
  195. boundary: number,
  196. cancelled: boolean,
  197. structured?: { captured?: { value: unknown } | undefined },
  198. ): SubagentResult {
  199. const own = child.session.events.slice(boundary)
  200. // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its
  201. // awaited first turn almost immediately, and the owner's own teardown is the
  202. // `cancelled` flag below. A cancellation with no accounting turn resolves
  203. // `error` through `toStopReason(undefined)`, which never overstates success.
  204. const lastEnd = foldConsumedWork(own).end
  205. // The seam's canonical selection rule; a partial answer survives cancel and truncation.
  206. const output: ContentBlock[] = finalAssistantOutput(own) ?? []
  207. const recorded = toStopReason(lastEnd?.data.reason)
  208. // Disposal can tear the owner down before the loop records its ordinary
  209. // `aborted` end, yielding `disposed` instead.
  210. const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded
  211. if (structured !== undefined) {
  212. if (structured.captured !== undefined) {
  213. return { output, structured: structured.captured.value, stopReason }
  214. }
  215. if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
  216. }
  217. return { output, stopReason }
  218. }