index.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  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 'cordis'
  11. import z from '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 import carries the loader Context merge for the settlement await.
  19. import type {} from '@cordisjs/plugin-loader'
  20. /** Stable Cordis plugin name. */
  21. export const name = 'headless-runner'
  22. /** Core services required before the one-shot turn can start. */
  23. export const inject = ['agentDefaultModel', 'agents', 'sessions']
  24. /** Plugin config: the task, patched in by the launcher. */
  25. export interface Config {
  26. /** The prompt text for the single run. */
  27. task: string
  28. }
  29. export const Config: z<Config> = z.object({
  30. task: z.string().required(),
  31. })
  32. /** Outcome of one owned run interval. */
  33. interface RunOutcome {
  34. text: string
  35. reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  36. }
  37. /**
  38. * Process-facing effects of one run, injectable for tests. The launcher owns
  39. * bounded tree shutdown and wires `exit()` to it.
  40. */
  41. export interface HeadlessIo {
  42. stdout: { write(chunk: string): unknown }
  43. stderr: { write(chunk: string): unknown }
  44. /** Request process exit with `code` after the tree disposes. */
  45. exit(code: number): void
  46. }
  47. declare module 'cordis' {
  48. interface Context {
  49. /** Process-facing effects provided before the headless tree mounts. */
  50. headlessIo?: HeadlessIo
  51. }
  52. }
  53. /** Aggregate the last assistant text and turn outcome in one owned interval. */
  54. function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
  55. let started = false
  56. let text = ''
  57. let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  58. for (const event of events) {
  59. if (event.seq < firstSeq) continue
  60. if (event.type === 'turn/start') {
  61. started = true
  62. continue
  63. }
  64. if (!started) continue
  65. if (event.type === 'assistant/message') {
  66. const joined = event.data.message.content
  67. .filter(block => block.type === 'text')
  68. .map(block => block.text)
  69. .join('')
  70. if (joined !== '') text = joined
  71. }
  72. if (event.type === 'turn/end') reason = event.data.reason
  73. }
  74. return { text, reason }
  75. }
  76. /** Report an unexpected direct-driver failure and request a failing exit. */
  77. function fail(io: HeadlessIo, error: unknown): void {
  78. io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
  79. io.exit(1)
  80. }
  81. /**
  82. * Run one task through a freshly created Agent and request process exit.
  83. * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
  84. * @param task - one-shot task text.
  85. * @param io - process-facing effects.
  86. */
  87. async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
  88. // Loader siblings mount concurrently. Await the complete application before
  89. // creating an Agent so its scoped tools and adapters are not half-composed.
  90. await ctx.get('loader')?.await()
  91. const agents = ctx.get('agents')
  92. const defaultModel = ctx.get('agentDefaultModel')
  93. const sessions = ctx.get('sessions')
  94. // Early process shutdown can dispose the tree while settlement is pending.
  95. if (agents === undefined || defaultModel === undefined || sessions === undefined) return
  96. const selection = defaultModel.currentSelection()
  97. const { agent } = await agents.create({
  98. sessionId: SessionId(`session-${randomUUID()}`),
  99. meta: { cwd: process.cwd() },
  100. agentOptions: { provider: selection.provider, model: selection.model },
  101. setup: (agentCtx) => {
  102. const selected: ModelSelectionRef = { current: selection, assembled: undefined }
  103. installModelSelection(agentCtx, selected)
  104. },
  105. })
  106. await agent.whenIdle()
  107. const firstSeq = agent.session.seq
  108. agent.followup(createUserMessage({
  109. content: [{ type: 'text', text: task }],
  110. source: { kind: 'user' },
  111. }))
  112. await agent.whenIdle()
  113. await sessions.flush(agent.session)
  114. const outcome = summarize(agent.session.events, firstSeq)
  115. io.stdout.write(outcome.text + '\n')
  116. if (outcome.reason?.kind === 'error') {
  117. io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
  118. }
  119. io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
  120. }
  121. /**
  122. * Mount the one-shot direct driver.
  123. * @param ctx - plugin context carrying core services and the launcher-owned IO seam.
  124. * @param config - validated task config.
  125. */
  126. export function apply(ctx: Context, config: Config): void {
  127. const io = ctx.headlessIo
  128. if (io === undefined) {
  129. throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
  130. }
  131. void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
  132. }