index.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. /**
  2. * @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
  3. * rides over dsh-base without Host, HTTP, or browser plugins; this runner
  4. * creates one Agent through the core registry, drives the task to quiescence,
  5. * streams provider reasoning to stderr, flushes its Session, prints the final
  6. * assistant text to stdout, and exits.
  7. *
  8. * @module @deepseek-ai/dsh-headless
  9. */
  10. import { randomUUID } from 'node:crypto'
  11. import type { Context } from '@deepseek-ai/cordis'
  12. import z from '@deepseek-ai/schemastery'
  13. import { brandString } from '@deepseek-ai/dsh-brand'
  14. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  15. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  16. import type {} from '@deepseek-ai/dsh-agent-default-model'
  17. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  18. import { assertNever } from '@deepseek-ai/dsh-util-values'
  19. import { SessionSeq } from '@deepseek-ai/dsh-session'
  20. import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
  21. // Empty type imports carry the loader Context merge for the settlement await
  22. // and the cmdline Context merge for the appExit host value.
  23. import type {} from '@deepseek-ai/cordis-plugin-loader'
  24. import type {} from '@deepseek-ai/dsh-cmdline'
  25. /** Stable Cordis plugin name. */
  26. export const name = 'headless-runner'
  27. /** Core services required before the one-shot turn can start. */
  28. export const inject = ['agentDefaultModel', 'agents', 'sessions']
  29. /** Plugin config: the task resolved from this app's injected provider service. */
  30. export interface Config {
  31. /** The prompt text for the single run. */
  32. task: string
  33. }
  34. export const Config: z<Config> = z.object({
  35. task: z.string().required(),
  36. })
  37. /** Outcome of one owned run interval. */
  38. interface RunOutcome {
  39. text: string
  40. reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  41. }
  42. /** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
  43. interface HeadlessIo {
  44. stdout: { write(chunk: string): unknown }
  45. stderr: { write(chunk: string): unknown }
  46. /** Request process exit with `code` after the tree disposes. */
  47. exit(code: number): void
  48. }
  49. /** The process streams the runner writes to; tests substitute captures. */
  50. export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
  51. stdout: process.stdout,
  52. stderr: process.stderr,
  53. }
  54. /** Aggregate the last assistant text and turn outcome in one owned interval. */
  55. function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
  56. let started = false
  57. let text = ''
  58. let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  59. const length = session.seq
  60. for (let seq = firstSeq; seq < length; seq++) {
  61. const event = session.eventAt(SessionSeq(seq))
  62. if (event === undefined) {
  63. throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`)
  64. }
  65. if (event.type === 'turn/start') {
  66. started = true
  67. continue
  68. }
  69. if (!started) continue
  70. if (event.type === 'assistant/message') {
  71. const joined = event.data.message.content
  72. .filter(block => block.type === 'text')
  73. .map(block => block.text)
  74. .join('')
  75. if (joined !== '') text = joined
  76. }
  77. if (event.type === 'turn/end') reason = event.data.reason
  78. }
  79. return { text, reason }
  80. }
  81. /**
  82. * Project provider-reported reasoning from one owned run to stderr as it is
  83. * streamed, while keeping final outcome derivation on the durable log.
  84. * @param ctx - plugin context carrying the live Assistant frame feed.
  85. * @param agent - the exact Agent whose reasoning belongs to this invocation.
  86. * @param stderr - progress output sink.
  87. * @returns a disposer that also terminates an unterminated reasoning line.
  88. */
  89. function streamReasoning(
  90. ctx: Context,
  91. agent: Agent,
  92. stderr: HeadlessIo['stderr'],
  93. ): () => void {
  94. let open = false
  95. let endsWithNewline = true
  96. const close = (): void => {
  97. if (!open) return
  98. if (!endsWithNewline) stderr.write('\n')
  99. open = false
  100. endsWithNewline = true
  101. }
  102. const dispose = ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
  103. if (subject !== agent) return
  104. if (frame.type === 'start') {
  105. close()
  106. return
  107. }
  108. if (frame.type === 'end') {
  109. close()
  110. return
  111. }
  112. const chunk = frame.chunk
  113. switch (chunk.type) {
  114. case 'reasoning-delta':
  115. if (chunk.text === '') return
  116. if (!open) {
  117. stderr.write('dsh: reasoning:\n')
  118. open = true
  119. }
  120. stderr.write(chunk.text)
  121. endsWithNewline = chunk.text.endsWith('\n')
  122. return
  123. case 'block-start':
  124. if (chunk.blockType !== 'reasoning') close()
  125. return
  126. case 'block-end':
  127. if (chunk.block.type !== 'reasoning') close()
  128. return
  129. case 'usage':
  130. return
  131. case 'text-delta':
  132. case 'tool-call-delta':
  133. case 'finish':
  134. close()
  135. return
  136. /* v8 ignore next -- closed-union exhaustiveness guard */
  137. default:
  138. return assertNever(chunk, 'headless reasoning stream')
  139. }
  140. })
  141. return () => {
  142. dispose()
  143. close()
  144. }
  145. }
  146. /** Report an unexpected direct-driver failure and request a failing exit. */
  147. function fail(io: HeadlessIo, error: unknown): void {
  148. io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
  149. io.exit(1)
  150. }
  151. /**
  152. * Run one task through a freshly created Agent and request process exit.
  153. * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
  154. * @param task - one-shot task text.
  155. * @param io - process-facing effects.
  156. */
  157. async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
  158. // Loader siblings mount concurrently. Await the complete application before
  159. // creating an Agent so its scoped tools and adapters are not half-composed.
  160. await ctx.get('loader')?.await()
  161. const agents = ctx.get('agents')
  162. const defaultModel = ctx.get('agentDefaultModel')
  163. const sessions = ctx.get('sessions')
  164. // Early process shutdown can dispose the tree while settlement is pending.
  165. if (agents === undefined || defaultModel === undefined || sessions === undefined) return
  166. const selection = defaultModel.currentSelection()
  167. // This bundle composes no preset roster, so the model-facing rows sit in the
  168. // host plane and the agent reads them from the global layer. A deployment
  169. // that DOES configure one has to join it here first
  170. // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
  171. const { agent } = await agents.create({
  172. sessionId: brandString<SessionId>(`session-${randomUUID()}`),
  173. meta: { cwd: process.cwd() },
  174. agentOptions: { provider: selection.provider, model: selection.model },
  175. setup: (agentCtx) => {
  176. const selected: ModelSelectionRef = { current: selection, assembled: undefined }
  177. installModelSelection(agentCtx, selected)
  178. },
  179. })
  180. await agent.whenIdle()
  181. const firstSeq = agent.session.seq
  182. const stopReasoning = streamReasoning(ctx, agent, io.stderr)
  183. try {
  184. agent.followup(createUserMessage({
  185. content: [{ type: 'text', text: task }],
  186. source: { kind: 'user' },
  187. }))
  188. await agent.whenIdle()
  189. } finally {
  190. stopReasoning()
  191. }
  192. await sessions.flush(agent.session)
  193. const outcome = summarize(agent.session, firstSeq)
  194. io.stdout.write(outcome.text + '\n')
  195. if (outcome.reason?.kind === 'error') {
  196. io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
  197. }
  198. io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
  199. }
  200. /**
  201. * Mount the one-shot direct driver.
  202. * @param ctx - plugin context carrying core services and the launcher-provided exit request.
  203. * @param config - validated task config.
  204. */
  205. export function apply(ctx: Context, config: Config): void {
  206. // Read through the global service store, not the property proxy: appExit is
  207. // an optional host value, never an injected dependency.
  208. const exit = ctx.get('appExit')
  209. if (exit === undefined) {
  210. throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
  211. }
  212. const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
  213. void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
  214. }