refactor(subagent): keep ACP permission diagnostics minimal

This commit is contained in:
pku-xht
2026-08-21 05:58:43 +08:00
parent d6de6bb0cb
commit 5e1494ff17
9 changed files with 25 additions and 42 deletions
@@ -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 .agents/notes/implemented/feature/2026-08-21-out-of-process-subagent-minimal-diagnostics.md
2026-08-21-out-of-process-subagent-minimal-diagnostics.md: 38cf32dc3de3fe157f73e1546a827df9b3622fa6
2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: 386f85e6b5665c8006e10a0ed0aa49845b6ffed0
2026-08-21-out-of-process-subagent-minimal-diagnostics.md: fa795fc12aafe2d7d7707e97cf8c4ca49103e089
2026-08-21-out-of-process-subagent-minimal-diagnostics.zh.md: 9516e924b7d8d13403cf3944b55efabfd2c80dff
@@ -16,7 +16,7 @@ Each out-of-process provider owns a small mapping from facts it already receives
### Safe failure text
The first line has this fixed field order:
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>)
@@ -24,7 +24,7 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
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 second fixed line records `policy`, the closed ACP tool `request` kind, and `decision`. Tool titles, raw input, locations, option names, and metadata are excluded. 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.
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
@@ -32,7 +32,7 @@ When an ACP permission request contributes to a non-completed result, a second f
| --- | --- | --- |
| `initialize` | Parent workspace resolution, spawn, and ACP initialize | `configuration`, `transport`, `process-start`, 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`, `remote-refusal`, `permission`, `transport`, or `unknown` |
| `prompt` | ACP prompt request, remote stop reason, and permission callback | `remote-limit`, `transport`, `unknown`, or a permission-only diagnostic |
| `process` | Managed child exits before a prompt terminal response | `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 |
@@ -16,7 +16,7 @@ ACP 子进程可能因为达到远端限制、拒绝必需权限、失去协议
### 安全失败文本
首行采用以下固定字段顺序:
通用 error 诊断采用以下固定字段顺序:
```text
Subagent failure (provider: <provider>; stage: <stage>; category: <category>; stop reason: <reason>; exit code: <code>; signal: <signal>)
@@ -24,7 +24,7 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
不可用的可选字段会被省略。共享结算边界会把完整结果限制在 4096 个 UTF-8 字节以内。成功结果和本地取消不携带失败诊断。部分 assistant 输出继续保留在 `SubagentResult.output` 中,并与诊断分开呈现。
当 ACP 权限请求参与非完成结果时,第二个固定行会记录 `policy`、ACP 闭集工具 `request` 种类和 `decision`。工具标题、raw input、位置、选项名称与 metadata 均被排除。带诊断的远端 `aborted` 结果仍保持公共结束原因;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
当 ACP 权限请求参与非完成结果时,个固定行会记录 `policy`、ACP 闭集工具 `request` 种类和 `decision`。工具标题、raw input、位置、选项名称与 metadata 均被排除。对于 `max-tokens``refusal` 或远端 `aborted`,公共结束原因已经携带终态事实,因此权限行就是完整诊断;通用 error 路径则把它附在失败行之后。带诊断的远端 `aborted` 结果仍保持公共结束原因;一次性 Job adapter 会把它判为 failed,而不带诊断的本地取消仍是 killed。
### ACP 事实
@@ -32,7 +32,7 @@ Subagent failure (provider: <provider>; stage: <stage>; category: <category>; st
| --- | --- | --- |
| `initialize` | 父工作区解析、spawn 与 ACP initialize | `configuration``transport``process-start``process-exit` |
| `new-session` | ACP `session/new` 与返回 session id 校验 | `protocol``transport``process-exit` |
| `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit``remote-refusal``permission``transport``unknown` |
| `prompt` | ACP prompt 请求、远端结束原因与权限回调 | `remote-limit``transport``unknown` 或仅权限诊断 |
| `process` | 受管子进程先于 prompt 终态响应退出 | `process-exit`,以及分别观测到的退出码与信号 |
| `teardown` | EOF 停稳与受管进程树终止 | 固定 teardown 事实;原始清理失败仍留在内部 |
@@ -15,7 +15,7 @@
{"type":"assistant/chunk","seq":13,"time":1787254574889,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":14,"time":1787254574889,"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":"ef8e9ff0-886c-4d55-bbdb-8e878258fb53"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
{"type":"tool/call","seq":15,"time":1787254574890,"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","seq":16,"time":1787254574996,"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: Subagent failure (provider: ACP; stage: prompt; category: permission; stop reason: cancelled)\nACP unattended decision (policy: reject; request: execute; decision: denied)"}],"isError":true}],"role":"user","id":"720dc6b6-6788-4f6f-8426-b888ccafc84a"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"tool/result","seq":16,"time":1787254574996,"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":"b1bef631-eac4-4139-84ab-809d61493b2c"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","seq":17,"time":1787254574996,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":18,"time":1787254575002,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":19,"time":1787254575006,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -35,7 +35,7 @@
{"type":"assistant/chunk","seq":33,"time":1787254575021,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":34,"time":1787254575021,"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":"b20d64f1-7fcf-498d-84ec-afe518983863"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}
{"type":"tool/call","seq":35,"time":1787254575021,"data":{"turn":1,"step":3,"callId":"call_acp_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
{"type":"tool/result","seq":36,"time":1787254575110,"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: Subagent failure (provider: ACP; stage: prompt; category: permission; stop reason: cancelled)\nACP unattended decision (policy: reject; request: execute; decision: denied)]"}],"isError":false}],"role":"user","id":"372a7fa1-e148-46ba-bc6a-33a3e0963276"}},"sourceEventSeqs":[35],"surfaceOp":"append"}
{"type":"tool/result","seq":36,"time":1787254575110,"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":"53294746-f70e-4494-a005-a97df6199d83"}},"sourceEventSeqs":[35],"surfaceOp":"append"}
{"type":"step/end","seq":37,"time":1787254575110,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":38,"time":1787254575116,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":39,"time":1787254575121,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -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: e01785a7a8cd5406fa545cc5e97b0a09d4f57fa1
README.zh.md: 9082ab4d57b4393a07dc2e02cf6ce95ca259ae8d
README.md: 6366a224b8f56f0b86466d6946f75ab45fee3997
README.zh.md: 490c17dd883224bcfe129bd3b29eea973a4e69c7
+3 -3
View File
@@ -58,15 +58,15 @@ ACP advertises no start-time capabilities because this process cannot enforce th
## Failure diagnostics
The first line has a fixed field order:
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 or refusal, permission-related cancellation, 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.
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 second fixed 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. Successful results and local cancellation omit both lines. 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.
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
+3 -3
View File
@@ -58,15 +58,15 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
## 失败诊断
首行采用固定字段顺序:
通用 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 字节以内。
不可用的可选字段会被省略。提供方从实际拥有失败的操作派生 `initialize``new-session``prompt``process``teardown`。category 区分配置、协议或传输失败、进程启动/退出、远端限制以及固定 unknown 回退。退出码与信号只来自受管子进程结果;stderr、异常消息、任务文本、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。
当运行请求过权限且最终未完成时,第二个固定行会记录已配置策略、ACP 闭集工具种类以及提供方允许还是拒绝。工具标题、raw input、位置与选项文本均被排除。成功结果和本地取消会省略行。带权限诊断的远端 `aborted` 结果仍保持 `aborted`;前台会呈现该诊断,而一次性 Job adapter 会把这种带诊断的远端取消判为 failed,避免与本地取消混淆。
当运行请求过权限且最终未完成时,个固定权限行会记录已配置策略、ACP 闭集工具种类以及提供方允许还是拒绝。工具标题、raw input、位置与选项文本均被排除。对于 `max-tokens``refusal` 或远端 `aborted`,公共结束原因已经携带终态事实,因此该权限行就是完整诊断;通用 error 路径则把它放在失败行之后。成功结果和本地取消会省略权限行。带权限诊断的远端 `aborted` 结果仍保持 `aborted`;前台会呈现该诊断,而一次性 Job adapter 会把这种带诊断的远端取消判为 failed,避免与本地取消混淆。
## 进程边界
+1 -9
View File
@@ -95,8 +95,6 @@ type AcpFailureCategory =
| 'process-start'
| 'process-exit'
| 'remote-limit'
| 'remote-refusal'
| 'permission'
| 'unknown'
interface AcpFailureFacts {
@@ -311,17 +309,11 @@ function terminalFailure(
stopReason: 'max_turn_requests',
}, permission)
case 'max_tokens':
return permission === undefined
? undefined
: diagnosticText({ stage: 'prompt', category: 'remote-limit', stopReason: reason }, permission)
case 'refusal':
return permission === undefined
? undefined
: diagnosticText({ stage: 'prompt', category: 'remote-refusal', stopReason: reason }, permission)
case 'cancelled':
return permission === undefined
? undefined
: diagnosticText({ stage: 'prompt', category: 'permission', stopReason: reason }, permission)
: permissionDiagnostic(permission)
default:
return diagnosticText({ stage: 'prompt', category: 'unknown', stopReason: 'unknown' }, permission)
}
@@ -455,9 +455,9 @@ describe('dsh-subagent-acp', () => {
})
it.each([
['max_tokens', 'max-tokens', 'remote-limit'],
['refusal', 'refusal', 'remote-refusal'],
] as const)('adds a permission fact to %s without changing its stop reason', async (remote, stopReason, category) => {
['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',
@@ -467,10 +467,7 @@ describe('dsh-subagent-acp', () => {
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe(stopReason)
expect(result.diagnostic).toBe(
`${expectedFailure(`stage: prompt; category: ${category}; stop reason: ${remote}`)}\n`
+ expectedPermission('reject', 'read', 'denied'),
)
expect(result.diagnostic).toBe(expectedPermission('reject', 'read', 'denied'))
await run.dispose()
})
@@ -830,10 +827,7 @@ describe('dsh-subagent-acp', () => {
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(
`${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
+ expectedPermission('reject', 'execute', 'denied'),
)
expect(result.diagnostic).toBe(expectedPermission('reject', 'execute', 'denied'))
await run.dispose()
})
@@ -854,10 +848,7 @@ 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(
`${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
+ expectedPermission('allow', 'unknown', 'denied'),
)
expect(result.diagnostic).toBe(expectedPermission('allow', 'unknown', 'denied'))
await run.dispose()
})