refactor: rename the backend to dsh-subagent-dsh-sdk

The group's convention is package suffix == provider default
(subagent-acp/'acp', subagent-spawn/'spawn', subagent-fork/'fork'), and the
provider default became dsh-sdk in the last review round — so the package
follows: @deepseek-ai/dsh-subagent-dsh-sdk at
packages/subagent/subagent-dsh-sdk, plugin name subagent-dsh-sdk,
diagnostics prefixed subagent-dsh-sdk:. The dsh echo has precedent
(dsh-llm-deepseek). Directory, fixture path, knip/tsconfig/examples
registrations, catalogs, READMEs (en+zh), and the Agent Note follow; the
sdk-client dispose ladder moves to its own module (src/dispose.ts) with the
deterministic FakeChild tier tests restored alongside it.
This commit is contained in:
Tianyi Cui
2026-07-27 22:17:10 +08:00
parent 87a249106f
commit ed8f9c2808
37 changed files with 521 additions and 242 deletions
@@ -0,0 +1,131 @@
/**
* 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-dsh-sdk
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_EOF_GRACE_MS,
DEFAULT_DISPOSE_GRACE_MS,
DEFAULT_SHUTDOWN_TIMEOUT_MS,
startSdkRun,
type SdkRunSpec,
} from './run.ts'
export const name = 'subagent-dsh-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 `dsh-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('dsh-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),
})
/** 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 = NO_START_CAPABILITIES
// 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-dsh-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-dsh-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('subagent-dsh-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('subagent-dsh-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-dsh-sdk', '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-dsh-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-dsh-sdk`.
* @module @deepseek-ai/dsh-subagent-dsh-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-dsh-sdk'
/** Cordis companion plugin name. */
export const name = 'subagent-dsh-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,210 @@
/**
* 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 to quiescence. The
* child is spawned BY the SDK client rather than through `ctx.subprocess` —
* the subprocess seam's documented exception for SDK-managed transports —
* so this driver applies the seam's shared env scrub itself.
*
* @module @deepseek-ai/dsh-subagent-dsh-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 { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-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 after
* the seam's `scrubbedParentEnv()` base, so an explicit credential or
* current `DSH_*` fact survives while ambient namesakes never leak.
*/
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: { ...scrubbedParentEnv(), ...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 }] : []
}
// Race the child turn against local cancellation; the shared settlement
// flattens failures under the seam's never-reject contract.
const result: Promise<SubagentResult> = settleRunResult({
attempt: async () => {
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) }
},
collectOutput,
cancelled: () => flags.cancelled,
onError: spec.onError,
signal: request.signal,
onAbort,
})
// There is no wire-level prompt cancel: dispose settles the result locally,
// then the bounded shutdown request + dispose ladder tears the child down.
return subprocessRunHandle({
id,
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: () => harness.close(),
})
}