# 智能体工具失败测试：及早发现不安全的重试

智能体工具失败测试应关注操作失败后智能体相信了什么，而不只是工具是否发出了错误。远程写入可能已经完成，但工具却报告「请求失败」，这种情况比工具停止并返回不完整答案更糟。你给出什么结果，智能体就会据此规划。

顺利路径会掩盖那些决定自主运行是否安全的选择：重试、请求批准、轮换访问权限、修复数据，还是停止。我见过一些工具套件，几百个测试全部通过，却从未在远程命令启动和返回输出之间强制制造网络中断。这些测试没有覆盖真正危险的部分。

## 失败响应会成为智能体规划器的输入

智能体把工具结果当作证据。如果结果暗示没有发生任何变化，智能体可能会重试。如果结果暗示凭据已经过期，智能体可能会寻找获得授权的恢复路径。如果结果在远程状态未知时却暗示成功，智能体可能会在这个虚构的事实之上安排后续操作。

应按调用方能够知道的事实区分失败。这个区别经常被混淆：

- 已确认拒绝，表示远程服务收到了请求并拒绝了它。
- 已确认失败，表示远程服务返回的结果说明它没有执行请求的工作。
- 结果未知，表示调用方无法确定远程端是否执行了这项工作。
- 本地失败，表示工具在发起有意义的远程尝试前就失败了。

TCP 会话建立前连接就被拒绝，通常属于本地失败。HTTP 403 属于已确认拒绝。发送 `POST` 后发生读取超时，则属于结果未知，除非远程服务提供了查询这次操作的方法。这些标签应出现在测试用例和工具结果结构中，不要把它们藏在一段必须由智能体自行解读的句子里。

一个紧凑的结果结构可以让契约变得可测试：

```json
{
  "ok": false,
  "category": "outcome_unknown",
  "operation": "create_deployment",
  "retry": "reconcile_first",
  "correlation_id": "case-ssh-017",
  "message": "Connection closed after the remote command started; remote completion is unknown."
}
```

名称本身并不重要，区分才重要。对于权限拒绝，使用 `retry: "never"`；对于超时的写入，使用 `retry: "reconcile_first"`。这样无需把整段错误消息变成提示词，也能向智能体传达不同的含义。

不要把服务商原始错误作为唯一接口。它们会变化，通常包含无关的文字，有时还包含不应交给智能体的请求内容。在受保护的诊断信息中保留原始状态、响应体和请求头，向调用方返回稳定且经过刻意精简的结果。

## 围绕操作和证据建立矩阵

有用的矩阵会把每项操作与可能改变其含义的失败模式交叉起来。先列出读取、创建、更新、删除、触发或执行类工具。读取操作超时，与改变生产主机的命令相比，恢复规则完全不同。

可以把下面的矩阵作为起点。替换操作名称和预期记录，但不要删除「是否已知远程影响」这一列。它会迫使那些令人不安的情况浮出水面。

| 用例 | 操作 | 注入条件 | 是否已知远程影响？ | 预期类别 | 智能体指令 |
| --- | --- | --- | --- | --- | --- |
| C01 | 读取 issue | DNS 查询失败 | 是，未发送请求 | local_failure | 在限定次数内重试 |
| C02 | 创建 issue | 令牌过期 | 是，已拒绝 | authentication_failed | 停止并请求经过授权的凭据恢复 |
| C03 | 删除 release | 权限被拒 | 是，已拒绝 | authorization_denied | 不要重试 |
| C04 | 读取 build | JSON 含有 `status: 7` | 是，已收到响应 | malformed_response | 停止并报告结构不匹配 |
| C05 | 创建 deployment | 响应延迟超过客户端截止时间 | 否 | outcome_unknown | 重试前先核对状态 |
| C06 | 运行 SSH 重启命令 | 远程命令启动后本地辅助进程终止 | 否 | outcome_unknown | 再发命令前检查远程状态 |
| C07 | 更新记录 | 服务返回 429 | 是，已拒绝 | rate_limited | 按指示等待，确认安全后再重试 |

为涉及付款、权限变更、凭据轮换或共享状态的操作添加更多行。这些操作需要不止一种超时用例。测试字节离开进程前、请求头离开后、服务接受请求后，以及响应体传输期间发生的超时。具体注入点取决于协议，但把它们合并成一个笼统的「超时」用例，会丢掉你真正需要验证的行为。

