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:
6
packages/subagent/subagent-dsh-sdk/README.i18n.yaml
Normal file
6
packages/subagent/subagent-dsh-sdk/README.i18n.yaml
Normal file
@@ -0,0 +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-dsh-sdk/README.md
|
||||
README.md: 904b70f4d197f1d5082521b519dde89b165324ef
|
||||
README.zh.md: f5879e7ae0924ac5ec2786b115bd4b9f9215c9da
|
||||
97
packages/subagent/subagent-dsh-sdk/README.md
Normal file
97
packages/subagent/subagent-dsh-sdk/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @deepseek-ai/dsh-subagent-dsh-sdk
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a fresh subprocess, driven over stdio JSON-RPC through the [TypeScript SDK client](../../sdk/sdk-client/README.md). It is the second out-of-process backend beside [`subagent-acp`](../subagent-acp/README.md), differing in the wire and the child contract: the ACP backend drives any Agent Client Protocol agent; this backend drives specifically a harness SDK runtime (`dsh-jsonrpc-agent` bin or packaged executable), so the child is a full peer harness — own `cordis.yml`-decided composition, session persistence, model route, and tools.
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`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 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.
|
||||
|
||||
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
|
||||
|
||||
## Stop-reason mapping
|
||||
|
||||
The child reports its turn outcome as a structured `TurnEndReason` on `session.finished`; the provider maps it into the seam vocabulary. `completed` → `completed`, `max-tokens` → `max-tokens`, `aborted` → `aborted`; everything else — `error`, `rejected`, `interrupted`, `disposed`, a future variant, or a turn that never ran — maps to `error`, so an unclean stop is never reported as success. Transport-level failures after publication flatten to `stopReason: 'error'` through the `onError` diagnostic sink (wired to `ctx.logger.warn`); the seam contract forbids `result` rejecting.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `providerName` | `dsh-sdk` | Registry name on `ctx.subagents`. |
|
||||
| `command` | required | Executable spawned per run (the child runtime bin or packaged exe). |
|
||||
| `args` | `[]` | Command arguments (typically the child's `cordis.yml` path). |
|
||||
| `cwd` | parent session cwd | Working-directory override; same validation as [`subagent-acp`](../subagent-acp/README.md). |
|
||||
| `provider` | `deepseek` | Provider route sent in the child's `initialize`. |
|
||||
| `model` | `deepseek-v4-flash` | Model sent in the child's `initialize`. |
|
||||
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment (e.g. the child's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). |
|
||||
| `shutdownTimeoutMs` | `1000` | Bound on the protocol `shutdown` exchange during dispose. |
|
||||
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
|
||||
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
|
||||
|
||||
```yaml
|
||||
- id: subagent-dsh-sdk
|
||||
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
|
||||
config:
|
||||
providerName: dsh-sdk
|
||||
command: node
|
||||
args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml']
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config: { provider: dsh-sdk, toolName: subagent, maxDepth: 'provider-managed' }
|
||||
```
|
||||
|
||||
## Process 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).
|
||||
|
||||
Keyless tests drive the SDK client package's scripted fake runtime over real stdio, including a Loader-composed e2e where the child is a real second harness runtime proving parent-session cwd inheritance end to end (`tests/loader-composition.e2e.ts`).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child-agent request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the parent request cache. Each SDK child can reuse only prefixes identical under its own provider, model, composition, and history; child steps otherwise grow append-only.
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-subagent`, the parent receives only the child's final assistant text (or accumulated partial text) or that consumer's exact stop-reason error, not intermediate messages or tool traffic.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child.
|
||||
- **No optional start-time capabilities** — the parent cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the child's own `cordis.yml` instead.
|
||||
- **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log.
|
||||
- **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend.
|
||||
97
packages/subagent/subagent-dsh-sdk/README.zh.md
Normal file
97
packages/subagent/subagent-dsh-sdk/README.zh.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @deepseek-ai/dsh-subagent-dsh-sdk
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时跑在全新子进程里,经由 [TypeScript SDK 客户端](../../sdk/sdk-client/README.md)走 stdio JSON-RPC 驱动。它是 [`subagent-acp`](../subagent-acp/README.md) 之外的第二个进程外后端,差异在线协议与子进程契约:ACP 后端能驱动任何 Agent Client Protocol 代理;本后端专门驱动 harness SDK 运行时(`dsh-jsonrpc-agent` bin 或打包可执行文件),因此子进程是一个完整的对等 harness——自有 `cordis.yml` 决定的组成、会话持久化、模型路由与工具。
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。
|
||||
|
||||
工作目录的解析与 ACP 后端完全一致,经由接缝共享的进程外助手([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。
|
||||
|
||||
返回的 run id 铸造于父命名空间;子运行时的会话 id 只存在于子进程内部。发布之后,provider 跑一个 SDK 回合,并从子会话事件中读取答案:最后一条完整 `assistant/message`,或回合被截断时已累积的 `text-delta` 流——部分答案在取消与错误路径上都得以保留。
|
||||
|
||||
`dispose()` 幂等:先把结果就地定格为 `aborted`(线上没有 prompt 取消方法),再关闭运行时——一次有界的协议 `shutdown` 请求,随后是共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出。
|
||||
|
||||
## 停止原因映射
|
||||
|
||||
子进程在 `session.finished` 上以结构化 `TurnEndReason` 报告回合结局;provider 把它映射进接缝词汇表。`completed` → `completed`,`max-tokens` → `max-tokens`,`aborted` → `aborted`;其余一切——`error`、`rejected`、`interrupted`、`disposed`、未来变体、或根本没跑回合——映射为 `error`,不洁终止绝不报告为成功。发布后的传输层失败经 `onError` 诊断汇(接到 `ctx.logger.warn`)压平为 `stopReason: 'error'`;接缝契约禁止 `result` 拒绝。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `providerName` | `dsh-sdk` | `ctx.subagents` 上的注册名。 |
|
||||
| `command` | 必填 | 每次 run 生成的可执行文件(子运行时 bin 或打包 exe)。 |
|
||||
| `args` | `[]` | 命令参数(通常是子进程的 `cordis.yml` 路径)。 |
|
||||
| `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 |
|
||||
| `provider` | `deepseek` | 写入子进程 `initialize` 的 provider 路由。 |
|
||||
| `model` | `deepseek-v4-flash` | 写入子进程 `initialize` 的模型。 |
|
||||
| `env` | `{}` | 在凭据擦除后的父环境之上叠加的显式子环境(例如子进程自己的 `DEEPSEEK_API_KEY`,或 `DSH_CORDIS_CONFIG`)。 |
|
||||
| `shutdownTimeoutMs` | `1000` | 处置期间协议 `shutdown` 交换的时限。 |
|
||||
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 |
|
||||
| `disposeGraceMs` | `3000` | 终止后的退出确认窗口;POSIX 在 SIGTERM 之后、SIGKILL 之前也等待同样时长。 |
|
||||
|
||||
```yaml
|
||||
- id: subagent-dsh-sdk
|
||||
name: '@deepseek-ai/dsh-subagent-dsh-sdk'
|
||||
config:
|
||||
providerName: dsh-sdk
|
||||
command: node
|
||||
args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml']
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config: { provider: dsh-sdk, toolName: subagent, maxDepth: 'provider-managed' }
|
||||
```
|
||||
|
||||
## 进程边界
|
||||
|
||||
子环境以 [`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)。
|
||||
|
||||
免密钥测试通过真实 stdio 驱动 SDK 客户端包的脚本化伪运行时,还包括一个 Loader 组合 e2e:子进程是真实的第二个 harness 运行时,端到端证明父会话 cwd 继承(`tests/loader-composition.e2e.ts`)。
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child-agent request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
子运行时的模型收到独立任务作为其用户消息,加上该运行时自己配置的系统提示、工具与全新会话。它收不到任何父对话。本 provider 不宣告可选启动期能力,因此本地服务会拒绝需要 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略。
|
||||
|
||||
#### Token effect
|
||||
|
||||
子进程支付一份独立的完整上下文与自己的多步历史。这些 token 绝不进入父上下文。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
独立于父请求缓存。每个 SDK 子进程只能复用在其自身 provider、模型、组成与历史下完全相同的前缀;子步骤在此之外只增不改。
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
经由 `dsh-tool-subagent`,父方只收到子进程的最终助手文本(或累积的部分文本),或该消费者精确的停止原因错误——收不到中间消息与工具流量。
|
||||
|
||||
#### Token effect
|
||||
|
||||
父输入只增长最终结果或错误,其大小依数据而定,保留至压缩。本 provider 自身不给父方增加任何 schema。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不使既有 KV 缓存条目失效。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **每次 run 一个全新运行时进程** —— 无池化;harness 运行时要启动完整插件树,单次生成成本高于 ACP 后端的典型子进程。
|
||||
- **无可选启动期能力** —— 父方无法在子进程内强制 `outputSchema`、深度、工具过滤或 persona;请改为配置子进程自己的 `cordis.yml`。
|
||||
- **子进程的转录留在其自己的会话根** —— 父日志只记录委托工具调用/结果(接缝的子隔离规则);流式 `session.event` 通道只用于提取输出,不桥接进父日志。
|
||||
- **仅限本地子进程** —— 解析出的 cwd 是本地路径;远程运行时需要自己的后端。
|
||||
55
packages/subagent/subagent-dsh-sdk/package.json
Normal file
55
packages/subagent/subagent-dsh-sdk/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"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,
|
||||
"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-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-subprocess": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
131
packages/subagent/subagent-dsh-sdk/src/index.ts
Normal file
131
packages/subagent/subagent-dsh-sdk/src/index.ts
Normal file
@@ -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))
|
||||
}
|
||||
31
packages/subagent/subagent-dsh-sdk/src/invariant.ts
Normal file
31
packages/subagent/subagent-dsh-sdk/src/invariant.ts
Normal file
@@ -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 */
|
||||
210
packages/subagent/subagent-dsh-sdk/src/run.ts
Normal file
210
packages/subagent/subagent-dsh-sdk/src/run.ts
Normal file
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Keyless REAL-composition coverage for parent-session cwd inheritance across
|
||||
* the SDK wire: a test-only cordis.yml boots the headless app through the
|
||||
* Loader with the SDK backend's `cwd` omitted, a scripted model delegates
|
||||
* once, and the child — a COMPLETE second harness runtime booted from its own
|
||||
* cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran.
|
||||
* Both the parent's tool result and the child's own persisted session log
|
||||
* must carry the parent session's cwd. Mock-only composition, so only this
|
||||
* keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
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-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))
|
||||
const runtimeBin = fileURLToPath(new URL('../../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function sessionEvents(log: string): Promise<SessionEvent[]> {
|
||||
const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
|
||||
return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
}
|
||||
|
||||
describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
|
||||
it('runs the child runtime in the parent session workspace', async () => {
|
||||
// The child launch honors the same src/lib mode as the driving harness,
|
||||
// per the shared example-launch resolver (testing policy forbids
|
||||
// hand-written `--import tsx` argv for example subprocesses).
|
||||
const childLaunch = resolveExampleLaunch({
|
||||
srcBin: runtimeBin,
|
||||
configArgs: [childConfigPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
})
|
||||
|
||||
let events: SessionEvent[] = []
|
||||
let childEvents: SessionEvent[] = []
|
||||
let workspace = ''
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'dsh-sdk-subagent cwd composition smoke',
|
||||
tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
// Two complete harness runtimes boot in sequence (driver, then the SDK
|
||||
// child); from-source tsx boots under load need more than the default
|
||||
// 30s window.
|
||||
processTimeoutMs: 120_000,
|
||||
env: {
|
||||
DSH_TEST_CHILD_COMMAND: childLaunch.command,
|
||||
DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args),
|
||||
DSH_TEST_CHILD_ENV: JSON.stringify({
|
||||
...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)),
|
||||
}),
|
||||
},
|
||||
inspect: async (cwd) => {
|
||||
// The child reports realpaths; canonicalize the temp workspace to match.
|
||||
workspace = realpathSync(cwd)
|
||||
const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(parentLogs).toHaveLength(1)
|
||||
events = await sessionEvents(parentLogs[0] as string)
|
||||
// The child runtime persisted its own transcript in ITS cwd — which
|
||||
// must be the parent session's workspace for the inheritance to hold.
|
||||
const childLogs = await jsonlFiles(join(cwd, '.child-sessions'))
|
||||
expect(childLogs).toHaveLength(1)
|
||||
childEvents = await sessionEvents(childLogs[0] as string)
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
|
||||
// The parent's tool result carries the child model's echo of its real
|
||||
// process.cwd() — the parent session's workspace, never the harness
|
||||
// process's launch directory.
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const resultText = results[0]!.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
expect(resultText).toBe(`child cwd: ${workspace}`)
|
||||
|
||||
// The child ran a real turn of its own: user message in, assistant out.
|
||||
expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
|
||||
const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
|
||||
expect(childAnswers.length).toBeGreaterThan(0)
|
||||
// 15s of vitest headroom past the subprocess deadline, mirroring
|
||||
// LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
|
||||
}, 135_000)
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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)
|
||||
// The Config type models the post-validation shape, so the default registry
|
||||
// name is stated here; the Loader-composition fixture omits providerName and
|
||||
// exercises the schemastery default end to end.
|
||||
await ctx.plugin(sdk, {
|
||||
providerName: 'dsh-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-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'))
|
||||
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('dsh-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-dsh-sdk-init-'))
|
||||
const recordFile = join(tmp, 'init.jsonl')
|
||||
try {
|
||||
const ctx = await setup({ FAKE_RECORD_INIT: recordFile })
|
||||
const run = await ctx.subagents.start('dsh-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('dsh-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('dsh-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('dsh-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('dsh-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('dsh-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-dsh-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 accumulated streamed text when the turn is cut short before a full message', async () => {
|
||||
// The fake streams one text-delta chunk and then violates the protocol on
|
||||
// the same pipe; frame order guarantees the chunk was dispatched before
|
||||
// the failure settles, so the accumulated partial text (no complete
|
||||
// assistant/message ever arrived) must survive into the error result.
|
||||
const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
|
||||
const run = await ctx.subagents.start('dsh-sdk', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(text(result.output)).toBe('streamed then cut short')
|
||||
await run.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
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('dsh-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-dsh-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('dsh-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('dsh-sdk', request())
|
||||
expect((await run.result).stopReason).toBe('error')
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('subagent-dsh-sdk "dsh-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-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())
|
||||
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('dsh-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-dsh-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-dsh-sdk/tsconfig.json
Normal file
48
packages/subagent/subagent-dsh-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": "../../support/loader-smoke"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user