index.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /**
  2. * @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch
  3. * (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless
  4. * session is web-observable while it runs — same composition), and this
  5. * runner plugin drives one task through the in-process API carrier
  6. * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
  7. * chain — serialization, zod, SSE framing — really runs), prints the final
  8. * assistant text at agent quiescence, and exits (completed → 0, else 1). The
  9. * task text arrives as launcher-patched config (`dsh run "task"`).
  10. * @module @deepseek-ai/dsh-headless
  11. */
  12. import type { Context } from 'cordis'
  13. import z from 'schemastery'
  14. import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  15. // Empty type imports carry the httpServer and agent/status Context merges used below.
  16. import type {} from '@deepseek-ai/dsh-host-webserver'
  17. import type {} from '@deepseek-ai/dsh-agent'
  18. // Empty type import carries the loader Context merge for the settlement await.
  19. import type {} from '@cordisjs/plugin-loader'
  20. import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  21. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  22. import type { SessionId } from '@deepseek-ai/dsh-session'
  23. /** Stable Cordis plugin name. */
  24. export const name = 'headless-runner'
  25. /** Services required before the one-shot turn can start. */
  26. export const inject = ['apiProxy', 'httpServer']
  27. /** Plugin config: the task, patched in by the launcher. */
  28. export interface Config {
  29. /** The prompt text for the single turn. */
  30. task: string
  31. }
  32. export const Config: z<Config> = z.object({
  33. task: z.string().required(),
  34. })
  35. /** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */
  36. interface TurnOutcome {
  37. text: string
  38. reason: string
  39. }
  40. /**
  41. * The process-facing effects of one run, injectable for tests: output
  42. * streams and the exit request (the launcher wires it to its bounded
  43. * shutdown controller).
  44. */
  45. export interface HeadlessIo {
  46. stdout: { write(chunk: string): unknown }
  47. stderr: { write(chunk: string): unknown }
  48. /** Request process exit with `code` after the tree disposes. */
  49. exit(code: number): void
  50. }
  51. /** Host seam: the launcher provides the exit wiring before the tree mounts. */
  52. declare module 'cordis' {
  53. interface Context {
  54. /** Process-facing effects for the one-shot headless runner. */
  55. headlessIo?: HeadlessIo
  56. }
  57. }
  58. /** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */
  59. async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
  60. if (response.result.ok) return response.result.value
  61. const { code, message } = response.result.error
  62. io.stderr.write(`dsh: ${code}: ${message}\n`)
  63. io.exit(1)
  64. // Exit is asynchronous (bounded tree disposal); park this turn forever so
  65. // no further request rides a session that is already being torn down.
  66. return new Promise<never>(() => {})
  67. }
  68. /**
  69. * Consume mux frames until the agent reaches idle, per the one-shot CLI
  70. * idle-to-idle contract: the stream opens immediately before the prompt, and
  71. * its first observed turn/start begins the task. Text is the last committed
  72. * assistant message of the whole interval (steering or injected work may run
  73. * further turns before quiescence), and the outcome reason is the final
  74. * turn/end's kind. Idleness is signalled out of band by the caller's
  75. * `agent/status` subscription; the stream itself carries no status frame.
  76. * @param frames - the mux stream opened before the prompt.
  77. * @param sessionId - the headless session.
  78. * @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
  79. * @param io - process-facing effects for stream diagnostics.
  80. * @returns the aggregated outcome.
  81. */
  82. async function consumeUntilIdle(
  83. frames: AsyncIterable<RpcRequest<MuxFrame>>,
  84. sessionId: SessionId,
  85. idle: Promise<number>,
  86. io: HeadlessIo,
  87. ): Promise<TurnOutcome> {
  88. let started = false
  89. let text = ''
  90. let reason: string = 'error'
  91. let observedSeq = -1
  92. let resolveProgress: (() => void) | undefined
  93. const streamDone = (async () => {
  94. try {
  95. for await (const frame of frames) {
  96. const payload = frame.payload
  97. if (payload.type === 'stream/error') return
  98. if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
  99. const event = payload.event
  100. observedSeq = event.seq
  101. resolveProgress?.()
  102. resolveProgress = undefined
  103. if (event.type === 'turn/start') {
  104. started = true
  105. continue
  106. }
  107. if (!started) continue
  108. if (event.type === 'assistant/message') {
  109. const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
  110. if (joined !== '') text = joined
  111. }
  112. if (event.type === 'turn/end') reason = event.data.reason.kind
  113. }
  114. } catch (error: unknown) {
  115. io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
  116. }
  117. })()
  118. const streamEnded = streamDone.then(() => 'ended' as const)
  119. const idleSeq = await idle
  120. while (observedSeq < idleSeq) {
  121. const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
  122. if (await Promise.race([progress, streamEnded]) === 'ended') break
  123. }
  124. return { text, reason }
  125. }
  126. /**
  127. * Run one headless task to quiescence and request exit (completed → 0, else 1).
  128. * @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo.
  129. * @param config - validated {@link Config}.
  130. */
  131. export function apply(ctx: Context, config: Config): void {
  132. const io = ctx.headlessIo
  133. if (io === undefined) {
  134. throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
  135. }
  136. // Fire-and-forget by design: the run outlives plugin activation, and every
  137. // failure path inside ends in io.exit, not a rejection.
  138. void (async () => {
  139. // The Loader mounts sibling rows concurrently and this plugin's inject
  140. // gate covers only apiProxy/httpServer; prompting before the agent loop,
  141. // adapters, and tools settle would fail the turn on a half-mounted tree.
  142. // The old launcher ran strictly after settled boot — preserve that.
  143. // A tree disposed mid-settlement (early SIGTERM) has nothing to run.
  144. await ctx.get('loader')?.await()
  145. if (ctx.get('httpServer') === undefined) return
  146. // The headless session is web-observable while it runs (same composition).
  147. io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
  148. const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
  149. const created = await unwrap(await api.sessions.create({}), io)
  150. // Open the stream before prompting so no frame is lost. The quiescence
  151. // anchor below is an in-process ctx subscription, so a remote-carrier
  152. // port of this runner must replace it with a wire-visible idle signal.
  153. const abort = new AbortController()
  154. const frames = api.events.mux({}, abort.signal)
  155. const idle = new Promise<number>((resolve) => {
  156. ctx.on('agent/status', ({ agent, status }) => {
  157. if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
  158. })
  159. })
  160. const done = consumeUntilIdle(frames, created.sessionId, idle, io)
  161. await unwrap(await api.sessions.prompt({
  162. sessionId: created.sessionId,
  163. mode: 'queue',
  164. content: [{ type: 'text', text: config.task }],
  165. }), io)
  166. const outcome = await done
  167. io.stdout.write(outcome.text + '\n')
  168. abort.abort()
  169. io.exit(outcome.reason === 'completed' ? 0 : 1)
  170. })()
  171. }