每一行都需要四项断言：

1. 断言工具结果类别和重试指令。
2. 断言智能体的下一步操作，包括它不会自行发起破坏性重试。
3. 断言远程状态，或断言状态仍未知的文档化原因。
4. 断言事件轨迹包含关联 ID 和观察到的结果。

这比断言 `ok == false` 要费事，但也能捕捉智能体已经执行多步操作后真正重要的失败。

## 凭据过期和操作拒绝需要不同的恢复方式

过期或撤销的凭据证明身份验证失败。被拒绝的操作则证明调用方已通过身份验证，但没有执行该操作的权限，除非服务商有意隐藏这种区别。把二者都归为「访问失败」，会导致智能体做出错误的行为。

RFC 9110 将 401 定义为未通过身份验证的请求，并要求服务器发送 `WWW-Authenticate` 挑战；它将 403 定义为拒绝履行请求，即使服务器没有说明原因。服务商不一定严格遵循这一划分，因此要测试服务商实际返回的响应。不过，只要证据足够，工具就应诚实地将观察结果映射到不同类别。

过期凭据测试应使用一个在设置阶段被接受、在实际调用阶段被拒绝的凭据。服务从未识别过的随机字符串只能测试无效凭据分支。你需要发现的是访问令牌真正过期后，缓存、刷新代码和错误映射器是否会出现不同表现。

一个简单的固定装置可以在不暴露秘密的情况下表达这两种情况：

```yaml
cases:
  - id: expired-token
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 401
      headers:
        www-authenticate: Bearer error="invalid_token"
      body: {"error":"token_expired"}
    expect:
      category: authentication_failed
      retry: never
      secret_in_result: false

  - id: denied-release
    request:
      method: POST
      path: /v1/releases
    fixture_response:
      status: 403
      body: {"error":"insufficient_scope"}
    expect:
      category: authorization_denied
      retry: never
      secret_in_result: false
```

`secret_in_result` 断言可以捕捉一个常见错误：调试压力很大时，代码把发出的授权请求头或配置对象附加到了异常上。测试序列化后的工具输出、跟踪输出，以及最终到达智能体的任何会话内容。一个日志记录器进行了脱敏，并不能保护其他日志记录器。

除非系统明确提供了一个不同且经过授权的身份，否则不要让智能体「尝试另一个凭据」。盲目选择凭据可能跨越权限边界，却看起来像解决了可用性问题。测试应证明身份验证失败会停止运行，或将流程转交给获批准的人工恢复路径。

## 格式错误的数据需要契约测试，而不只是 JSON 解析测试

格式错误的数据包括语法有效、但代码无法安全使用的 JSON。语法无效是最简单的情况。生产环境中更常见的是字段类型发生变化、必需标识符消失、错误结构替代了成功结构，或者代理关闭连接后响应被截断。

JSON-RPC 2.0 规范将解析错误（`-32700`）与无效请求（`-32600`）分开。这种划分很有用，因为它区分了不可读取的字节和可读取但违反协议的消息。对领域响应也应采用同样的严谨性：解析成功并不代表响应符合工具契约。

对于每种使用到的服务商响应，都应编写一次只违反一个假设的固定数据测试：

- 将字符串 ID 替换为 `null`、数字和对象。
- 删除后续工具调用进行状态核对时所需的字段。
- 返回成功状态，却提供错误结构的响应体。
- 返回错误状态，却提供 HTML 响应体或截断的 JSON 文档。
- 在代码会选择第一项时，复制一项或改变项目顺序。

然后断言精确的行为。工具应在受保护的诊断信息中指出失败字段或契约条件，向智能体返回 `malformed_response`，并且不能根据猜测出的值继续修改状态。

一个常见的错误模式看起来很无害：`response.id || request.id`。当服务商遗漏 `id` 时，它能让工作流继续运行，但如果请求身份与响应身份不同，就可能对无关对象执行更新或删除。测试应确保响应身份缺失会停止操作。失败的工作流总比错误的写入便宜。

