index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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 (or adopts the exact Session a
  5. * `--session-id` names), drives the task to quiescence, streams provider
  6. * reasoning to stderr, flushes its Session, prints the final assistant text to
  7. * stdout, and exits. With `--json` it projects the run as newline-delimited
  8. * events instead of the final text.
  9. *
  10. * @module @deepseek-ai/dsh-headless
  11. */
  12. import { randomUUID } from 'node:crypto'
  13. import type { Context } from '@deepseek-ai/cordis'
  14. import z from '@deepseek-ai/schemastery'
  15. import { brandString } from '@deepseek-ai/dsh-brand'
  16. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  17. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  18. import type {} from '@deepseek-ai/dsh-agent-default-model'
  19. import type {} from '@deepseek-ai/dsh-fs'
  20. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  21. import { assertNever } from '@deepseek-ai/dsh-util-values'
  22. import { SessionSeq } from '@deepseek-ai/dsh-session'
  23. import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
  24. import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
  25. // Empty type imports carry the loader Context merge for the settlement await,
  26. // the cmdline Context merge for the appExit host value, and the sessionQuery
  27. // Context merge for exact Session adoption.
  28. import type {} from '@deepseek-ai/cordis-plugin-loader'
  29. import type {} from '@deepseek-ai/dsh-cmdline'
  30. import type {} from '@deepseek-ai/dsh-session-query'
  31. import { internals } from './runner-internals.ts'
  32. import { projectJsonRun, boundJsonLine } from './json-stream.ts'
  33. /** Stable Cordis plugin name. */
  34. export const name = 'headless-runner'
  35. /** Core services required before the one-shot turn can start. */
  36. export const inject = ['agentDefaultModel', 'agents', 'sessions']
  37. /** Plugin config: the task and run options resolved from this app's injected provider service. */
  38. export interface Config {
  39. /** The prompt text for the single run; absent when the task arrives on stdin. */
  40. task?: string
  41. /** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
  42. sessionId?: string
  43. /** Whether stdout carries the machine-readable event stream instead of final text. */
  44. json?: boolean
  45. }
  46. export const Config: z<Config> = z.object({
  47. task: z.string(),
  48. sessionId: z.string(),
  49. json: z.boolean(),
  50. })
  51. /** Outcome of one owned run interval. */
  52. interface RunOutcome {
  53. text: string
  54. reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  55. }
  56. /** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
  57. interface HeadlessIo {
  58. stdout: { write(chunk: string): unknown }
  59. stderr: { write(chunk: string): unknown }
  60. /** Request process exit with `code` after the tree disposes. */
  61. exit(code: number): void
  62. }
  63. /** Aggregate the last assistant text and turn outcome in one owned interval. */
  64. function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
  65. let started = false
  66. let text = ''
  67. let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
  68. const length = session.seq
  69. for (let seq = firstSeq; seq < length; seq++) {
  70. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  71. const event = session.eventAt(SessionSeq(seq))
  72. if (event === undefined) {
  73. throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`)
  74. }
  75. if (event.type === 'turn/start') {
  76. started = true
  77. continue
  78. }
  79. if (!started) continue
  80. if (event.type === 'assistant/message') {
  81. const joined = event.data.message.content
  82. .filter(block => block.type === 'text')
  83. .map(block => block.text)
  84. .join('')
  85. if (joined !== '') text = joined
  86. }
  87. if (event.type === 'turn/end') reason = event.data.reason
  88. }
  89. return { text, reason }
  90. }
  91. /**
  92. * Project provider-reported reasoning from one owned run to stderr as it is
  93. * streamed, while keeping final outcome derivation on the durable log.
  94. * @param ctx - plugin context carrying the live Assistant frame feed.
  95. * @param agent - the exact Agent whose reasoning belongs to this invocation.
  96. * @param stderr - progress output sink.
  97. * @returns a disposer that also terminates an unterminated reasoning line.
  98. */
  99. function streamReasoning(
  100. ctx: Context,
  101. agent: Agent,
  102. stderr: HeadlessIo['stderr'],
  103. ): () => void {
  104. let open = false
  105. let endsWithNewline = true
  106. const close = (): void => {
  107. if (!open) return
  108. if (!endsWithNewline) stderr.write('\n')
  109. open = false
  110. endsWithNewline = true
  111. }
  112. const dispose = ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
  113. if (subject !== agent) return
  114. if (frame.type === 'start') {
  115. close()
  116. return
  117. }
  118. if (frame.type === 'end') {
  119. close()
  120. return
  121. }
  122. const chunk = frame.chunk
  123. switch (chunk.type) {
  124. case 'reasoning-delta':
  125. if (chunk.text === '') return
  126. if (!open) {
  127. stderr.write('dsh: reasoning:\n')
  128. open = true
  129. }
  130. stderr.write(chunk.text)
  131. endsWithNewline = chunk.text.endsWith('\n')
  132. return
  133. case 'block-start':
  134. if (chunk.blockType !== 'reasoning') close()
  135. return
  136. case 'block-end':
  137. if (chunk.block.type !== 'reasoning') close()
  138. return
  139. case 'usage':
  140. return
  141. case 'text-delta':
  142. case 'tool-call-delta':
  143. case 'finish':
  144. close()
  145. return
  146. /* v8 ignore next -- closed-union exhaustiveness guard */
  147. default:
  148. return assertNever(chunk, 'headless reasoning stream')
  149. }
  150. })
  151. return () => {
  152. dispose()
  153. close()
  154. }
  155. }
  156. /** The Session facts that decide whether the runner may drive it directly. */
  157. interface AdoptableHeader {
  158. cwd?: string | undefined
  159. origin?: 'subagent' | undefined
  160. parentSession?: SessionId | undefined
  161. agentPreset?: string | undefined
  162. }
  163. /** Iterate a live Session's durable events in order. */
  164. function* liveEvents(session: Session): Generator<SessionEvent> {
  165. const length = session.seq
  166. for (let seq = 0; seq < length; seq++) {
  167. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  168. const event = session.eventAt(SessionSeq(seq))
  169. if (event === undefined) {
  170. throw new Error(`headless adoption cannot read seq ${String(seq)} below captured length ${String(length)}`)
  171. }
  172. yield event
  173. }
  174. }
  175. /**
  176. * The preset a Session currently runs under: its creation header advanced by
  177. * the last `agent-preset/selected` event. The header is only a creation fact;
  178. * the presets plugin reconstructs a session's composition from the projection.
  179. */
  180. function currentPreset(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId): string | undefined {
  181. let preset = header.agentPreset
  182. for (const event of events) {
  183. // Owned by dsh-agent-presets, which this bundle does not compose, so the
  184. // event is read structurally rather than through its module augmentation.
  185. const candidate = event as unknown as { type: string; data?: { agentPreset?: unknown } }
  186. if (candidate.type !== 'agent-preset/selected') continue
  187. const selected = candidate.data?.agentPreset
  188. // A corrupt record must not read as "no preset": that would let the run
  189. // continue under this bundle's composition instead of the recorded one.
  190. if (typeof selected !== 'string' || selected === '') {
  191. throw new Error(`session "${sessionId}" records a malformed agent-preset/selected event and cannot be adopted`)
  192. }
  193. preset = selected
  194. }
  195. return preset
  196. }
  197. /** Reject a Session the one-shot runner must not adopt. */
  198. function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId, cwd: string): void {
  199. const preset = currentPreset(header, events, sessionId)
  200. if (preset !== undefined) {
  201. // This bundle composes no preset roster, so resuming the session here would
  202. // silently run it under the headless tools and prompts instead of the
  203. // composition its log records.
  204. throw new Error(
  205. `session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`,
  206. )
  207. }
  208. if (header.origin === 'subagent' || header.parentSession !== undefined) {
  209. throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`)
  210. }
  211. if (header.cwd === undefined) {
  212. throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`)
  213. }
  214. if (header.cwd !== cwd) {
  215. throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${cwd}"`)
  216. }
  217. }
  218. /**
  219. * Resolve the Agent for one run: adopt the persisted Session with the requested
  220. * id. The identity must already exist, and no Agent may be live under it; a
  221. * first round omits the option instead, so a typo cannot pass as a brand-new
  222. * conversation.
  223. * @param ctx - plugin context carrying the Session query service.
  224. * @param agents - the core Agent registry.
  225. * @param sessionId - exact Session identity to adopt.
  226. * @param agentOptions - provider/model pair for this run.
  227. * @param setup - per-Agent scope setup installing the model selection.
  228. * @param cwd - working directory resolved in the mounted filesystem.
  229. * @returns the resumed Agent.
  230. */
  231. async function resolveAgent(
  232. ctx: Context,
  233. agents: Context['agents'],
  234. sessionId: SessionId,
  235. agentOptions: { provider: string; model: string },
  236. setup: (agentCtx: Context) => void,
  237. cwd: string,
  238. ): Promise<Agent> {
  239. // Resuming promises the caller a log a later process can continue. Without a
  240. // durable log the run would succeed, print the id, and still lose the whole
  241. // history at exit, so a miscomposed profile fails loud before the resume.
  242. if (ctx.get('sessionPersistence') === undefined) {
  243. throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
  244. }
  245. // A later process holds no live Agent and has to find the id through the
  246. // query service, so every --session-id run requires it.
  247. const query = ctx.get('sessionQuery')
  248. if (query === undefined) {
  249. throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
  250. }
  251. const live = agents.get(sessionId)
  252. if (live !== undefined) {
  253. // A live Agent already has an owner that may still drive it, and `whenIdle`
  254. // is not a single-message signal: folding its next interval into this run
  255. // would mix that owner's events — even its final answer — into the stream.
  256. // The runner cannot claim an exclusive interval over an Agent it did not
  257. // create, so it refuses the identity; the adoptability rules run first so a
  258. // real mismatch is named instead of the generic refusal.
  259. assertAdoptable(live.session.header, liveEvents(live.session), sessionId, cwd)
  260. throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`)
  261. }
  262. try {
  263. using observation = await query.observeSession(sessionId)
  264. assertAdoptable(observation.header, observation.events, sessionId, cwd)
  265. const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
  266. // The observation is a snapshot: another writer may have appended a preset
  267. // selection before this process took the write lease. Re-check the log
  268. // resume actually attached, now that no other process can append.
  269. assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd)
  270. return agent
  271. } catch (error: unknown) {
  272. if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
  273. // --session-id resumes a conversation that already exists; starting a new
  274. // one is the no-id path, which generates its own identity and reports it in
  275. // the `session` event. Creating the requested id here would turn a typo
  276. // into a brand-new empty history the caller believes it is continuing.
  277. throw new Error(`session "${sessionId}" does not exist; omit --session-id to start a new Session`)
  278. }
  279. }
  280. /** Report an unexpected direct-driver failure and request a failing exit. */
  281. function fail(io: HeadlessIo, error: unknown, json: boolean): void {
  282. const message = error instanceof Error ? error.message : String(error)
  283. if (json) io.stdout.write(`${boundJsonLine({ type: 'error', message })}\n`)
  284. io.stderr.write(`dsh: ${message}\n`)
  285. io.exit(1)
  286. }
  287. /**
  288. * Run one task through one Agent and request process exit.
  289. * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
  290. * @param config - task, optional exact Session identity, and output mode.
  291. * @param io - process-facing effects.
  292. */
  293. async function run(ctx: Context, config: Config, io: HeadlessIo): Promise<void> {
  294. // Loader siblings mount concurrently. Await the complete application before
  295. // creating an Agent so its scoped tools and adapters are not half-composed.
  296. await ctx.get('loader')?.await()
  297. const agents = ctx.get('agents')
  298. const defaultModel = ctx.get('agentDefaultModel')
  299. const sessions = ctx.get('sessions')
  300. // Early process shutdown can dispose the tree while settlement is pending.
  301. if (agents === undefined || defaultModel === undefined || sessions === undefined) return
  302. // A Cordis overlay sets the row directly and bypasses the CLI trim check, so
  303. // the same public setting must fail here rather than become a blank identity.
  304. if (config.sessionId !== undefined && config.sessionId.trim() === '') {
  305. throw new Error('headless-runner: sessionId must not be blank')
  306. }
  307. const task = config.task === undefined || config.task === '-'
  308. ? await internals.readStdin()
  309. : config.task
  310. if (task.trim() === '') {
  311. throw new Error('a task is required, for example: dsh --profile headless "run the tests"')
  312. }
  313. const selection = defaultModel.currentSelection()
  314. const agentOptions = { provider: selection.provider, model: selection.model }
  315. // This bundle composes no preset roster, so the model-facing rows sit in the
  316. // host plane and the agent reads them from the global layer. A deployment
  317. // that DOES configure one has to join it here first
  318. // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
  319. const setup = (agentCtx: Context): void => {
  320. const selected: ModelSelectionRef = { current: selection, assembled: undefined }
  321. installModelSelection(agentCtx, selected)
  322. }
  323. const sessionId = brandString<SessionId>(config.sessionId ?? `session-${randomUUID()}`)
  324. const fs = ctx.get('fs')
  325. const cwd = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.'))
  326. const agent = config.sessionId === undefined
  327. ? (await agents.create({
  328. sessionId,
  329. meta: { cwd },
  330. agentOptions,
  331. setup,
  332. })).agent
  333. : await resolveAgent(ctx, agents, sessionId, agentOptions, setup, cwd)
  334. await agent.whenIdle()
  335. if (config.sessionId !== undefined) {
  336. // The resume-time check read a snapshot; an overlay can still append a
  337. // preset selection between it and the interval this run now owns, so
  338. // re-read the log the runner holds before submitting the task.
  339. assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd)
  340. }
  341. const firstSeq = agent.session.seq
  342. const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout, { cwd }) : undefined
  343. const stopReasoning = projection === undefined ? streamReasoning(ctx, agent, io.stderr) : undefined
  344. try {
  345. try {
  346. agent.followup(createUserMessage({
  347. content: [{ type: 'text', text: task }],
  348. source: { kind: 'user' },
  349. }))
  350. await agent.whenIdle()
  351. } finally {
  352. stopReasoning?.()
  353. }
  354. await sessions.flush(agent.session)
  355. const outcome = summarize(agent.session, firstSeq)
  356. if (projection === undefined) io.stdout.write(outcome.text + '\n')
  357. else projection.finish(outcome.text)
  358. if (outcome.reason?.kind === 'error') {
  359. io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
  360. }
  361. io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
  362. } finally {
  363. projection?.dispose()
  364. }
  365. }
  366. /**
  367. * Mount the one-shot direct driver.
  368. * @param ctx - plugin context carrying core services and the launcher-provided exit request.
  369. * @param config - validated task and run options.
  370. */
  371. export function apply(ctx: Context, config: Config): void {
  372. // Read through the global service store, not the property proxy: appExit is
  373. // an optional host value, never an injected dependency.
  374. const exit = ctx.get('appExit')
  375. if (exit === undefined) {
  376. throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
  377. }
  378. const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
  379. void run(ctx, config, io).catch((error: unknown) => { fail(io, error, config.json === true) })
  380. }