@deepseek-ai/dsh-subagent-acp 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +100 -0
- package/README.zh.md +100 -0
- package/lib/index.js +367 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/run.d.ts +112 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md
|
|
5
|
+
README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669
|
|
6
|
+
README.zh.md: 80afd65e5f4815042f05c22e4597bbb2677fb4fc
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-subagent-acp
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools.
|
|
6
|
+
|
|
7
|
+
## Start and ownership
|
|
8
|
+
|
|
9
|
+
`start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
|
|
10
|
+
|
|
11
|
+
The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
|
|
12
|
+
|
|
13
|
+
The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
|
|
14
|
+
|
|
15
|
+
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
|
|
16
|
+
|
|
17
|
+
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented.
|
|
18
|
+
|
|
19
|
+
## Capabilities and context
|
|
20
|
+
|
|
21
|
+
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
| Key | Default | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
|
|
28
|
+
| `command` | required | Executable spawned for each run. |
|
|
29
|
+
| `args` | `[]` | Command arguments. |
|
|
30
|
+
| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
|
|
31
|
+
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. |
|
|
32
|
+
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
|
|
33
|
+
| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
|
|
34
|
+
| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
- id: subagent-acp
|
|
38
|
+
name: '@deepseek-ai/dsh-subagent-acp'
|
|
39
|
+
config:
|
|
40
|
+
providerName: acp
|
|
41
|
+
command: node
|
|
42
|
+
args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
|
43
|
+
permission: reject
|
|
44
|
+
env:
|
|
45
|
+
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Stop-reason mapping
|
|
49
|
+
|
|
50
|
+
| ACP | Harness |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `end_turn` | `completed` |
|
|
53
|
+
| `max_tokens` | `max-tokens` |
|
|
54
|
+
| `refusal` | `refusal` |
|
|
55
|
+
| `cancelled` | `aborted` |
|
|
56
|
+
| `max_turn_requests` or unknown | `error` |
|
|
57
|
+
|
|
58
|
+
## Process boundary
|
|
59
|
+
|
|
60
|
+
The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
|
|
61
|
+
|
|
62
|
+
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).
|
|
63
|
+
|
|
64
|
+
## Model Experience
|
|
65
|
+
|
|
66
|
+
### Child-agent request
|
|
67
|
+
|
|
68
|
+
#### What the model sees
|
|
69
|
+
|
|
70
|
+
The remote child receives the standalone task content through ACP plus its own process's 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.
|
|
71
|
+
|
|
72
|
+
#### Token effect
|
|
73
|
+
|
|
74
|
+
The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context.
|
|
75
|
+
|
|
76
|
+
#### KV Cache effect
|
|
77
|
+
|
|
78
|
+
Independent of the parent request cache. Each ACP child can reuse only prefixes identical under its own provider, model, composition, and history; child steps otherwise grow append-only.
|
|
79
|
+
|
|
80
|
+
### Parent tool result, indirectly
|
|
81
|
+
|
|
82
|
+
#### What the model sees
|
|
83
|
+
|
|
84
|
+
Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: <message>`.
|
|
85
|
+
|
|
86
|
+
#### Token effect
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
#### KV Cache effect
|
|
91
|
+
|
|
92
|
+
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
|
93
|
+
|
|
94
|
+
## Known Limitations and Deferred Work
|
|
95
|
+
|
|
96
|
+
- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)).
|
|
97
|
+
- **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here.
|
|
98
|
+
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
|
|
99
|
+
- **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP.
|
|
100
|
+
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission`.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-subagent-acp
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 subagent,并作为 Agent Client Protocol 客户端驱动它。这是 spawn 与 fork 的进程外替代方案:子 agent(智能体)拥有自己的运行时、会话、模型配置和工具。
|
|
6
|
+
|
|
7
|
+
## 启动与所有权
|
|
8
|
+
|
|
9
|
+
`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn、初始化、新建会话或发布前取消失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在尚未 spawn 任何内容时拒绝。
|
|
10
|
+
|
|
11
|
+
工作目录优先使用已配置的 `cwd` 覆盖值,否则使用执行委派的父会话 cwd,绝不使用服务器进程自身的 cwd,因为同一个服务器进程会服务来自多个工作区的会话。从父级取得的值必须是绝对路径,指向 harness 可以进入的目录(具备搜索权限,这是子进程 cwd 的要求);解析后的同一路径同时作为子进程 cwd 和 ACP `session/new` 工作区。
|
|
12
|
+
|
|
13
|
+
返回的运行 id 在父级命名空间中生成。子服务器的会话 id 只用于 ACP 协议调用,因为 ACP 只保证它在该全新子进程中唯一;若将其用作父级生命周期 id,可能与另一个远程运行或本地 agent 冲突。
|
|
14
|
+
|
|
15
|
+
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。
|
|
16
|
+
|
|
17
|
+
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
|
|
18
|
+
|
|
19
|
+
## 能力与上下文
|
|
20
|
+
|
|
21
|
+
ACP 不声明任何启动时能力,因为当前进程无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
| 键 | 默认值 | 含义 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `providerName` | `acp` | `ctx.subagents` 上的注册表名称。 |
|
|
28
|
+
| `command` | 必填 | 每次运行时 spawn 的可执行文件。 |
|
|
29
|
+
| `args` | `[]` | 命令参数。 |
|
|
30
|
+
| `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 |
|
|
31
|
+
| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 |
|
|
32
|
+
| `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 |
|
|
33
|
+
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
|
|
34
|
+
| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
- id: subagent-acp
|
|
38
|
+
name: '@deepseek-ai/dsh-subagent-acp'
|
|
39
|
+
config:
|
|
40
|
+
providerName: acp
|
|
41
|
+
command: node
|
|
42
|
+
args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
|
43
|
+
permission: reject
|
|
44
|
+
env:
|
|
45
|
+
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 结束原因映射
|
|
49
|
+
|
|
50
|
+
| ACP | Harness |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `end_turn` | `completed` |
|
|
53
|
+
| `max_tokens` | `max-tokens` |
|
|
54
|
+
| `refusal` | `refusal` |
|
|
55
|
+
| `cancelled` | `aborted` |
|
|
56
|
+
| `max_turn_requests` 或未知值 | `error` |
|
|
57
|
+
|
|
58
|
+
## 进程边界
|
|
59
|
+
|
|
60
|
+
子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
|
|
61
|
+
|
|
62
|
+
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。
|
|
63
|
+
|
|
64
|
+
## 模型体验
|
|
65
|
+
|
|
66
|
+
### 子 agent 请求
|
|
67
|
+
|
|
68
|
+
#### 模型看到的内容
|
|
69
|
+
|
|
70
|
+
远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。
|
|
71
|
+
|
|
72
|
+
#### Token 影响
|
|
73
|
+
|
|
74
|
+
子 agent 为独立的完整上下文及其多步骤历史支付 token 成本。这些 token 绝不会进入父级上下文。
|
|
75
|
+
|
|
76
|
+
#### KV Cache 影响
|
|
77
|
+
|
|
78
|
+
与父级请求缓存相互独立。每个 ACP 子 agent 只能在其自身提供方、模型、组合和历史均相同时复用前缀;其余情况下,子 agent 步骤仅追加增长。
|
|
79
|
+
|
|
80
|
+
### 父级工具结果(间接)
|
|
81
|
+
|
|
82
|
+
#### 模型看到的内容
|
|
83
|
+
|
|
84
|
+
通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败按原样传递为 `Error: <message>`。
|
|
85
|
+
|
|
86
|
+
#### Token 影响
|
|
87
|
+
|
|
88
|
+
父级输入只增加最终结果或错误,其内容依赖数据,并保留到压缩(compaction)为止。该提供方自身不会添加父级 schema。
|
|
89
|
+
|
|
90
|
+
#### KV Cache 影响
|
|
91
|
+
|
|
92
|
+
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
|
93
|
+
|
|
94
|
+
## 已知限制与暂缓事项
|
|
95
|
+
|
|
96
|
+
- **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md))。
|
|
97
|
+
- **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,本包尚未设计。
|
|
98
|
+
- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。
|
|
99
|
+
- **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。
|
|
100
|
+
- **权限提示自动回答**(`permission: allow | reject`):不会把子 agent 的 `session/request_permission` 呈现给人。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { Readable, Writable } from "node:stream";
|
|
7
|
+
import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
8
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
|
+
//#region lib/types/run.js
|
|
10
|
+
/**
|
|
11
|
+
* Fresh-process ACP subagent client. Drives one child session and owns cancellation and
|
|
12
|
+
* quiescent disposal.
|
|
13
|
+
*
|
|
14
|
+
* TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
|
|
15
|
+
* sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
|
|
16
|
+
* with-key coverage drives the real ACP example.
|
|
17
|
+
* @module @deepseek-ai/dsh-subagent-acp/run
|
|
18
|
+
*/
|
|
19
|
+
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
|
|
20
|
+
const DEFAULT_DISPOSE_EOF_GRACE_MS = 6e3;
|
|
21
|
+
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
|
|
22
|
+
const DEFAULT_DISPOSE_GRACE_MS = 3e3;
|
|
23
|
+
/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
|
|
24
|
+
async function treeExitsWithin(child, ms) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
controller.abort();
|
|
28
|
+
}, ms);
|
|
29
|
+
try {
|
|
30
|
+
return await child.waitForExit(controller.signal);
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cooperative teardown ladder for an out-of-process agent, over the seam's
|
|
37
|
+
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
|
|
38
|
+
* window to flush persistence and reap its own descendants), then the
|
|
39
|
+
* terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
|
|
40
|
+
* whole-tree exit proof.
|
|
41
|
+
* @param child - the spawned ACP child's handle.
|
|
42
|
+
* @param eofGraceMs - tier-1 window after stdin EOF.
|
|
43
|
+
*/
|
|
44
|
+
async function disposeAcpChild(child, eofGraceMs) {
|
|
45
|
+
if (child.pid <= 0) {
|
|
46
|
+
await child.done.catch(() => {});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
child.stdin?.end();
|
|
50
|
+
if (await treeExitsWithin(child, eofGraceMs)) return;
|
|
51
|
+
child.terminate();
|
|
52
|
+
await child.waitForExit();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
|
|
56
|
+
* @param reason - the terminal reason from the child's `session/prompt` response.
|
|
57
|
+
* @returns the harness equivalent; `max_turn_requests` and any unknown future
|
|
58
|
+
* variant map to `error`, so an unclean stop is never reported as `completed`.
|
|
59
|
+
*/
|
|
60
|
+
function acpStopReason(reason) {
|
|
61
|
+
switch (reason) {
|
|
62
|
+
case "end_turn": return "completed";
|
|
63
|
+
case "max_tokens": return "max-tokens";
|
|
64
|
+
case "refusal": return "refusal";
|
|
65
|
+
case "cancelled": return "aborted";
|
|
66
|
+
case "max_turn_requests": return "error";
|
|
67
|
+
default: return "error";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Collect the text of an ACP content block (non-text blocks contribute nothing).
|
|
72
|
+
* @param content - the content block off a streamed `agent_message_chunk`.
|
|
73
|
+
* @returns the block's text, or `''` for a non-text block.
|
|
74
|
+
*/
|
|
75
|
+
function acpContentText(content) {
|
|
76
|
+
return content.type === "text" ? content.text : "";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Translate the harness prompt blocks into ACP prompt blocks (text only).
|
|
80
|
+
* @param prompt - the harness prompt; non-text blocks are dropped.
|
|
81
|
+
* @returns the ACP text blocks, in order.
|
|
82
|
+
*/
|
|
83
|
+
function toAcpPrompt(prompt) {
|
|
84
|
+
const blocks = [];
|
|
85
|
+
for (const block of prompt) if (block.type === "text") blocks.push({
|
|
86
|
+
type: "text",
|
|
87
|
+
text: block.text
|
|
88
|
+
});
|
|
89
|
+
return blocks;
|
|
90
|
+
}
|
|
91
|
+
/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */
|
|
92
|
+
function toError(value) {
|
|
93
|
+
/* v8 ignore next */
|
|
94
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start and publish one ACP child after initialization and session creation.
|
|
98
|
+
* Child failures resolve through the run result; startup failures reject after
|
|
99
|
+
* process reap. Disposal cancels, kills, and reaps the child.
|
|
100
|
+
* @param request - the start request; its signal is the cancellation channel.
|
|
101
|
+
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
|
102
|
+
* policy, dispose graces, and the optional error sink.
|
|
103
|
+
* @returns the ready run handle for the child subprocess.
|
|
104
|
+
*/
|
|
105
|
+
async function startAcpRun(request, spec) {
|
|
106
|
+
if (request.signal.aborted) throw new Error("subagent request was aborted before the ACP child started");
|
|
107
|
+
const id = SessionId(randomUUID());
|
|
108
|
+
const child = spec.spawn({
|
|
109
|
+
argv: [spec.command, ...spec.args],
|
|
110
|
+
cwd: spec.cwd,
|
|
111
|
+
stdio: {
|
|
112
|
+
stdin: "pipe",
|
|
113
|
+
stdout: "pipe",
|
|
114
|
+
stderr: "inherit"
|
|
115
|
+
},
|
|
116
|
+
graceMs: spec.disposeGraceMs,
|
|
117
|
+
env: spec.env
|
|
118
|
+
});
|
|
119
|
+
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
|
|
120
|
+
if (child.stdin === void 0 || child.stdout === void 0) throw new Error("subagent-acp: subprocess implementation dropped a piped protocol stream");
|
|
121
|
+
/* v8 ignore stop */
|
|
122
|
+
const spawnFailed = child.done.then(
|
|
123
|
+
/* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
|
|
124
|
+
() => new Promise(() => {}),
|
|
125
|
+
(err) => Promise.reject(toError(err))
|
|
126
|
+
);
|
|
127
|
+
spawnFailed.catch(() => {});
|
|
128
|
+
let processDisposal;
|
|
129
|
+
const disposeProcess = () => processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs);
|
|
130
|
+
const output = [];
|
|
131
|
+
const flags = { cancelled: false };
|
|
132
|
+
const makeClient = (_agent) => ({
|
|
133
|
+
sessionUpdate(params) {
|
|
134
|
+
const update = params.update;
|
|
135
|
+
if (update.sessionUpdate === "agent_message_chunk") output.push(acpContentText(update.content));
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
},
|
|
138
|
+
requestPermission(params) {
|
|
139
|
+
if (spec.permission === "allow") {
|
|
140
|
+
const allow = params.options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
|
|
141
|
+
if (allow !== void 0) return Promise.resolve({ outcome: {
|
|
142
|
+
outcome: "selected",
|
|
143
|
+
optionId: allow.optionId
|
|
144
|
+
} });
|
|
145
|
+
}
|
|
146
|
+
return Promise.resolve({ outcome: { outcome: "cancelled" } });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
const conn = new ClientSideConnection(makeClient, ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
150
|
+
let sessionId;
|
|
151
|
+
let signalCancelSettled;
|
|
152
|
+
const cancelSettled = new Promise((resolve) => {
|
|
153
|
+
signalCancelSettled = resolve;
|
|
154
|
+
});
|
|
155
|
+
const requestCancel = () => {
|
|
156
|
+
if (flags.cancelled) return;
|
|
157
|
+
flags.cancelled = true;
|
|
158
|
+
signalCancelSettled();
|
|
159
|
+
/* v8 ignore next */
|
|
160
|
+
if (sessionId !== void 0) conn.cancel({ sessionId }).catch(() => {});
|
|
161
|
+
};
|
|
162
|
+
const onAbort = () => {
|
|
163
|
+
requestCancel();
|
|
164
|
+
};
|
|
165
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
166
|
+
const collectOutput = () => {
|
|
167
|
+
const text = output.join("");
|
|
168
|
+
return text.length > 0 ? [{
|
|
169
|
+
type: "text",
|
|
170
|
+
text
|
|
171
|
+
}] : [];
|
|
172
|
+
};
|
|
173
|
+
try {
|
|
174
|
+
await Promise.race([
|
|
175
|
+
(async () => {
|
|
176
|
+
await conn.initialize({
|
|
177
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
178
|
+
clientCapabilities: {}
|
|
179
|
+
});
|
|
180
|
+
const session = await conn.newSession({
|
|
181
|
+
cwd: spec.cwd,
|
|
182
|
+
mcpServers: []
|
|
183
|
+
});
|
|
184
|
+
const returnedSessionId = Reflect.get(session, "sessionId");
|
|
185
|
+
if (typeof returnedSessionId !== "string") throw new Error("ACP child published without a session id");
|
|
186
|
+
sessionId = returnedSessionId;
|
|
187
|
+
if (flags.cancelled) throw new Error("subagent cancelled before the ACP session started");
|
|
188
|
+
})(),
|
|
189
|
+
spawnFailed,
|
|
190
|
+
cancelSettled.then(() => {
|
|
191
|
+
throw new Error("subagent cancelled before the ACP session started");
|
|
192
|
+
})
|
|
193
|
+
]);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
196
|
+
await disposeProcess();
|
|
197
|
+
if (flags.cancelled) throw new Error("subagent request was aborted before the ACP child started");
|
|
198
|
+
throw toError(error);
|
|
199
|
+
}
|
|
200
|
+
/* v8 ignore next */
|
|
201
|
+
if (sessionId === void 0) throw new Error("unreachable: ACP startup fulfilled without a session id");
|
|
202
|
+
const remoteSessionId = sessionId;
|
|
203
|
+
const result = (async () => {
|
|
204
|
+
try {
|
|
205
|
+
const prompt = async () => {
|
|
206
|
+
const promptResult = await conn.prompt({
|
|
207
|
+
sessionId: remoteSessionId,
|
|
208
|
+
prompt: toAcpPrompt(request.prompt)
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
output: collectOutput(),
|
|
212
|
+
stopReason: acpStopReason(promptResult.stopReason)
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
return await Promise.race([prompt(), cancelSettled.then(() => ({
|
|
216
|
+
output: collectOutput(),
|
|
217
|
+
stopReason: "aborted"
|
|
218
|
+
}))]);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
/* v8 ignore next */
|
|
221
|
+
if (flags.cancelled) return {
|
|
222
|
+
output: collectOutput(),
|
|
223
|
+
stopReason: "aborted"
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
spec.onError?.(toError(error), "error");
|
|
227
|
+
} catch {}
|
|
228
|
+
return {
|
|
229
|
+
output: collectOutput(),
|
|
230
|
+
stopReason: "error"
|
|
231
|
+
};
|
|
232
|
+
} finally {
|
|
233
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
let disposal;
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
localAgent: void 0,
|
|
240
|
+
result,
|
|
241
|
+
dispose() {
|
|
242
|
+
if (disposal !== void 0) return disposal;
|
|
243
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
244
|
+
requestCancel();
|
|
245
|
+
disposal = disposeProcess();
|
|
246
|
+
return disposal;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region lib/types/index.js
|
|
252
|
+
/**
|
|
253
|
+
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
|
|
254
|
+
* tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
|
|
255
|
+
* the ONE thing it reads off `request.parent` is the session's workspace cwd (see
|
|
256
|
+
* {@link resolveCwd}). This plugin uses named exports only; a default would hide its
|
|
257
|
+
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
|
258
|
+
* @module @deepseek-ai/dsh-subagent-acp
|
|
259
|
+
*/
|
|
260
|
+
const name = "subagent-acp";
|
|
261
|
+
const inject = ["subagents", "subprocess"];
|
|
262
|
+
const Config = z.object({
|
|
263
|
+
providerName: z.string().default("acp"),
|
|
264
|
+
command: z.string().required(),
|
|
265
|
+
args: z.array(z.string()).default([]),
|
|
266
|
+
cwd: z.string(),
|
|
267
|
+
permission: z.union(["allow", "reject"]).default("reject"),
|
|
268
|
+
env: z.dict(z.string()).default({}),
|
|
269
|
+
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
|
|
270
|
+
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS)
|
|
271
|
+
});
|
|
272
|
+
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
|
|
273
|
+
function assertPositiveFinite(name, value) {
|
|
274
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Whether `path` names an existing directory the harness can ENTER. The
|
|
278
|
+
* search-permission probe matters: `statSync().isDirectory()` is true for a
|
|
279
|
+
* mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES.
|
|
280
|
+
*/
|
|
281
|
+
function isDirectory(path) {
|
|
282
|
+
try {
|
|
283
|
+
if (!statSync(path).isDirectory()) return false;
|
|
284
|
+
accessSync(path, constants.X_OK);
|
|
285
|
+
return true;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Assert `cwd` can actually host the child: absolute (it doubles as the ACP
|
|
292
|
+
* session workspace, and a relative path would be re-anchored to the server
|
|
293
|
+
* process's launch directory) and an existing directory (fail here, before the
|
|
294
|
+
* process boundary, instead of as an ambiguous spawn ENOENT).
|
|
295
|
+
* @param label - which source supplied the value, for the diagnostic.
|
|
296
|
+
* @param cwd - the candidate working directory.
|
|
297
|
+
* @returns `cwd`, validated.
|
|
298
|
+
*/
|
|
299
|
+
function assertUsableCwd(label, cwd) {
|
|
300
|
+
if (!isAbsolute(cwd)) throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`);
|
|
301
|
+
if (!isDirectory(cwd)) throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`);
|
|
302
|
+
return cwd;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the child's working directory: the deployment `cwd` override when
|
|
306
|
+
* configured (already validated at load), else the parent session's workspace
|
|
307
|
+
* cwd (validated here, its earliest resolvable point). Fails loud when neither
|
|
308
|
+
* exists — falling back to the harness process cwd would silently bind the
|
|
309
|
+
* child to the server's launch directory instead of the delegating session's
|
|
310
|
+
* workspace (one server process serves many sessions, each with its own cwd).
|
|
311
|
+
*/
|
|
312
|
+
function resolveCwd(configured, request) {
|
|
313
|
+
if (configured !== void 0) return configured;
|
|
314
|
+
const parentCwd = request.parent.session.header.cwd;
|
|
315
|
+
if (parentCwd === void 0) throw new Error("subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one");
|
|
316
|
+
return assertUsableCwd("parent session cwd", parentCwd);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The ACP provider. Advertises NO start-time capabilities: an out-of-process
|
|
320
|
+
* child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
|
|
321
|
+
* a request needing any of them before `start` runs).
|
|
322
|
+
*/
|
|
323
|
+
var AcpProvider = class {
|
|
324
|
+
name;
|
|
325
|
+
ctx;
|
|
326
|
+
config;
|
|
327
|
+
capabilities = {
|
|
328
|
+
outputSchema: false,
|
|
329
|
+
depthLimit: false,
|
|
330
|
+
toolFilter: false,
|
|
331
|
+
persona: false
|
|
332
|
+
};
|
|
333
|
+
inheritsParentContext = false;
|
|
334
|
+
constructor(name, ctx, config) {
|
|
335
|
+
this.name = name;
|
|
336
|
+
this.ctx = ctx;
|
|
337
|
+
this.config = config;
|
|
338
|
+
}
|
|
339
|
+
start(request) {
|
|
340
|
+
return startAcpRun(request, {
|
|
341
|
+
command: this.config.command,
|
|
342
|
+
args: this.config.args,
|
|
343
|
+
cwd: resolveCwd(this.config.cwd, request),
|
|
344
|
+
permission: this.config.permission,
|
|
345
|
+
env: this.config.env,
|
|
346
|
+
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
|
347
|
+
disposeGraceMs: this.config.disposeGraceMs,
|
|
348
|
+
spawn: (spec) => this.ctx.subprocess.spawn(spec),
|
|
349
|
+
onError: (error, stopReason) => {
|
|
350
|
+
this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
function apply(ctx, config) {
|
|
356
|
+
const resolved = config;
|
|
357
|
+
assertPositiveFinite("disposeEofGraceMs", resolved.disposeEofGraceMs);
|
|
358
|
+
assertPositiveFinite("disposeGraceMs", resolved.disposeGraceMs);
|
|
359
|
+
if (resolved.cwd === "") throw new Error("subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd");
|
|
360
|
+
const validated = resolved.cwd === void 0 ? resolved : {
|
|
361
|
+
...resolved,
|
|
362
|
+
cwd: assertUsableCwd("config cwd", resolve(resolved.cwd))
|
|
363
|
+
};
|
|
364
|
+
ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated));
|
|
365
|
+
}
|
|
366
|
+
//#endregion
|
|
367
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`.
|
|
4
|
+
* @module @deepseek-ai/dsh-subagent-acp/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-subagent-acp";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "subagent-acp-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
|
13
|
+
* beyond contracts enforced at its owning seam.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Out-of-process ACP subagent backend. Each child has its own process, session, model, and
|
|
3
|
+
* tools, so it shares no Cordis context and advertises no parent-enforced start capabilities;
|
|
4
|
+
* the ONE thing it reads off `request.parent` is the session's workspace cwd (see
|
|
5
|
+
* {@link resolveCwd}). This plugin uses named exports only; a default would hide its
|
|
6
|
+
* loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`).
|
|
7
|
+
* @module @deepseek-ai/dsh-subagent-acp
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import z from '@deepseek-ai/schemastery';
|
|
11
|
+
import { type PermissionPolicy } from './run.ts';
|
|
12
|
+
export declare const name = "subagent-acp";
|
|
13
|
+
export declare const inject: string[];
|
|
14
|
+
/** Config: how to spawn and drive the child ACP agent process. */
|
|
15
|
+
export interface Config {
|
|
16
|
+
/** Provider name on `ctx.subagents` (default `acp`). */
|
|
17
|
+
providerName: string;
|
|
18
|
+
/** The executable to spawn for each run (the child ACP agent). */
|
|
19
|
+
command: string;
|
|
20
|
+
/** Arguments passed to {@link command}. */
|
|
21
|
+
args: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Working directory override for the child process and its ACP session.
|
|
24
|
+
* Must be non-empty; a relative path resolves against the harness launch
|
|
25
|
+
* directory at load, and the result must be an existing directory. When
|
|
26
|
+
* omitted, each child inherits its delegating parent session's cwd — and
|
|
27
|
+
* starting one from a parent session that has no cwd fails.
|
|
28
|
+
*/
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/**
|
|
31
|
+
* How to auto-answer the child's `session/request_permission` prompts:
|
|
32
|
+
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
|
33
|
+
* `allow_once` or `allow_always` option). No prompt is surfaced to a human.
|
|
34
|
+
*/
|
|
35
|
+
permission: PermissionPolicy;
|
|
36
|
+
/**
|
|
37
|
+
* Extra environment variables for the child process — e.g. the child
|
|
38
|
+
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
|
39
|
+
* copy of the parent env, so an explicit key here reaches the child while
|
|
40
|
+
* ambient secrets do not leak implicitly.
|
|
41
|
+
*/
|
|
42
|
+
env: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
|
|
45
|
+
* window to flush persistence and tear down its own nested subprocesses
|
|
46
|
+
* before the parent escalates to a signal. Must not exceed
|
|
47
|
+
* `MAX_TIMER_DELAY_MS`.
|
|
48
|
+
*/
|
|
49
|
+
disposeEofGraceMs?: number;
|
|
50
|
+
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
|
51
|
+
disposeGraceMs?: number;
|
|
52
|
+
}
|
|
53
|
+
export declare const Config: z<Config>;
|
|
54
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
55
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-acp`.
|
|
3
|
+
* @module @deepseek-ai/dsh-subagent-acp/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "subagent-acp-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fresh-process ACP subagent client. Drives one child session and owns cancellation and
|
|
3
|
+
* quiescent disposal.
|
|
4
|
+
*
|
|
5
|
+
* TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
|
|
6
|
+
* sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
|
|
7
|
+
* with-key coverage drives the real ACP example.
|
|
8
|
+
* @module @deepseek-ai/dsh-subagent-acp/run
|
|
9
|
+
*/
|
|
10
|
+
import { type ContentBlock as AcpContentBlock, type StopReason } from '@agentclientprotocol/sdk';
|
|
11
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import type { SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent';
|
|
13
|
+
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
14
|
+
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
|
|
15
|
+
export type PermissionPolicy = 'allow' | 'reject';
|
|
16
|
+
/** Resolved spawn spec for an ACP child process (no defaults — see Config). */
|
|
17
|
+
export interface AcpRunSpec {
|
|
18
|
+
/** The executable to spawn (the child ACP agent). */
|
|
19
|
+
command: string;
|
|
20
|
+
/** Arguments passed to {@link command}. */
|
|
21
|
+
args: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Absolute working directory for the child process AND its ACP session
|
|
24
|
+
* `cwd`. The provider resolves it before this spec exists: config override,
|
|
25
|
+
* else the delegating parent session's workspace.
|
|
26
|
+
*/
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** How to auto-answer the child's permission prompts. */
|
|
29
|
+
permission: PermissionPolicy;
|
|
30
|
+
/**
|
|
31
|
+
* Extra environment variables to ADD for the child (e.g. the child harness's
|
|
32
|
+
* `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed
|
|
33
|
+
* parent env. A value here is forwarded even if its name matches the
|
|
34
|
+
* credential-scrub pattern (an explicit opt-in for the child's own creds).
|
|
35
|
+
* Explicit `DSH_*` entries are deployment-owned facts for the child harness
|
|
36
|
+
* (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that
|
|
37
|
+
* dropped their stale ambient namesakes.
|
|
38
|
+
*/
|
|
39
|
+
env: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* Grace period (ms) for the child's EOF-driven quiesce in
|
|
42
|
+
* {@link SubagentRun.dispose} — the window to flush persistence and tear down
|
|
43
|
+
* its OWN nested subprocesses before the parent escalates to a signal. The
|
|
44
|
+
* plugin fills this from its `disposeEofGraceMs` config.
|
|
45
|
+
*/
|
|
46
|
+
disposeEofGraceMs: number;
|
|
47
|
+
/**
|
|
48
|
+
* Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
|
|
49
|
+
* waits this long after `SIGTERM` before `SIGKILL`, while Windows
|
|
50
|
+
* force-terminates directly. The plugin fills it from `disposeGraceMs`.
|
|
51
|
+
*/
|
|
52
|
+
disposeGraceMs: number;
|
|
53
|
+
/**
|
|
54
|
+
* Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the
|
|
55
|
+
* child rides the shared scrub, tree-scoped teardown, and service-owned
|
|
56
|
+
* lifetime instead of a package-local child_process path.
|
|
57
|
+
*/
|
|
58
|
+
spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
|
|
59
|
+
/**
|
|
60
|
+
* Sink for a child-level failure that the run flattened into a stop reason
|
|
61
|
+
* (the seam contract forbids `result` rejecting). The driver calls this with
|
|
62
|
+
* the original error and the chosen stop reason so the fault is preserved
|
|
63
|
+
* rather than silently lost; the provider wires it to `ctx.logger.warn`.
|
|
64
|
+
* A throw from the sink itself is contained — it cannot reject `result`.
|
|
65
|
+
* Optional — omitted in a unit test that asserts the stop reason directly.
|
|
66
|
+
*/
|
|
67
|
+
onError?: (error: Error, stopReason: SubagentStopReason) => void;
|
|
68
|
+
}
|
|
69
|
+
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
|
|
70
|
+
export declare const DEFAULT_DISPOSE_EOF_GRACE_MS = 6000;
|
|
71
|
+
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
|
|
72
|
+
export declare const DEFAULT_DISPOSE_GRACE_MS = 3000;
|
|
73
|
+
/**
|
|
74
|
+
* Cooperative teardown ladder for an out-of-process agent, over the seam's
|
|
75
|
+
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
|
|
76
|
+
* window to flush persistence and reap its own descendants), then the
|
|
77
|
+
* terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
|
|
78
|
+
* whole-tree exit proof.
|
|
79
|
+
* @param child - the spawned ACP child's handle.
|
|
80
|
+
* @param eofGraceMs - tier-1 window after stdin EOF.
|
|
81
|
+
*/
|
|
82
|
+
export declare function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
|
|
85
|
+
* @param reason - the terminal reason from the child's `session/prompt` response.
|
|
86
|
+
* @returns the harness equivalent; `max_turn_requests` and any unknown future
|
|
87
|
+
* variant map to `error`, so an unclean stop is never reported as `completed`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function acpStopReason(reason: StopReason): SubagentStopReason;
|
|
90
|
+
/**
|
|
91
|
+
* Collect the text of an ACP content block (non-text blocks contribute nothing).
|
|
92
|
+
* @param content - the content block off a streamed `agent_message_chunk`.
|
|
93
|
+
* @returns the block's text, or `''` for a non-text block.
|
|
94
|
+
*/
|
|
95
|
+
export declare function acpContentText(content: AcpContentBlock): string;
|
|
96
|
+
/**
|
|
97
|
+
* Translate the harness prompt blocks into ACP prompt blocks (text only).
|
|
98
|
+
* @param prompt - the harness prompt; non-text blocks are dropped.
|
|
99
|
+
* @returns the ACP text blocks, in order.
|
|
100
|
+
*/
|
|
101
|
+
export declare function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[];
|
|
102
|
+
/**
|
|
103
|
+
* Start and publish one ACP child after initialization and session creation.
|
|
104
|
+
* Child failures resolve through the run result; startup failures reject after
|
|
105
|
+
* process reap. Disposal cancels, kills, and reaps the child.
|
|
106
|
+
* @param request - the start request; its signal is the cancellation channel.
|
|
107
|
+
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
|
108
|
+
* policy, dispose graces, and the optional error sink.
|
|
109
|
+
* @returns the ready run handle for the child subprocess.
|
|
110
|
+
*/
|
|
111
|
+
export declare function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun>;
|
|
112
|
+
//# sourceMappingURL=run.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-subagent-acp",
|
|
3
|
+
"description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/subagent/subagent-acp"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
37
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
38
|
+
"@deepseek-ai/dsh-subagent": "^0.0.1-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-subprocess": "^0.0.1-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-timeout": "^0.0.1-rc.1",
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-session": "^0.0.1-rc.1"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@agentclientprotocol/sdk": "0.25.1",
|
|
46
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
52
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.1-rc.1",
|
|
53
|
+
"@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.1",
|
|
54
|
+
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
|
55
|
+
"@deepseek-ai/dsh-subagent": "^0.0.1-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-subprocess": "^0.0.1-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-timeout": "^0.0.1-rc.1",
|
|
58
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-subprocess-local": "^0.0.1-rc.1"
|
|
60
|
+
}
|
|
61
|
+
}
|