MCP 工具客户端同样需要谨慎处理。Model Context Protocol 工具结果格式支持用于工具级失败的 `isError` 信号。工具无法完成承诺的工作时应使用它，同时让结果内容足够具体，使智能体能够选择安全分支。不要把上游格式错误伪装成以「Error:」开头的普通文本结果。许多客户端会将这种情况视为工具执行成功，然后把剩下的判断交给智能体。

## 写入开始后，超时意味着什么并不明确

超时只能告诉你截止时间已到，无法说明远程操作处于什么状态。这个道理听起来很明显，但重试循环常常会悄悄把丢失的响应变成重复账单、两次部署或第二次重启。

应在确定性发生变化的边界测试超时行为。故障注入器或模拟服务应记录每个阶段：

```text
case=C05 request_id=case-http-005 received=true
case=C05 request_id=case-http-005 mutation_committed=true
case=C05 response_write=delayed
client case=C05 deadline_exceeded=true
```

预期断言不是「客户端收到了超时」。断言应是客户端返回 `outcome_unknown`，不发送第二个 `POST`，并在继续操作前使用状态查询或幂等机制。

幂等令牌只有在远程 API 为相关操作明确记录并实际支持它时才有用。应把它作为完整序列测试：使用唯一令牌提交请求，将首次响应延迟到调用方放弃，沿恢复路径提交同一个令牌，然后确认服务报告的是一次逻辑操作。仅仅添加一个服务商会忽略的请求头，并不能证明操作具有幂等性。

对于没有状态核对端点或幂等支持的操作，应在工具结果中明确说明。安全的做法可能是停止并请人检查远程系统。这不是工程失败。为了让工作流继续而假装确定，才是失败。

如果客户端支持，应按阶段设置超时：连接、写入请求、收到第一个响应字节，以及整个操作的持续时间。一个很大的统一截止时间无法告诉你对端从未接受连接，还是接受写入后才停止响应。测试不必把每个阶段都暴露给智能体，但诊断信息需要包含足够细节，让操作人员能够复现事件。

## 远程命令中断时必须保留不确定性

SSH 命令存在一个 HTTP 开发者经常低估的失败窗口。客户端可能已经发送命令，远程 shell 可能已经启动命令，连接却在调用方收到退出状态前关闭。本地进程崩溃或网络路由丢失，不会撤销远程主机已经开始的工作。

OpenSSH 文档说明，只要能够获取远程命令的退出状态，客户端就会返回该状态。传输先中断时，调用方拿不到这个状态。应有意测试这种情况，不要把本地进程的非零退出直接当作远程命令失败的证据。

创建一个远程测试命令，记录开始标记、等待一段时间、记录完成标记，并写入可识别的结果。等待期间终止本地传输。测试应限制在你拥有的隔离主机或容器中。

```sh
# remote command used only in an isolated test environment
id="case-ssh-017"
printf '%s start\n' "$id" \u003e\u003e /tmp/agent-tool-test.log
sleep 20
printf '%s complete\n' "$id" \u003e\u003e /tmp/agent-tool-test.log
```

通过工具使用的同一 SSH 路径运行命令，等待开始标记出现，然后终止本地辅助进程。远程等待结束后检查日志。运行两次测试：一次让远程进程完成，另一次让远程端在开始标记后终止进程。两次都会产生本地中断，但恢复方式不同。

对于会改变状态的命令，应先设计状态核对命令，再设计重试。服务重启可以查询进程运行时间或部署版本；软件包安装可以查询已安装版本。无法核对状态的命令，在中断后应要求人工明确处理。

避免使用会通过 `\u0026\u0026` 链隐藏部分完成状态、并输出含糊信息的 shell 片段。在修改状态的部分开始前发出持久的操作 ID，并在后续检查中使用它。如果远程环境无法保留任何标记，工具就没有依据告诉智能体重试是否安全。

## 人工拒绝是正常结果，不是损坏的测试

用户拒绝操作后，应返回独立的结果，并干净地结束当前操作分支。团队经常测试批准界面会出现，却忘了测试拒绝路径，结果智能体不断重试、换一种说法重复请求，或把批准失败误报为网络问题。

在每个暴露出来的授权边界测试拒绝。确认拒绝后工具不会连接远程服务。确认它不会把一次批准保留给后续进程，或保留给需要重新决策的后续操作。确认智能体收到的语言可以直接使用，不会把拒绝理解成寻找绕过方法的邀请。

