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:
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user