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:
@@ -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))
|
||||
}
|
||||
|
||||
55
packages/subagent/subagent-sdk/package.json
Normal file
55
packages/subagent/subagent-sdk/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-sdk",
|
||||
"description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-client": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
138
packages/subagent/subagent-sdk/src/index.ts
Normal file
138
packages/subagent/subagent-sdk/src/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Out-of-process SDK subagent backend. Each child is a complete DeepSeek
|
||||
* Harness runtime in its own process — own `cordis.yml`-decided composition,
|
||||
* session, model route, and tools — driven over stdio JSON-RPC through the
|
||||
* TypeScript SDK client, so it shares no Cordis context and advertises no
|
||||
* parent-enforced start capabilities; the ONE thing it reads off
|
||||
* `request.parent` is the session's workspace cwd. This plugin uses named
|
||||
* exports only; a default would hide its loader metadata (see
|
||||
* `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
||||
* @module @deepseek-ai/dsh-subagent-sdk
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
import {
|
||||
DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
||||
startSdkRun,
|
||||
type SdkRunSpec,
|
||||
} from './run.ts'
|
||||
|
||||
export const name = 'subagent-sdk'
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: how to spawn and drive the child SDK runtime process. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `sdk`). */
|
||||
providerName: string
|
||||
/** The executable to spawn for each run (the child runtime bin or packaged exe). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory override for the child process and its SDK session
|
||||
* workspace. Must be non-empty; a relative path resolves against the
|
||||
* harness launch directory at load, and the result must be an existing
|
||||
* directory. When omitted, each child inherits its delegating parent
|
||||
* session's cwd — and starting one from a parent session that has no cwd
|
||||
* fails.
|
||||
*/
|
||||
cwd?: string
|
||||
/** Provider route the child runtime initializes with (default `deepseek`). */
|
||||
provider: string
|
||||
/** Model the child runtime initializes with (default `deepseek-v4-flash`). */
|
||||
model: string
|
||||
/**
|
||||
* Extra environment variables for the child process — e.g. the child
|
||||
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG` naming its
|
||||
* config. Forwarded on top of a credential-scrubbed copy of the parent
|
||||
* env, so an explicit key here reaches the child while ambient secrets do
|
||||
* not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
/** Bound (ms) on the protocol `shutdown` exchange during dispose. */
|
||||
shutdownTimeoutMs?: number
|
||||
/**
|
||||
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
|
||||
* window to flush persistence and tear down its own nested subprocesses
|
||||
* before the parent escalates to a signal.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Termination confirmation window (ms), including forced exit on every platform. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
providerName: z.string().default('sdk'),
|
||||
command: z.string().required(),
|
||||
args: z.array(z.string()).default([]),
|
||||
cwd: z.string(),
|
||||
provider: z.string().default('deepseek'),
|
||||
model: z.string().default('deepseek-v4-flash'),
|
||||
env: z.dict(z.string()).default({}),
|
||||
shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
|
||||
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
/** A timing bound must be a positive finite number (it bounds a teardown wait). */
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`subagent-sdk: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
/**
|
||||
* The SDK provider. Advertises NO start-time capabilities: an out-of-process
|
||||
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the
|
||||
* service rejects a request needing any of them before `start` runs).
|
||||
*/
|
||||
class SdkProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
|
||||
// Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
const spec: SdkRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: resolveChildCwd('subagent-sdk', this.config.cwd, request.parent.session.header.cwd),
|
||||
provider: this.config.provider,
|
||||
model: this.config.model,
|
||||
env: this.config.env,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
onError: (error, stopReason) => {
|
||||
// The seam forbids `result` rejecting, so a child-level failure is
|
||||
// flattened to a stop reason — preserve it here rather than losing it.
|
||||
this.ctx.logger.warn(`subagent-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`)
|
||||
},
|
||||
}
|
||||
return startSdkRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
|
||||
assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs)
|
||||
assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs)
|
||||
// Interpret a relative configured cwd against the harness launch directory
|
||||
// ONCE, at load, and fail a misconfigured directory here — not per start.
|
||||
const configuredCwd = validateConfiguredCwd('subagent-sdk', resolved.cwd)
|
||||
const validated: ResolvedConfig = configuredCwd === undefined
|
||||
? resolved
|
||||
: { ...resolved, cwd: configuredCwd }
|
||||
ctx.subagents.registerProvider(new SdkProvider(validated.providerName, ctx, validated))
|
||||
}
|
||||
31
packages/subagent/subagent-sdk/src/invariant.ts
Normal file
31
packages/subagent/subagent-sdk/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-sdk`.
|
||||
* @module @deepseek-ai/dsh-subagent-sdk/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-sdk'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-sdk-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: run lifecycle pairing is owned and checked by the
|
||||
* subagent seam's invariant; this backend's own state lives in the child
|
||||
* process beyond this context's event streams.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
217
packages/subagent/subagent-sdk/src/run.ts
Normal file
217
packages/subagent/subagent-sdk/src/run.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Fresh-process SDK subagent client. Drives one child DeepSeek Harness
|
||||
* runtime over stdio JSON-RPC through `@deepseek-ai/dsh-sdk-client` and owns
|
||||
* cancellation and quiescent disposal. Structure mirrors the ACP backend
|
||||
* (`@deepseek-ai/dsh-subagent-acp`): publish after the child handshake,
|
||||
* flatten child failures into stop reasons, tear down through the shared
|
||||
* subprocess dispose ladder.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-sdk/run
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-client'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
|
||||
|
||||
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
|
||||
export interface SdkRunSpec {
|
||||
/** The executable to spawn (the child runtime — a `dsh-jsonrpc-agent` bin or packaged exe). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */
|
||||
args: string[]
|
||||
/**
|
||||
* Absolute working directory for the child process AND the workspace cwd
|
||||
* of its SDK session. The provider resolves it before this spec exists:
|
||||
* config override, else the delegating parent session's workspace.
|
||||
*/
|
||||
cwd: string
|
||||
/** Provider route the child runtime initializes with. */
|
||||
provider: string
|
||||
/** Model the child runtime initializes with. */
|
||||
model: string
|
||||
/**
|
||||
* Extra environment variables to ADD for the child (e.g. the child
|
||||
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged on top
|
||||
* of the credential-scrubbed ambient env — see `buildChildEnv`.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
/** Bound (ms) on the protocol `shutdown` exchange during dispose. */
|
||||
shutdownTimeoutMs: number
|
||||
/** Grace period (ms) for the child's EOF-driven quiesce on dispose. */
|
||||
disposeEofGraceMs: number
|
||||
/** Termination confirmation window (ms), including forced exit on every platform. */
|
||||
disposeGraceMs: number
|
||||
/**
|
||||
* Sink for a child-level failure that the run flattened into a stop reason
|
||||
* (the seam contract forbids `result` rejecting). A throw from the sink
|
||||
* itself is contained. Optional — omitted in unit tests that assert the
|
||||
* stop reason directly.
|
||||
*/
|
||||
onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
|
||||
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/** Default bound on the protocol `shutdown` exchange during dispose. */
|
||||
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 1_000
|
||||
|
||||
/**
|
||||
* Map a child turn-end reason to a harness {@link SubagentStopReason}.
|
||||
* @param reason - the `session.finished` reason, or `undefined` when the
|
||||
* child settled without running a turn.
|
||||
* @returns the harness equivalent; an absent or unknown reason maps to
|
||||
* `error`, so an unclean stop is never reported as `completed`.
|
||||
*/
|
||||
export function sdkStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
switch (reason?.kind) {
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
case 'max-tokens':
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
// error / rejected / interrupted / disposed / a future merged variant /
|
||||
// no turn at all: the task did NOT finish cleanly — surface a generic
|
||||
// failure so the consumer maps it to an isError result.
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
|
||||
function toError(value: unknown): Error {
|
||||
// The catch only sees rejections from the SDK client, which are always
|
||||
// `Error`s; the `String(value)` arm is a defensive fallback for a non-Error
|
||||
// throw that the typed surfaces cannot produce.
|
||||
/* v8 ignore next */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start and publish one SDK runtime child after its `initialize` handshake.
|
||||
* Child failures resolve through the run result; startup failures reject
|
||||
* after process reap. Disposal shuts the runtime down and reaps it.
|
||||
* @param request - the start request; its signal is the cancellation channel.
|
||||
* @param spec - the resolved spawn spec: command/args/cwd, the child's
|
||||
* provider/model route, env, timeouts, and the optional error sink.
|
||||
* @returns the ready run handle for the child subprocess.
|
||||
*/
|
||||
export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpec): Promise<SubagentRun> {
|
||||
if (request.signal.aborted) throw new Error('subagent request was aborted before the SDK child started')
|
||||
// The run id lives in the parent namespace; the child runtime's session id
|
||||
// (minted below, private to the wire) exists only inside the child process.
|
||||
const id = SessionId(randomUUID())
|
||||
|
||||
const harness = new DeepSeekHarness({
|
||||
launch: {
|
||||
command: spec.command,
|
||||
args: spec.args,
|
||||
cwd: spec.cwd,
|
||||
env: buildChildEnv(spec.env),
|
||||
shutdownTimeoutMs: spec.shutdownTimeoutMs,
|
||||
disposeEofGraceMs: spec.disposeEofGraceMs,
|
||||
disposeGraceMs: spec.disposeGraceMs,
|
||||
},
|
||||
cwd: spec.cwd,
|
||||
provider: spec.provider,
|
||||
model: spec.model,
|
||||
})
|
||||
|
||||
// Cancellation settles the result without waiting for a cooperative child.
|
||||
const flags = { cancelled: false }
|
||||
let signalCancelSettled!: () => void
|
||||
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
|
||||
const requestCancel = (): void => {
|
||||
if (flags.cancelled) return
|
||||
flags.cancelled = true
|
||||
signalCancelSettled()
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// Establish the child handshake before publishing a handle. Any failure
|
||||
// owns the still-private process and reaps it before rejecting.
|
||||
try {
|
||||
await Promise.race([
|
||||
harness.start(),
|
||||
cancelSettled.then((): never => { throw new Error('subagent cancelled before the SDK child initialized') }),
|
||||
])
|
||||
// Defensive: an abort() is a macrotask and no user callback runs inside
|
||||
// the microtask drain between handshake fulfillment and this continuation,
|
||||
// so the recheck is not schedulable today; it guards future reentrancy.
|
||||
/* v8 ignore next */
|
||||
if (flags.cancelled) throw new Error('subagent cancelled before the SDK child initialized')
|
||||
} catch (error: unknown) {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
await harness.close()
|
||||
if (flags.cancelled) throw new Error('subagent request was aborted before the SDK child started')
|
||||
throw toError(error)
|
||||
}
|
||||
|
||||
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
|
||||
// The child's final answer: the last complete assistant message when one
|
||||
// exists, else the text streamed so far (a partial answer surviving cancel).
|
||||
let lastMessage: ContentBlock[] | undefined
|
||||
const partial: string[] = []
|
||||
const observe = (notification: HarnessNotification): void => {
|
||||
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
|
||||
const event = notification.params.event as SessionEvent
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
partial.push(event.data.chunk.text)
|
||||
} else if (event.type === 'assistant/message') {
|
||||
lastMessage = event.data.content
|
||||
}
|
||||
}
|
||||
const collectOutput = (): ContentBlock[] => {
|
||||
if (lastMessage !== undefined) return lastMessage
|
||||
const text = partial.join('')
|
||||
return text.length > 0 ? [{ type: 'text', text }] : []
|
||||
}
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
const turn = await Promise.race([
|
||||
harness.session(childSessionId).run(request.prompt, { onNotification: observe }),
|
||||
cancelSettled.then(() => 'cancelled' as const),
|
||||
])
|
||||
if (turn === 'cancelled') return { output: collectOutput(), stopReason: 'aborted' }
|
||||
return { output: collectOutput(), stopReason: sdkStopReason(turn.reason) }
|
||||
} catch (error: unknown) {
|
||||
// Cover a transport rejection already queued when cancellation arrives.
|
||||
/* v8 ignore next */
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// Flatten post-publication transport failures while preserving diagnostics.
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
// The diagnostic sink cannot reject the run result.
|
||||
}
|
||||
return { output: collectOutput(), stopReason: 'error' }
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
// There is no wire-level prompt cancel: settle the result locally, then
|
||||
// the bounded shutdown request + dispose ladder tears the child down.
|
||||
requestCancel()
|
||||
disposal = harness.close()
|
||||
return disposal
|
||||
},
|
||||
}
|
||||
}
|
||||
417
packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts
Normal file
417
packages/subagent/subagent-sdk/tests/subagent-sdk.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
48
packages/subagent/subagent-sdk/tsconfig.json
Normal file
48
packages/subagent/subagent-sdk/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
86
packages/subagent/subagent-subprocess/src/cwd.ts
Normal file
86
packages/subagent/subagent-subprocess/src/cwd.ts
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user