/** * @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch * rides over dsh-base without Host, HTTP, or browser plugins; this runner * creates one Agent through the core registry, drives the task to quiescence, * streams provider reasoning to stderr, flushes its Session, prints the final * assistant text to stdout, and exits. * * @module @deepseek-ai/dsh-headless */ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' // Empty type imports carry the loader Context merge for the settlement await // and the cmdline Context merge for the appExit host value. import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] /** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string } export const Config: z = z.object({ task: z.string().required(), }) /** Outcome of one owned run interval. */ interface RunOutcome { text: string reason: SessionEvent<'turn/end'>['data']['reason'] | undefined } /** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */ interface HeadlessIo { stdout: { write(chunk: string): unknown } stderr: { write(chunk: string): unknown } /** Request process exit with `code` after the tree disposes. */ exit(code: number): void } /** The process streams the runner writes to; tests substitute captures. */ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = { stdout: process.stdout, stderr: process.stderr, } /** Aggregate the last assistant text and turn outcome in one owned interval. */ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome { let started = false let text = '' let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined const length = session.seq for (let seq = firstSeq; seq < length; seq++) { const event = session.eventAt(SessionSeq(seq)) if (event === undefined) { throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) } if (event.type === 'turn/start') { started = true continue } if (!started) continue if (event.type === 'assistant/message') { const joined = event.data.message.content .filter(block => block.type === 'text') .map(block => block.text) .join('') if (joined !== '') text = joined } if (event.type === 'turn/end') reason = event.data.reason } return { text, reason } } /** * Project provider-reported reasoning from one owned run to stderr as it is * streamed, while keeping final outcome derivation on the durable log. * @param ctx - plugin context carrying the live Assistant frame feed. * @param agent - the exact Agent whose reasoning belongs to this invocation. * @param stderr - progress output sink. * @returns a disposer that also terminates an unterminated reasoning line. */ function streamReasoning( ctx: Context, agent: Agent, stderr: HeadlessIo['stderr'], ): () => void { let open = false let endsWithNewline = true const close = (): void => { if (!open) return if (!endsWithNewline) stderr.write('\n') open = false endsWithNewline = true } const dispose = ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => { if (subject !== agent) return if (frame.type === 'start') { close() return } if (frame.type === 'end') { close() return } const chunk = frame.chunk switch (chunk.type) { case 'reasoning-delta': if (chunk.text === '') return if (!open) { stderr.write('dsh: reasoning:\n') open = true } stderr.write(chunk.text) endsWithNewline = chunk.text.endsWith('\n') return case 'block-start': if (chunk.blockType !== 'reasoning') close() return case 'block-end': if (chunk.block.type !== 'reasoning') close() return case 'usage': return case 'text-delta': case 'tool-call-delta': case 'finish': close() return /* v8 ignore next -- closed-union exhaustiveness guard */ default: return assertNever(chunk, 'headless reasoning stream') } }) return () => { dispose() close() } } /** Report an unexpected direct-driver failure and request a failing exit. */ function fail(io: HeadlessIo, error: unknown): void { io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`) io.exit(1) } /** * Run one task through a freshly created Agent and request process exit. * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services. * @param task - one-shot task text. * @param io - process-facing effects. */ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { // Loader siblings mount concurrently. Await the complete application before // creating an Agent so its scoped tools and adapters are not half-composed. await ctx.get('loader')?.await() const agents = ctx.get('agents') const defaultModel = ctx.get('agentDefaultModel') const sessions = ctx.get('sessions') // Early process shutdown can dispose the tree while settlement is pending. if (agents === undefined || defaultModel === undefined || sessions === undefined) return const selection = defaultModel.currentSelection() // This bundle composes no preset roster, so the model-facing rows sit in the // host plane and the agent reads them from the global layer. A deployment // that DOES configure one has to join it here first // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const { agent } = await agents.create({ sessionId: brandString(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, agentOptions: { provider: selection.provider, model: selection.model }, setup: (agentCtx) => { const selected: ModelSelectionRef = { current: selection, assembled: undefined } installModelSelection(agentCtx, selected) }, }) await agent.whenIdle() const firstSeq = agent.session.seq const stopReasoning = streamReasoning(ctx, agent, io.stderr) try { agent.followup(createUserMessage({ content: [{ type: 'text', text: task }], source: { kind: 'user' }, })) await agent.whenIdle() } finally { stopReasoning() } await sessions.flush(agent.session) const outcome = summarize(agent.session, firstSeq) io.stdout.write(outcome.text + '\n') if (outcome.reason?.kind === 'error') { io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`) } io.exit(outcome.reason?.kind === 'completed' ? 0 : 1) } /** * Mount the one-shot direct driver. * @param ctx - plugin context carrying core services and the launcher-provided exit request. * @param config - validated task config. */ export function apply(ctx: Context, config: Config): void { // Read through the global service store, not the property proxy: appExit is // an optional host value, never an injected dependency. const exit = ctx.get('appExit') if (exit === undefined) { throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts') } const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit } void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) }) }