/** * High-level run API over {@link HarnessClient}: route text to prompts or native * commands and collect prompt activity for text and image input. * * @module @deepseek-ai/dsh-sdk-client/api */ import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import { createProcessHarnessClient, HarnessClient, isRecord, queryInput, SdkProtocolError } from './client.ts' import type { RuntimeProcessOptions } from './launch.ts' import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, RunResult, SdkPromptContentBlock } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime * subprocess. The subprocess starts lazily on first use and stays owned by * this instance until {@link close}; always close (or `await using`) so the * child is reaped. */ export class DeepSeekHarness implements AsyncDisposable { private clientInstance: HarnessClient private readonly createClient: () => HarnessClient private readonly cwd: string private readonly provider: string private readonly model: string private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort'] private readonly maxTokens: number | undefined private readonly approvalRequests: boolean | undefined private initialized: Promise | undefined private closed = false /** @param options - dsh launch configuration plus the session route, effort, and output cap. */ constructor(options?: DeepSeekHarnessOptions) constructor(options: DeepSeekHarnessOptions = {}, clientFactory?: () => HarnessClient) { this.createClient = clientFactory ?? (() => new HarnessClient(options)) this.clientInstance = this.createClient() // Absolute before the handshake: the child spawns relative to THIS // process's cwd, but the wire cwd is resolved again inside the child — a // relative value would double-resolve (e.g. `worker` → `worker/worker`). this.cwd = resolve(options.cwd ?? options.processCwd ?? process.cwd()) this.provider = options.provider ?? 'deepseek-official' this.model = options.model ?? 'deepseek-v4-flash' this.reasoningEffort = options.reasoningEffort this.maxTokens = options.maxTokens this.approvalRequests = options.approvalRequests } /** * The underlying JSON-RPC client (exposed for low-level access). A failed * handshake swaps in a fresh instance only after cleanup proves the runtime * exited; cleanup failure retains this client, so do not cache it across a * failed {@link start}. * @returns the client currently owning the runtime subprocess. */ get client(): HarnessClient { return this.clientInstance } /** * Start the subprocess and perform the `initialize` handshake once. On * failure, successful SDK-owned cleanup reaps the runtime and installs a * fresh client (`HarnessClient.close` is permanent), so a later call retries * with a new subprocess unless {@link close} already ended this harness. If * cleanup also fails, rejects with an `AggregateError` whose ordered errors * preserve both causes and retains the failed client rather than spawning * alongside a process whose exit was not proved. * @returns settlement of the (memoized) handshake. */ start(): Promise { this.initialized ??= (async () => { try { this.clientInstance.start() await this.clientInstance.initialize({ cwd: this.cwd, provider: this.provider, model: this.model, ...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort }, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, ...this.approvalRequests === undefined ? {} : { approvalRequests: this.approvalRequests }, }) } catch (error) { this.initialized = undefined try { await this.clientInstance.close() } catch (cleanupError: unknown) { throw new AggregateError( [error, cleanupError], 'DeepSeek Harness initialization and cleanup failed', ) } if (!this.closed) this.clientInstance = this.createClient() throw error } })() return this.initialized } /** * Open a session handle (no wire traffic; the runtime creates the session * on its first prompt or working-directory operation). * @param sessionId - explicit id to reuse; omitted mints a fresh one. * @returns the session handle. */ session(sessionId?: string): HarnessSession { return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`) } /** * Start the runtime and restore a persisted session without sending input. * The configured Harness home must contain its history; missing or occupied sessions reject. * @param sessionId - exact persisted identity to continue. * @returns a handle ready to run new prompts on the restored history. */ async resumeSession(sessionId: string): Promise { await this.start() await this.client.resumeSession(sessionId) return new HarnessSession(this, sessionId) } /** * Run text or content blocks on a fresh or named Session through native input routing. * @param input - prompt or command text, optionally accompanied by image content blocks. * @param options - optional Session identity and notification observer. * @returns the completed input's RunResult, including its Session id. */ run(input: string | SdkPromptContentBlock[], options?: RunOptions): Promise { return this.session(options?.sessionId).run(input, options) } /** * Shut down and reap the runtime subprocess. Idempotent and terminal — * a closed harness no longer retries a failed handshake. * @returns settlement of the complete teardown. */ close(): Promise { this.closed = true return this.clientInstance.close() } /** * `await using` support: {@link close}. * @returns settlement of the teardown. */ [Symbol.asyncDispose](): Promise { return this.close() } } /** Construct the high-level API against a generic process for package-local fake-runtime tests. */ export function createProcessDeepSeekHarness( runtime: RuntimeProcessOptions, options: DeepSeekHarnessOptions = {}, ): DeepSeekHarness { const Constructor = DeepSeekHarness as new ( publicOptions: DeepSeekHarnessOptions, clientFactory: () => HarnessClient, ) => DeepSeekHarness return new Constructor({ ...runtime.cwd === undefined ? {} : { processCwd: runtime.cwd }, ...options, }, () => createProcessHarnessClient(runtime)) } /** Per-run Session identity and notification observer. */ export interface RunOptions { /** Session id to run on; omitted mints a fresh session per call. */ sessionId?: string /** * Observe the input's Session-tree activity in wire order until completion. * The interval can include overlapping work from other inputs. */ onNotification?: (notification: HarnessNotification) => void } /** Collect one input's root events and Session-tree notifications from its admission. */ class RunActivity { commandDone = false private received = false private readonly events: SessionEvent[] = [] private readonly notifications: HarnessNotification[] = [] constructor(private readonly sessionId: string, private readonly receipt: { messageId: string } | { commandId: string }) {} /** Ignore pre-receipt traffic; validate root events before invoking the observer. */ record(notification: HarnessNotification, onNotification?: RunOptions['onNotification']): boolean { if (notification.method === 'session.query.completed') return false if (!this.received) { if (notification.method !== 'session.event' || notification.params.sessionId !== this.sessionId || !this.matchesReceipt(notification.params.event)) return false this.received = true } const event = notification.method === 'session.event' && notification.params.sessionId === this.sessionId ? validatedSessionEvent(notification.params.event) : undefined if (event?.type === 'command/done' && 'commandId' in this.receipt && isRecord(event.data) && event.data.commandId === this.receipt.commandId) this.commandDone = true this.notifications.push(notification) onNotification?.(notification) if (event !== undefined) this.events.push(event) return true } private matchesReceipt(event: unknown): boolean { return 'messageId' in this.receipt ? isInboxReceipt(event, this.receipt.messageId) : isRecord(event) && event.type === 'command/run' && isRecord(event.data) && event.data.commandId === this.receipt.commandId } result(completed?: { finalResponse: string; finishReason: string }): RunResult { if (completed !== undefined && !this.commandDone) { throw new SdkProtocolError('Command completion is missing matching lifecycle events') } const terminal = this.events.findLast(event => event.type === 'turn/end') return { sessionId: this.sessionId, finalResponse: completed?.finalResponse ?? finalResponse(this.events), finishReason: completed?.finishReason ?? terminal?.data.reason.kind ?? null, events: this.events, notifications: this.notifications, } } } /** * One SDK session: a stable id plus owned activity intervals. */ export class HarnessSession { /** * @param harness - the owning harness (supplies the client and handshake). * @param id - the wire session id this handle runs on. */ constructor(readonly harness: DeepSeekHarness, readonly id: string) {} /** * Read the effective directory and recover a missing directory to the Session origin. * @returns the absolute directory used by new independent operations. */ async getWorkingDirectory(): Promise { await this.harness.start() return this.harness.client.getWorkingDirectory(this.id) } /** * Change the effective directory without moving existing processes or changing permissions. * @param path - absolute or Session-current-directory-relative directory. * @returns the validated absolute directory. */ async setWorkingDirectory(path: string): Promise { await this.harness.start() return this.harness.client.setWorkingDirectory(this.id, path) } /** * Run text or content blocks through native prompt-or-command routing. * @param input - prompt or command text, optionally accompanied by image content blocks; image-only input is an ordinary prompt. * @param options - a per-notification observer. * @returns RunResult after prompt activity, a state-only command, or command-started model work ends. * Goal creation and resume include automatic rounds through goal termination and the closing reply. * Native handler refusals return finishReason="error". * @throws on routing, protocol, transport or observer failure; runtime work is not rolled back. */ async run(input: string | SdkPromptContentBlock[], options?: Pick): Promise { await this.harness.start() const client = this.harness.client const subscription = client.subscribeSessionTree(this.id) try { const response = await queryInput(client, this.id, input) if (response.kind === 'command') { const activity = new RunActivity(this.id, { commandId: response.execution.commandId }) while (true) { const notification = await subscription.next() const params = notification.params if (notification.method === 'session.query.completed' && params.sessionId === this.id) { if (typeof params.commandId !== 'string' || params.commandId.length === 0) { throw new SdkProtocolError('Invalid session.query.completed command identity') } if (params.commandId !== response.execution.commandId) continue if (typeof params.finalResponse !== 'string' || typeof params.finishReason !== 'string' || params.finishReason.length === 0) throw new SdkProtocolError('Invalid session.query.completed result') // Failure completion can precede handler settlement; its lifecycle is queued before the RPC response. while (!activity.commandDone) { const queued = subscription.tryNext() if (queued === undefined) break activity.record(queued, options?.onNotification) } return activity.result({ finalResponse: params.finalResponse, finishReason: params.finishReason }) } activity.record(notification, options?.onNotification) } } const activity = new RunActivity(this.id, { messageId: response.messageId }) while (true) { const notification = await subscription.next() if (!activity.record(notification, options?.onNotification)) continue if (notification.method === 'session.status' && notification.params.sessionId === this.id && notification.params.status === 'idle') break } return activity.result() } finally { subscription.close() } } } /** Validate the provider-read fields of one wire turn-end reason. */ function validatedTurnEndReason(value: unknown): TurnEndReason { if (!isRecord(value) || typeof value.kind !== 'string') { throw new SdkProtocolError(`turn/end carried no reason envelope: ${JSON.stringify(value)}`) } if (value.kind === 'aborted') { if (!isRecord(value.reason) || typeof value.reason.kind !== 'string') { throw new SdkProtocolError(`turn/end carried a malformed aborted reason: ${JSON.stringify(value)}`) } switch (value.reason.kind) { case 'user': case 'parent': case 'disposed': case 'legacy': break case 'hook': if (typeof value.reason.reason !== 'string') { throw new SdkProtocolError(`turn/end carried a malformed hook abort reason: ${JSON.stringify(value)}`) } break default: throw new SdkProtocolError(`turn/end carried an unknown abort reason: ${JSON.stringify(value)}`) } } return value as TurnEndReason } /** * Validate the event envelope and assistant/turn fields consumed by SDK readers. * @param value - the raw `session.event` event payload. * @returns the event, or throws a protocol error for malformed consumed fields. */ export function validatedSessionEvent(value: unknown): SessionEvent { if (!isRecord(value) || typeof value.type !== 'string') { throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`) } // The one variant this module reads into (finalResponse) must carry // kind-tagged content blocks; other variants pass through under their // envelope shape. if (value.type === 'assistant/message') { const message = isRecord(value.data) ? value.data.message : undefined const content = isRecord(message) ? message.content : undefined if (!Array.isArray(content) || !content.every(block => isRecord(block) && typeof block.type === 'string')) { throw new SdkProtocolError(`assistant/message event carried malformed content: ${JSON.stringify(value)}`) } } if (value.type === 'turn/end') { const data = isRecord(value.data) ? value.data : undefined if (data === undefined) { throw new SdkProtocolError(`turn/end event carried malformed data: ${JSON.stringify(value)}`) } validatedTurnEndReason(data.reason) } return value as SessionEvent } /** Whether a raw session event is the durable enqueue receipt for `messageId`. */ function isInboxReceipt(value: unknown, messageId: string): boolean { if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false const inserted = value.data.inserted return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId) } /** * Extract the concatenated text of the last assistant message. * @param events - the activity interval's `session.event` payloads in wire order. * @returns the final response text, or `''` when no assistant message exists. */ export function finalResponse(events: SessionEvent[]): string { for (let index = events.length - 1; index >= 0; index--) { const event = events[index] if (event?.type !== 'assistant/message') continue return event.data.message.content .filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text') .map(block => block.text) .join('') } return '' }