Sallyport 的保险库锁定时会拒绝所有操作，其会话批准和逐次调用批准让你可以在不把凭据放入智能体进程的情况下测试这些决策。这样的划分很有用，因为锁定的保险库、被拒绝的会话和被拒绝的单次调用，虽然原因不同，却都可能停止操作。

批准疲劳本身就是一种测试失败。如果一次普通运行中的无害读取不断生成提示，人们最终会不看内容就批准。如果破坏性调用意外继承了范围过大的批准，人们就永远不会得到原本预期的决策点。除了测试提示是否出现，也要测试提示的数量、时机和范围。

使用一个测试智能体，依次尝试一次获批操作、一次被拒操作，以及进程退出后的操作。最后一次调用可以捕捉批准状态是否泄漏到了预期会话之外。不要只通过切换内存中的布尔值来模拟；应启动一个新进程，让测试共享用户实际运行时所经历的生命周期。

## 日志应说明发生了什么，同时不暴露访问权限

有用的失败记录可以重建因果关系：哪个智能体运行尝试了哪项操作，使用了什么请求标识符，远程系统观察到了什么，工具返回了什么，以及智能体接下来做了什么。记录不应包含授权调用所需的凭据。

在答案可能发生变化的每个节点记录事件。对于一次计时写入，记录请求构造、开始分发、远程接受情况（如果固定装置能够报告）、截止时间到期、状态核对尝试和最终分类。在首次网络操作前生成关联 ID。不要从秘密派生它，也不要在多项操作之间重复使用。

下面的记录格式适用于本地测试工具：

```json
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"dispatch_started"}
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"remote_committed"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"client_timeout"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"result","category":"outcome_unknown"}
```

这样，断言就可以按 `id` 对比客户端记录和固定装置记录。如果固定装置说 `remote_committed`，而工具说 `confirmed_failure`，测试就应失败。即使所有代码路径都返回了格式整齐的错误对象，这种不一致也会暴露不安全的判断。

要获得防篡改的记录链，也应测试验证过程。Sallyport 从加密哈希链式审计日志中生成会话和活动记录，`sp audit verify` 无需保险库密钥即可离线检查链条。失败测试应添加一组已知事件，验证它，修改一份复制的记录，然后断言修改后的副本验证失败。

默认情况下，不要把完整请求体放入普通日志。请求数据经常包含个人信息、源代码，或粗心调用方嵌入的令牌。记录操作名称、目标分类、关联 ID、结果类别和受保护的诊断引用。只有在清楚固定数据内容的受控测试环境中，才扩大采集范围。

## 测试智能体的恢复行为，而不只是适配器

单元测试可以证明适配器会把 403 映射为 `authorization_denied`，但不能证明智能体收到这个结果后会停止。运行一套小型端到端测试，使用确定性的智能体指令和一个能够公开注入事件日志的虚假远程服务。

为每次运行提供范围狭窄的任务和明确边界。例如，创建一条记录，读回它，然后附加一条备注。让创建响应在记录提交后延迟。正确的智能体行为是在第二次创建前，先通过关联 ID 或幂等令牌检查状态。如果它创建了另一条记录，即使最后完成了任务，测试也应失败。

让这套测试中的智能体提示保持稳定。如果同时更改提示、工具契约、固定装置行为和模型版本，测试失败就很难说明问题。记录工具会话和智能体的下一次调用，然后将其与允许的状态转换进行比较：

```text
create -\u003e outcome_unknown -\u003e lookup_by_request_id -\u003e found -\u003e attach_note
create -\u003e outcome_unknown -\u003e create
```

只有在查询确认原始创建操作时，第一种转换才允许继续。第二种就是失败。这样的状态机检查，比判断最终文字回复听起来是否合理更有用。

每次更改工具代码、结果结构、授权处理或重试逻辑时，都运行确定性的矩阵用例。在隔离基础设施中反复运行中断用例，因为执行时序会影响结果。出现新事故时，在修复之前先把最小复现加入矩阵。否则，同一条看似合理却错误的恢复路径会在下一次重构中重新出现。

衡量工具的标准不是它能否在每次故障后继续前进，而是它能否如实说明已知事实，留下证据，并拒绝把不确定性变成第二次破坏性操作。
