index.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  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. * flushes its Session, prints the final assistant text, and exits.
  6. *
  7. * @module @deepseek-ai/dsh-headless
  8. */
  9. import { randomUUID } from 'node:crypto'
  10. import type { Context } from '@deepseek-ai/cordis'
  11. import z from '@deepseek-ai/schemastery'
  12. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  13. import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  14. import type {} from '@deepseek-ai/dsh-agent-default-model'
  15. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  16. import { SessionId } from '@deepseek-ai/dsh-session'
  17. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  18. // Empty type imports carry the loader Context merge for the settlement await
  19. // and the cmdline Context merge for the appExit host value.
  20. import type {} from '@deepseek-ai/cordis-plugin-loader'
  21. import type {} from '@deepseek-ai/dsh-cmdline'
  22. /** Stable Cordis plugin name. */
  23. export const name = 'headless-runner'
  24. /** Core services required before the one-shot turn can start. */
  25. export const inject = ['agentDefaultModel', 'agents', 'sessions']
  26. /** Plugin config: the task resolved from this app's injected provider service. */
  27. export interface Config {
  28. /** The prompt text for the single run. */
  29. task: string
  30. }
  31. export const Config: z<Config> = z.object({
  32. task: z.string().required(),
  33. })
  34. /** Outcome of one owned run interval. */
  35. interface RunOutcome {
  36. text: string
  37. reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  38. }
  39. /** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
  40. interface HeadlessIo {
  41. stdout: { write(chunk: string): unknown }
  42. stderr: { write(chunk: string): unknown }
  43. /** Request process exit with `code` after the tree disposes. */
  44. exit(code: number): void
  45. }
  46. /** The process streams the runner writes to; tests substitute captures. */
  47. export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
  48. stdout: process.stdout,
  49. stderr: process.stderr,
  50. }
  51. /** Aggregate the last assistant text and turn outcome in one owned interval. */
  52. function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
  53. let started = false
  54. let text = ''
  55. let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  56. for (const event of events) {
  57. if (event.seq < firstSeq) continue
  58. if (event.type === 'turn/start') {
  59. started = true
  60. continue
  61. }
  62. if (!started) continue
  63. if (event.type === 'assistant/message') {
  64. const joined = event.data.message.content
  65. .filter(block => block.type === 'text')
  66. .map(block => block.text)
  67. .join('')
  68. if (joined !== '') text = joined
  69. }
  70. if (event.type === 'turn/end') reason = event.data.reason
  71. }
  72. return { text, reason }
  73. }
  74. /** Report an unexpected direct-driver failure and request a failing exit. */
  75. function fail(io: HeadlessIo, error: unknown): void {
  76. io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
  77. io.exit(1)
  78. }
  79. /**
  80. * Run one task through a freshly created Agent and request process exit.
  81. * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
  82. * @param task - one-shot task text.
  83. * @param io - process-facing effects.
  84. */
  85. async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
  86. // Loader siblings mount concurrently. Await the complete application before
  87. // creating an Agent so its scoped tools and adapters are not half-composed.
  88. await ctx.get('loader')?.await()
  89. const agents = ctx.get('agents')
  90. const defaultModel = ctx.get('agentDefaultModel')
  91. const sessions = ctx.get('sessions')
  92. // Early process shutdown can dispose the tree while settlement is pending.
  93. if (agents === undefined || defaultModel === undefined || sessions === undefined) return
  94. const selection = defaultModel.currentSelection()
  95. // This bundle composes no preset roster, so the model-facing rows sit in the
  96. // host plane and the agent reads them from the global layer. A deployment
  97. // that DOES configure one has to join it here first
  98. // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
  99. const { agent } = await agents.create({
  100. sessionId: SessionId(`session-${randomUUID()}`),
  101. meta: { cwd: process.cwd() },
  102. agentOptions: { provider: selection.provider, model: selection.model },
  103. setup: (agentCtx) => {
  104. const selected: ModelSelectionRef = { current: selection, assembled: undefined }
  105. installModelSelection(agentCtx, selected)
  106. },
  107. })
  108. await agent.whenIdle()
  109. const firstSeq = agent.session.seq
  110. agent.followup(createUserMessage({
  111. content: [{ type: 'text', text: task }],
  112. source: { kind: 'user' },
  113. }))
  114. await agent.whenIdle()
  115. await sessions.flush(agent.session)
  116. const outcome = summarize(agent.session.events, firstSeq)
  117. io.stdout.write(outcome.text + '\n')
  118. if (outcome.reason?.kind === 'error') {
  119. io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
  120. }
  121. io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
  122. }
  123. /**
  124. * Mount the one-shot direct driver.
  125. * @param ctx - plugin context carrying core services and the launcher-provided exit request.
  126. * @param config - validated task config.
  127. */
  128. export function apply(ctx: Context, config: Config): void {
  129. // Read through the global service store, not the property proxy: appExit is
  130. // an optional host value, never an injected dependency.
  131. const exit = ctx.get('appExit')
  132. if (exit === undefined) {
  133. throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
  134. }
  135. const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
  136. void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
  137. }