run.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /**
  2. * Fresh-process ACP subagent client. Drives one child session and owns cancellation and
  3. * quiescent disposal.
  4. *
  5. * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
  6. * sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
  7. * with-key coverage drives the real ACP example.
  8. * @module @deepseek-ai/dsh-subagent-acp/run
  9. */
  10. import { randomUUID } from 'node:crypto'
  11. import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream'
  12. import {
  13. ClientSideConnection,
  14. ndJsonStream,
  15. PROTOCOL_VERSION,
  16. type Agent as AcpAgent,
  17. type Client,
  18. type ContentBlock as AcpContentBlock,
  19. type RequestPermissionRequest,
  20. type RequestPermissionResponse,
  21. type SessionNotification,
  22. type StopReason,
  23. } from '@agentclientprotocol/sdk'
  24. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  25. import { SessionId } from '@deepseek-ai/dsh-session'
  26. import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
  27. import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  28. /** Fixed response to child permission requests: reject by default, or select the first allow option. */
  29. export type PermissionPolicy = 'allow' | 'reject'
  30. /** Resolved spawn spec for an ACP child process (no defaults — see Config). */
  31. export interface AcpRunSpec {
  32. /** The executable to spawn (the child ACP agent). */
  33. command: string
  34. /** Arguments passed to {@link command}. */
  35. args: string[]
  36. /**
  37. * Absolute working directory for the child process AND its ACP session
  38. * `cwd`. The provider resolves it before this spec exists: config override,
  39. * else the delegating parent session's workspace.
  40. */
  41. cwd: string
  42. /** How to auto-answer the child's permission prompts. */
  43. permission: PermissionPolicy
  44. /**
  45. * Extra environment variables to ADD for the child (e.g. the child harness's
  46. * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed
  47. * parent env. A value here is forwarded even if its name matches the
  48. * credential-scrub pattern (an explicit opt-in for the child's own creds).
  49. * Explicit `DSH_*` entries are deployment-owned facts for the child harness
  50. * (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that
  51. * dropped their stale ambient namesakes.
  52. */
  53. env: Record<string, string>
  54. /**
  55. * Grace period (ms) for the child's EOF-driven quiesce in
  56. * {@link SubagentRun.dispose} — the window to flush persistence and tear down
  57. * its OWN nested subprocesses before the parent escalates to a signal. The
  58. * plugin fills this from its `disposeEofGraceMs` config.
  59. */
  60. disposeEofGraceMs: number
  61. /**
  62. * Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
  63. * `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
  64. * fills this from its `disposeGraceMs` config.
  65. */
  66. disposeGraceMs: number
  67. /**
  68. * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the
  69. * child rides the shared scrub, tree-scoped teardown, and service-owned
  70. * lifetime instead of a package-local child_process path.
  71. */
  72. spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  73. /**
  74. * Sink for a child-level failure that the run flattened into a stop reason
  75. * (the seam contract forbids `result` rejecting). The driver calls this with
  76. * the original error and the chosen stop reason so the fault is preserved
  77. * rather than silently lost; the provider wires it to `ctx.logger.warn`.
  78. * A throw from the sink itself is contained — it cannot reject `result`.
  79. * Optional — omitted in a unit test that asserts the stop reason directly.
  80. */
  81. onError?: (error: Error, stopReason: SubagentStopReason) => void
  82. }
  83. /** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
  84. export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
  85. /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
  86. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  87. /** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
  88. async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
  89. const controller = new AbortController()
  90. const timer = setTimeout(() => { controller.abort() }, ms)
  91. try {
  92. return await child.waitForExit(controller.signal)
  93. } finally {
  94. clearTimeout(timer)
  95. }
  96. }
  97. /**
  98. * Cooperative teardown ladder for an out-of-process agent, over the seam's
  99. * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
  100. * window to flush persistence and reap its own descendants), then the
  101. * terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
  102. * confirmation wait.
  103. * @param child - the spawned ACP child's handle.
  104. * @param eofGraceMs - tier-1 window after stdin EOF.
  105. * @param graceMs - confirmation window after the escalation's SIGKILL.
  106. * @throws when the tree still has not exited `graceMs` after forced termination.
  107. */
  108. export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
  109. // A spawn failure has no process to tear down; observe the rejection so
  110. // disposal in a finally block cannot surface it as unhandled.
  111. if (child.pid <= 0) {
  112. await child.done.catch(() => {})
  113. return
  114. }
  115. child.stdin?.end()
  116. if (await treeExitsWithin(child, eofGraceMs)) return
  117. // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
  118. // (this plugin passes disposeGraceMs there), so the bound covers both the
  119. // escalation window and an equal confirmation window after the SIGKILL.
  120. child.terminate()
  121. if (!(await treeExitsWithin(child, graceMs * 2))) {
  122. throw new Error('ACP child process tree did not exit within its dispose windows')
  123. }
  124. }
  125. /**
  126. * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
  127. * @param reason - the terminal reason from the child's `session/prompt` response.
  128. * @returns the harness equivalent; `max_turn_requests` and any unknown future
  129. * variant map to `error`, so an unclean stop is never reported as `completed`.
  130. */
  131. export function acpStopReason(reason: StopReason): SubagentStopReason {
  132. switch (reason) {
  133. case 'end_turn':
  134. return 'completed'
  135. case 'max_tokens':
  136. return 'max-tokens'
  137. case 'refusal':
  138. return 'refusal'
  139. case 'cancelled':
  140. return 'aborted'
  141. // `max_turn_requests` (the child hit its turn-request budget) has no direct
  142. // harness equivalent and means the task did NOT finish cleanly — surface it
  143. // as a generic failure so the consumer maps it to an isError result rather
  144. // than reporting a partial answer as success.
  145. case 'max_turn_requests':
  146. return 'error'
  147. // ACP StopReason is a closed wire union, but a future SDK could add a
  148. // variant; treat an unknown terminal reason as a failure (never silently
  149. // 'completed').
  150. default:
  151. return 'error'
  152. }
  153. }
  154. /**
  155. * Collect the text of an ACP content block (non-text blocks contribute nothing).
  156. * @param content - the content block off a streamed `agent_message_chunk`.
  157. * @returns the block's text, or `''` for a non-text block.
  158. */
  159. export function acpContentText(content: AcpContentBlock): string {
  160. return content.type === 'text' ? content.text : ''
  161. }
  162. /**
  163. * Translate the harness prompt blocks into ACP prompt blocks (text only).
  164. * @param prompt - the harness prompt; non-text blocks are dropped.
  165. * @returns the ACP text blocks, in order.
  166. */
  167. export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] {
  168. const blocks: AcpContentBlock[] = []
  169. for (const block of prompt) {
  170. if (block.type === 'text') blocks.push({ type: 'text', text: block.text })
  171. }
  172. return blocks
  173. }
  174. /** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
  175. function toError(value: unknown): Error {
  176. // The catch only sees rejections from the ACP SDK RPCs and the spawn `error`
  177. // event, which are always `Error`s; the `String(value)` arm is a defensive
  178. // fallback for a non-Error throw that the typed surfaces cannot produce.
  179. /* v8 ignore next */
  180. return value instanceof Error ? value : new Error(String(value))
  181. }
  182. /**
  183. * Start and publish one ACP child after initialization and session creation.
  184. * Child failures resolve through the run result; startup failures reject after
  185. * process reap. Disposal cancels, kills, and reaps the child.
  186. * @param request - the start request; its signal is the cancellation channel.
  187. * @param spec - the resolved spawn spec: command/args/cwd, env, permission
  188. * policy, dispose graces, and the optional error sink.
  189. * @returns the ready run handle for the child subprocess.
  190. */
  191. export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
  192. if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
  193. // ACP session ids are unique only within the child server. The lifecycle id
  194. // is minted in the parent namespace so fresh processes cannot collide with
  195. // each other or with a local agent that happens to use the same session id.
  196. const id = SessionId(randomUUID())
  197. // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes
  198. // to the result. The seam's scrub drops ambient credentials and DSH_* names
  199. // while spec.env (the child's own key, its deployment facts) merges after it.
  200. const child = spec.spawn({
  201. argv: [spec.command, ...spec.args],
  202. cwd: spec.cwd,
  203. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  204. graceMs: spec.disposeGraceMs,
  205. env: spec.env,
  206. })
  207. /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
  208. if (child.stdin === undefined || child.stdout === undefined) {
  209. throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream')
  210. }
  211. /* v8 ignore stop */
  212. // Spawn-level failure surfaces as `done` rejecting into the startup race; a
  213. // clean exit must never win it, so the success arm parks forever. (The ACP
  214. // connection observing its streams closing bounds a child that exits
  215. // without speaking the protocol.)
  216. const spawnFailed: Promise<never> = child.done.then(
  217. /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
  218. () => new Promise<never>(() => {}),
  219. (err: unknown) => Promise.reject(toError(err)),
  220. )
  221. spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ })
  222. // Startup rollback and the published handle share one process teardown.
  223. let processDisposal: Promise<void> | undefined
  224. const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
  225. // Accumulate the child's streamed assistant text — the SubagentResult output.
  226. const output: string[] = []
  227. // Shared mutable state keeps cancellation visible across async closures.
  228. const flags = { cancelled: false }
  229. const makeClient = (_agent: AcpAgent): Client => ({
  230. sessionUpdate(params: SessionNotification): Promise<void> {
  231. const update = params.update
  232. if (update.sessionUpdate === 'agent_message_chunk') {
  233. output.push(acpContentText(update.content))
  234. }
  235. // Other updates (thoughts, tool calls, plans) are consumed but not
  236. // surfaced in this cut — the subagent returns only its final answer.
  237. return Promise.resolve()
  238. },
  239. requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
  240. // Auto-answer by the configured policy. `allow` selects the first
  241. // allow-shaped option the child offered; if it offered none (or we
  242. // reject), answer `cancelled` so the child does not proceed.
  243. if (spec.permission === 'allow') {
  244. const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always')
  245. if (allow !== undefined) {
  246. return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } })
  247. }
  248. }
  249. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  250. },
  251. })
  252. const conn = new ClientSideConnection(
  253. makeClient,
  254. ndJsonStream(
  255. NodeWritable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  256. NodeReadable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
  257. ),
  258. )
  259. let sessionId: string | undefined
  260. // Cancellation settles the result without waiting for a cooperative child.
  261. let signalCancelSettled!: () => void
  262. const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
  263. const requestCancel = (): void => {
  264. if (flags.cancelled) return
  265. flags.cancelled = true
  266. signalCancelSettled()
  267. // Best-effort ACP cancel; process teardown remains authoritative.
  268. /* v8 ignore next */
  269. if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
  270. }
  271. const onAbort = (): void => { requestCancel() }
  272. request.signal.addEventListener('abort', onAbort, { once: true })
  273. // The accumulated child text as harness ContentBlocks (empty array when the
  274. // child streamed nothing). Read at every return so a partial answer survives
  275. // a later cancel/error.
  276. const collectOutput = (): ContentBlock[] => {
  277. const text = output.join('')
  278. return text.length > 0 ? [{ type: 'text', text }] : []
  279. }
  280. // Establish the remote session before publishing a handle. Any failure owns
  281. // the still-private process and therefore reaps it before rejecting.
  282. try {
  283. await Promise.race([
  284. (async (): Promise<void> => {
  285. await conn.initialize({
  286. protocolVersion: PROTOCOL_VERSION,
  287. // Advertise NO optional client capabilities (no fs, no terminal): the
  288. // child self-serves in its own process.
  289. clientCapabilities: {},
  290. })
  291. const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
  292. const returnedSessionId: unknown = Reflect.get(session, 'sessionId')
  293. if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id')
  294. sessionId = returnedSessionId
  295. if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
  296. })(),
  297. spawnFailed,
  298. cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
  299. ])
  300. } catch (error: unknown) {
  301. request.signal.removeEventListener('abort', onAbort)
  302. await disposeProcess()
  303. if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
  304. throw toError(error)
  305. }
  306. // The startup transaction validates the returned id before it can fulfill.
  307. // This assertion carries that cross-closure invariant into TypeScript.
  308. /* v8 ignore next */
  309. if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
  310. const remoteSessionId = sessionId
  311. const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
  312. try {
  313. // Race the remote turn against local cancellation.
  314. const prompt = async (): Promise<SubagentResult> => {
  315. // The startup phase cannot fulfill without assigning the session id.
  316. const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) })
  317. return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
  318. }
  319. return await Promise.race([
  320. prompt(),
  321. cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
  322. ])
  323. } catch (error: unknown) {
  324. // Cover a process rejection already queued when cancellation arrives.
  325. /* v8 ignore next */
  326. if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
  327. // Flatten post-publication transport failures while preserving diagnostics.
  328. try {
  329. spec.onError?.(toError(error), 'error')
  330. } catch {
  331. // The diagnostic sink cannot reject the run result.
  332. }
  333. return { output: collectOutput(), stopReason: 'error' }
  334. } finally {
  335. request.signal.removeEventListener('abort', onAbort)
  336. }
  337. })()
  338. let disposal: Promise<void> | undefined
  339. return {
  340. id,
  341. localAgent: undefined,
  342. result,
  343. dispose(): Promise<void> {
  344. if (disposal !== undefined) return disposal
  345. request.signal.removeEventListener('abort', onAbort)
  346. requestCancel()
  347. // The shared platform-aware ladder awaits exit. ACP normally quiesces from
  348. // stdin EOF, including the final flush, so this backend uses a wider EOF
  349. // grace before process termination escalates.
  350. disposal = disposeProcess()
  351. return disposal
  352. },
  353. }
  354. }