index.ts 8.4 KB

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