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:
33 files changed
+2738
-122
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 */
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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> }
|
||||
}
|
||||
+27
-3
@@ -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 () => {
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../sdk/sdk-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
|
||||
Generated
+85
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user