diff --git a/packages/sdk/sdk-client/package.json b/packages/sdk/sdk-client/package.json new file mode 100644 index 0000000000..6f5b962ce0 --- /dev/null +++ b/packages/sdk/sdk-client/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-sdk-client", + "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/sdk-client/src/api.ts b/packages/sdk/sdk-client/src/api.ts new file mode 100644 index 0000000000..433a80d8c7 --- /dev/null +++ b/packages/sdk/sdk-client/src/api.ts @@ -0,0 +1,193 @@ +/** + * High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one + * runtime subprocess across many sessions; `HarnessSession.run` sends a + * prompt and settles with the final response once `session.finished` arrives. + * Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair. + * + * @module @deepseek-ai/dsh-sdk-client/api + */ + +import { randomUUID } from 'node:crypto' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import { HarnessClient } from './client.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnResult } 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 { + /** The underlying JSON-RPC client (exposed for low-level access). */ + readonly client: HarnessClient + private readonly cwd: string + private readonly provider: string + private readonly model: string + private initialized: Promise | undefined + + /** @param options - runtime launch spec plus the session route (cwd/provider/model). */ + constructor(options: DeepSeekHarnessOptions) { + this.client = new HarnessClient(options.launch) + this.cwd = options.cwd ?? options.launch.cwd ?? process.cwd() + this.provider = options.provider ?? 'deepseek' + this.model = options.model ?? 'deepseek-v4-flash' + } + + /** + * Start the subprocess and perform the `initialize` handshake once. + * @returns settlement of the (memoized) handshake. + */ + start(): Promise { + this.initialized ??= (async () => { + try { + this.client.start() + await this.client.initialize({ cwd: this.cwd, provider: this.provider, model: this.model }) + } catch (error) { + this.initialized = undefined + await this.client.close() + throw error + } + })() + return this.initialized + } + + /** + * Open a session handle (no wire traffic; the runtime creates the session + * on its first prompt). + * @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('-', '')}`) + } + + /** + * Run one prompt on a fresh (or named) session. + * @param input - prompt text, or content blocks sent verbatim. + * @param options - optional session id and per-notification observer. + * @returns the settled turn result. + */ + run(input: string | ContentBlock[], options?: RunOptions): Promise { + return this.session(options?.sessionId).run(input, options) + } + + /** + * Shut down and reap the runtime subprocess. Idempotent. + * @returns settlement of the complete teardown. + */ + close(): Promise { + return this.client.close() + } + + /** + * `await using` support: {@link close}. + * @returns settlement of the teardown. + */ + [Symbol.asyncDispose](): Promise { + return this.close() + } +} + +/** Per-run options: target session and streaming observer. */ +export interface RunOptions { + /** Session id to run on; omitted mints a fresh session per call. */ + sessionId?: string + /** Observer invoked with every notification for this session tree, in wire order. */ + onNotification?: (notification: HarnessNotification) => void +} + +/** + * One SDK session: a stable id plus the turn loop that pairs a + * `session/prompt` with its `session.finished`. + */ +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) {} + + /** + * Run one prompt turn to settlement. + * @param input - prompt text, or content blocks sent verbatim. + * @param options - optional per-notification observer. + * @returns the settled turn result; rejects on transport loss, timeout, or + * a protocol error — never on a model-level failure (that is + * `status: 'error'` in the result). + */ + async run(input: string | ContentBlock[], options?: Pick): Promise { + await this.harness.start() + const client = this.harness.client + const contentBlocks = normalizeInput(input) + const events: SessionEvent[] = [] + const notifications: HarnessNotification[] = [] + let status: TurnResult['status'] = 'error' + let reason: TurnEndReason | undefined + let finished = false + + const subscription = client.subscribeSessionTree(this.id) + const collect = (notification: HarnessNotification): void => { + notifications.push(notification) + options?.onNotification?.(notification) + if (notification.method === 'session.event' && notification.params.sessionId === this.id) { + events.push(notification.params.event as SessionEvent) + } + if (notification.method === 'session.finished' && notification.params.sessionId === this.id) { + status = notification.params.status === 'ok' ? 'ok' : 'error' + reason = notification.params.reason as TurnEndReason | undefined + finished = true + } + } + const accepted = client.prompt(this.id, contentBlocks) + // Drain concurrently so observers see progress while the prompt request + // is still pending (its response arrives only after settlement). + const drain = (async () => { + while (!finished) collect(await subscription.next()) + })() + try { + await Promise.all([accepted, drain]) + } finally { + // On a prompt rejection the drain is still parked on next(); closing the + // subscription settles it, and the swallow keeps that secondary + // TransportClosedError from surfacing as an unhandled rejection. + subscription.close() + await drain.catch(() => {}) + } + + return { + sessionId: this.id, + status, + reason, + finalResponse: finalResponse(events), + events, + notifications, + } + } +} + +/** + * Normalize run input: a string becomes one text block; blocks pass verbatim. + * @param input - prompt text or content blocks. + * @returns the content blocks to send. + */ +export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] { + return typeof input === 'string' ? [{ type: 'text', text: input }] : input +} + +/** + * Extract the concatenated text of the last assistant message. + * @param events - the turn'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.content + .filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text') + .map(block => block.text) + .join('') + } + return '' +} diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts new file mode 100644 index 0000000000..f802ee479f --- /dev/null +++ b/packages/sdk/sdk-client/src/client.ts @@ -0,0 +1,425 @@ +/** + * Low-level JSON-RPC client for a DeepSeek Harness SDK runtime subprocess. + * {@link HarnessClient} owns the child process: it spawns the runtime, speaks + * the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans + * server notifications out to subscriptions, and tears the child down to + * quiescence through the shared subprocess dispose ladder. The design twin is + * the Python SDK's `HarnessClient` (`python/sdk`); both drive the same + * runtime protocol. + * + * @module @deepseek-ai/dsh-sdk-client/client + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { + JsonRpcLineTransport, + JsonRpcResponseError, + type InitializeParams, + type InitializeResult, + type SessionPromptParams, +} from '@deepseek-ai/dsh-sdk-protocol' +import { disposeChildProcess } from '@deepseek-ai/dsh-subagent-subprocess' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts' + +/** Retained stderr lines used to diagnose an unexpected runtime death. */ +const STDERR_TAIL_LIMIT = 400 + +/** Grace for the runtime's stdio streams to settle after its exit edge. */ +const STREAM_SETTLE_MS = 100 + +/** + * The runtime subprocess is gone or unusable: it exited, its stdio closed, or + * it was never launchable. The message carries the exit code and a stderr + * tail when available. + */ +export class TransportClosedError extends Error { + /** @param message - the failure description, including any stderr tail. */ + constructor(message: string) { + super(message) + this.name = 'TransportClosedError' + } +} + +/** A request exceeded {@link HarnessClientOptions.requestTimeoutMs}. */ +export class RequestTimeoutError extends Error { + /** @param message - which method timed out. */ + constructor(message: string) { + super(message) + this.name = 'RequestTimeoutError' + } +} + +/** + * The runtime answered outside its documented protocol (for example a + * `session/prompt` response without `accepted: true`). + */ +export class SdkProtocolError extends Error { + /** @param message - the protocol violation description. */ + constructor(message: string) { + super(message) + this.name = 'SdkProtocolError' + } +} + +interface SubscriptionState { + readonly queue: HarnessNotification[] + readonly waiters: { resolve: (item: HarnessNotification) => void; reject: (error: Error) => void }[] + readonly filter: NotificationFilter | undefined + failure: Error | undefined +} + +/** + * One client-side notification stream. Delivery order matches the wire; + * {@link close} detaches it from the client, after which {@link next} rejects. + */ +export class NotificationSubscription implements AsyncIterable { + constructor( + private readonly state: SubscriptionState, + private readonly unsubscribe: () => void, + ) {} + + /** + * Await the next matching notification. + * @returns the notification; rejects once the runtime is closed or the + * subscription itself is closed. + */ + next(): Promise { + const queued = this.state.queue.shift() + if (queued !== undefined) return Promise.resolve(queued) + if (this.state.failure !== undefined) return Promise.reject(this.state.failure) + return new Promise((resolve, reject) => { + this.state.waiters.push({ resolve, reject }) + }) + } + + /** + * Drain one already-delivered notification without waiting. + * @returns the next queued notification, or `undefined` when none is queued. + */ + tryNext(): HarnessNotification | undefined { + return this.state.queue.shift() + } + + /** Detach from the client; queued items drop and pending waiters reject. */ + close(): void { + this.unsubscribe() + this.fail(new TransportClosedError('notification subscription closed')) + } + + /** Reject pending and future waits with `error` (delivery stops). */ + fail(error: Error): void { + this.state.failure ??= error + for (const waiter of this.state.waiters.splice(0)) waiter.reject(this.state.failure) + } + + /** Deliver one notification to a waiter or the queue when the filter matches. */ + push(notification: HarnessNotification): void { + if (this.state.filter !== undefined && !this.state.filter(notification)) return + const waiter = this.state.waiters.shift() + if (waiter !== undefined) waiter.resolve(notification) + else this.state.queue.push(notification) + } + + /** + * Iterate notifications until the subscription or runtime closes (the + * terminating rejection propagates). + * @returns an async iterator over {@link next} results. + */ + async * [Symbol.asyncIterator](): AsyncIterator { + for (;;) yield await this.next() + } +} + +/** + * JSON-RPC client for the DeepSeek Harness SDK runtime over subprocess stdio. + * + * The subprocess starts lazily on {@link start} and is owned by this instance + * until {@link close}, which requests protocol `shutdown` and then walks the + * shared EOF → SIGTERM → SIGKILL dispose ladder to quiescence. There is no + * wire-level cancel: a timed-out request stays running server-side until the + * runtime is closed. + */ +export class HarnessClient { + private child: ChildProcess | undefined + private transport: JsonRpcLineTransport | undefined + private readonly stderrTail: string[] = [] + private readonly subscriptions = new Map() + private readonly sessionParents = new Map() + private subscriptionSerial = 0 + private exitCode: number | null | undefined + private spawnError: Error | undefined + private streamsSettled: Promise = Promise.resolve() + private closeTask: Promise | undefined + + /** @param options - launch spec, complete child environment, and timeouts. */ + constructor(readonly options: HarnessClientOptions) {} + + /** + * Spawn the runtime subprocess and start reading frames. Idempotent while + * the process is live; rejects reuse after {@link close}. + */ + start(): void { + if (this.closeTask !== undefined) throw new TransportClosedError('DeepSeek Harness runtime client is closed') + if (this.child !== undefined) return + const child = spawn(this.options.command, this.options.args ?? [], { + cwd: this.options.cwd, + env: this.options.env ?? process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.child = child + child.once('error', (error) => { + this.spawnError = error + // A spawn failure destroys the pipes without an input 'end' edge, so the + // transport's pending requests must be failed here. + this.transport?.close() + this.failSubscriptions(this.closedError('DeepSeek Harness runtime failed to start')) + }) + // Writes racing the runtime's death EPIPE on stdin; the exit edge below is + // the real signal, so the stream-level error only needs to be non-fatal. + // The timing of that race is not deterministically reproducible. + /* v8 ignore next */ + child.stdin.on('error', () => {}) + let stderrBuffer = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + stderrBuffer += chunk + const newline = stderrBuffer.lastIndexOf('\n') + if (newline >= 0) { + this.appendStderr(stderrBuffer.slice(0, newline).split('\n')) + stderrBuffer = stderrBuffer.slice(newline + 1) + } + }) + let signalStreamsSettled!: () => void + this.streamsSettled = new Promise((resolve) => { signalStreamsSettled = resolve }) + const settled = { stderr: false, exited: false } + const maybeSettle = (): void => { + if (settled.stderr && settled.exited) signalStreamsSettled() + } + child.stderr.once('close', () => { + if (stderrBuffer.length > 0) this.appendStderr([stderrBuffer]) + settled.stderr = true + maybeSettle() + }) + child.once('exit', (code) => { + this.exitCode = code + settled.exited = true + maybeSettle() + this.failSubscriptions(this.closedError('DeepSeek Harness runtime exited')) + }) + child.once('close', () => { + // All stdio has settled: stdout 'end' already drained every tail frame, + // so closing now cannot drop responses — it only fails requests that + // will never be answered. + this.transport?.close() + }) + const transport = new JsonRpcLineTransport(child.stdout, child.stdin) + transport.onNotification((method, params) => { this.dispatchNotification({ method, params }) }) + transport.start() + this.transport = transport + } + + /** + * Perform the process-wide handshake. + * @param params - workspace cwd plus the provider/model route. + * @returns the runtime's wire identity. + */ + async initialize(params: InitializeParams): Promise { + const result = await this.request('initialize', { ...params }) + if (!isRecord(result) || !isRecord(result.serverInfo) + || typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') { + throw new SdkProtocolError(`initialize returned no server identity: ${JSON.stringify(result)}`) + } + return { serverInfo: { name: result.serverInfo.name, version: result.serverInfo.version } } + } + + /** + * Run one prompt turn to settlement (the response arrives only after the + * turn settled; progress streams as notifications meanwhile). + * @param sessionId - target session; an unknown id creates it. + * @param contentBlocks - the user message, sent verbatim. + */ + async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise { + const params: SessionPromptParams = { sessionId, contentBlocks } + const result = await this.request('session/prompt', { ...params }) + if (!isRecord(result) || result.accepted !== true) { + throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`) + } + } + + /** + * Send one JSON-RPC request and await its result. + * @param method - the wire method name. + * @param params - the params object; omitted params send `{}`. + * @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}. + * @returns the raw result; rejects with {@link JsonRpcResponseError} on a + * protocol error response, {@link RequestTimeoutError} on timeout, and + * {@link TransportClosedError} when the runtime is gone. + */ + async request(method: string, params?: object, timeoutMs?: number): Promise { + this.start() + // A dead runtime cannot answer; fail with process context instead of + // writing into a destroyed pipe and hanging until the timeout. + if (this.exitCode !== undefined || this.spawnError !== undefined) { + await this.settleStreams() + throw this.closedError('DeepSeek Harness runtime is not running') + } + const transport = this.transport + /* v8 ignore next -- start() either sets the transport or throws */ + if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running') + const pending = transport.request(method, params ?? {}) + const timeout = timeoutMs ?? this.options.requestTimeoutMs + try { + if (timeout === undefined) return await pending + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout(() => { + // The abandoned wire promise settles on close; keep it handled. + pending.catch(() => {}) + reject(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`)) + }, timeout) + }), + ]) + } finally { + clearTimeout(timer) + } + } catch (error) { + if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error + // Transport-level failures gain process context: exit code + stderr tail. + await this.settleStreams() + throw this.closedError(errorMessage(error)) + } + } + + /** + * Subscribe to server notifications. + * @param filter - optional predicate; omitted means every notification. + * @returns the subscription handle; close it to stop delivery. + */ + subscribe(filter?: NotificationFilter): NotificationSubscription { + const id = String(this.subscriptionSerial++) + const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined } + const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) }) + this.subscriptions.set(id, subscription) + return subscription + } + + /** + * Subscribe to one session and the descendants discovered from + * `subagent.started` lineage edges (the runtime notifies for every session + * in its context; scoping is client-side, mirroring the Python SDK). + * @param sessionId - the root session id. + * @returns the filtered subscription handle. + */ + subscribeSessionTree(sessionId: string): NotificationSubscription { + return this.subscribe((notification) => { + const params = notification.params + if (notification.method === 'subagent.started' || notification.method === 'subagent.finished') { + const parentId = params.parentSessionId + if (typeof parentId === 'string' && this.isDescendantOf(parentId, sessionId)) return true + return params.childSessionId === sessionId + } + const relatedId = params.sessionId + return typeof relatedId === 'string' && this.isDescendantOf(relatedId, sessionId) + }) + } + + /** + * Shut the runtime down and reap it: a best-effort protocol `shutdown` + * bounded by `shutdownTimeoutMs`, then the shared stdin-EOF → SIGTERM → + * SIGKILL ladder until the process actually exited. Idempotent. + * @returns settlement of the complete teardown. + */ + close(): Promise { + this.closeTask ??= this.performClose() + return this.closeTask + } + + private async performClose(): Promise { + const child = this.child + if (child === undefined) return + try { + await this.request('shutdown', undefined, this.options.shutdownTimeoutMs ?? 1_000) + } catch (error) { + // Diagnostic only: the dispose ladder below is the authoritative teardown + // for a runtime that cannot answer shutdown anymore. + this.appendStderr([`shutdown request failed: ${errorMessage(error)}`]) + } + await disposeChildProcess(child, { + disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000, + disposeGraceMs: this.options.disposeGraceMs ?? 3_000, + }) + this.transport?.close() + this.failSubscriptions(this.closedError('DeepSeek Harness runtime closed')) + } + + private dispatchNotification(notification: HarnessNotification): void { + this.recordSessionRelationship(notification) + for (const subscription of this.subscriptions.values()) subscription.push(notification) + } + + private recordSessionRelationship(notification: HarnessNotification): void { + if (notification.method !== 'subagent.started') return + const parentId = notification.params.parentSessionId + const childId = notification.params.childSessionId + if (typeof parentId === 'string' && parentId !== '' && typeof childId === 'string' && childId !== '' && parentId !== childId) { + this.sessionParents.set(childId, parentId) + } + } + + private isDescendantOf(sessionId: string, rootSessionId: string): boolean { + const visited = new Set() + let current = sessionId + while (!visited.has(current)) { + if (current === rootSessionId) return true + visited.add(current) + const parent = this.sessionParents.get(current) + if (parent === undefined) return false + current = parent + } + // The parent map only ever extends chains upward, so a cycle cannot form. + /* v8 ignore next */ + return false + } + + private failSubscriptions(error: Error): void { + for (const subscription of this.subscriptions.values()) subscription.fail(error) + } + + private appendStderr(lines: string[]): void { + const kept = lines.filter(line => line.length > 0) + this.stderrTail.push(...kept) + if (this.stderrTail.length > STDERR_TAIL_LIMIT) { + this.stderrTail.splice(0, this.stderrTail.length - STDERR_TAIL_LIMIT) + } + } + + private settleStreams(): Promise { + return Promise.race([ + this.streamsSettled, + new Promise((resolve) => { setTimeout(resolve, STREAM_SETTLE_MS) }), + ]) + } + + private closedError(reason: string): TransportClosedError { + const parts = [reason] + if (this.spawnError !== undefined) parts.push(`spawn error: ${this.spawnError.message}`) + if (this.exitCode !== undefined) parts.push(`exit code: ${String(this.exitCode)}`) + if (this.stderrTail.length > 0) parts.push(`stderr tail:\n${this.stderrTail.join('\n')}`) + return new TransportClosedError(parts.join('\n')) + } +} + +/** Whether `value` is a plain JSON object (the wire-boundary shape probe). */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */ +function errorMessage(error: unknown): string { + /* v8 ignore next -- the transport and dispose ladder reject only with Errors */ + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/sdk/sdk-client/src/index.ts b/packages/sdk/sdk-client/src/index.ts new file mode 100644 index 0000000000..6b8ac9a58e --- /dev/null +++ b/packages/sdk/sdk-client/src/index.ts @@ -0,0 +1,14 @@ +/** + * TypeScript client SDK for the DeepSeek Harness runtime: spawn the + * `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over + * stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API; + * `HarnessClient` is the lower-level protocol client. A pure library — it + * registers nothing on a Cordis context; the runtime process it spawns is a + * complete harness configured by its own `cordis.yml`. + * + * @module @deepseek-ai/dsh-sdk-client + */ + +export * from './api.ts' +export * from './client.ts' +export type * from './types.ts' diff --git a/packages/sdk/sdk-client/src/invariant.ts b/packages/sdk/sdk-client/src/invariant.ts new file mode 100644 index 0000000000..db40e4e005 --- /dev/null +++ b/packages/sdk/sdk-client/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`. + * @module @deepseek-ai/dsh-sdk-client/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client' + +/** Cordis companion plugin name. */ +export const name = 'sdk-client-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this client library runs outside any harness context + * (its peer is a separate runtime process); the runtime's own packages own + * the event-stream relations. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sdk/sdk-client/src/types.ts b/packages/sdk/sdk-client/src/types.ts new file mode 100644 index 0000000000..e1d38a7c0b --- /dev/null +++ b/packages/sdk/sdk-client/src/types.ts @@ -0,0 +1,77 @@ +/** + * Types for the TypeScript SDK client: launch options, notification shapes, + * and turn results. + * + * @module @deepseek-ai/dsh-sdk-client/types + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol' + +/** One server-to-client notification as received off the wire. */ +export interface HarnessNotification { + /** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */ + method: string + /** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */ + params: Record +} + +/** Predicate deciding whether a subscription receives a notification. */ +export type NotificationFilter = (notification: HarnessNotification) => boolean + +/** Launch and timeout options for {@link HarnessClient}. */ +export interface HarnessClientOptions { + /** The runtime executable (the `dsh-jsonrpc-agent` bin, a packaged exe, or `node`). */ + command: string + /** Arguments passed to {@link command}. */ + args?: string[] + /** Working directory for the runtime process itself. */ + cwd?: string + /** + * The complete child environment. `undefined` inherits the parent env + * verbatim; passing an object replaces it entirely, so callers own + * credential policy (see `buildChildEnv` in + * `@deepseek-ai/dsh-subagent-subprocess` for the scrub-then-inject helper). + */ + env?: NodeJS.ProcessEnv + /** Per-request timeout (ms); `undefined` waits indefinitely (a turn can legitimately run long). */ + requestTimeoutMs?: number + /** Bound (ms) on the protocol `shutdown` exchange inside `close()` (default 1000). */ + shutdownTimeoutMs?: number + /** Grace (ms) for the runtime's stdin-EOF quiesce during `close()` (default 6000). */ + disposeEofGraceMs?: number + /** Termination confirmation window (ms) after SIGTERM/SIGKILL during `close()` (default 3000). */ + disposeGraceMs?: number +} + +/** Options for the high-level {@link DeepSeekHarness} wrapper. */ +export interface DeepSeekHarnessOptions { + /** Launch spec for the runtime subprocess (command, args, cwd, env, timeouts). */ + launch: HarnessClientOptions + /** Workspace cwd recorded on every SDK-created session (default: the launch cwd, else `process.cwd()`). */ + cwd?: string + /** Provider route for SDK-created agents (default `deepseek`). */ + provider?: string + /** Model for SDK-created agents (default `deepseek-v4-flash`). */ + model?: string +} + +/** The settled outcome of one {@link HarnessSession.run} turn. */ +export interface TurnResult { + /** The session the turn ran on. */ + sessionId: string + /** Deployment-mapped turn outcome from `session.finished`. */ + status: SdkRunStatus + /** Why the last message-triggered turn ended; `undefined` when no turn ran. */ + reason: TurnEndReason | undefined + /** Concatenated text of the session's last assistant message (empty when none). */ + finalResponse: string + /** Every `session.event` payload for this session tree, in wire order. */ + events: SessionEvent[] + /** Every notification observed during the turn, in wire order. */ + notifications: HarnessNotification[] +} + +/** Re-exported content-block alias so SDK callers need no extra import. */ +export type { ContentBlock } diff --git a/packages/sdk/sdk-client/tests/fake-runtime.ts b/packages/sdk/sdk-client/tests/fake-runtime.ts new file mode 100644 index 0000000000..622f2605a7 --- /dev/null +++ b/packages/sdk/sdk-client/tests/fake-runtime.ts @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Scripted stand-in for the DeepSeek Harness SDK runtime, driven entirely by + * env vars — no model, no network, no harness imports. Speaks the runtime's + * newline-delimited JSON-RPC protocol on stdio: answers `initialize`, + * `session/prompt` (streaming scripted `session.event` notifications, then + * `session.finished`, then the response), and `shutdown`. + * + * Script vocabulary (all optional): + * - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`). + * - `FAKE_STATUS`: the `session.finished` status (default `ok`). + * - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason). + * - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished). + * - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd. + * - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text. + * - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted). + * - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted). + * - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7. + * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe). + * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize` + * arrives, then poll for the GO file before answering (deterministic + * cancel-during-handshake window). + * - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests). + * - `FAKE_STREAM_THEN_HANG`: stream a text chunk for the prompt, then never + * finish the turn or answer (partial-output cancel probe). Touches + * `FAKE_STREAM_READY` after the chunk when set. + * - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe). + * - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe). + * - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe). + * - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe). + * - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe). + * - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe). + */ + +import { appendFileSync, existsSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import { createInterface } from 'node:readline' + +const env = process.env + +if (env.FAKE_STDERR !== undefined) process.stderr.write(`${env.FAKE_STDERR}\n`) +if (env.FAKE_STDERR_NO_NEWLINE !== undefined) process.stderr.write(env.FAKE_STDERR_NO_NEWLINE) +if (env.FAKE_EXIT_BEFORE_INIT !== undefined) process.exit(3) + +if (env.FAKE_IGNORE_EOF !== undefined) { + // Simulate a runtime that never quiesces from EOF so the dispose ladder + // must escalate; record which rung fired. + process.stdin.resume() + process.stdin.on('end', () => { setInterval(() => {}, 1_000) }) + process.on('SIGTERM', () => { + if (env.FAKE_SIGTERM_FILE !== undefined) writeFileSync(env.FAKE_SIGTERM_FILE, 'sigterm\n') + if (env.FAKE_TRAP_SIGTERM === undefined) process.exit(0) + }) +} + +function write(message: object): void { + process.stdout.write(`${JSON.stringify(message)}\n`) +} + +function notify(method: string, params: object): void { + write({ jsonrpc: '2.0', method, params }) +} + +let seq = 0 +function event(sessionId: string, type: string, data: object): void { + notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } }) +} + +function assistantText(): string { + const parts: string[] = [] + if (env.FAKE_ECHO_CWD !== undefined) parts.push(`cwd=${process.cwd()}`) + for (const name of (env.FAKE_ECHO_ENV ?? '').split(',').filter(entry => entry.length > 0)) { + parts.push(`${name}=${env[name] ?? ''}`) + } + parts.push(env.FAKE_TEXT ?? 'hello from fake runtime') + return parts.join('\n') +} + +function runTurn(sessionId: string): void { + const text = assistantText() + event(sessionId, 'turn/start', { turn: 0 }) + event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } }) + event(sessionId, 'assistant/message', { + turn: 0, + step: 0, + content: [{ type: 'text', text }], + provenance: { provider: 'fake', model: 'fake' }, + }) + const reasonKind = env.FAKE_REASON_KIND ?? 'completed' + event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } }) + if (env.FAKE_SUBAGENT !== undefined) { + const childId = `${sessionId}-child` + notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId }) + event(childId, 'assistant/message', { + turn: 0, + step: 0, + content: [{ type: 'text', text: 'child says hi' }], + provenance: { provider: 'fake', model: 'fake' }, + }) + notify('subagent.finished', { + provider: 'spawn', + agentId: childId, + parentSessionId: sessionId, + childSessionId: childId, + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'child says hi' }], + }) + } + notify('session.finished', { + sessionId, + status: env.FAKE_STATUS ?? 'ok', + ...(reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }), + }) +} + +function sessionIdOf(params: Record | undefined): string { + const value = params?.sessionId + return typeof value === 'string' ? value : '' +} + +const reader = createInterface({ input: process.stdin }) +reader.on('line', (line) => { + if (line.trim().length === 0) return + const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record } + if (frame.method === undefined || frame.id === undefined) return + const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) } + switch (frame.method) { + case 'initialize': + if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`) + if (env.FAKE_HANG_INIT !== undefined) return + if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) { + writeFileSync(env.FAKE_INIT_READY, 'ready\n') + const go = env.FAKE_INIT_GO + const id = frame.id + const poll = setInterval(() => { + if (!existsSync(go)) return + clearInterval(poll) + write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } }) + }, 5) + return + } + if (env.FAKE_INIT_ERROR !== undefined) { + write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } }) + return + } + if (env.FAKE_MALFORMED !== undefined) { + respond({}) + return + } + respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) + return + case 'session/prompt': { + if (env.FAKE_STREAM_THEN_HANG !== undefined) { + const sessionId = sessionIdOf(frame.params) + event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then hung' } }) + if (env.FAKE_STREAM_READY !== undefined) writeFileSync(env.FAKE_STREAM_READY, 'streamed\n') + return + } + if (env.FAKE_HANG_PROMPT !== undefined) return + if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) { + respond({}) + return + } + const sessionId = sessionIdOf(frame.params) + runTurn(sessionId) + respond({ accepted: true }) + return + } + case 'shutdown': + respond({}) + // An EOF-ignoring fake also refuses the protocol exit, so the client's + // dispose ladder (not this cooperative path) must reap it. + if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0)) + return + default: + write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } }) + } +}) diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts new file mode 100644 index 0000000000..3feff693f4 --- /dev/null +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -0,0 +1,344 @@ +/** + * SDK client against a real scripted runtime subprocess + * (`tests/fake-runtime.ts`, protocol-only — the only faked boundary is the + * model-owning runtime itself). Covers the turn loop, notification routing + * and session-tree scoping, error surfaces, timeouts, and the dispose ladder. + */ + +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { + DeepSeekHarness, + finalResponse, + HarnessClient, + normalizeInput, + RequestTimeoutError, + SdkProtocolError, + TransportClosedError, + type HarnessNotification, +} from '../src/index.ts' +import { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol' + +const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url)) + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() +}) + +type LaunchOverrides = Partial[0]> + +/** Launch options running the fake runtime on the current node (type stripping). */ +function fakeLaunch(env: Record = {}, extra: LaunchOverrides = {}) { + return { + command: process.execPath, + args: [fakeRuntime], + env: { ...process.env as Record, ...env }, + ...extra, + } +} + +function harnessWith(env: Record = {}, extra: LaunchOverrides = {}): DeepSeekHarness { + const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) }) + cleanups.push(() => harness.close()) + return harness +} + +async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +describe('DeepSeekHarness', () => { + it('runs a turn end to end and reuses the runtime across sessions', async () => { + const harness = harnessWith({ FAKE_TEXT: 'turn answer' }) + const first = await harness.run('say hi') + expect(first.status).toBe('ok') + expect(first.reason).toEqual({ kind: 'completed' }) + expect(first.finalResponse).toBe('turn answer') + expect(first.events.map(event => event.type)).toEqual(['turn/start', 'assistant/chunk', 'assistant/message', 'turn/end']) + + // Same subprocess, second session: ids differ, protocol state is reusable. + const second = await harness.run([{ type: 'text', text: 'again' }]) + expect(second.status).toBe('ok') + expect(second.sessionId).not.toBe(first.sessionId) + await harness.close() + }) + + it('streams notifications to the observer and scopes them to the session tree', async () => { + const harness = harnessWith({ FAKE_SUBAGENT: '1' }) + const seen: HarnessNotification[] = [] + const result = await harness.run('delegate', { + sessionId: 'parent-1', + onNotification: (n) => { seen.push(n) }, + }) + + expect(result.status).toBe('ok') + // The child session's events arrive through subagent.started lineage. + expect(seen.map(n => n.method)).toContain('subagent.started') + expect(seen.map(n => n.method)).toContain('subagent.finished') + const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child') + expect(childEvents.length).toBeGreaterThan(0) + // Child events do not count as the parent's own turn events. + expect(result.events.every(event => event.type !== 'assistant/message' + || (event.data as { content: { type: string; text?: string }[] }).content[0]?.text !== 'child says hi')).toBe(true) + await harness.close() + }) + + it('reports an error status with the turn-end reason', async () => { + const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' }) + const result = await harness.run('overflow') + expect(result.status).toBe('error') + expect(result.reason).toEqual({ kind: 'max-tokens' }) + await harness.close() + }) + + it('omits the reason when the runtime settled without one', async () => { + const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' }) + const result = await harness.run('no turn') + expect(result.status).toBe('error') + expect(result.reason).toBeUndefined() + await harness.close() + }) + + it('sends the configured cwd/provider/model in the handshake exactly once', async () => { + const dir = await tempDir('sdk-client-init-') + const recordFile = join(dir, 'init.jsonl') + const harness = new DeepSeekHarness({ + launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile }), + cwd: dir, + provider: 'custom-provider', + model: 'custom-model', + }) + cleanups.push(() => harness.close()) + await harness.run('one') + await harness.run('two') + await harness.close() + const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object) + expect(records).toEqual([{ cwd: dir, provider: 'custom-provider', model: 'custom-model' }]) + }) + + it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => { + const harness = harnessWith({ FAKE_INIT_ERROR: '1' }) + const failure = await harness.run('boom').then( + () => { throw new Error('run unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(JsonRpcResponseError) + expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } }) + // The failed handshake reset lets a later start retry instead of wedging. + await expect(harness.run('later')).rejects.toThrow() + }) + + it('rejects a malformed initialize result as a protocol error', async () => { + const harness = harnessWith({ FAKE_MALFORMED: '1' }) + await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError) + }) + + it('supports await using disposal', async () => { + let captured: DeepSeekHarness + { + await using harness = new DeepSeekHarness({ launch: fakeLaunch() }) + captured = harness + const result = await harness.run('scoped') + expect(result.status).toBe('ok') + } + // After scope exit the runtime is closed: reuse fails loudly. + await expect(captured.run('after')).rejects.toThrow(TransportClosedError) + }) +}) + +describe('HarnessClient', () => { + it('times out a hung request at the per-call bound', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' })) + cleanups.push(() => client.close()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200)) + .rejects.toThrow(RequestTimeoutError) + await client.close() + }) + + it('applies the client-wide request timeout when no per-call bound is given', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 })) + cleanups.push(() => client.close()) + // The bound applies from send, so it holds regardless of runtime boot time. + await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError) + await client.close() + }) + + it('rejects a malformed prompt acceptance as a protocol error', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_MALFORMED: '1' })) + cleanups.push(() => client.close()) + await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError) + await client.close() + }) + + it('fails pending requests with exit code and stderr tail when the runtime dies', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' })) + cleanups.push(() => client.close()) + const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then( + () => { throw new Error('initialize unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(TransportClosedError) + expect(String(failure)).toContain('exit code: 3') + expect(String(failure)).toContain('fatal: scripted death') + // Requests after death fail immediately with the same context. + await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3') + }) + + it('flushes an unterminated stderr line into the tail at close', async () => { + const client = new HarnessClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' })) + cleanups.push(() => client.close()) + const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then( + () => { throw new Error('initialize unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(String(failure)).toContain('no trailing newline') + }) + + it('fails fast when the command does not exist', async () => { + const client = new HarnessClient({ command: join(tmpdir(), 'dsh-no-such-runtime-bin') }) + cleanups.push(() => client.close()) + await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError) + }) + + it('close() is idempotent, reaps the child, and fails later use', async () => { + const client = new HarnessClient(fakeLaunch()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + await Promise.all([client.close(), client.close()]) + expect(() => { client.start() }).toThrow(TransportClosedError) + await expect(client.request('anything')).rejects.toThrow(TransportClosedError) + // Close with no child ever spawned is a no-op. + const untouched = new HarnessClient(fakeLaunch()) + await untouched.close() + }) + + it('escalates through SIGTERM when the runtime ignores EOF', async () => { + const dir = await tempDir('sdk-client-ladder-') + const sigtermFile = join(dir, 'sigterm.txt') + const client = new HarnessClient(fakeLaunch( + { FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile }, + { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 }, + )) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + await client.close() + expect((await stat(sigtermFile)).isFile()).toBe(true) + }) + + it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => { + const client = new HarnessClient(fakeLaunch( + { FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' }, + { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 }, + )) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + // Resolves (does not hang or reject): the SIGKILL rung reaped the child. + await client.close() + }) + + it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => { + const client = new HarnessClient(fakeLaunch()) + cleanups.push(() => client.close()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + + const all = client.subscribe() + const finishedOnly = client.subscribe(n => n.method === 'session.finished') + await client.prompt('sub-test', normalizeInput('go')) + + const first = await all.next() + expect(first.method).toBe('session.event') + const finished = await finishedOnly.next() + expect(finished.method).toBe('session.finished') + expect(finishedOnly.tryNext()).toBeUndefined() + + // Async iteration consumes queued items and then parks. + const collected: string[] = [] + for await (const notification of all) { + collected.push(notification.method) + if (notification.method === 'session.finished') break + } + expect(collected.at(-1)).toBe('session.finished') + + all.close() + finishedOnly.close() + await expect(all.next()).rejects.toThrow('notification subscription closed') + await client.close() + }) + + it('closes subscriptions with the runtime and rejects parked waiters', async () => { + const client = new HarnessClient(fakeLaunch()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + const subscription = client.subscribe() + const parked = subscription.next() + await client.close() + await expect(parked).rejects.toThrow(TransportClosedError) + }) + + it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => { + const client = new HarnessClient(fakeLaunch()) + cleanups.push(() => client.close()) + await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) + + const tree = client.subscribeSessionTree('root') + // Lineage edges arrive as subagent.started notifications. + const inject = (method: string, params: Record): void => { + (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) + } + inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) + inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' }) + inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } }) + inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } }) + inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' }) + inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' }) + // Self-loop and empty edges must not corrupt the lineage map. + inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' }) + inject('subagent.started', { parentSessionId: '', childSessionId: 'x' }) + inject('subagent.finished', { childSessionId: 'root' }) + + expect((await tree.next()).method).toBe('subagent.started') + expect((await tree.next()).method).toBe('subagent.started') + expect((await tree.next()).params.sessionId).toBe('grandchild') + expect((await tree.next()).method).toBe('subagent.finished') + // The foreign-root edge and stranger event were filtered; next is the root-child edge. + expect((await tree.next()).params.childSessionId).toBe('root') + tree.close() + await client.close() + }) +}) + +describe('stderr tail bound', () => { + it('keeps only the newest lines up to the limit', async () => { + const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n') + const client = new HarnessClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' })) + cleanups.push(() => client.close()) + const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then( + () => { throw new Error('initialize unexpectedly succeeded') }, + (error: unknown) => error, + ) + const text = String(failure) + // The tail is bounded to the newest 400 lines: the oldest are dropped. + expect(text).toContain('line-449') + expect(text).not.toContain('line-0\n') + }) +}) + +describe('pure helpers', () => { + it('normalizeInput wraps strings and passes blocks through', () => { + expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }]) + const blocks = [{ type: 'text' as const, text: 'y' }] + expect(normalizeInput(blocks)).toBe(blocks) + }) + + it('finalResponse reads the last assistant message and tolerates absence', () => { + expect(finalResponse([])).toBe('') + expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('') + expect(finalResponse([ + { type: 'assistant/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'first' }] } } as never, + { type: 'assistant/message', seq: 1, time: 0, data: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } as never, + ])).toBe('ab') + }) +}) diff --git a/packages/sdk/sdk-client/tsconfig.json b/packages/sdk/sdk-client/tsconfig.json new file mode 100644 index 0000000000..d884f41f98 --- /dev/null +++ b/packages/sdk/sdk-client/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../sdk-protocol" + }, + { + "path": "../../subagent/subagent-subprocess" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sdk/sdk-protocol/package.json b/packages/sdk/sdk-protocol/package.json new file mode 100644 index 0000000000..6eba1c3091 --- /dev/null +++ b/packages/sdk/sdk-protocol/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-sdk-protocol", + "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/sdk-protocol/src/index.ts b/packages/sdk/sdk-protocol/src/index.ts new file mode 100644 index 0000000000..da2d54c170 --- /dev/null +++ b/packages/sdk/sdk-protocol/src/index.ts @@ -0,0 +1,12 @@ +/** + * Shared wire protocol for the DeepSeek Harness SDK runtime: the + * newline-delimited JSON-RPC stdio transport plus the named request, result, + * and notification types both wire ends speak. The runtime server plugin + * (`@deepseek-ai/dsh-jsonrpc`) serves this protocol; SDK clients + * (`@deepseek-ai/dsh-sdk-client`, the Python SDK) drive it. + * + * @module @deepseek-ai/dsh-sdk-protocol + */ + +export * from './transport.ts' +export * from './types.ts' diff --git a/packages/sdk/sdk-protocol/src/invariant.ts b/packages/sdk/sdk-protocol/src/invariant.ts new file mode 100644 index 0000000000..c1f0b45f2d --- /dev/null +++ b/packages/sdk/sdk-protocol/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-protocol`. + * @module @deepseek-ai/dsh-sdk-protocol/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-protocol' + +/** Cordis companion plugin name. */ +export const name = 'sdk-protocol-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a pure wire library (transport class + type + * declarations) with no event stream or mutable data relation of its own; + * both wire ends own their protocol behavior. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/sdk/sdk-protocol/src/transport.ts similarity index 85% rename from packages/ui/jsonrpc/src/transport.ts rename to packages/sdk/sdk-protocol/src/transport.ts index 2e081f6041..a1291f019b 100644 --- a/packages/ui/jsonrpc/src/transport.ts +++ b/packages/sdk/sdk-protocol/src/transport.ts @@ -3,7 +3,7 @@ * `method` are requests, `id` alone is a response, and `method` alone is a * notification. Malformed lines are ignored; handler failures become error frames. * - * @module @deepseek-ai/dsh-jsonrpc/transport + * @module @deepseek-ai/dsh-sdk-protocol/transport */ import { randomUUID } from 'node:crypto' @@ -14,23 +14,38 @@ type JsonRpcId = string | number type RequestHandler = (method: string, params: Record) => Promise type NotificationHandler = (method: string, params: Record) => void +/** A JSON-RPC error response, preserving the wire `code` and optional `data`. */ +export class JsonRpcResponseError extends Error { + /** + * @param code - the wire error code, or `undefined` when the peer sent none. + * @param message - the wire error message. + * @param data - the optional structured error payload, verbatim. + */ + constructor(readonly code: number | undefined, message: string, readonly data?: unknown) { + super(message) + this.name = 'JsonRpcResponseError' + } +} + /** - * Outbound request and notification surface used by {@link HarnessSdkServer}. + * Outbound request and notification surface used by the runtime server and + * SDK clients. */ export interface JsonRpcTransportPeer { /** * Send a request and await its response. * @param method - the JSON-RPC method name. * @param params - the request parameters object. - * @returns the result; rejects on an error response, write failure, or closure. + * @returns the result; rejects with {@link JsonRpcResponseError} on an error + * response, and with a plain `Error` on a write failure or closure. */ - request(method: string, params: Record): Promise + request(method: string, params: object): Promise /** * Send a notification; omitted params produce no `params` member. * @param method - the JSON-RPC method name. * @param params - the optional notification parameters object. */ - notify(method: string, params?: Record): void + notify(method: string, params?: object): void } interface PendingRequest { @@ -94,7 +109,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.notificationHandler = handler } - request(method: string, params: Record): Promise { + request(method: string, params: object): Promise { const id = `req_${randomUUID().replaceAll('-', '')}` const message = { jsonrpc: '2.0', id, method, params } return new Promise((resolve, reject) => { @@ -108,7 +123,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { }) } - notify(method: string, params?: Record): void { + notify(method: string, params?: object): void { this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }) } @@ -196,7 +211,11 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.pending.delete(id) if (frame.error && typeof frame.error === 'object') { const error = frame.error as Record - pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error')) + pending.reject(new JsonRpcResponseError( + typeof error.code === 'number' ? error.code : undefined, + typeof error.message === 'string' ? error.message : 'JSON-RPC error', + error.data, + )) return } pending.resolve(frame.result) diff --git a/packages/sdk/sdk-protocol/src/types.ts b/packages/sdk/sdk-protocol/src/types.ts new file mode 100644 index 0000000000..73459923ae --- /dev/null +++ b/packages/sdk/sdk-protocol/src/types.ts @@ -0,0 +1,105 @@ +/** + * Named wire types for the DeepSeek Harness SDK runtime protocol: the three + * request/result pairs and the four server-to-client notification payloads + * exchanged over the newline-delimited JSON-RPC stdio transport. The server + * plugin (`@deepseek-ai/dsh-jsonrpc`) and SDK clients share these shapes; + * `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. + * + * @module @deepseek-ai/dsh-sdk-protocol/types + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +/** Parameters for the process-wide SDK handshake. */ +export interface InitializeParams { + /** Working directory recorded on every SDK-created session's header. */ + cwd: string + /** Provider route every SDK-created agent runs on. */ + provider: string + /** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkServer.initialize`). */ + model: string +} + +/** Wire-stable server identity returned by initialization. */ +export interface InitializeResult { + /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ + serverInfo: { name: string; version: string } +} + +/** One user turn on one SDK session. */ +export interface SessionPromptParams { + /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ + sessionId: string + /** The prompt content blocks, sent verbatim as the user message. */ + contentBlocks: ContentBlock[] +} + +/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ +export interface SessionPromptResult { + /** Always `true`; the turn outcome is the paired `session.finished` notification. */ + accepted: true +} + +/** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */ +export type SdkRunStatus = 'ok' | 'error' + +/** `session.event` payload: one session-log event, streamed as it is recorded. */ +export interface SessionEventNotification { + /** Session the event belongs to (every session in the runtime, not only SDK-created ones). */ + sessionId: string + /** The full session-log event envelope. */ + event: SessionEvent +} + +/** `session.finished` payload: one per accepted prompt, after turn settlement. */ +export interface SessionFinishedNotification { + /** The settled session. */ + sessionId: string + /** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */ + status: SdkRunStatus + /** Why the last message-triggered turn ended; absent when no turn ran. */ + reason: TurnEndReason | undefined +} + +/** `subagent.started` payload: an in-runtime child session was created. */ +export interface SubagentStartedNotification { + /** The delegating session. */ + parentSessionId: string + /** The new child session. */ + childSessionId: string +} + +/** `subagent.finished` payload: an in-process subagent run ended (remote runs are not reported). */ +export interface SubagentFinishedNotification { + /** Subagent provider name that ran the child. */ + provider: string + /** The child agent's id (equals {@link childSessionId} for local runs). */ + agentId: string + /** The delegating session. */ + parentSessionId: string + /** The child session. */ + childSessionId: string + /** Deployment-mapped run outcome. */ + status: SdkRunStatus + /** The provider-reported stop reason. */ + stopReason: SubagentStopReason + /** The child's final assistant message, when it produced one. */ + lastAssistantMessage?: ContentBlock[] +} + +/** Server-to-client notifications by JSON-RPC method name. */ +export interface HarnessSdkNotificationMap { + 'session.event': SessionEventNotification + 'session.finished': SessionFinishedNotification + 'subagent.started': SubagentStartedNotification + 'subagent.finished': SubagentFinishedNotification +} + +/** Client-to-server request methods with their param and result shapes. */ +export interface HarnessSdkRequestMap { + 'initialize': { params: InitializeParams; result: InitializeResult } + 'session/prompt': { params: SessionPromptParams; result: SessionPromptResult } + 'shutdown': { params: undefined; result: Record } +} diff --git a/packages/ui/jsonrpc/tests/transport.spec.ts b/packages/sdk/sdk-protocol/tests/transport.spec.ts similarity index 85% rename from packages/ui/jsonrpc/tests/transport.spec.ts rename to packages/sdk/sdk-protocol/tests/transport.spec.ts index 2f3173c7c9..7eedb12867 100644 --- a/packages/ui/jsonrpc/tests/transport.spec.ts +++ b/packages/sdk/sdk-protocol/tests/transport.spec.ts @@ -1,7 +1,7 @@ import { once } from 'node:events' import { PassThrough, Writable } from 'node:stream' import { describe, expect, it } from 'vitest' -import { JsonRpcLineTransport } from '../src/index.ts' +import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts' function transportPair() { const aToB = new PassThrough() @@ -41,7 +41,7 @@ describe('JsonRpcLineTransport', () => { b.close() }) - it('reports JSON-RPC request errors from the remote peer', async () => { + it('reports JSON-RPC request errors from the remote peer with their wire code', async () => { const { a, b } = transportPair() a.onRequest(async () => { throw new Error('handler boom') @@ -49,12 +49,36 @@ describe('JsonRpcLineTransport', () => { a.start() b.start() - await expect(b.request('explode', {})).rejects.toThrow('handler boom') + const failure = await b.request('explode', {}).then( + () => { throw new Error('request unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(JsonRpcResponseError) + expect(failure).toMatchObject({ message: 'handler boom', code: -32603, data: undefined }) a.close() b.close() }) + it('preserves structured error data from an error response frame', async () => { + const { aToB, bToA, b } = transportPair() + b.start() + + const pending = b.request('remote-error-data', {}) + const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string + const request = JSON.parse(String(requestChunk)) as { id: string } + aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: 7, message: 'structured', data: { detail: 'x' } } })}\n`) + + const failure = await pending.then( + () => { throw new Error('request unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(JsonRpcResponseError) + expect(failure).toMatchObject({ code: 7, message: 'structured', data: { detail: 'x' } }) + + b.close() + }) + it('stringifies non-Error request handler failures', async () => { const { a, b } = transportPair() a.onRequest(async () => { diff --git a/packages/sdk/sdk-protocol/tsconfig.json b/packages/sdk/sdk-protocol/tsconfig.json new file mode 100644 index 0000000000..bc0619fb85 --- /dev/null +++ b/packages/sdk/sdk-protocol/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index f8b3cd78c6..9d953d529a 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -7,11 +7,10 @@ * @module @deepseek-ai/dsh-subagent-acp */ -import { accessSync, constants, statSync } from 'node:fs' -import { isAbsolute, resolve } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess' import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' @@ -77,60 +76,6 @@ function assertPositiveFinite(name: string, value: number): void { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick -/** - * Whether `path` names an existing directory the harness can ENTER. The - * search-permission probe matters: `statSync().isDirectory()` is true for a - * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES. - */ -function isDirectory(path: string): boolean { - try { - if (!statSync(path).isDirectory()) return false - accessSync(path, constants.X_OK) - return true - } catch { - // statSync/accessSync throw only filesystem access errors here - // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot - // serve as the child's cwd. - return false - } -} - -/** - * Assert `cwd` can actually host the child: absolute (it doubles as the ACP - * session workspace, and a relative path would be re-anchored to the server - * process's launch directory) and an existing directory (fail here, before the - * process boundary, instead of as an ambiguous spawn ENOENT). - * @param label - which source supplied the value, for the diagnostic. - * @param cwd - the candidate working directory. - * @returns `cwd`, validated. - */ -function assertUsableCwd(label: string, cwd: string): string { - if (!isAbsolute(cwd)) { - throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`) - } - if (!isDirectory(cwd)) { - throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`) - } - return cwd -} - -/** - * Resolve the child's working directory: the deployment `cwd` override when - * configured (already validated at load), else the parent session's workspace - * cwd (validated here, its earliest resolvable point). Fails loud when neither - * exists — falling back to the harness process cwd would silently bind the - * child to the server's launch directory instead of the delegating session's - * workspace (one server process serves many sessions, each with its own cwd). - */ -function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string { - if (configured !== undefined) return configured - const parentCwd = request.parent.session.header.cwd - if (parentCwd === undefined) { - throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one') - } - return assertUsableCwd('parent session cwd', parentCwd) -} - /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects @@ -147,7 +92,7 @@ class AcpProvider implements SubagentProvider { const spec: AcpRunSpec = { command: this.config.command, args: this.config.args, - cwd: resolveCwd(this.config.cwd, request), + cwd: resolveChildCwd('subagent-acp', this.config.cwd, request.parent.session.header.cwd), permission: this.config.permission, env: this.config.env, disposeEofGraceMs: this.config.disposeEofGraceMs, @@ -167,15 +112,11 @@ export function apply(ctx: Context, config: Config): void { const resolved = config as ResolvedConfig assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) - // `path.resolve('')` is the process cwd — an empty string would silently - // reintroduce the launch-directory fallback this resolution removed. - if (resolved.cwd === '') { - throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd') - } // Interpret a relative configured cwd against the harness launch directory // ONCE, at load, and fail a misconfigured directory here — not per start. - const validated: ResolvedConfig = resolved.cwd === undefined + const configuredCwd = validateConfiguredCwd('subagent-acp', resolved.cwd) + const validated: ResolvedConfig = configuredCwd === undefined ? resolved - : { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) } + : { ...resolved, cwd: configuredCwd } ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated)) } diff --git a/packages/subagent/subagent-sdk/package.json b/packages/subagent/subagent-sdk/package.json new file mode 100644 index 0000000000..59ef79b065 --- /dev/null +++ b/packages/subagent/subagent-sdk/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-subagent-sdk", + "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-client": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sdk-client": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-sdk/src/index.ts b/packages/subagent/subagent-sdk/src/index.ts new file mode 100644 index 0000000000..d61fd5c49e --- /dev/null +++ b/packages/subagent/subagent-sdk/src/index.ts @@ -0,0 +1,138 @@ +/** + * Out-of-process SDK subagent backend. Each child is a complete DeepSeek + * Harness runtime in its own process — own `cordis.yml`-decided composition, + * session, model route, and tools — driven over stdio JSON-RPC through the + * TypeScript SDK client, so it shares no Cordis context and advertises no + * parent-enforced start capabilities; the ONE thing it reads off + * `request.parent` is the session's workspace cwd. This plugin uses named + * exports only; a default would hide its loader metadata (see + * `docs/postmortem/0001-acp-default-export-drops-inject.md`). + * @module @deepseek-ai/dsh-subagent-sdk + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess' +import { + DEFAULT_DISPOSE_EOF_GRACE_MS, + DEFAULT_DISPOSE_GRACE_MS, + DEFAULT_SHUTDOWN_TIMEOUT_MS, + startSdkRun, + type SdkRunSpec, +} from './run.ts' + +export const name = 'subagent-sdk' +export const inject = ['subagents'] + +/** Config: how to spawn and drive the child SDK runtime process. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `sdk`). */ + providerName: string + /** The executable to spawn for each run (the child runtime bin or packaged exe). */ + command: string + /** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */ + args: string[] + /** + * Working directory override for the child process and its SDK session + * workspace. Must be non-empty; a relative path resolves against the + * harness launch directory at load, and the result must be an existing + * directory. When omitted, each child inherits its delegating parent + * session's cwd — and starting one from a parent session that has no cwd + * fails. + */ + cwd?: string + /** Provider route the child runtime initializes with (default `deepseek`). */ + provider: string + /** Model the child runtime initializes with (default `deepseek-v4-flash`). */ + model: string + /** + * Extra environment variables for the child process — e.g. the child + * runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG` naming its + * config. Forwarded on top of a credential-scrubbed copy of the parent + * env, so an explicit key here reaches the child while ambient secrets do + * not leak implicitly. + */ + env: Record + /** Bound (ms) on the protocol `shutdown` exchange during dispose. */ + shutdownTimeoutMs?: number + /** + * Grace period (ms) for the child's EOF-driven quiesce on dispose — its + * window to flush persistence and tear down its own nested subprocesses + * before the parent escalates to a signal. + */ + disposeEofGraceMs?: number + /** Termination confirmation window (ms), including forced exit on every platform. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + providerName: z.string().default('sdk'), + command: z.string().required(), + args: z.array(z.string()).default([]), + cwd: z.string(), + provider: z.string().default('deepseek'), + model: z.string().default('deepseek-v4-flash'), + env: z.dict(z.string()).default({}), + shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +/** A timing bound must be a positive finite number (it bounds a teardown wait). */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`subagent-sdk: ${name} must be a positive finite number`) + } +} + +/** The shape after schemastery applied the defaults (cwd has none). */ +type ResolvedConfig = Required> & Pick + +/** + * The SDK provider. Advertises NO start-time capabilities: an out-of-process + * child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the + * service rejects a request needing any of them before `start` runs). + */ +class SdkProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } + // Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary. + readonly inheritsParentContext = false + + constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} + + start(request: SubagentStartRequest) { + const spec: SdkRunSpec = { + command: this.config.command, + args: this.config.args, + cwd: resolveChildCwd('subagent-sdk', this.config.cwd, request.parent.session.header.cwd), + provider: this.config.provider, + model: this.config.model, + env: this.config.env, + shutdownTimeoutMs: this.config.shutdownTimeoutMs, + disposeEofGraceMs: this.config.disposeEofGraceMs, + disposeGraceMs: this.config.disposeGraceMs, + onError: (error, stopReason) => { + // The seam forbids `result` rejecting, so a child-level failure is + // flattened to a stop reason — preserve it here rather than losing it. + this.ctx.logger.warn(`subagent-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`) + }, + } + return startSdkRun(request, spec) + } +} + +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) + // Interpret a relative configured cwd against the harness launch directory + // ONCE, at load, and fail a misconfigured directory here — not per start. + const configuredCwd = validateConfiguredCwd('subagent-sdk', resolved.cwd) + const validated: ResolvedConfig = configuredCwd === undefined + ? resolved + : { ...resolved, cwd: configuredCwd } + ctx.subagents.registerProvider(new SdkProvider(validated.providerName, ctx, validated)) +} diff --git a/packages/subagent/subagent-sdk/src/invariant.ts b/packages/subagent/subagent-sdk/src/invariant.ts new file mode 100644 index 0000000000..d47322706a --- /dev/null +++ b/packages/subagent/subagent-sdk/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-sdk`. + * @module @deepseek-ai/dsh-subagent-sdk/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-sdk' + +/** Cordis companion plugin name. */ +export const name = 'subagent-sdk-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: run lifecycle pairing is owned and checked by the + * subagent seam's invariant; this backend's own state lives in the child + * process beyond this context's event streams. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-sdk/src/run.ts b/packages/subagent/subagent-sdk/src/run.ts new file mode 100644 index 0000000000..1fedfd20b5 --- /dev/null +++ b/packages/subagent/subagent-sdk/src/run.ts @@ -0,0 +1,217 @@ +/** + * Fresh-process SDK subagent client. Drives one child DeepSeek Harness + * runtime over stdio JSON-RPC through `@deepseek-ai/dsh-sdk-client` and owns + * cancellation and quiescent disposal. Structure mirrors the ACP backend + * (`@deepseek-ai/dsh-subagent-acp`): publish after the child handshake, + * flatten child failures into stop reasons, tear down through the shared + * subprocess dispose ladder. + * + * @module @deepseek-ai/dsh-subagent-sdk/run + */ + +import { randomUUID } from 'node:crypto' +import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' + +/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */ +export interface SdkRunSpec { + /** The executable to spawn (the child runtime — a `dsh-jsonrpc-agent` bin or packaged exe). */ + command: string + /** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */ + args: string[] + /** + * Absolute working directory for the child process AND the workspace cwd + * of its SDK session. The provider resolves it before this spec exists: + * config override, else the delegating parent session's workspace. + */ + cwd: string + /** Provider route the child runtime initializes with. */ + provider: string + /** Model the child runtime initializes with. */ + model: string + /** + * Extra environment variables to ADD for the child (e.g. the child + * runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged on top + * of the credential-scrubbed ambient env — see `buildChildEnv`. + */ + env: Record + /** Bound (ms) on the protocol `shutdown` exchange during dispose. */ + shutdownTimeoutMs: number + /** Grace period (ms) for the child's EOF-driven quiesce on dispose. */ + disposeEofGraceMs: number + /** Termination confirmation window (ms), including forced exit on every platform. */ + disposeGraceMs: number + /** + * Sink for a child-level failure that the run flattened into a stop reason + * (the seam contract forbids `result` rejecting). A throw from the sink + * itself is contained. Optional — omitted in unit tests that assert the + * stop reason directly. + */ + onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ +export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 + +/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** Default bound on the protocol `shutdown` exchange during dispose. */ +export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000 + +/** + * Map a child turn-end reason to a harness {@link SubagentStopReason}. + * @param reason - the `session.finished` reason, or `undefined` when the + * child settled without running a turn. + * @returns the harness equivalent; an absent or unknown reason maps to + * `error`, so an unclean stop is never reported as `completed`. + */ +export function sdkStopReason(reason: TurnEndReason | undefined): SubagentStopReason { + switch (reason?.kind) { + case 'completed': + return 'completed' + case 'max-tokens': + return 'max-tokens' + case 'aborted': + return 'aborted' + // error / rejected / interrupted / disposed / a future merged variant / + // no turn at all: the task did NOT finish cleanly — surface a generic + // failure so the consumer maps it to an isError result. + default: + return 'error' + } +} + +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { + // The catch only sees rejections from the SDK client, which are always + // `Error`s; the `String(value)` arm is a defensive fallback for a non-Error + // throw that the typed surfaces cannot produce. + /* v8 ignore next */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Start and publish one SDK runtime child after its `initialize` handshake. + * Child failures resolve through the run result; startup failures reject + * after process reap. Disposal shuts the runtime down and reaps it. + * @param request - the start request; its signal is the cancellation channel. + * @param spec - the resolved spawn spec: command/args/cwd, the child's + * provider/model route, env, timeouts, and the optional error sink. + * @returns the ready run handle for the child subprocess. + */ +export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpec): Promise { + if (request.signal.aborted) throw new Error('subagent request was aborted before the SDK child started') + // The run id lives in the parent namespace; the child runtime's session id + // (minted below, private to the wire) exists only inside the child process. + const id = SessionId(randomUUID()) + + const harness = new DeepSeekHarness({ + launch: { + command: spec.command, + args: spec.args, + cwd: spec.cwd, + env: buildChildEnv(spec.env), + shutdownTimeoutMs: spec.shutdownTimeoutMs, + disposeEofGraceMs: spec.disposeEofGraceMs, + disposeGraceMs: spec.disposeGraceMs, + }, + cwd: spec.cwd, + provider: spec.provider, + model: spec.model, + }) + + // Cancellation settles the result without waiting for a cooperative child. + const flags = { cancelled: false } + let signalCancelSettled!: () => void + const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) + const requestCancel = (): void => { + if (flags.cancelled) return + flags.cancelled = true + signalCancelSettled() + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + // Establish the child handshake before publishing a handle. Any failure + // owns the still-private process and reaps it before rejecting. + try { + await Promise.race([ + harness.start(), + cancelSettled.then((): never => { throw new Error('subagent cancelled before the SDK child initialized') }), + ]) + // Defensive: an abort() is a macrotask and no user callback runs inside + // the microtask drain between handshake fulfillment and this continuation, + // so the recheck is not schedulable today; it guards future reentrancy. + /* v8 ignore next */ + if (flags.cancelled) throw new Error('subagent cancelled before the SDK child initialized') + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + await harness.close() + if (flags.cancelled) throw new Error('subagent request was aborted before the SDK child started') + throw toError(error) + } + + const childSessionId = `session-${randomUUID().replaceAll('-', '')}` + // The child's final answer: the last complete assistant message when one + // exists, else the text streamed so far (a partial answer surviving cancel). + let lastMessage: ContentBlock[] | undefined + const partial: string[] = [] + const observe = (notification: HarnessNotification): void => { + if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return + const event = notification.params.event as SessionEvent + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + partial.push(event.data.chunk.text) + } else if (event.type === 'assistant/message') { + lastMessage = event.data.content + } + } + const collectOutput = (): ContentBlock[] => { + if (lastMessage !== undefined) return lastMessage + const text = partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : [] + } + + const result: Promise = (async (): Promise => { + try { + const turn = await Promise.race([ + harness.session(childSessionId).run(request.prompt, { onNotification: observe }), + cancelSettled.then(() => 'cancelled' as const), + ]) + if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' } + return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) } + } catch (error: unknown) { + // Cover a transport rejection already queued when cancellation arrives. + /* v8 ignore next */ + if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } + // Flatten post-publication transport failures while preserving diagnostics. + try { + spec.onError?.(toError(error), 'error') + } catch { + // The diagnostic sink cannot reject the run result. + } + return { output: collectOutput(), stopReason: 'error' } + } finally { + request.signal.removeEventListener('abort', onAbort) + } + })() + + let disposal: Promise | undefined + return { + id, + localAgent: undefined, + result, + dispose(): Promise { + if (disposal !== undefined) return disposal + request.signal.removeEventListener('abort', onAbort) + // There is no wire-level prompt cancel: settle the result locally, then + // the bounded shutdown request + dispose ladder tears the child down. + requestCancel() + disposal = harness.close() + return disposal + }, + } +} diff --git a/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts new file mode 100644 index 0000000000..2198f02693 --- /dev/null +++ b/packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts @@ -0,0 +1,417 @@ +/** + * Keyless integration tests for the SDK subagent backend. Each spawns a REAL + * subprocess — the SDK client package's scripted fake runtime — and drives it + * through the REAL backend over real stdio JSON-RPC, so the handshake, the + * turn round-trip, stop-reason mapping, cancellation, env scrubbing, and + * quiescent disposal are all exercised end to end. No model, no key. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as sdk from '../src/index.ts' +import { + DEFAULT_DISPOSE_EOF_GRACE_MS, + DEFAULT_DISPOSE_GRACE_MS, + DEFAULT_SHUTDOWN_TIMEOUT_MS, + sdkStopReason, + startSdkRun, + type SdkRunSpec, +} from '../src/run.ts' + +const fakeRuntime = fileURLToPath(new URL('../../../sdk/sdk-client/tests/fake-runtime.ts', import.meta.url)) + +/** A parent Agent stub. The SDK backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */ +const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent + +function request(text = 'p', signal = new AbortController().signal) { + return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal } +} + +/** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */ +async function setup(fakeEnv: Record = {}, config: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(sdk, { + providerName: 'sdk', + command: process.execPath, + args: [fakeRuntime], + provider: 'fake-provider', + model: 'fake-model', + env: fakeEnv, + ...config, + }) + return ctx +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +/** + * Poll until `file` exists (the fake touches it once the probed state is + * reached), so cancel tests wait on a CONDITION rather than an arbitrary + * timeout. Fails loud if the child never signals readiness. + */ +async function waitForFile(file: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) throw new Error(`fake runtime never became ready (${file})`) + await new Promise(r => setTimeout(r, 10)) + } +} + +describe('sdkStopReason', () => { + it('maps each child turn-end reason to the harness vocabulary', () => { + expect(sdkStopReason({ kind: 'completed' })).toBe('completed') + expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens') + expect(sdkStopReason({ kind: 'aborted' })).toBe('aborted') + expect(sdkStopReason({ kind: 'error', step: 0, message: 'x' })).toBe('error') + expect(sdkStopReason({ kind: 'rejected', reason: 'policy' })).toBe('error') + }) + + it('treats an absent or unknown reason as an error', () => { + expect(sdkStopReason(undefined)).toBe('error') + expect(sdkStopReason({ kind: 'something-new' } as never)).toBe('error') + }) +}) + +describe('dsh-subagent-sdk provider', () => { + it('runs a child turn end to end with a parent-unique run id', async () => { + const ctx = await setup({ FAKE_TEXT: 'hello from sdk child' }) + const run = await ctx.subagents.start('sdk', request('do X')) + expect(run.localAgent).toBeUndefined() + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('hello from sdk child') + // dispose is idempotent (one memoized teardown). + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + + const nextRun = await ctx.subagents.start('sdk', request('again')) + expect(nextRun.id).not.toBe(run.id) + await nextRun.result + await nextRun.dispose() + await ctx.fiber.dispose() + }) + + it('initializes the child with the configured provider/model and the parent cwd', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-init-')) + const recordFile = join(tmp, 'init.jsonl') + try { + const ctx = await setup({ FAKE_RECORD_INIT: recordFile }) + const run = await ctx.subagents.start('sdk', request()) + await run.result + await run.dispose() + const { readFileSync } = await import('node:fs') + const records = readFileSync(recordFile, 'utf8').trim().split('\n').map(line => JSON.parse(line) as Record) + expect(records).toEqual([{ cwd: process.cwd(), provider: 'fake-provider', model: 'fake-model' }]) + await ctx.fiber.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('scrubs ambient credentials but forwards explicit config env', async () => { + process.env.DSH_TEST_AMBIENT_SECRET_KEY = 'leak-me-not' + try { + const ctx = await setup({ + FAKE_ECHO_ENV: 'DSH_TEST_AMBIENT_SECRET_KEY,DEEPSEEK_API_KEY', + DEEPSEEK_API_KEY: 'explicit-child-key', + FAKE_TEXT: 'done', + }) + const run = await ctx.subagents.start('sdk', request()) + const result = await run.result + const answer = text(result.output) + expect(answer).toContain('DSH_TEST_AMBIENT_SECRET_KEY=\n') + expect(answer).toContain('DEEPSEEK_API_KEY=explicit-child-key') + await run.dispose() + await ctx.fiber.dispose() + } finally { + delete process.env.DSH_TEST_AMBIENT_SECRET_KEY + } + }) + + it('maps a max-tokens child turn end', async () => { + const ctx = await setup({ FAKE_REASON_KIND: 'max-tokens', FAKE_STATUS: 'error' }) + const run = await ctx.subagents.start('sdk', request()) + expect((await run.result).stopReason).toBe('max-tokens') + await run.dispose() + await ctx.fiber.dispose() + }) + + it('flattens a child turn error into stopReason error and keeps partial text', async () => { + const ctx = await setup({ FAKE_REASON_KIND: 'error', FAKE_STATUS: 'error', FAKE_TEXT: 'partial answer' }) + const run = await ctx.subagents.start('sdk', request()) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(text(result.output)).toBe('partial answer') + await run.dispose() + await ctx.fiber.dispose() + }) + + it('reports a settled-without-turn child as an error', async () => { + const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' }) + const run = await ctx.subagents.start('sdk', request()) + expect((await run.result).stopReason).toBe('error') + await run.dispose() + await ctx.fiber.dispose() + }) + + it('aborting the required signal settles a hung child as aborted', async () => { + const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { disposeEofGraceMs: 200, disposeGraceMs: 200 }) + const controller = new AbortController() + const run = await ctx.subagents.start('sdk', request('p', controller.signal)) + controller.abort('test') + const result = await run.result + expect(result.stopReason).toBe('aborted') + // The hung child streamed nothing, so the aborted result has no output. + expect(result.output).toEqual([]) + await run.dispose() + await ctx.fiber.dispose() + }) + + it('cancelling between handshake and publish rejects start after reap', async () => { + // The abort lands while the child is INSIDE initialize (ready-file + // handshake window): the fake touches READY, we abort, then GO lets the + // handshake complete — so the post-race `flags.cancelled` recheck must + // reject even though the handshake itself succeeded. + const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-midcancel-')) + const ready = join(tmp, 'ready') + const go = join(tmp, 'go') + try { + const controller = new AbortController() + const spec: SdkRunSpec = { + command: process.execPath, + args: [fakeRuntime], + cwd: process.cwd(), + provider: 'p', + model: 'm', + env: { FAKE_INIT_READY: ready, FAKE_INIT_GO: go }, + shutdownTimeoutMs: 100, + disposeEofGraceMs: 200, + disposeGraceMs: 200, + } + const pending = startSdkRun(request('p', controller.signal), spec) + await waitForFile(ready) + controller.abort('mid-handshake') + const { writeFileSync } = await import('node:fs') + writeFileSync(go, 'go\n') + await expect(pending).rejects.toThrow('aborted before the SDK child started') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('keeps partial streamed text when aborted mid-turn', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-partial-')) + const streamed = join(tmp, 'streamed') + try { + const ctx = await setup( + { FAKE_STREAM_THEN_HANG: '1', FAKE_STREAM_READY: streamed }, + { disposeEofGraceMs: 200, disposeGraceMs: 200, shutdownTimeoutMs: 100 }, + ) + const controller = new AbortController() + const run = await ctx.subagents.start('sdk', request('p', controller.signal)) + // Cancel only after the chunk has demonstrably streamed (condition, not a sleep). + await waitForFile(streamed) + controller.abort('test') + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(text(result.output)).toBe('streamed then hung') + await run.dispose() + await ctx.fiber.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('dispose cancels a hung child locally and reaps it', async () => { + const ctx = await setup({ FAKE_HANG_PROMPT: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 }) + const run = await ctx.subagents.start('sdk', request()) + await run.dispose() + expect((await run.result).stopReason).toBe('aborted') + await ctx.fiber.dispose() + }) + + it('rejects WITHOUT spawning when the signal is already aborted', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-preabort-')) + const sentinel = join(tmp, 'spawned') + try { + const controller = new AbortController() + controller.abort() + await expect(startSdkRun( + request('p', controller.signal), + // `touch ` — runs only if the process is actually spawned. + { + command: 'touch', + args: [sentinel], + cwd: tmp, + provider: 'p', + model: 'm', + env: {}, + shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + }, + )).rejects.toThrow('aborted before the SDK child started') + expect(existsSync(sentinel)).toBe(false) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('rejects after reaping when the child dies before the handshake', async () => { + const ctx = await setup({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'scripted boot failure' }) + const failure = await ctx.subagents.start('sdk', request()).then( + () => { throw new Error('start unexpectedly succeeded') }, + (error: unknown) => error, + ) + expect(String(failure)).toContain('exit code: 3') + expect(String(failure)).toContain('scripted boot failure') + await ctx.fiber.dispose() + }) + + it('cancelling mid-handshake rejects start after reaping the child', async () => { + const controller = new AbortController() + const spec: SdkRunSpec = { + command: process.execPath, + args: [fakeRuntime], + cwd: process.cwd(), + provider: 'p', + model: 'm', + env: { FAKE_HANG_INIT: '1' }, + shutdownTimeoutMs: 100, + disposeEofGraceMs: 200, + disposeGraceMs: 200, + } + const pending = startSdkRun(request('p', controller.signal), spec) + controller.abort('now') + await expect(pending).rejects.toThrow('aborted before the SDK child started') + }) + + it('routes a post-publication child failure through onError and settles error', async () => { + const seen: string[] = [] + const spec: SdkRunSpec = { + command: process.execPath, + args: [fakeRuntime], + cwd: process.cwd(), + provider: 'p', + model: 'm', + // The fake dies as soon as the prompt arrives: FAKE_HANG_PROMPT plus a + // short-lived process is simulated by killing via dispose below instead; + // here use FAKE_MALFORMED to make the prompt reply violate the protocol. + env: { FAKE_MALFORMED_PROMPT: '1' }, + shutdownTimeoutMs: 100, + disposeEofGraceMs: 200, + disposeGraceMs: 200, + onError: (error) => { + seen.push(error.message) + throw new Error('sink failure must be contained') + }, + } + const run = await startSdkRun(request(), spec) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(seen).toHaveLength(1) + await run.dispose() + }) + + it('routes provider-level onError through ctx.logger.warn', async () => { + const ctx = await setup({ FAKE_MALFORMED_PROMPT: '1' }) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const run = await ctx.subagents.start('sdk', request()) + expect((await run.result).stopReason).toBe('error') + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('subagent-sdk "sdk": child run failed (error)') + await run.dispose() + await ctx.fiber.dispose() + }) + + it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(sdk, { + providerName: 'sdk-hmr', + command: process.execPath, + args: [fakeRuntime], + provider: 'p', + model: 'm', + env: {}, + }) + expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr') + expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false) + expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({ + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }) + await fiber.dispose() + expect(ctx.subagents.getProvider('sdk-hmr')).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rejects non-positive timing bounds at load', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const base = { providerName: 'sdk', command: 'true', args: [], provider: 'p', model: 'm', env: {} } + await expect(ctx.plugin(sdk, { ...base, shutdownTimeoutMs: 0 })).rejects.toThrow('shutdownTimeoutMs must be a positive finite number') + await expect(ctx.plugin(sdk, { ...base, disposeEofGraceMs: -1 })).rejects.toThrow('disposeEofGraceMs must be a positive finite number') + await expect(ctx.plugin(sdk, { ...base, disposeGraceMs: Number.NaN })).rejects.toThrow('disposeGraceMs must be a positive finite number') + await ctx.fiber.dispose() + }) + + it('rejects an empty config cwd at load', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(sdk, { + providerName: 'sdk', + command: 'true', + args: [], + cwd: '', + provider: 'p', + model: 'm', + env: {}, + })).rejects.toThrow('config cwd must not be empty') + await ctx.fiber.dispose() + }) + + it('uses a validated config cwd override instead of the parent session cwd', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-cwd-')) + try { + const ctx = await setup({ FAKE_ECHO_CWD: '1', FAKE_TEXT: 'done' }, { cwd: tmp }) + const run = await ctx.subagents.start('sdk', request()) + const result = await run.result + const { realpathSync } = await import('node:fs') + expect(text(result.output)).toContain(`cwd=${realpathSync(tmp)}`) + await run.dispose() + await ctx.fiber.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('fails loud when neither config cwd nor parent session cwd exists', async () => { + const ctx = await setup() + const parent = { id: 'parent', session: { header: {} } } as unknown as Agent + await expect(ctx.subagents.start('sdk', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + .rejects.toThrow('no working directory for the child') + await ctx.fiber.dispose() + }) + + it('keeps named plugin exports with no default export (loader shape)', () => { + expect(sdk.name).toBe('subagent-sdk') + expect(sdk.inject).toEqual(['subagents']) + expect(typeof sdk.apply).toBe('function') + expect(typeof sdk.Config).toBe('function') + expect((sdk as Record).default).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent-sdk/tsconfig.json b/packages/subagent/subagent-sdk/tsconfig.json new file mode 100644 index 0000000000..c35a947d23 --- /dev/null +++ b/packages/subagent/subagent-sdk/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../sdk/sdk-client" + }, + { + "path": "../../sdk/sdk-protocol" + }, + { + "path": "../subagent" + }, + { + "path": "../subagent-subprocess" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-subprocess/src/cwd.ts b/packages/subagent/subagent-subprocess/src/cwd.ts new file mode 100644 index 0000000000..c41ce486ef --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/cwd.ts @@ -0,0 +1,86 @@ +/** + * Child working-directory resolution shared by out-of-process subagent + * backends: a deployment `cwd` override validated at load, else the + * delegating parent session's workspace cwd validated per start — never the + * server process's own cwd, because one server process serves many sessions, + * each with its own workspace. + * + * @module @deepseek-ai/dsh-subagent-subprocess/cwd + */ + +import { accessSync, constants, statSync } from 'node:fs' +import { isAbsolute, resolve } from 'node:path' + +/** + * Whether `path` names an existing directory the harness can ENTER. The + * search-permission probe matters: `statSync().isDirectory()` is true for a + * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES. + */ +function isDirectory(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + // statSync/accessSync throw only filesystem access errors here + // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot + // serve as the child's cwd. + return false + } +} + +/** + * Assert `cwd` can actually host the child: absolute (it doubles as the + * child's workspace identity, and a relative path would be re-anchored to the + * server process's launch directory) and an existing directory (fail here, + * before the process boundary, instead of as an ambiguous spawn ENOENT). + * @param prefix - the consuming plugin's diagnostic prefix (e.g. `subagent-acp`). + * @param label - which source supplied the value, for the diagnostic. + * @param cwd - the candidate working directory. + * @returns `cwd`, validated. + */ +export function assertUsableCwd(prefix: string, label: string, cwd: string): string { + if (!isAbsolute(cwd)) { + throw new Error(`${prefix}: ${label} must be an absolute path: ${cwd}`) + } + if (!isDirectory(cwd)) { + throw new Error(`${prefix}: ${label} is not an accessible directory: ${cwd}`) + } + return cwd +} + +/** + * Validate a configured `cwd` override ONCE, at plugin load: reject the empty + * string (`path.resolve('')` is the process cwd — it would silently + * reintroduce the launch-directory fallback this resolution removes), + * interpret a relative path against the harness launch directory, and require + * an enterable directory. + * @param prefix - the consuming plugin's diagnostic prefix. + * @param cwd - the configured override, or `undefined` when the config omits it. + * @returns the validated absolute override, or `undefined` when omitted. + */ +export function validateConfiguredCwd(prefix: string, cwd: string | undefined): string | undefined { + if (cwd === undefined) return undefined + if (cwd === '') { + throw new Error(`${prefix}: config cwd must not be empty — omit the key to inherit the parent session cwd`) + } + return assertUsableCwd(prefix, 'config cwd', resolve(cwd)) +} + +/** + * Resolve the child's working directory at start: the deployment override + * when configured (already validated at load), else the parent session's + * workspace cwd (validated here, its earliest resolvable point). Fails loud + * when neither exists. + * @param prefix - the consuming plugin's diagnostic prefix. + * @param configured - the load-validated override, or `undefined`. + * @param parentCwd - the delegating parent session's workspace cwd, if any. + * @returns the absolute child working directory. + */ +export function resolveChildCwd(prefix: string, configured: string | undefined, parentCwd: string | undefined): string { + if (configured !== undefined) return configured + if (parentCwd === undefined) { + throw new Error(`${prefix}: no working directory for the child — configure \`cwd\` or delegate from a parent session that has one`) + } + return assertUsableCwd(prefix, 'parent session cwd', parentCwd) +} diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 47a97bafb6..e54df67398 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -11,6 +11,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +export * from './cwd.ts' + /** * Credential-shaped ambient env vars are NOT forwarded to a child by default * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index 72dd9fe8c3..ff51571ace 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -47,6 +48,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 3da953400f..78ac9ef2be 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -12,11 +12,10 @@ import type { Context } from 'cordis' import type { Readable, Writable } from 'node:stream' import Schema from 'schemastery' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkServer } from './server.ts' -import { JsonRpcLineTransport } from './transport.ts' export * from './server.ts' -export * from './transport.ts' export const name = 'jsonrpc' // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 5200a2de13..aba7e20e88 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -7,44 +7,23 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import type { JsonRpcTransportPeer } from './transport.ts' - -/** Parameters for the process-wide SDK handshake. */ -export interface InitializeParams { - /** Working directory recorded on every SDK-created session's header. */ - cwd: string - /** Provider route every SDK-created agent runs on. */ - provider: string - /** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */ - model: string -} - -/** Wire-stable server identity returned by initialization. */ -export interface InitializeResult { - /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ - serverInfo: { name: string; version: string } -} - -/** One user turn on one SDK session. */ -export interface SessionPromptParams { - /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ - sessionId: string - /** The prompt content blocks, sent verbatim as the user message. */ - contentBlocks: ContentBlock[] -} - -/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ -export interface SessionPromptResult { - /** Always `true`; the turn outcome is the paired `session.finished` notification. */ - accepted: true -} +import type { + InitializeParams, + InitializeResult, + JsonRpcTransportPeer, + SessionEventNotification, + SessionFinishedNotification, + SessionPromptParams, + SessionPromptResult, + SubagentFinishedNotification, + SubagentStartedNotification, +} from '@deepseek-ai/dsh-sdk-protocol' interface SessionRecord { handle: AgentHandle @@ -97,15 +76,17 @@ export class HarnessSdkServer { rec.lastTurnEnd = event.data.reason } } - this.transport.notify('session.event', { sessionId: String(session.id), event }) + const payload: SessionEventNotification = { sessionId: String(session.id), event } + this.transport.notify('session.event', payload) })) this.disposers.push(ctx.on('session/created', (session) => { const parentSession = session.header.parentSession if (parentSession === undefined) return - this.transport.notify('subagent.started', { + const payload: SubagentStartedNotification = { parentSessionId: String(parentSession), childSessionId: String(session.id), - }) + } + this.transport.notify('subagent.started', payload) })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { const parent = subagentParentOf(this) @@ -113,7 +94,7 @@ export class HarnessSdkServer { // snapshots the provider's exact run provenance through child disposal; // matching ids or parent lineage alone never establishes locality. if (!info.local) return - transport.notify('subagent.finished', { + const payload: SubagentFinishedNotification = { provider: info.provider, agentId: String(info.id), parentSessionId: String(parent.session.id), @@ -121,7 +102,8 @@ export class HarnessSdkServer { status: successStatus(info.stopReason, serverOptions), stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), - }) + } + transport.notify('subagent.finished', payload) })) } @@ -154,12 +136,12 @@ export class HarnessSdkServer { rec.lastTurnEnd = undefined rec.handle.agent.followup(params.contentBlocks) await rec.handle.agent.whenIdle() - const status = this.finishedStatus(rec.lastTurnEnd) - this.transport.notify('session.finished', { + const payload: SessionFinishedNotification = { sessionId: params.sessionId, - status, + status: this.finishedStatus(rec.lastTurnEnd), reason: rec.lastTurnEnd, - }) + } + this.transport.notify('session.finished', payload) return { accepted: true } } finally { rec.activePrompt = false diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 91d3503263..548de1d116 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -12,17 +12,18 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' -import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' +import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { HarnessSdkServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { notifications: { method: string; params?: Record }[] = [] - async request(method: string, params: Record): Promise { + async request(method: string, params: object): Promise { throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`) } - notify(method: string, params?: Record): void { - this.notifications.push(params === undefined ? { method } : { method, params }) + notify(method: string, params?: object): void { + this.notifications.push(params === undefined ? { method } : { method, params: params as Record }) } } diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json index 14a70d8eaa..00ac7000e6 100644 --- a/packages/ui/jsonrpc/tsconfig.json +++ b/packages/ui/jsonrpc/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/session" }, + { + "path": "../../sdk/sdk-protocol" + }, { "path": "../../subagent/subagent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc4b87c934..8783218d1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3012,6 +3012,45 @@ importers: specifier: ^4.22.4 version: 4.22.4 + packages/sdk/sdk-client: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../../subagent/subagent-subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/sdk/sdk-protocol: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/sdk/telemetry: dependencies: yaml: @@ -3685,6 +3724,46 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-sdk: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sdk-client': + specifier: workspace:^ + version: link:../../sdk/sdk-client + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../subagent-subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent-spawn: dependencies: schemastery: @@ -4076,6 +4155,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -4743,6 +4825,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../packages/sdk/sdk-protocol '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index abf943793d..4a22d484fe 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..b1c2cf6f9f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -142,6 +142,7 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, { "path": "./packages/tasks/tool-tasks" }, @@ -159,7 +160,9 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/webserver" }, + { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, + { "path": "./packages/sdk/sdk-protocol" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, { "path": "./packages/sdk/telemetry" },