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

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 8414836efd756f60258566ae3e4e00de2d4110d7
README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188
# pnpm run verify-translation-pairing --write packages/subagent/README.md
README.md: fed0c3d6b252f5eeb8355c3b544066765999120a
README.zh.md: 45f3c83f57613c16ba00063c9da9ac720a57727e

View File

@@ -11,9 +11,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) |
| `subagent-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md).

View File

@@ -11,9 +11,9 @@ subagent seam 允许 agent智能体把工作委派给子 agent。与 [bash
| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents` |
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACPAgent Client Protocol驱动的子 agent | (注册到 `ctx.subagents` |
| `subagent-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents` |
| `subagent-dsh-sdk/` | 进程外后端:在派生子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯。测试只用包内 fixture测试前置数据替换子 agent 边界。
接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程共享的凭据清除、以进程树为范围的拆卸、dispose资源释放阶梯。测试只用包内 fixture测试前置数据替换子 agent 边界。
提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-sdk/README.md
README.md: 92c31e4823c4f8a3b2526441ab01dc732fcecbdd
README.zh.md: 3610bfcf93ddd2f458640e6149d398f0f7f4b173
# pnpm run verify-translation-pairing --write packages/subagent/subagent-dsh-sdk/README.md
README.md: 904b70f4d197f1d5082521b519dde89b165324ef
README.zh.md: f5879e7ae0924ac5ec2786b115bd4b9f9215c9da

View File

@@ -1,4 +1,4 @@
# @deepseek-ai/dsh-subagent-sdk
# @deepseek-ai/dsh-subagent-dsh-sdk
English | [中文](README.zh.md)
@@ -8,7 +8,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
The working directory resolves exactly like the ACP backend, through the shared [`subagent-subprocess` helpers](../subagent-subprocess/README.md): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider runs one SDK turn and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated so far when the turn was cut short — a partial answer survives cancel and error paths.
@@ -38,8 +38,8 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
```yaml
- id: subagent-sdk
name: '@deepseek-ai/dsh-subagent-sdk'
- id: subagent-dsh-sdk
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
config:
providerName: dsh-sdk
command: node
@@ -53,7 +53,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/
## Process boundary
The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The JSON-RPC wire is the real serialization boundary.
The child environment is the [`dsh-subprocess`](../../subprocess/README.md) seam's `scrubbedParentEnv()` base — ambient credential-shaped and `DSH_*` names dropped — with explicit `config.env` values merged after the scrub. The child is spawned by the SDK client rather than through `ctx.subprocess` (the subprocess README's documented exception for SDK-managed transports), which is why this backend applies the scrub itself. The JSON-RPC wire is the real serialization boundary.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).

View File

@@ -1,4 +1,4 @@
# @deepseek-ai/dsh-subagent-sdk
# @deepseek-ai/dsh-subagent-dsh-sdk
[English](README.md) | 中文
@@ -8,7 +8,7 @@ SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时
`start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。
工作目录的解析与 ACP 后端完全一致,经由共享的 [`subagent-subprocess` 助手](../subagent-subprocess/README.md):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。
工作目录的解析与 ACP 后端完全一致,经由接缝共享的进程外助手([`dsh-subagent`](../subagent/README.md):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。
返回的 run id 铸造于父命名空间;子运行时的会话 id 只存在于子进程内部。发布之后provider 跑一个 SDK 回合,并从子会话事件中读取答案:最后一条完整 `assistant/message`,或回合被截断时已累积的 `text-delta` 流——部分答案在取消与错误路径上都得以保留。
@@ -38,8 +38,8 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
| `disposeGraceMs` | `3000` | 终止后的退出确认窗口POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 |
```yaml
- id: subagent-sdk
name: '@deepseek-ai/dsh-subagent-sdk'
- id: subagent-dsh-sdk
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
config:
providerName: dsh-sdk
command: node
@@ -53,7 +53,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
## 进程边界
子环境 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除形似凭据的环境变量,再应用显式 `config.env`。JSON-RPC 线就是真实的序列化边界。
子环境 [`dsh-subprocess`](../../subprocess/README.md) 接缝的 `scrubbedParentEnv()` 为基底——移除形似凭据与 `DSH_*` 的环境变量——再在擦除之后合并显式 `config.env` 值。子进程由 SDK 客户端生成而非经 `ctx.subprocess`subprocess README 记载的 SDK 托管传输例外),因此本后端自行应用该擦除。JSON-RPC 线就是真实的序列化边界。
本包没有默认导出。否则 Cordis loader 解包会隐藏具名 `inject` 元数据;见[事后分析 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。

View File

@@ -1,5 +1,5 @@
{
"name": "@deepseek-ai/dsh-subagent-sdk",
"name": "@deepseek-ai/dsh-subagent-dsh-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,

View File

@@ -7,7 +7,7 @@
* `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
* @module @deepseek-ai/dsh-subagent-dsh-sdk
*/
import type { Context } from 'cordis'
@@ -22,7 +22,7 @@ import {
type SdkRunSpec,
} from './run.ts'
export const name = 'subagent-sdk'
export const name = 'subagent-dsh-sdk'
export const inject = ['subagents']
/** Config: how to spawn and drive the child SDK runtime process. */
@@ -98,7 +98,7 @@ class SdkProvider implements SubagentProvider {
const spec: SdkRunSpec = {
command: this.config.command,
args: this.config.args,
cwd: resolveChildCwd('subagent-sdk', this.config.cwd, request.parent.session.header.cwd),
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,
@@ -108,7 +108,7 @@ class SdkProvider implements SubagentProvider {
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}`)
this.ctx.logger.warn(`subagent-dsh-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`)
},
}
return startSdkRun(request, spec)
@@ -118,12 +118,12 @@ class SdkProvider implements SubagentProvider {
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveFinite('subagent-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('subagent-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-sdk', 'disposeGraceMs', resolved.disposeGraceMs)
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-sdk', resolved.cwd)
const configuredCwd = validateConfiguredCwd('subagent-dsh-sdk', resolved.cwd)
const validated: ResolvedConfig = configuredCwd === undefined
? resolved
: { ...resolved, cwd: configuredCwd }

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-sdk`.
* @module @deepseek-ai/dsh-subagent-sdk/invariant
* 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-sdk'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-dsh-sdk'
/** Cordis companion plugin name. */
export const name = 'subagent-sdk-invariant'
export const name = 'subagent-dsh-sdk-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -8,7 +8,7 @@
* 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-sdk/run
* @module @deepseek-ai/dsh-subagent-dsh-sdk/run
*/
import { randomUUID } from 'node:crypto'

View File

@@ -17,7 +17,7 @@ import { describe, expect, it } from 'vitest'
import { type SessionEvent } from '@deepseek-ai/dsh-session'
import { resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/', import.meta.url)
const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/', import.meta.url)
const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir))
const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir))
@@ -54,8 +54,8 @@ describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
let childEvents: SessionEvent[] = []
let workspace = ''
const { stderr } = await runLoaderSmoke({
label: 'sdk-subagent cwd composition smoke',
tempDirPrefix: 'sdk-subagent-cwd-e2e-',
label: 'dsh-sdk-subagent cwd composition smoke',
tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
binScript: driver,
libBinScript: driver,
configPath,

View File

@@ -84,7 +84,7 @@ describe('sdkStopReason', () => {
})
})
describe('dsh-subagent-sdk provider', () => {
describe('dsh-subagent-dsh-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('dsh-sdk', request('do X'))
@@ -105,7 +105,7 @@ describe('dsh-subagent-sdk provider', () => {
})
it('initializes the child with the configured provider/model and the parent cwd', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-init-'))
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-init-'))
const recordFile = join(tmp, 'init.jsonl')
try {
const ctx = await setup({ FAKE_RECORD_INIT: recordFile })
@@ -185,7 +185,7 @@ describe('dsh-subagent-sdk provider', () => {
// 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 tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-midcancel-'))
const ready = join(tmp, 'ready')
const go = join(tmp, 'go')
try {
@@ -235,7 +235,7 @@ describe('dsh-subagent-sdk provider', () => {
})
it('rejects WITHOUT spawning when the signal is already aborted', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-preabort-'))
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-preabort-'))
const sentinel = join(tmp, 'spawned')
try {
const controller = new AbortController()
@@ -324,7 +324,7 @@ describe('dsh-subagent-sdk provider', () => {
const run = await ctx.subagents.start('dsh-sdk', request())
expect((await run.result).stopReason).toBe('error')
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('subagent-sdk "dsh-sdk": child run failed (error)')
expect(warnings[0]).toContain('subagent-dsh-sdk "dsh-sdk": child run failed (error)')
await run.dispose()
await ctx.fiber.dispose()
})
@@ -379,7 +379,7 @@ describe('dsh-subagent-sdk provider', () => {
})
it('uses a validated config cwd override instead of the parent session cwd', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-sdk-cwd-'))
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-cwd-'))
try {
const ctx = await setup({ FAKE_ECHO_CWD: '1', FAKE_TEXT: 'done' }, { cwd: tmp })
const run = await ctx.subagents.start('dsh-sdk', request())
@@ -402,7 +402,7 @@ describe('dsh-subagent-sdk provider', () => {
})
it('keeps named plugin exports with no default export (loader shape)', () => {
expect(sdk.name).toBe('subagent-sdk')
expect(sdk.name).toBe('subagent-dsh-sdk')
expect(sdk.inject).toEqual(['subagents'])
expect(typeof sdk.apply).toBe('function')
expect(typeof sdk.Config).toBe('function')

View File

@@ -4,7 +4,7 @@
* under their never-reject and idempotence contracts.
*/
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -43,6 +43,17 @@ describe('child cwd resolution', () => {
expect(() => assertUsableCwd('p', 'config cwd', join(tmpdir(), 'dsh-no-such-dir-xyz'))).toThrow('not an accessible directory')
})
it('rejects an existing path that is a file, not a directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'oop-file-'))
const file = join(tmp, 'plain.txt')
try {
writeFileSync(file, 'not a dir\n')
expect(() => assertUsableCwd('p', 'config cwd', file)).toThrow('not an accessible directory')
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
it.skipIf(process.platform === 'win32')('rejects a directory without search permission', () => {
// statSync().isDirectory() is true for a mode-600 directory, but a