mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into worktree/2848-image-token-pressure
This commit is contained in:
+6
@@ -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 .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
|
||||
2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 533ace5a13df75fb594e0cecc65a743df27b6baf
|
||||
2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: fe8adf764b240d77cfcde95999ee6689bf11b4a4
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Agent Note: Out-of-process subagents expose minimal actionable diagnostics
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An ACP child can stop because it reached a remote limit, denied a required permission, lost its protocol transport, or exited as a process. The shared result historically reduced these outcomes to a stop reason such as `error`, while startup and cleanup rejection messages could expose the original exception. A parent could not choose between narrowing the task, adjusting permission policy, or repairing the child deployment without Host logs.
|
||||
|
||||
Copying exceptions, stderr, task content, tool input, paths, environment values, credentials, or protocol payloads into `SubagentResult.diagnostic` would make untrusted child text model-visible. Reusing a complete product-specific error union would also duplicate independently versioned authorities in the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Each out-of-process provider owns a small mapping from facts it already receives at its protocol and process lifecycle points to fixed safe display text. The ACP provider implements that rule from its closed stop reasons, current operation, closed tool kind, configured permission policy, selected permission outcome, and the managed subprocess exit code or signal. Consumers continue to use the existing optional `SubagentResult.diagnostic`; they do not parse its punctuation or provider-private category names.
|
||||
|
||||
### Safe failure text
|
||||
|
||||
Generic error diagnostics have this fixed field order:
|
||||
|
||||
```text
|
||||
Subagent failure (provider: <provider>; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
Unavailable optional fields are omitted. The complete result is limited to 4096 UTF-8 bytes by the shared settlement boundary. Successful results and local cancellation carry no failure diagnostic. Partial assistant output remains in `SubagentResult.output` and is presented separately.
|
||||
|
||||
When an ACP permission request contributes to a non-completed result, a fixed line records `policy`, the closed ACP tool `request` kind, and `decision`. Tool titles, raw input, locations, option names, and metadata are excluded. For `max-tokens`, `refusal`, or remote `aborted`, the public stop reason already carries the terminal fact, so the permission line is the complete diagnostic; generic error paths append it after the failure line. A diagnostic-bearing remote `aborted` result keeps its public stop reason; the one-shot Job adapter treats it as failed, while diagnostic-free local cancellation remains killed.
|
||||
|
||||
### ACP facts
|
||||
|
||||
| Stage | Owned operation | Safe categories and facts |
|
||||
| --- | --- | --- |
|
||||
| `initialize` | Parent workspace resolution and ACP initialize | `configuration`, `transport`, or `process-exit` |
|
||||
| `new-session` | ACP `session/new` and returned session-id validation | `protocol`, `transport`, or `process-exit` |
|
||||
| `prompt` | ACP prompt request, remote stop reason, and permission callback | `remote-limit`, `transport`, `unknown`, or a permission-only diagnostic |
|
||||
| `process` | Child-process spawn failure, or a managed child exits before a prompt terminal response | `process-start`, or `process-exit` plus independently observed exit code and signal |
|
||||
| `teardown` | EOF quiescence and managed process-tree termination | Fixed teardown facts; the original cleanup failure remains internal |
|
||||
|
||||
`max_turn_requests` remains the shared `error` stop reason and adds `remote-limit`. An unknown stop reason remains `error` and becomes the fixed `unknown` category without copying the value. `max_tokens`, `refusal`, and `cancelled` keep their existing shared stop reasons; they add a diagnostic only when a permission decision must be explained.
|
||||
|
||||
### Ownership and lifecycle
|
||||
|
||||
| Fact or resource | Owner | Consumer behavior |
|
||||
| --- | --- | --- |
|
||||
| ACP stop reason and tool kind | ACP server and SDK | The provider maps only closed values and uses fixed unknown fallbacks |
|
||||
| Current failure stage and latest permission decision | One ACP run | Derived at the failure point and discarded with the run; concurrent runs share no diagnostic state |
|
||||
| Exit code and signal | `dsh-subprocess` handle | Displayed only after the managed outcome is observed; stderr is never parsed |
|
||||
| Diagnostic bytes and presentation | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text stays separate from assistant output in foreground and one-shot background modes |
|
||||
| Raw failure | Child runtime, Error cause chain, and Host logger | Available for Host diagnosis only, never copied into the parent model result |
|
||||
|
||||
Startup publishes no run until initialize and new-session succeed. A startup failure rolls the private child back to quiescence before rejecting with safe facts. A published run settles its result without rejection, and `dispose()` independently reports a safe teardown failure while still using the backend's existing whole-tree cleanup ladder.
|
||||
|
||||
## Verification
|
||||
|
||||
ACP package tests drive a real stdio protocol child and pin every stop-reason mapping, remote-limit and unknown fallbacks, permission allow/deny facts, configuration, initialize, new-session, prompt, process, and teardown stages, startup rollback, successful-result and local-cancellation omission, partial output, concurrent-run isolation, Host-only raw errors, process quiescence, and the shared multibyte diagnostic limit. A Loader composition proves the real configured provider reaches the model-visible foreground result. The keyless ACP snapshot pins the same diagnostic and permission fact in foreground error output and one-shot background `job_output` detail.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return raw exceptions, stderr, or protocol payloads.** These values can contain task content, tool input, paths, environment values, credentials, and upstream prose. Fixed allowlisted facts preserve the actionable distinction without expanding the model-visible trust boundary.
|
||||
|
||||
**Add a shared structured error enum.** ACP and other process-backed providers own different lifecycle points and closed termination vocabularies. A shared enum would invent false equivalence and force unrelated consumers to track provider releases.
|
||||
|
||||
**Parse exception messages or stderr into categories.** Free-form text is neither stable nor safe. Only closed protocol values, typed errors, current call sites, and managed process outcomes qualify as diagnostic inputs.
|
||||
|
||||
**Change existing stop reasons.** The stop reason remains the provider-neutral terminal result. The optional diagnostic explains why a non-completed result needs a different next action without adding new public result states.
|
||||
|
||||
**Add retries, recovery state, or interactive approval.** Diagnostics report a failure; they do not own remediation. Retry policy, session recovery, and human interaction require separate user contracts and lifecycle owners.
|
||||
|
||||
## Consequences
|
||||
|
||||
The parent can distinguish an ACP remote limit, permission involvement, protocol or transport failure, deployment/process failure, and teardown failure without receiving child-controlled text. Startup and cleanup errors use the same safe facts as published results, while Host observation retains the original cause.
|
||||
|
||||
The diagnostic remains display text rather than a public protocol. Consumers may present it but must not branch on its format. This decision adds no retry policy, recovery controller, shared provider-error enum, stderr classifier, authentication taxonomy, session persistence, progress stream, or new ACP capability.
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Agent Note: 进程外 subagent 公开最小可行动诊断
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-21-out-of-process-subagent-minimal-diagnostics.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
ACP 子进程可能因为达到远端限制、拒绝必需权限、失去协议传输或进程退出而停止。共享结果以往只把这些结果压成 `error` 等结束原因,而启动和清理拒绝的消息还可能暴露原始异常。父 agent 若不读取 Host 日志,就无法决定应缩小任务、调整权限策略还是修复子运行时部署。
|
||||
|
||||
若把异常、stderr、任务内容、工具输入、路径、环境值、凭证或协议 payload 复制进 `SubagentResult.diagnostic`,不受信任的子进程文本就会变成模型可见内容。若复用完整的产品专属错误联合,又会在提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.zh.md) 中复制彼此独立版本化的权威。
|
||||
|
||||
## Decision
|
||||
|
||||
每个进程外提供方分别拥有一份小型映射,把其协议与进程生命周期位置已经收到的事实转换成固定安全展示文本。ACP 提供方使用闭集结束原因、当前操作、闭集工具种类、已配置权限策略、选中的权限结果,以及受管子进程退出码或信号来实现该规则。消费方继续使用现有可选 `SubagentResult.diagnostic`,且不解析其标点或提供方私有 category 名称。
|
||||
|
||||
### 安全失败文本
|
||||
|
||||
通用 error 诊断采用以下固定字段顺序:
|
||||
|
||||
```text
|
||||
Subagent failure (provider: <provider>; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
不可用的可选字段会被省略。共享结算边界会把完整结果限制在 4096 个 UTF-8 字节以内。成功结果和本地取消不携带失败诊断。部分 assistant 输出继续保留在 `SubagentResult.output` 中,并与诊断分开呈现。
|
||||
|
||||
当 ACP 权限请求参与非完成结果时,一个固定行会记录 `policy`、ACP 闭集工具 `request` 种类和 `decision`。工具标题、raw input、位置、选项名称与 metadata 均被排除。对于 `max-tokens`、`refusal` 或远端 `aborted`,公共结束原因已经携带终态事实,因此权限行就是完整诊断;通用 error 路径则把它附在失败行之后。带诊断的远端 `aborted` 结果仍保持公共结束原因;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
|
||||
|
||||
### ACP 事实
|
||||
|
||||
| Stage | 归属操作 | 安全 category 与事实 |
|
||||
| --- | --- | --- |
|
||||
| `initialize` | 父工作区解析与 ACP initialize | `configuration`、`transport` 或 `process-exit` |
|
||||
| `new-session` | ACP `session/new` 与返回 session id 校验 | `protocol`、`transport` 或 `process-exit` |
|
||||
| `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit`、`transport`、`unknown` 或仅权限诊断 |
|
||||
| `process` | 子进程 spawn 失败,或受管子进程先于 prompt 终态响应退出 | `process-start`,或 `process-exit` 以及分别观测到的退出码与信号 |
|
||||
| `teardown` | EOF 停稳与受管进程树终止 | 固定 teardown 事实;原始清理失败仍留在内部 |
|
||||
|
||||
`max_turn_requests` 继续映射到共享 `error`,并附加 `remote-limit`。未知结束原因继续映射到 `error`,category 固定为 `unknown`,不会复制原值。`max_tokens`、`refusal` 与 `cancelled` 保持既有共享结束原因;只有需要解释权限决定时才会附加诊断。
|
||||
|
||||
### 所有权与生命周期
|
||||
|
||||
| 事实或资源 | Owner | 消费方行为 |
|
||||
| --- | --- | --- |
|
||||
| ACP 结束原因与工具种类 | ACP server 与 SDK | 提供方只映射闭集值,并对闭集外值使用固定 unknown 回退 |
|
||||
| 当前失败 stage 与最新权限决定 | 单次 ACP 运行 | 只在失败点派生,并随运行丢弃;并发运行不共享诊断状态 |
|
||||
| 退出码与信号 | `dsh-subprocess` 句柄 | 仅在观测到受管结果后展示;绝不解析 stderr |
|
||||
| 诊断字节与呈现 | `dsh-subagent`、前台工具与 Job 运行时 | 前台和一次性后台模式都把同一份有界文本与 assistant 输出分开 |
|
||||
| 原始失败 | 子运行时、Error cause 链与 Host logger | 只供 Host 排障,绝不复制进父模型结果 |
|
||||
|
||||
启动只有在 initialize 与 new-session 成功后才发布运行。启动失败会先把私有子进程回滚到完全停稳,再以安全事实拒绝。已发布运行的结果不会拒绝,而 `dispose()` 会独立报告安全 teardown 失败,并继续使用后端既有的整棵进程树清理阶梯。
|
||||
|
||||
## Verification
|
||||
|
||||
ACP 包测试通过真实 stdio 协议子进程固定全部结束原因映射、远端限制与 unknown 回退、权限 allow/deny 事实、configuration、initialize、new-session、prompt、process 与 teardown stage、启动回滚、成功结果与本地取消省略、部分输出、并发运行隔离、仅 Host 可见的原始错误、进程完全停稳,以及共享多字节诊断限制。Loader 组合证明真实配置的提供方会到达模型可见前台结果。无密钥 ACP snapshot 会在前台错误输出与一次性后台 `job_output` detail 中固定同一份诊断与权限事实。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**返回原始异常、stderr 或协议 payload。** 这些值可能包含任务内容、工具输入、路径、环境值、凭证和上游文本。固定白名单事实能够保留可行动差异,而不扩大模型可见信任边界。
|
||||
|
||||
**增加共享结构化错误 enum。** ACP 与其他进程外提供方拥有不同生命周期位置和闭集终止词汇。共享 enum 会制造虚假的统一,并迫使无关消费方跟随提供方版本。
|
||||
|
||||
**解析异常消息或 stderr 来分类。** 自由文本既不稳定也不安全。只有闭集协议值、typed 错误、当前调用位置与受管进程结果可以成为诊断输入。
|
||||
|
||||
**修改既有结束原因。** 结束原因继续表示提供方无关的终态结果。可选诊断说明非完成结果为何要求不同的下一步,而不增加新的公共结果状态。
|
||||
|
||||
**增加重试、恢复状态或交互审批。** 诊断只负责报告失败,不拥有修复动作。重试策略、会话恢复与人工交互需要独立用户约定和生命周期责任方。
|
||||
|
||||
## Consequences
|
||||
|
||||
父 agent 可以区分 ACP 远端限制、权限参与、协议或传输失败、部署/进程失败与 teardown 失败,同时不会接收子进程控制的文本。启动和清理错误与已发布结果使用同一套安全事实,而 Host 观测仍保留原始 cause。
|
||||
|
||||
诊断仍是展示文本,不是公共协议。消费方可以呈现它,但不得按格式分支。本决策不增加重试策略、恢复控制器、共享提供方错误 enum、stderr 分类器、认证分类、会话持久化、进度流或新的 ACP 能力。
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 docs/config-catalog.md
|
||||
config-catalog.md: a0b92c00c5fa442c64c383283ac7daae62a8551f
|
||||
config-catalog.zh.md: 302d8682a415d7366bbdcea2ebd326cff4a2054d
|
||||
config-catalog.md: e87e48b8f3d8a97a65940eb438068876d095f188
|
||||
config-catalog.zh.md: a9519ea9131a14abb4a398010ff3014d0321b39d
|
||||
|
||||
@@ -2265,7 +2265,7 @@ export interface Config {
|
||||
* `MAX_TIMER_DELAY_MS`.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
/** Failure-observation and termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -2267,7 +2267,7 @@ export interface Config {
|
||||
* `MAX_TIMER_DELAY_MS`.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
/** Failure-observation and termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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-acp/README.md
|
||||
README.md: cc4deb5b97152f106caabf747b8c7ccb2f5ddf8e
|
||||
README.zh.md: e28fb556801d4567bcc606a777e3b090e0551e03
|
||||
README.md: baee82fccf2ca4190a46d9ec6ce49ee06f55306c
|
||||
README.zh.md: 9617110e4f882e91fe33d510bf7bb2de9f1cc89a
|
||||
|
||||
@@ -6,13 +6,13 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`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.
|
||||
`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 ordinarily rejects after the subprocess is reaped; when cleanup itself rejects, ordered safe facts preserve startup plus teardown for an ordinary failure, or teardown alone after cancellation, without claiming whole-tree quiescence. A working-directory resolution failure rejects before anything is spawned. Non-cancellation rejections expose only fixed provider, stage, and category facts in their Error message; the original failure remains on the internal cause chain and in Host diagnostics.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport or early-process failure resolves with `stopReason: 'error'` and a safe `SubagentResult.diagnostic`; local cancellation resolves as `aborted` without failure detail. Partial assistant text remains in `output`, separate from the diagnostic.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -31,7 +31,7 @@ ACP advertises no start-time capabilities because this process cannot apply `req
|
||||
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. |
|
||||
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
|
||||
| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
|
||||
| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
|
||||
| `disposeGraceMs` | `3000` | Positive bound for observing structured process facts after failure and, on POSIX, the SIGTERM-to-SIGKILL grace (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
|
||||
|
||||
A DeepSeek Harness child uses the product launcher and an explicit absolute `DSH_HOME`. The isolated home prevents a nested runtime from discovering the launching person's profiles or credentials; the generic ACP provider does not impose this requirement on non-DSH agents.
|
||||
|
||||
@@ -50,13 +50,26 @@ A DeepSeek Harness child uses the product launcher and an explicit absolute `DSH
|
||||
|
||||
## Stop-reason mapping
|
||||
|
||||
| ACP | Harness |
|
||||
|---|---|
|
||||
| `end_turn` | `completed` |
|
||||
| `max_tokens` | `max-tokens` |
|
||||
| `refusal` | `refusal` |
|
||||
| `cancelled` | `aborted` |
|
||||
| `max_turn_requests` or unknown | `error` |
|
||||
| ACP | Harness | Additional diagnostic |
|
||||
|---|---|---|
|
||||
| `end_turn` | `completed` | None. |
|
||||
| `max_tokens` | `max-tokens` | Only a contributing permission decision. |
|
||||
| `refusal` | `refusal` | Only a contributing permission decision. |
|
||||
| `cancelled` | `aborted` | Only a contributing permission decision; local cancellation never adds one. |
|
||||
| `max_turn_requests` | `error` | `remote-limit` with the closed stop reason. |
|
||||
| unknown | `error` | Fixed `unknown`; the wire value is not copied. |
|
||||
|
||||
## Failure diagnostics
|
||||
|
||||
Failure diagnostics for generic error paths have a fixed field order:
|
||||
|
||||
```text
|
||||
Subagent failure (provider: ACP; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
Unavailable optional fields are omitted. The provider derives `initialize`, `new-session`, `prompt`, `process`, or `teardown` at the operation that owns the failure. Categories distinguish configuration, protocol or transport failure, process start/exit, remote limits, and the fixed unknown fallback. Exit code and signal come only from the managed subprocess outcome; stderr, exception messages, task text, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. The shared result boundary limits the complete text to 4096 UTF-8 bytes.
|
||||
|
||||
When a run requested permission and did not complete, a fixed permission line records the configured policy, the ACP closed tool kind, and whether the provider allowed or denied it. Tool titles, raw input, locations, and option text are excluded. For `max-tokens`, `refusal`, or remote `aborted`, this is the complete diagnostic because the public stop reason already carries the terminal fact; generic error paths put it after the failure line. Successful results and local cancellation omit it. A permission-diagnosed remote `aborted` result remains `aborted`; foreground presentation includes its diagnostic, while the one-shot Job adapter classifies that diagnostic-bearing remote abort as failed instead of conflating it with local cancellation.
|
||||
|
||||
## Process boundary
|
||||
|
||||
@@ -84,7 +97,7 @@ Independent of the parent request cache. Each ACP child can reuse only prefixes
|
||||
|
||||
#### What the model sees
|
||||
|
||||
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>`.
|
||||
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. Non-completed results present the safe diagnostic before separately preserved partial assistant output. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; another start failure contains only the fixed `Subagent failure (...)` line.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn 失败、初始化失败、新建会话失败或因发布前取消而失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在尚未 spawn 任何进程时拒绝。
|
||||
`start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn 失败、初始化失败、新建会话失败或因发布前取消而失败时,通常会在子进程已回收后拒绝;若清理自身也拒绝,有序的安全事实会在普通失败时保留 startup 与 teardown,在取消后只保留 teardown,且不会宣称整棵进程树已经完全停稳。工作目录解析失败则会在尚未 spawn 任何进程时拒绝。非取消拒绝的 Error 消息只公开固定的 provider、stage 与 category 事实;原始失败仍保留在内部 cause 链和 Host 诊断中。
|
||||
|
||||
工作目录优先使用已配置的 `cwd` 覆盖值,否则使用执行委派的父会话 cwd,绝不使用服务器进程自身的 cwd,因为同一个服务器进程会服务来自多个工作区的会话。从父级取得的值必须是绝对路径,指向 harness 可以进入的目录(具备搜索权限,这是子进程 cwd 的要求);解析后的同一路径同时作为子进程 cwd 和 ACP `session/new` 工作区。
|
||||
|
||||
返回的运行 id 在父级命名空间中生成。子服务器的会话 id 只用于 ACP 协议调用,因为 ACP 只保证它在该全新子进程中唯一;若将其用作父级生命周期 id,可能与另一个远程运行或本地 agent 冲突。
|
||||
|
||||
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。
|
||||
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败或进程提前退出会以 `stopReason: 'error'` 和安全的 `SubagentResult.diagnostic` 兑现;本地取消以 `aborted` 兑现,且不携带失败细节。部分 assistant 文本继续保留在 `output` 中,与诊断分开。
|
||||
|
||||
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
|
||||
|
||||
@@ -31,7 +31,7 @@ ACP 不声明任何启动时能力,因为当前进程无法应用 `request.age
|
||||
| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 |
|
||||
| `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 |
|
||||
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 |
|
||||
| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 |
|
||||
| `disposeGraceMs` | `3000` | 失败后观测结构化进程事实的正数时限;在 POSIX 上也作为 SIGTERM 到 SIGKILL 的宽限时间(Windows 直接强制终止),且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 |
|
||||
|
||||
DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `DSH_HOME`。隔离 home 可防止嵌套 runtime 发现启动者个人的 profile 或凭据;通用 ACP provider 不会把这一要求强加给非 DSH agent。
|
||||
|
||||
@@ -50,13 +50,26 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D
|
||||
|
||||
## 结束原因映射
|
||||
|
||||
| ACP | Harness |
|
||||
|---|---|
|
||||
| `end_turn` | `completed` |
|
||||
| `max_tokens` | `max-tokens` |
|
||||
| `refusal` | `refusal` |
|
||||
| `cancelled` | `aborted` |
|
||||
| `max_turn_requests` 或未知值 | `error` |
|
||||
| ACP | Harness | 附加诊断 |
|
||||
|---|---|---|
|
||||
| `end_turn` | `completed` | 无。 |
|
||||
| `max_tokens` | `max-tokens` | 仅记录参与失败的权限决定。 |
|
||||
| `refusal` | `refusal` | 仅记录参与失败的权限决定。 |
|
||||
| `cancelled` | `aborted` | 仅记录参与失败的权限决定;本地取消绝不附加。 |
|
||||
| `max_turn_requests` | `error` | `remote-limit` 与闭集结束原因。 |
|
||||
| 未知值 | `error` | 固定 `unknown`,不复制 wire 原值。 |
|
||||
|
||||
## 失败诊断
|
||||
|
||||
通用 error 路径的失败诊断采用固定字段顺序:
|
||||
|
||||
```text
|
||||
Subagent failure (provider: ACP; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
不可用的可选字段会被省略。提供方从实际拥有失败的操作派生 `initialize`、`new-session`、`prompt`、`process` 或 `teardown`。category 区分配置、协议或传输失败、进程启动/退出、远端限制以及固定 unknown 回退。退出码与信号只来自受管子进程结果;stderr、异常消息、任务文本、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。
|
||||
|
||||
当运行请求过权限且最终未完成时,一个固定权限行会记录已配置策略、ACP 闭集工具种类以及提供方允许还是拒绝。工具标题、raw input、位置与选项文本均被排除。对于 `max-tokens`、`refusal` 或远端 `aborted`,公共结束原因已经携带终态事实,因此该权限行就是完整诊断;通用 error 路径则把它放在失败行之后。成功结果和本地取消会省略权限行。带权限诊断的远端 `aborted` 结果仍保持 `aborted`;前台会呈现该诊断,而一次性 Job adapter 会把这种带诊断的远端取消判为 failed,避免与本地取消混淆。
|
||||
|
||||
## 进程边界
|
||||
|
||||
@@ -84,7 +97,7 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败按原样传递为 `Error: <message>`。
|
||||
通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。非完成结果会先呈现安全诊断,再单独呈现保留的部分 assistant 输出。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败只包含固定的 `Subagent failure (...)` 行。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
import { acpConfigurationFailure, type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
export const inject = ['subagents', 'subprocess']
|
||||
@@ -59,7 +59,7 @@ export interface Config {
|
||||
* `MAX_TIMER_DELAY_MS`.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
/** Failure-observation and termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const Config: z<Config> = z.object({
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
|
||||
/** A process grace must fit every Node timer that observes or terminates the child. */
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
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}`)
|
||||
@@ -157,10 +157,21 @@ class AcpProvider implements SubagentProvider {
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
if (request.signal.aborted) {
|
||||
throw new Error('subagent request was aborted before the ACP child started')
|
||||
}
|
||||
let cwd: string
|
||||
try {
|
||||
cwd = resolveCwd(this.config.cwd, request)
|
||||
} catch (error: unknown) {
|
||||
const failure = acpConfigurationFailure(error)
|
||||
this.ctx.logger.warn(`subagent-acp "${this.name}": child start failed: %o`, error)
|
||||
throw failure
|
||||
}
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: resolveCwd(this.config.cwd, request),
|
||||
cwd,
|
||||
permission: this.config.permission,
|
||||
env: this.config.env,
|
||||
disposeEofGraceMs: this.config.disposeEofGraceMs,
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
* Fresh-process ACP subagent client. Drives one child session and owns cancellation and
|
||||
* quiescent disposal.
|
||||
*
|
||||
* TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
|
||||
* sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
|
||||
* with-key coverage drives the real ACP example.
|
||||
* @module @deepseek-ai/dsh-subagent-acp/run
|
||||
*/
|
||||
|
||||
@@ -17,12 +14,13 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
type ContentBlock as AcpContentBlock,
|
||||
type StopReason,
|
||||
type ToolKind,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent'
|
||||
import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
|
||||
export type PermissionPolicy = 'allow' | 'reject'
|
||||
@@ -59,9 +57,11 @@ export interface AcpRunSpec {
|
||||
*/
|
||||
disposeEofGraceMs: number
|
||||
/**
|
||||
* Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
|
||||
* waits this long after `SIGTERM` before `SIGKILL`, while Windows
|
||||
* force-terminates directly. The plugin fills it from `disposeGraceMs`.
|
||||
* Process-observation and termination-escalation grace (ms). Failure
|
||||
* classification waits at most this long for structured exit facts; POSIX
|
||||
* dispose also waits this long after `SIGTERM` before `SIGKILL`, while
|
||||
* Windows force-terminates directly. The plugin fills it from
|
||||
* `disposeGraceMs`.
|
||||
*/
|
||||
disposeGraceMs: number
|
||||
/**
|
||||
@@ -71,12 +71,9 @@ export interface AcpRunSpec {
|
||||
*/
|
||||
spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
|
||||
/**
|
||||
* Sink for a child-level failure that the run flattened into a stop reason
|
||||
* (the seam contract forbids `result` rejecting). The driver calls this with
|
||||
* the original error and the chosen stop reason so the fault is preserved
|
||||
* rather than silently lost; the provider wires it to `ctx.logger.warn`.
|
||||
* A throw from the sink itself is contained — it cannot reject `result`.
|
||||
* Optional — omitted in a unit test that asserts the stop reason directly.
|
||||
* Host sink for startup, published-run, or teardown failures. Model-visible
|
||||
* text uses fixed safe facts, while this callback retains the original Error
|
||||
* when one exists. A throw from the sink itself is contained.
|
||||
*/
|
||||
onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
@@ -87,6 +84,90 @@ 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
|
||||
|
||||
type AcpFailureStage = 'initialize' | 'new-session' | 'prompt' | 'process' | 'teardown'
|
||||
|
||||
type AcpFailureCategory =
|
||||
| 'protocol'
|
||||
| 'configuration'
|
||||
| 'transport'
|
||||
| 'process-start'
|
||||
| 'process-exit'
|
||||
| 'remote-limit'
|
||||
| 'unknown'
|
||||
|
||||
interface AcpFailureFacts {
|
||||
readonly stage: AcpFailureStage
|
||||
readonly category: AcpFailureCategory
|
||||
readonly stopReason?: StopReason | 'unknown'
|
||||
readonly outcome?: SubprocessOutcome | undefined
|
||||
}
|
||||
|
||||
interface AcpPermissionDecision {
|
||||
readonly policy: PermissionPolicy
|
||||
readonly request: ToolKind | 'unknown'
|
||||
readonly decision: 'allowed' | 'denied'
|
||||
}
|
||||
|
||||
const ACP_TOOL_KINDS: ReadonlySet<string> = new Set([
|
||||
'read', 'edit', 'delete', 'move', 'search',
|
||||
'execute', 'think', 'fetch', 'switch_mode', 'other',
|
||||
])
|
||||
|
||||
/** Fixed safe failure text derived only from provider-owned structured facts. */
|
||||
function failureDiagnostic(facts: AcpFailureFacts): string {
|
||||
const fields = [
|
||||
'provider: ACP',
|
||||
`stage: ${facts.stage}`,
|
||||
`category: ${facts.category}`,
|
||||
]
|
||||
if (facts.stopReason !== undefined) fields.push(`stop reason: ${facts.stopReason}`)
|
||||
if (facts.outcome?.exitCode !== null && facts.outcome?.exitCode !== undefined) {
|
||||
fields.push(`exit code: ${facts.outcome.exitCode}`)
|
||||
}
|
||||
if (facts.outcome?.signal !== null && facts.outcome?.signal !== undefined) {
|
||||
fields.push(`signal: ${facts.outcome.signal}`)
|
||||
}
|
||||
return `Subagent failure (${fields.join('; ')})`
|
||||
}
|
||||
|
||||
/** Fixed permission fact; ACP tool titles and option text never enter it. */
|
||||
function permissionDiagnostic(permission: AcpPermissionDecision): string {
|
||||
return `ACP unattended decision (policy: ${permission.policy}; request: ${permission.request}; decision: ${permission.decision})`
|
||||
}
|
||||
|
||||
/** Put the operation failure first, followed by the latest permission decision. */
|
||||
function diagnosticText(facts: AcpFailureFacts, permission?: AcpPermissionDecision): string {
|
||||
const failure = failureDiagnostic(facts)
|
||||
return permission === undefined ? failure : `${failure}\n${permissionDiagnostic(permission)}`
|
||||
}
|
||||
|
||||
class AcpRunFailure extends Error {
|
||||
constructor(facts: AcpFailureFacts, cause: unknown) {
|
||||
super(
|
||||
`subagent-acp: ${failureDiagnostic(facts)}`,
|
||||
{ cause },
|
||||
)
|
||||
this.name = 'AcpRunFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a pre-spawn workspace/configuration failure behind fixed safe facts.
|
||||
* @param cause - original Host failure retained on the Error cause chain.
|
||||
* @returns an Error whose message contains only the fixed ACP failure line.
|
||||
*/
|
||||
export function acpConfigurationFailure(cause: unknown): Error {
|
||||
return new AcpRunFailure({ stage: 'initialize', category: 'configuration' }, cause)
|
||||
}
|
||||
|
||||
/** Keep only the closed ACP tool-kind vocabulary; future values use a fixed fallback. */
|
||||
function permissionRequestKind(kind: ToolKind | null | undefined): ToolKind | 'unknown' {
|
||||
const candidate = kind ?? 'unknown'
|
||||
return ACP_TOOL_KINDS.has(candidate)
|
||||
? candidate
|
||||
: 'unknown'
|
||||
}
|
||||
|
||||
/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
|
||||
async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
|
||||
const controller = new AbortController()
|
||||
@@ -183,10 +264,65 @@ function toError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/** Report an original Host failure without letting the observation sink replace it. */
|
||||
function reportFailure(spec: AcpRunSpec, error: unknown): void {
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
// Host diagnostic logging cannot replace the child failure.
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify an unpublished failure from the active protocol operation and observed process facts. */
|
||||
function startupFailure(
|
||||
error: unknown,
|
||||
stage: Extract<AcpFailureStage, 'initialize' | 'new-session'>,
|
||||
child: SubprocessHandle,
|
||||
outcome: SubprocessOutcome | undefined,
|
||||
): AcpRunFailure {
|
||||
if (child.pid <= 0) {
|
||||
return new AcpRunFailure({ stage: 'process', category: 'process-start' }, error)
|
||||
}
|
||||
return new AcpRunFailure(
|
||||
outcome === undefined
|
||||
? { stage, category: 'transport' }
|
||||
: { stage, category: 'process-exit', outcome },
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
/** Map one remote terminal reason to the optional safe failure line it needs. */
|
||||
function terminalFailure(
|
||||
reason: StopReason,
|
||||
permission: AcpPermissionDecision | undefined,
|
||||
): string | undefined {
|
||||
switch (reason) {
|
||||
case 'end_turn':
|
||||
return undefined
|
||||
case 'max_turn_requests':
|
||||
return diagnosticText({
|
||||
stage: 'prompt',
|
||||
category: 'remote-limit',
|
||||
stopReason: 'max_turn_requests',
|
||||
}, permission)
|
||||
case 'max_tokens':
|
||||
case 'refusal':
|
||||
case 'cancelled':
|
||||
return permission === undefined
|
||||
? undefined
|
||||
: permissionDiagnostic(permission)
|
||||
default:
|
||||
return diagnosticText({ stage: 'prompt', category: 'unknown', stopReason: 'unknown' }, permission)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start and publish one ACP child after initialization and session creation.
|
||||
* Child failures resolve through the run result; startup failures reject after
|
||||
* process reap. Disposal cancels, kills, and reaps the child.
|
||||
* Child failures resolve through the run result. Startup rejects with fixed
|
||||
* safe facts after provider-owned cleanup; successful cleanup proves process
|
||||
* reap. Cleanup failure preserves startup plus teardown facts for an ordinary
|
||||
* failure, or teardown alone after cancellation, without claiming quiescence.
|
||||
* Disposal cancels, kills, and reaps the child.
|
||||
* @param request - the start request; its signal is the cancellation channel.
|
||||
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
||||
* policy, dispose graces, and the optional error sink.
|
||||
@@ -202,29 +338,61 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
// Keep diagnostics on parent stderr ('inherit'); only ACP output contributes
|
||||
// to the result. The seam's scrub drops ambient credentials and DSH_* names
|
||||
// while spec.env (the child's own key, its deployment facts) merges after it.
|
||||
const child = spec.spawn({
|
||||
argv: [spec.command, ...spec.args],
|
||||
cwd: spec.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs: spec.disposeGraceMs,
|
||||
env: spec.env,
|
||||
})
|
||||
let child: SubprocessHandle
|
||||
try {
|
||||
child = spec.spawn({
|
||||
argv: [spec.command, ...spec.args],
|
||||
cwd: spec.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs: spec.disposeGraceMs,
|
||||
env: spec.env,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
reportFailure(spec, error)
|
||||
throw new AcpRunFailure({ stage: 'process', category: 'process-start' }, error)
|
||||
}
|
||||
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
|
||||
if (child.stdin === undefined || child.stdout === undefined) {
|
||||
throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream')
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
let processOutcome: SubprocessOutcome | undefined
|
||||
const processDone = child.done.then((outcome) => {
|
||||
processOutcome = outcome
|
||||
return outcome
|
||||
})
|
||||
|
||||
// Spawn-level failure surfaces as `done` rejecting into the startup race; a
|
||||
// clean exit must never win it, so the success arm parks forever. (The ACP
|
||||
// connection observing its streams closing bounds a child that exits
|
||||
// without speaking the protocol.)
|
||||
const spawnFailed: Promise<never> = child.done.then(
|
||||
const spawnFailed: Promise<never> = processDone.then(
|
||||
/* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
|
||||
() => new Promise<never>(() => {}),
|
||||
(err: unknown) => Promise.reject(toError(err)),
|
||||
)
|
||||
spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ })
|
||||
|
||||
const observeProcessOutcome = async (signal?: AbortSignal): Promise<SubprocessOutcome | undefined> => {
|
||||
if (processOutcome !== undefined || child.pid <= 0) return processOutcome
|
||||
const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs))
|
||||
const bound = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
|
||||
const aborted = Promise.withResolvers<undefined>()
|
||||
const onObservationAbort = (): void => { aborted.resolve(undefined) }
|
||||
bound.addEventListener('abort', onObservationAbort, { once: true })
|
||||
/* v8 ignore next -- closes the event-loop race between listener registration and the preceding derived-signal check. */
|
||||
if (bound.aborted) onObservationAbort()
|
||||
try {
|
||||
return await Promise.race([processDone, aborted.promise])
|
||||
} catch {
|
||||
// The active protocol failure remains authoritative when exit observation fails.
|
||||
/* v8 ignore next -- a published child.done cannot reject; spawn rejection is consumed before publication. */
|
||||
return processOutcome
|
||||
} finally {
|
||||
bound.removeEventListener('abort', onObservationAbort)
|
||||
}
|
||||
}
|
||||
|
||||
// Startup rollback and the published handle share one process teardown.
|
||||
let processDisposal: Promise<void> | undefined
|
||||
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
|
||||
@@ -234,6 +402,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
const fold = new AssistantOutputFold()
|
||||
// Shared mutable state keeps cancellation visible across async closures.
|
||||
const flags = { cancelled: false }
|
||||
let latestPermission: AcpPermissionDecision | undefined
|
||||
|
||||
const clientApp = createAcpClientApp({ name: 'deepseek-harness-subagent-acp' })
|
||||
.onNotification(methods.client.session.update, ({ params }) => {
|
||||
@@ -252,9 +421,19 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
if (spec.permission === 'allow') {
|
||||
const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always')
|
||||
if (allow !== undefined) {
|
||||
latestPermission = {
|
||||
policy: 'allow',
|
||||
request: permissionRequestKind(params.toolCall.kind),
|
||||
decision: 'allowed',
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } })
|
||||
}
|
||||
}
|
||||
latestPermission = {
|
||||
policy: spec.permission,
|
||||
request: permissionRequestKind(params.toolCall.kind),
|
||||
decision: 'denied',
|
||||
}
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
})
|
||||
|
||||
@@ -265,6 +444,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
const agent = connection.agent
|
||||
|
||||
let sessionId: string | undefined
|
||||
let startupStage: Extract<AcpFailureStage, 'initialize' | 'new-session'> = 'initialize'
|
||||
// Cancellation settles the result without waiting for a cooperative child.
|
||||
let signalCancelSettled!: () => void
|
||||
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
|
||||
@@ -295,9 +475,15 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
// child self-serves in its own process.
|
||||
clientCapabilities: {},
|
||||
})
|
||||
startupStage = 'new-session'
|
||||
const session = await agent.request(methods.agent.session.new, { cwd: spec.cwd, mcpServers: [] })
|
||||
const returnedSessionId: unknown = Reflect.get(session, 'sessionId')
|
||||
if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id')
|
||||
if (typeof returnedSessionId !== 'string') {
|
||||
throw new AcpRunFailure(
|
||||
{ stage: 'new-session', category: 'protocol' },
|
||||
new Error('ACP child published without a session id'),
|
||||
)
|
||||
}
|
||||
sessionId = returnedSessionId
|
||||
/* v8 ignore next -- cancelSettled wins the startup race before this post-response guard can settle it. */
|
||||
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
|
||||
@@ -307,9 +493,47 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
await disposeProcess()
|
||||
if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
|
||||
throw toError(error)
|
||||
const cancelledBeforeCleanup = flags.cancelled
|
||||
// A child closing its protocol stream can precede whole-tree exit
|
||||
// observation. Local cancellation does not need the discarded startup
|
||||
// classification; other failures use the configured process grace.
|
||||
const startup = cancelledBeforeCleanup
|
||||
? { kind: 'cancelled' } as const
|
||||
: {
|
||||
kind: 'failed',
|
||||
failure: error instanceof AcpRunFailure
|
||||
? error
|
||||
: startupFailure(error, startupStage, child, await observeProcessOutcome()),
|
||||
} as const
|
||||
if (startup.kind === 'cancelled') {
|
||||
// Local cancellation owns the startup outcome; only cleanup failure is
|
||||
// reported below when teardown itself rejects.
|
||||
} else {
|
||||
reportFailure(spec, error instanceof AcpRunFailure
|
||||
? error.cause
|
||||
: error)
|
||||
}
|
||||
try {
|
||||
await disposeProcess()
|
||||
} catch (cleanupError: unknown) {
|
||||
reportFailure(spec, cleanupError)
|
||||
const cleanupFailure = new AcpRunFailure({
|
||||
stage: 'teardown',
|
||||
category: processOutcome === undefined ? 'unknown' : 'process-exit',
|
||||
...(processOutcome === undefined ? {} : { outcome: processOutcome }),
|
||||
}, cleanupError)
|
||||
if (startup.kind === 'cancelled') {
|
||||
throw new AggregateError([cleanupFailure], cleanupFailure.message)
|
||||
}
|
||||
throw new AggregateError(
|
||||
[startup.failure, cleanupFailure],
|
||||
`${startup.failure.message}; ${cleanupFailure.message}`,
|
||||
)
|
||||
}
|
||||
if (startup.kind === 'cancelled') {
|
||||
throw new Error('subagent request was aborted before the ACP child started')
|
||||
}
|
||||
throw startup.failure
|
||||
}
|
||||
// The startup transaction validates the returned id before it can fulfill.
|
||||
// This assertion carries that cross-closure invariant into TypeScript.
|
||||
@@ -317,51 +541,62 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id')
|
||||
const remoteSessionId = sessionId
|
||||
|
||||
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
// Race the remote turn against local cancellation.
|
||||
const prompt = async (): Promise<SubagentResult> => {
|
||||
// The startup phase cannot fulfill without assigning the session id.
|
||||
const promptResult = await agent.request(methods.agent.session.prompt, {
|
||||
sessionId: remoteSessionId,
|
||||
prompt: toAcpPrompt(request.prompt),
|
||||
})
|
||||
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
|
||||
}
|
||||
return await Promise.race([
|
||||
prompt(),
|
||||
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
// Cover a process rejection already queued when cancellation arrives.
|
||||
/* v8 ignore next */
|
||||
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
|
||||
// Flatten post-publication transport failures while preserving diagnostics.
|
||||
let diagnostic: string | undefined
|
||||
const result: Promise<SubagentResult> = settleRunResult({
|
||||
attempt: async (): Promise<SubagentResult> => {
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
// The diagnostic sink cannot reject the run result.
|
||||
const promptResult = await Promise.race([
|
||||
agent.request(methods.agent.session.prompt, {
|
||||
sessionId: remoteSessionId,
|
||||
prompt: toAcpPrompt(request.prompt),
|
||||
}),
|
||||
cancelSettled.then((): never => { throw new Error('subagent cancelled while the ACP prompt was running') }),
|
||||
])
|
||||
const stopReason = acpStopReason(promptResult.stopReason)
|
||||
diagnostic = terminalFailure(promptResult.stopReason, latestPermission)
|
||||
return {
|
||||
output: collectOutput(),
|
||||
...(diagnostic === undefined ? {} : { diagnostic }),
|
||||
stopReason,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!flags.cancelled) {
|
||||
const outcome = await observeProcessOutcome(request.signal)
|
||||
const facts = outcome === undefined
|
||||
? { stage: 'prompt', category: 'transport' } as const
|
||||
: { stage: 'process', category: 'process-exit', outcome } as const
|
||||
diagnostic = diagnosticText(facts, latestPermission)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { output: collectOutput(), stopReason: 'error' }
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
requestCancel()
|
||||
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
|
||||
// stdin EOF, including the final flush, so this backend uses a wider EOF
|
||||
// grace before process termination escalates.
|
||||
disposal = disposeProcess()
|
||||
return disposal
|
||||
},
|
||||
}
|
||||
collectOutput,
|
||||
collectDiagnostic: () => diagnostic,
|
||||
cancelled: () => flags.cancelled,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
return subprocessRunHandle({
|
||||
id,
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: async () => {
|
||||
try {
|
||||
// ACP normally quiesces from stdin EOF, including the final flush, so
|
||||
// this backend uses a wider EOF grace before process termination.
|
||||
await disposeProcess()
|
||||
} catch (error: unknown) {
|
||||
reportFailure(spec, error)
|
||||
throw new AcpRunFailure({
|
||||
stage: 'teardown',
|
||||
category: processOutcome === undefined ? 'unknown' : 'process-exit',
|
||||
...(processOutcome === undefined ? {} : { outcome: processOutcome }),
|
||||
}, error)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Test-only composition: the ACP subagent backend on the real Loader/app path.
|
||||
# The scripted model delegates once; the scripted mock ACP child (MOCK_ECHO_CWD)
|
||||
# echoes its process cwd and announced session cwd, so parent-session cwd
|
||||
# inheritance is asserted keylessly end to end. `cwd` is deliberately omitted —
|
||||
# the inheritance branch under test. The child command path is machine-absolute,
|
||||
# so the driving e2e supplies it via DSH_TEST_MOCK_ACP_SERVER.
|
||||
# The scripted model delegates once. The driving e2e selects either the cwd
|
||||
# echo or a remote-limit diagnostic through DSH_TEST_ACP_MODE. `cwd` is
|
||||
# deliberately omitted so both paths exercise parent-session inheritance. The
|
||||
# child command path is machine-absolute and arrives through
|
||||
# DSH_TEST_MOCK_ACP_SERVER.
|
||||
- id: mock-llm
|
||||
name: './mock-delegating-llm.ts'
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
args:
|
||||
- !!js process.env.DSH_TEST_MOCK_ACP_SERVER
|
||||
permission: reject
|
||||
env:
|
||||
MOCK_ECHO_CWD: '1'
|
||||
env: !!js "process.env.DSH_TEST_ACP_MODE === 'diagnostic' ? { MOCK_TEXT: 'partial loader answer', MOCK_STOP: 'max_turn_requests' } : { MOCK_ECHO_CWD: '1' }"
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
|
||||
@@ -7,12 +7,10 @@ import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
/**
|
||||
* Keyless REAL-composition coverage for parent-session cwd inheritance: a
|
||||
* test-only cordis.yml boots the headless app through the Loader with the ACP
|
||||
* backend's `cwd` omitted, a scripted model delegates once, and the scripted
|
||||
* mock ACP child echoes where it actually ran plus the workspace it was
|
||||
* announced — both must be the parent session's cwd. Mock-only composition, so
|
||||
* only this keyless tier applies (the with-key tier lives in subagent-acp.e2e.ts).
|
||||
* Keyless REAL-composition coverage for the ACP provider through a test-only
|
||||
* cordis.yml: parent-session cwd inheritance and model-visible failure detail
|
||||
* both cross the Loader, subprocess, ACP, tool, and persisted-session paths.
|
||||
* The with-key tier lives in subagent-acp.e2e.ts.
|
||||
*/
|
||||
|
||||
const driver = fileURLToPath(new URL(
|
||||
@@ -36,6 +34,15 @@ async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
function toolResultText(events: SessionEvent[]): string {
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
return results[0]!.data.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
|
||||
it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
@@ -62,12 +69,34 @@ describe('ACP subagent cwd inheritance through a real cordis.yml', () => {
|
||||
// The tool result carries the child's two-line echo: its real process.cwd()
|
||||
// and the cwd the backend announced in `session/new` — both 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.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
expect(resultText).toBe(`${workspace}\n${workspace}`)
|
||||
expect(toolResultText(events)).toBe(`${workspace}\n${workspace}`)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('presents the ACP remote-limit diagnostic separately from partial output', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'acp-subagent diagnostic composition smoke',
|
||||
tempDirPrefix: 'acp-subagent-diagnostic-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
DSH_TEST_MOCK_ACP_SERVER: mockServer,
|
||||
DSH_TEST_ACP_MODE: 'diagnostic',
|
||||
},
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(toolResultText(events)).toBe(
|
||||
'Error: subagent run failed\n'
|
||||
+ 'Diagnostic: Subagent failure (provider: ACP; stage: prompt; category: remote-limit; stop reason: max_turn_requests)\n'
|
||||
+ 'Partial output before the run ended:\npartial loader answer',
|
||||
)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
* `dispose()` must still kill the process.
|
||||
* - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission`
|
||||
* before answering, to exercise the client's auto-answer.
|
||||
* - `MOCK_PERMISSION_IGNORE_DECISION` — if `1`, continue after a denied
|
||||
* permission so the terminal failure can carry the
|
||||
* provider's fixed permission fact.
|
||||
* - `MOCK_CRASH_ON_INITIALIZE` — exit while the unpublished initialize
|
||||
* operation is active.
|
||||
* - `MOCK_CLOSE_PROTOCOL_ON_INITIALIZE` — close stdout while keeping the
|
||||
* process alive, producing initialize-stage transport.
|
||||
* - `MOCK_CLOSE_PROTOCOL_ON_PROMPT` — close stdout while keeping the process
|
||||
* alive, producing a prompt-stage transport failure.
|
||||
* - `MOCK_CRASH_AFTER_CHUNK` — exit after streaming the assistant chunk, so
|
||||
* the parent preserves partial output with process facts.
|
||||
* - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead:
|
||||
* the agent PROCESS's `process.cwd()` and the `cwd` the
|
||||
* client announced in `session/new` — so a test can assert
|
||||
@@ -70,6 +81,7 @@ import {
|
||||
type PromptResponse,
|
||||
type RequestPermissionResponse,
|
||||
type StopReason,
|
||||
type ToolKind,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
// When MOCK_ECHO_ENV names a variable, stream that variable's value in place
|
||||
@@ -82,11 +94,17 @@ const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1'
|
||||
const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason
|
||||
const HANG = process.env.MOCK_HANG === '1'
|
||||
const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
|
||||
const IGNORE_PERMISSION_DECISION = process.env.MOCK_PERMISSION_IGNORE_DECISION === '1'
|
||||
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
|
||||
const THOUGHT = process.env.MOCK_THOUGHT === '1'
|
||||
const CRASH_ON_INITIALIZE = process.env.MOCK_CRASH_ON_INITIALIZE === '1'
|
||||
const CLOSE_PROTOCOL_ON_INITIALIZE = process.env.MOCK_CLOSE_PROTOCOL_ON_INITIALIZE === '1'
|
||||
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
|
||||
const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
|
||||
const CLOSE_PROTOCOL_ON_PROMPT = process.env.MOCK_CLOSE_PROTOCOL_ON_PROMPT === '1'
|
||||
const CRASH_AFTER_CHUNK = process.env.MOCK_CRASH_AFTER_CHUNK === '1'
|
||||
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
|
||||
const TOOL_KIND = process.env.MOCK_TOOL_KIND as ToolKind | undefined
|
||||
const READY_FILE = process.env.MOCK_READY_FILE
|
||||
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
|
||||
// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks
|
||||
@@ -104,6 +122,12 @@ function makeAgent() {
|
||||
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
if (CRASH_ON_INITIALIZE) process.exit(11)
|
||||
if (CLOSE_PROTOCOL_ON_INITIALIZE) {
|
||||
process.stdout.end()
|
||||
setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000)
|
||||
return new Promise<InitializeResponse>(() => {})
|
||||
}
|
||||
return Promise.resolve({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: { promptCapabilities: { image: false, audio: false, embeddedContext: false } },
|
||||
@@ -128,6 +152,11 @@ function makeAgent() {
|
||||
},
|
||||
async prompt(params: PromptRequest, conn: AgentContext): Promise<PromptResponse> {
|
||||
if (CRASH_ON_PROMPT) process.exit(1)
|
||||
if (CLOSE_PROTOCOL_ON_PROMPT) {
|
||||
process.stdout.end()
|
||||
setInterval(() => { /* keep the process alive after protocol EOF */ }, 1000)
|
||||
return new Promise<PromptResponse>(() => {})
|
||||
}
|
||||
if (WANT_PERMISSION) {
|
||||
// Ask the client to approve before answering; honor its decision. Under
|
||||
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy
|
||||
@@ -140,10 +169,14 @@ function makeAgent() {
|
||||
]
|
||||
const decision = await conn.request(methods.client.session.requestPermission, {
|
||||
sessionId: params.sessionId,
|
||||
toolCall: { toolCallId: 'mock-call', title: 'mock side effect' },
|
||||
toolCall: {
|
||||
toolCallId: 'mock-call',
|
||||
title: 'mock side effect',
|
||||
...(TOOL_KIND === undefined ? {} : { kind: TOOL_KIND }),
|
||||
},
|
||||
options,
|
||||
}) as RequestPermissionResponse
|
||||
if (decision.outcome.outcome === 'cancelled') {
|
||||
if (decision.outcome.outcome === 'cancelled' && !IGNORE_PERMISSION_DECISION) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
@@ -164,6 +197,10 @@ function makeAgent() {
|
||||
content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT },
|
||||
},
|
||||
})
|
||||
if (CRASH_AFTER_CHUNK) {
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
process.exit(17)
|
||||
}
|
||||
// Signal "prompt is in flight" by touching the readiness file, so a test
|
||||
// can wait on a CONDITION (file exists) rather than an arbitrary timeout
|
||||
// before cancelling — deterministic regardless of subprocess cold-start.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import * as acp from '../src/index.ts'
|
||||
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
@@ -59,6 +59,14 @@ function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
function expectedFailure(fields: string): string {
|
||||
return `Subagent failure (provider: ACP; ${fields})`
|
||||
}
|
||||
|
||||
function expectedPermission(policy: 'allow' | 'reject', requestKind: string, decision: 'allowed' | 'denied'): string {
|
||||
return `ACP unattended decision (policy: ${policy}; request: ${requestKind}; decision: ${decision})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
||||
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
||||
@@ -73,6 +81,46 @@ async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFinalExitWait(child: SubprocessHandle, message: string): SubprocessHandle {
|
||||
return {
|
||||
pid: child.pid,
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
collected: child.collected,
|
||||
done: child.done,
|
||||
terminate: () => { child.terminate() },
|
||||
waitForExit: (signal?: AbortSignal) => signal === undefined
|
||||
? Promise.reject(new Error(message))
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFinalExitWaitAfterExit(child: SubprocessHandle, message: string): SubprocessHandle {
|
||||
return {
|
||||
...rejectFinalExitWait(child, message),
|
||||
waitForExit: (signal?: AbortSignal) => signal === undefined
|
||||
? child.done.then(() => Promise.reject(new Error(message)))
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
}
|
||||
|
||||
function tapBoundedExitWait(child: SubprocessHandle, onWait: () => void): SubprocessHandle {
|
||||
return {
|
||||
pid: child.pid,
|
||||
stdin: child.stdin,
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
collected: child.collected,
|
||||
done: child.done,
|
||||
terminate: () => { child.terminate() },
|
||||
waitForExit: (signal?: AbortSignal) => {
|
||||
if (signal !== undefined) onWait()
|
||||
return child.waitForExit(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('acpStopReason', () => {
|
||||
it('maps each ACP stop reason to the harness vocabulary', () => {
|
||||
expect(acpStopReason('end_turn')).toBe('completed')
|
||||
@@ -228,7 +276,7 @@ describe('cwd resolution', () => {
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('no working directory')
|
||||
.rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
||||
// Resolution failed BEFORE the process boundary — nothing was launched.
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
@@ -349,7 +397,7 @@ describe('cwd resolution', () => {
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('must be an absolute path')
|
||||
.rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
||||
})
|
||||
|
||||
it('rejects a parent session cwd that names a FILE, not a directory', async () => {
|
||||
@@ -360,7 +408,7 @@ describe('cwd resolution', () => {
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
.rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -376,7 +424,7 @@ describe('cwd resolution', () => {
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
.rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
@@ -391,6 +439,7 @@ describe('dsh-subagent-acp', () => {
|
||||
expect(run.id).not.toBe('acp-child-session')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
expect(text(result.output)).toBe('hello from acp child')
|
||||
const disposal = run.dispose()
|
||||
expect(run.dispose()).toBe(disposal)
|
||||
@@ -408,6 +457,7 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -416,6 +466,56 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('refusal')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['max_tokens', 'max-tokens'],
|
||||
['refusal', 'refusal'],
|
||||
] as const)('adds a permission fact to %s without changing its stop reason', async (remote, stopReason) => {
|
||||
const ctx = await setup({
|
||||
MOCK_PERMISSION: '1',
|
||||
MOCK_PERMISSION_IGNORE_DECISION: '1',
|
||||
MOCK_TOOL_KIND: 'read',
|
||||
MOCK_STOP: remote,
|
||||
}, 'reject')
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe(stopReason)
|
||||
expect(result.diagnostic).toBe(expectedPermission('reject', 'read', 'denied'))
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps an ordinary remote cancelled stop diagnostic-free', async () => {
|
||||
const ctx = await setup({ MOCK_STOP: 'cancelled' })
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'mock child answer' }], stopReason: 'aborted' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('preserves max_turn_requests as an actionable remote limit', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: 'max_turn_requests' })
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [{ type: 'text', text: 'partial' }],
|
||||
diagnostic: expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests'),
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('uses a fixed fallback for an unknown remote stop reason', async () => {
|
||||
const rawReason = 'private/path/SECRET_TOKEN'
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: rawReason })
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
expectedFailure('stage: prompt; category: unknown; stop reason: unknown'),
|
||||
)
|
||||
expect(result.diagnostic).not.toContain(rawReason)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -432,6 +532,7 @@ describe('dsh-subagent-acp', () => {
|
||||
controller.abort('test')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
@@ -459,9 +560,56 @@ describe('dsh-subagent-acp', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', {
|
||||
prompt: [{ type: 'text' as const, text: 'p' }],
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
})).rejects.toThrow('subagent request was aborted before the ACP child started')
|
||||
})
|
||||
|
||||
it('reports an initialize-stage process exit without copying the transport error', async () => {
|
||||
const error = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CRASH_ON_INITIALIZE: '1' },
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
spawn: spawnSubprocess,
|
||||
}).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: initialize; category: process-exit; exit code: 11')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('reports initialize-stage transport when the child closes the protocol but stays alive', async () => {
|
||||
const error = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_INITIALIZE: '1' },
|
||||
disposeEofGraceMs: 50,
|
||||
disposeGraceMs: 50,
|
||||
spawn: spawnSubprocess,
|
||||
}).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('reaps a child whose session/new response omits the session id', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
|
||||
const flushed = join(tmp, 'flushed')
|
||||
let boundedWaits = 0
|
||||
try {
|
||||
await expect(startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
@@ -475,11 +623,83 @@ describe('dsh-subagent-acp', () => {
|
||||
},
|
||||
disposeEofGraceMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
spawn: spawnSubprocess,
|
||||
})).rejects.toThrow('ACP child published without a session id')
|
||||
spawn: spec => tapBoundedExitWait(spawnSubprocess(spec), () => { boundedWaits += 1 }),
|
||||
})).rejects.toThrow(
|
||||
`subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}`,
|
||||
)
|
||||
// Startup rejects only after its private child reaches quiescence. The
|
||||
// marker proves rollback closed stdin and allowed the child's EOF flush.
|
||||
expect(existsSync(flushed)).toBe(true)
|
||||
expect(boundedWaits).toBe(1)
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('aggregates safe startup and teardown facts when rollback itself fails', async () => {
|
||||
const rawCleanup = 'rollback leaked /private/path SECRET_TOKEN'
|
||||
let realChild: SubprocessHandle | undefined
|
||||
const errors: string[] = []
|
||||
const error = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_MISSING_SESSION_ID: '1' },
|
||||
disposeEofGraceMs: 10,
|
||||
disposeGraceMs: 10,
|
||||
spawn: (spec) => {
|
||||
realChild = spawnSubprocess(spec)
|
||||
return rejectFinalExitWaitAfterExit(realChild, rawCleanup)
|
||||
},
|
||||
onError: (failure) => { errors.push(failure.message) },
|
||||
}).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as Error).message).toContain(
|
||||
`subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}; `
|
||||
+ 'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
|
||||
)
|
||||
expect((error as Error).message).not.toContain(rawCleanup)
|
||||
expect(errors).toContain('ACP child published without a session id')
|
||||
expect(errors).toContain(rawCleanup)
|
||||
await realChild?.done
|
||||
})
|
||||
|
||||
it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const go = join(tmp, 'go')
|
||||
const rawCleanup = 'cancel rollback leaked SECRET_TOKEN'
|
||||
const errors: string[] = []
|
||||
let realChild: SubprocessHandle | undefined
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const starting = startAcpRun(request('p', controller.signal), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go },
|
||||
disposeEofGraceMs: 10,
|
||||
disposeGraceMs: 10,
|
||||
spawn: (spec) => {
|
||||
realChild = spawnSubprocess(spec)
|
||||
return rejectFinalExitWait(realChild, rawCleanup)
|
||||
},
|
||||
onError: (error) => { errors.push(error.message) },
|
||||
})
|
||||
await waitForFile(ready)
|
||||
controller.abort()
|
||||
writeFileSync(go, 'go')
|
||||
const error = await starting.catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toHaveLength(1)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
|
||||
)
|
||||
expect((error as Error).message).not.toContain(rawCleanup)
|
||||
expect(errors).toEqual([rawCleanup])
|
||||
await realChild?.done
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
@@ -632,6 +852,7 @@ describe('dsh-subagent-acp', () => {
|
||||
controller.abort()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
@@ -639,11 +860,12 @@ describe('dsh-subagent-acp', () => {
|
||||
})
|
||||
|
||||
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
|
||||
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1', MOCK_TOOL_KIND: 'execute' }, 'reject')
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
// The child asked permission, the backend rejected, the child returned cancelled.
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBe(expectedPermission('reject', 'execute', 'denied'))
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -652,6 +874,7 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
expect(text(result.output)).toBe('approved answer')
|
||||
await run.dispose()
|
||||
})
|
||||
@@ -663,6 +886,41 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBe(expectedPermission('allow', 'unknown', 'denied'))
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('appends a rejected permission fact to a later remote failure', async () => {
|
||||
const ctx = await setup({
|
||||
MOCK_PERMISSION: '1',
|
||||
MOCK_PERMISSION_IGNORE_DECISION: '1',
|
||||
MOCK_TOOL_KIND: 'edit',
|
||||
MOCK_STOP: 'max_turn_requests',
|
||||
}, 'reject')
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
`${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
|
||||
+ expectedPermission('reject', 'edit', 'denied'),
|
||||
)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('appends an allowed permission fact only when the run later fails', async () => {
|
||||
const ctx = await setup({
|
||||
MOCK_PERMISSION: '1',
|
||||
MOCK_PERMISSION_IGNORE_DECISION: '1',
|
||||
MOCK_TOOL_KIND: 'execute',
|
||||
MOCK_STOP: 'max_turn_requests',
|
||||
}, 'allow')
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
`${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
|
||||
+ expectedPermission('allow', 'execute', 'allowed'),
|
||||
)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -678,11 +936,103 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('classifies a prompt transport failure without copying SDK text', async () => {
|
||||
const run = await startAcpRun(request('private prompt text'), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
|
||||
disposeEofGraceMs: 100,
|
||||
disposeGraceMs: 100,
|
||||
spawn: spawnSubprocess,
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [],
|
||||
diagnostic: expectedFailure('stage: prompt; category: transport'),
|
||||
stopReason: 'error',
|
||||
})
|
||||
expect(result.diagnostic).not.toContain('private prompt text')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('lets local cancellation interrupt prompt-failure process observation', async () => {
|
||||
const controller = new AbortController()
|
||||
const protocolEnded = Promise.withResolvers<undefined>()
|
||||
let boundedExitWaits = 0
|
||||
const run = await startAcpRun(request('p', controller.signal), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
|
||||
disposeEofGraceMs: 100,
|
||||
disposeGraceMs: 5000,
|
||||
spawn: (spec) => {
|
||||
const child = spawnSubprocess(spec)
|
||||
child.stdout?.once('end', () => { protocolEnded.resolve(undefined) })
|
||||
return tapBoundedExitWait(child, () => { boundedExitWaits += 1 })
|
||||
},
|
||||
})
|
||||
await protocolEnded.promise
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
controller.abort()
|
||||
await expect(Promise.race([
|
||||
run.result,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('cancellation waited for process observation')) }, 500)
|
||||
}),
|
||||
])).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
expect(boundedExitWaits).toBe(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('preserves partial output and structured process facts when the child exits', async () => {
|
||||
const ctx = await setup({ MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' })
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [{ type: 'text', text: 'partial answer' }],
|
||||
diagnostic: expectedFailure('stage: process; category: process-exit; exit code: 17'),
|
||||
stopReason: 'error',
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects a spawn failure after provider-owned cleanup', async () => {
|
||||
await expect(startAcpRun(
|
||||
const privateCommand = '/nonexistent/private/SECRET_TOKEN/acp-agent'
|
||||
const error = await startAcpRun(
|
||||
request(),
|
||||
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
|
||||
)).rejects.toThrow()
|
||||
{ command: privateCommand, args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
|
||||
).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
|
||||
)
|
||||
expect((error as Error).message).not.toContain(privateCommand)
|
||||
})
|
||||
|
||||
it('sanitizes a synchronous subprocess-provider spawn rejection', async () => {
|
||||
const rawMessage = 'spawn rejected /private/path SECRET_TOKEN'
|
||||
const errors: string[] = []
|
||||
const error = await startAcpRun(request(), {
|
||||
command: 'unused',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
spawn: () => { throw new Error(rawMessage) },
|
||||
onError: (failure) => { errors.push(failure.message) },
|
||||
}).catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
|
||||
)
|
||||
expect((error as Error).message).not.toContain(rawMessage)
|
||||
expect(errors).toEqual([rawMessage])
|
||||
})
|
||||
|
||||
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
|
||||
@@ -746,7 +1096,95 @@ describe('dsh-subagent-acp', () => {
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
})
|
||||
await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
|
||||
await expect(ctx.subagents.start('acp', request())).rejects.toThrow(
|
||||
`subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps permission diagnostics isolated across concurrent runs', async () => {
|
||||
const start = (permission: 'allow' | 'reject', kind: 'edit' | 'execute') => startAcpRun(
|
||||
request(),
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission,
|
||||
env: {
|
||||
MOCK_PERMISSION: '1',
|
||||
MOCK_PERMISSION_IGNORE_DECISION: '1',
|
||||
MOCK_TOOL_KIND: kind,
|
||||
MOCK_STOP: 'max_turn_requests',
|
||||
},
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
spawn: spawnSubprocess,
|
||||
},
|
||||
)
|
||||
const [allowed, denied] = await Promise.all([
|
||||
start('allow', 'execute'),
|
||||
start('reject', 'edit'),
|
||||
])
|
||||
const [allowedResult, deniedResult] = await Promise.all([allowed.result, denied.result])
|
||||
expect(allowedResult.diagnostic).toContain(expectedPermission('allow', 'execute', 'allowed'))
|
||||
expect(allowedResult.diagnostic).not.toContain('policy: reject')
|
||||
expect(deniedResult.diagnostic).toContain(expectedPermission('reject', 'edit', 'denied'))
|
||||
expect(deniedResult.diagnostic).not.toContain('policy: allow')
|
||||
await Promise.all([allowed.dispose(), denied.dispose()])
|
||||
})
|
||||
|
||||
it('wraps a teardown rejection with safe facts and keeps the raw cause in Host diagnostics', async () => {
|
||||
const rawMessage = 'teardown leaked /private/path SECRET_TOKEN'
|
||||
const errors: string[] = []
|
||||
let realChild: SubprocessHandle | undefined
|
||||
const run = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
|
||||
disposeEofGraceMs: 10,
|
||||
disposeGraceMs: 10,
|
||||
spawn: (spec) => {
|
||||
const child = spawnSubprocess(spec)
|
||||
realChild = child
|
||||
return rejectFinalExitWait(child, rawMessage)
|
||||
},
|
||||
onError: (error) => { errors.push(error.message) },
|
||||
})
|
||||
const error = await run.dispose().catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe(
|
||||
`subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
|
||||
)
|
||||
expect((error as Error).message).not.toContain(rawMessage)
|
||||
expect(errors).toContain(rawMessage)
|
||||
await realChild?.done
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('adds an observed process outcome to a teardown failure', async () => {
|
||||
let realChild: SubprocessHandle | undefined
|
||||
const run = await startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
|
||||
disposeEofGraceMs: 10,
|
||||
disposeGraceMs: 10,
|
||||
spawn: (spec) => {
|
||||
const child = spawnSubprocess(spec)
|
||||
realChild = child
|
||||
return rejectFinalExitWaitAfterExit(child, 'post-exit wait failed')
|
||||
},
|
||||
})
|
||||
const error = await run.dispose().catch((cause: unknown) => cause)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(
|
||||
'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
|
||||
)
|
||||
expect((error as Error).message).toMatch(/(?:exit code|signal): /)
|
||||
await realChild?.done
|
||||
})
|
||||
|
||||
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
|
||||
@@ -771,6 +1209,9 @@ describe('dsh-subagent-acp', () => {
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
||||
)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.stopReason).toBe('error')
|
||||
expect(errors[0]!.message.length).toBeGreaterThan(0)
|
||||
@@ -784,6 +1225,9 @@ describe('dsh-subagent-acp', () => {
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
||||
)
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('subagent-acp "acp": child run failed (error):'),
|
||||
])
|
||||
@@ -810,6 +1254,9 @@ describe('dsh-subagent-acp', () => {
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(
|
||||
expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
||||
)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -828,6 +1275,7 @@ describe('dsh-subagent-acp', () => {
|
||||
controller.abort('crash it')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
@@ -854,6 +1302,7 @@ describe('dsh-subagent-acp', () => {
|
||||
new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
|
||||
])
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.diagnostic).toBeUndefined()
|
||||
await run.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
|
||||
@@ -41,6 +41,13 @@ function limitSubagentDiagnostic(diagnostic: string): string {
|
||||
+ DIAGNOSTIC_TRUNCATION_SUFFIX
|
||||
}
|
||||
|
||||
/** Enforce the byte limit on a provider-returned diagnostic. */
|
||||
function normalizeSubagentDiagnostic(result: SubagentResult): SubagentResult {
|
||||
return result.diagnostic === undefined
|
||||
? result
|
||||
: { ...result, diagnostic: limitSubagentDiagnostic(result.diagnostic) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability advertisement of an out-of-process backend: NONE. A child in
|
||||
* another process cannot honor parent-enforced start features
|
||||
@@ -177,7 +184,8 @@ export interface RunResultSettlement {
|
||||
* rejects after publication. A normally completed or rejected attempt resolves
|
||||
* as `aborted` when cancellation already settled locally; another rejection is
|
||||
* flattened to `stopReason: 'error'` through the contained diagnostic sink.
|
||||
* The abort listener is removed on every path.
|
||||
* Provider-returned diagnostics use the same byte limit. The abort listener is
|
||||
* removed on every path.
|
||||
* @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
|
||||
* @returns the terminal result (never a rejection).
|
||||
*/
|
||||
@@ -186,7 +194,7 @@ export async function settleRunResult(parts: RunResultSettlement): Promise<Subag
|
||||
const result = await parts.attempt()
|
||||
return parts.cancelled()
|
||||
? { output: parts.collectOutput(), stopReason: 'aborted' }
|
||||
: result
|
||||
: normalizeSubagentDiagnostic(result)
|
||||
} catch (error: unknown) {
|
||||
// Cover a rejection already queued when cancellation arrives.
|
||||
if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' }
|
||||
|
||||
@@ -27,8 +27,10 @@ function failureDetail(result: SubagentResult): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a child result to the task outcome: completed carries final text,
|
||||
* aborted is killed, and every other reason is failed without partial output.
|
||||
* Map a child result to the task outcome: completed carries final text, local
|
||||
* cancellation (`aborted` without a diagnostic) is killed, and provider-
|
||||
* diagnosed remote aborts plus every other reason are failed without partial
|
||||
* output.
|
||||
* @param result - child terminal result.
|
||||
* @returns outcome for the `ctx.jobs` registration.
|
||||
*/
|
||||
@@ -37,7 +39,9 @@ function runOutcome(result: SubagentResult): JobOutcome {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: finalText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
return result.diagnostic === undefined
|
||||
? { status: 'killed' }
|
||||
: { status: 'failed', detail: failureDetail(result) }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
|
||||
@@ -84,6 +84,22 @@ describe('outcome mapping helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a diagnostic-bearing remote abort as failed without changing local cancellation', async () => {
|
||||
await expect(settleRun({
|
||||
id: SessionId('child-remote-abort'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({
|
||||
output: [],
|
||||
diagnostic: 'ACP permission was denied',
|
||||
stopReason: 'aborted',
|
||||
}),
|
||||
dispose: () => Promise.resolve(),
|
||||
})).resolves.toEqual({
|
||||
status: 'failed',
|
||||
detail: 'aborted; diagnostic: ACP permission was denied',
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds multibyte diagnostics and marks truncation', async () => {
|
||||
const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
@@ -114,4 +130,44 @@ describe('outcome mapping helpers', () => {
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(limited)
|
||||
})
|
||||
|
||||
it('applies the same diagnostic rules to provider-returned results', async () => {
|
||||
const controller = new AbortController()
|
||||
const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
const failed = await settleRunResult({
|
||||
attempt: () => Promise.resolve({
|
||||
output: [],
|
||||
diagnostic: oversized,
|
||||
stopReason: 'error',
|
||||
}),
|
||||
collectOutput: () => [],
|
||||
cancelled: () => false,
|
||||
signal: controller.signal,
|
||||
onAbort: () => {},
|
||||
})
|
||||
expect(Buffer.byteLength(failed.diagnostic ?? '', 'utf8'))
|
||||
.toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
expect(failed.diagnostic).toMatch(/\[diagnostic truncated\]$/)
|
||||
|
||||
const plainFailure = await settleRunResult({
|
||||
attempt: () => Promise.resolve({ output: [], stopReason: 'error' }),
|
||||
collectOutput: () => [],
|
||||
cancelled: () => false,
|
||||
signal: controller.signal,
|
||||
onAbort: () => {},
|
||||
})
|
||||
expect(plainFailure).toEqual({ output: [], stopReason: 'error' })
|
||||
|
||||
const cancelledAfterAttempt = await settleRunResult({
|
||||
attempt: () => Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
collectOutput: () => [{ type: 'text', text: 'partial' }],
|
||||
cancelled: () => true,
|
||||
signal: controller.signal,
|
||||
onAbort: () => {},
|
||||
})
|
||||
expect(cancelledAfterAttempt).toEqual({
|
||||
output: [{ type: 'text', text: 'partial' }],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Keyless twin of subagent-acp-diagnostic.cordis.yml: keep the real ACP child
|
||||
# permission-denial path and replace only the external parent model adapter.
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- id: deepseek-v4-pro
|
||||
- id: subagent-acp-diagnostic
|
||||
name: '@deepseek-ai/dsh-subagent-acp'
|
||||
config:
|
||||
providerName: acp-diagnostic
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- !!js "decodeURIComponent(new URL('../../../packages/subagent/subagent-acp/tests/mock-acp-server.ts', 'file://' + process.env.DSH_SNAPSHOT_FILE).pathname)"
|
||||
permission: reject
|
||||
env:
|
||||
MOCK_PERMISSION: '1'
|
||||
MOCK_TOOL_KIND: execute
|
||||
- id: tool-subagent-acp-diagnostic
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: acp-diagnostic
|
||||
toolName: subagent_acp
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
@@ -0,0 +1,22 @@
|
||||
# Add the real ACP provider behind a one-shot delegation tool. The denied
|
||||
# execute permission returns `cancelled` and exercises diagnostic-bearing
|
||||
# remote-abort parity.
|
||||
- insert:
|
||||
- id: subagent-acp-diagnostic
|
||||
name: '@deepseek-ai/dsh-subagent-acp'
|
||||
config:
|
||||
providerName: acp-diagnostic
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- !!js "decodeURIComponent(new URL('../../../packages/subagent/subagent-acp/tests/mock-acp-server.ts', 'file://' + process.env.DSH_SNAPSHOT_FILE).pathname)"
|
||||
permission: reject
|
||||
env:
|
||||
MOCK_PERMISSION: '1'
|
||||
MOCK_TOOL_KIND: execute
|
||||
- id: tool-subagent-acp-diagnostic
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: acp-diagnostic
|
||||
toolName: subagent_acp
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_acp_foreground", "name": "subagent_acp", "argumentsDelta": "{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_foreground", "name": "subagent_acp", "arguments": "{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_acp_background", "name": "subagent_acp", "argumentsDelta": "{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_background", "name": "subagent_acp", "arguments": "{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_acp_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_acp_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "text" },
|
||||
{ "type": "text-delta", "index": 0, "text": "PARENT_OBSERVED_ACP_DIAGNOSTIC" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_OBSERVED_ACP_DIAGNOSTIC" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } },
|
||||
{ "type": "finish", "reason": { "kind": "stop" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
{"type":"session","version":0,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe the ACP diagnostic twice with subagent_acp. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_ACP_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Observe the ACP diagnostic twice with subagent_acp. First call it in the foreground. Then call it in the background and collect subagent-1 with job_output using wait true. After both failures, reply with exactly PARENT_OBSERVED_ACP_DIAGNOSTIC. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","data":{"title":"Observe the ACP diagnostic twice","messageSeqs":[7],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_foreground","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_acp_foreground","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP foreground failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":false}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_acp_foreground"},"content":[{"type":"tool-result","toolCallId":"call_acp_foreground","content":[{"type":"text","text":"Error: subagent run was cancelled\nDiagnostic: ACP unattended decision (policy: reject; request: execute; decision: denied)"}],"isError":true}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_background","name":"subagent_acp","argumentsDelta":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_acp_background","name":"subagent_acp","arguments":"{\"description\":\"Observe ACP background failure\",\"prompt\":\"Return the scripted ACP failure.\",\"run_in_background\":true}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_acp_background"},"content":[{"type":"tool-result","toolCallId":"call_acp_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_acp_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_acp_output"},"content":[{"type":"tool-result","toolCallId":"call_acp_output","content":[{"type":"text","text":"(no new output)\n[status: failed, aborted; diagnostic: ACP unattended decision (policy: reject; request: execute; decision: denied)]"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_ACP_DIAGNOSTIC"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,11 @@
|
||||
version: 1
|
||||
scenario: subagent-acp-diagnostic
|
||||
profile: headless
|
||||
composition: subagent-acp-diagnostic
|
||||
recording: authored
|
||||
header:
|
||||
class: subagent-acp-diagnostic
|
||||
pin: true
|
||||
systemPromptSource: product-subagent-codex
|
||||
replay:
|
||||
override: true
|
||||
@@ -0,0 +1,722 @@
|
||||
{
|
||||
"initial": [
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "number",
|
||||
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"description"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_goal",
|
||||
"description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The concrete completion objective inferred from the direct human request."
|
||||
},
|
||||
"max_goal_rounds": {
|
||||
"type": "number",
|
||||
"description": "Optional positive safe-integer limit on automatic continuation rounds."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"objective"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"description": "Edit an existing UTF-8 text file by replacing literal text.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to edit, resolved by the filesystem backend."
|
||||
},
|
||||
"old_string": {
|
||||
"type": "string",
|
||||
"description": "Literal text to replace. Must match exactly."
|
||||
},
|
||||
"new_string": {
|
||||
"type": "string",
|
||||
"description": "Literal replacement text. Use an empty string to delete the match."
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"old_string",
|
||||
"new_string"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exit_plan_mode",
|
||||
"description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The complete plan, as markdown, starting with a # heading that names it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plan"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_goal",
|
||||
"description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "glob",
|
||||
"description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "grep",
|
||||
"description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regular expression to search for (ripgrep syntax)."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it."
|
||||
},
|
||||
"include": {
|
||||
"type": "string",
|
||||
"description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_kill",
|
||||
"description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job id returned by the tool that started the background work."
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Optional short reason, recorded in the log and forwarded to the job."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"job_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_list",
|
||||
"description": "List your background jobs (running and finished) with their ids, kinds, and statuses.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_output",
|
||||
"description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job id returned by the tool that started the background work."
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"job_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_subagent_models",
|
||||
"description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Registered LLM provider id. Omit to list providers."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The immutable completion objective for every fresh Ralph round."
|
||||
},
|
||||
"maxRounds": {
|
||||
"type": "number",
|
||||
"description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"objective"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read",
|
||||
"description": "Read a UTF-8 text file and return line-numbered content.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to read, resolved by the filesystem backend."
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"description": "1-based first line to return. Defaults to 1."
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of lines to return. Defaults to 2000."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read_image",
|
||||
"description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the image file, resolved by the filesystem backend."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subagent_id": {
|
||||
"type": "string",
|
||||
"description": "The subagent id returned when the background subagent was started."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to deliver to the subagent."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subagent_id",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skill",
|
||||
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The exact skill name from the available skills list."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "str_replace_editor",
|
||||
"description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
|
||||
"enum": [
|
||||
"view",
|
||||
"create",
|
||||
"str_replace",
|
||||
"insert"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `create` command, with the content of the file to be created."
|
||||
},
|
||||
"insert_line": {
|
||||
"type": "integer",
|
||||
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
|
||||
},
|
||||
"new_str": {
|
||||
"type": "string",
|
||||
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
|
||||
},
|
||||
"old_str": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
|
||||
},
|
||||
"view_range": {
|
||||
"type": "array",
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short (3-5 word) description of the delegated task, for display."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subagent_acp",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short (3-5 word) description of the delegated task, for display."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subagent_fork",
|
||||
"description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short (3-5 word) description of the delegated task, for display."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo_write",
|
||||
"description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "The COMPLETE task list, replacing any previous list.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "What the task is — a short imperative line."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "pending (not started) | in_progress (now) | completed (done).",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"status"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"todos"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_goal",
|
||||
"description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"goal_id": {
|
||||
"type": "string",
|
||||
"description": "Exact id returned by get_goal."
|
||||
},
|
||||
"revision": {
|
||||
"type": "number",
|
||||
"description": "Exact positive revision returned by get_goal."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "edit | pause | resume | complete | blocked",
|
||||
"enum": [
|
||||
"edit",
|
||||
"pause",
|
||||
"resume",
|
||||
"complete",
|
||||
"blocked"
|
||||
]
|
||||
},
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "Replacement objective; valid only with action edit."
|
||||
},
|
||||
"max_goal_rounds": {
|
||||
"type": "number",
|
||||
"description": "Replacement cap; valid only with action edit."
|
||||
},
|
||||
"blocked_reason": {
|
||||
"type": "string",
|
||||
"description": "Concrete blocking condition; required only with action blocked."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"goal_id",
|
||||
"revision",
|
||||
"action"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Required search queries; accepts 1–4 items and merges their results.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"queries"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow",
|
||||
"description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "The workflow identity block (plain JSON — never code).",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short kebab-case workflow name."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One-line description of what the workflow does."
|
||||
},
|
||||
"whenToUse": {
|
||||
"type": "string",
|
||||
"description": "Optional guidance on when this workflow applies."
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"description": "Optional phase declarations matched by phase() calls.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The phase title phase() calls match by exact string."
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Optional one-line description of the phase."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Optional provider override this phase is expected to use."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override this phase is expected to use."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
]
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"script",
|
||||
"meta"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write",
|
||||
"description": "Create or fully replace a UTF-8 text file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to write, resolved by the filesystem backend."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full UTF-8 text content to write."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"content"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"changes": []
|
||||
}
|
||||
Reference in New Issue
Block a user