feat(sdk): TypeScript SDK client + shared wire protocol + SDK subagent backend

- @deepseek-ai/dsh-sdk-protocol: extract the line transport from dsh-jsonrpc
  and name the request/result/notification wire types both ends share; error
  responses preserve wire code/data via JsonRpcResponseError.
- @deepseek-ai/dsh-sdk-client: TypeScript twin of the Python SDK — spawns the
  dsh-jsonrpc-agent runtime as a subprocess, drives stdio JSON-RPC turns
  (DeepSeekHarness high-level API + HarnessClient protocol client), scopes
  notifications to session trees client-side, and reaps the child through the
  shared subprocess dispose ladder.
- @deepseek-ai/dsh-subagent-sdk: out-of-process subagent backend driving a
  child harness runtime through the TS SDK; shares cwd resolution with
  subagent-acp via new dsh-subagent-subprocess cwd helpers.
- Keyless unit suites drive real subprocesses (scripted fake runtime peer);
  100% per-file coverage on all touched packages.
This commit is contained in:
Tianyi Cui
2026-07-27 03:21:06 +08:00
parent 46e70be34a
commit 4ad37344a8
33 files changed
+2738 -122

No files matched your search

+45
View File
@@ -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"
}
}
+193
View File
@@ -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<void> | 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<void> {
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<TurnResult> {
return this.session(options?.sessionId).run(input, options)
}
/**
* Shut down and reap the runtime subprocess. Idempotent.
* @returns settlement of the complete teardown.
*/
close(): Promise<void> {
return this.client.close()
}
/**
* `await using` support: {@link close}.
* @returns settlement of the teardown.
*/
[Symbol.asyncDispose](): Promise<void> {
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<RunOptions, 'onNotification'>): Promise<TurnResult> {
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 ''
}
+425
View File
@@ -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<HarnessNotification> {
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<HarnessNotification> {
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<HarnessNotification> {
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<string, NotificationSubscription>()
private readonly sessionParents = new Map<string, string>()
private subscriptionSerial = 0
private exitCode: number | null | undefined
private spawnError: Error | undefined
private streamsSettled: Promise<void> = Promise.resolve()
private closeTask: Promise<void> | 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<InitializeResult> {
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<void> {
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<unknown> {
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<never>((_, 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<void> {
this.closeTask ??= this.performClose()
return this.closeTask
}
private async performClose(): Promise<void> {
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<string>()
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<void> {
return Promise.race([
this.streamsSettled,
new Promise<void>((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<string, unknown> {
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)
}
+14
View File
@@ -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'
+31
View File
@@ -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 */
+77
View File
@@ -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<string, unknown>
}
/** 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 }
@@ -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<string, unknown> | 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<string, unknown> }
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}` } })
}
})
@@ -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<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
})
type LaunchOverrides = Partial<ConstructorParameters<typeof HarnessClient>[0]>
/** Launch options running the fake runtime on the current node (type stripping). */
function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}) {
return {
command: process.execPath,
args: [fakeRuntime],
env: { ...process.env as Record<string, string>, ...env },
...extra,
}
}
function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) })
cleanups.push(() => harness.close())
return harness
}
async function tempDir(prefix: string): Promise<string> {
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<string, unknown>): 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')
})
})
+33
View File
@@ -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"
}
]
}
+43
View File
@@ -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"
}
}
+12
View File
@@ -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'
@@ -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 */
@@ -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<string, unknown>) => Promise<unknown>
type NotificationHandler = (method: string, params: Record<string, unknown>) => 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<string, unknown>): Promise<unknown>
request(method: string, params: object): Promise<unknown>
/**
* 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<string, unknown>): 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<string, unknown>): Promise<unknown> {
request(method: string, params: object): Promise<unknown> {
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<string, unknown>): 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<string, unknown>
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)
+105
View File
@@ -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<string, never> }
}
@@ -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 () => {
+30
View File
@@ -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"
}
]
}
+5 -64
View File
@@ -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<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* 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))
}
@@ -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"
}
}
+138
View File
@@ -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<string, string>
/** 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<Config> = 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<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/**
* 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))
}
@@ -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 */
+217
View File
@@ -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<string, string>
/** 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<SubagentRun> {
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<void>((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<SubagentResult> = (async (): Promise<SubagentResult> => {
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<void> | undefined
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
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
},
}
}
@@ -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<string, string> = {}, config: Partial<sdk.Config> = {}) {
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<void> {
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<string, unknown>)
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 <sentinel>` — 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<string, unknown>).default).toBeUndefined()
})
})
@@ -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"
}
]
}
@@ -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)
}
@@ -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
+2
View File
@@ -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:^",
+1 -2
View File
@@ -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().
+23 -41
View File
@@ -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<SubagentService>, 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
+5 -4
View File
@@ -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<string, unknown> }[] = []
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
async request(method: string, params: object): Promise<unknown> {
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
}
notify(method: string, params?: Record<string, unknown>): 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<string, unknown> })
}
}
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../../core/session"
},
{
"path": "../../sdk/sdk-protocol"
},
{
"path": "../../subagent/subagent"
},
+85
View File
@@ -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
+1
View File
@@ -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:^",
+3
View File
@@ -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" },