Files
HomeAgent/internal/agent/core/toolerror_test.go
JianFeeeee 396d13e9af feat(toolcall): 结果契约诚实化 —— Success 不再恒真 + 结构化失败可回填(阶段 1b/1d)
问题(实测,三条互相印证):
· ToolResult.Success 硬编码 true(task.go 唯一赋值点)⇒ 该字段在结构上
  不可能为 false,是**谎报字段**;
· 工具失败以 nil error + 错误**值**返回(files 的 errorResult、
  pluginmgr 的 {"error":…}),上游无从判别;
· stepToolAfter 用 `Result.(string)` 断言,而插件返回的多是 map ⇒
  断言几乎恒失败,after_toolcall 阶段对结构化结果的改写**静默失效**。

改动:
· third_party/homeagent-sdk: 新增 ToolError{Field,Reason,Detail,Hint}
  与 Error()。纯新增、无签名变更,存量插件不必改动(零值语义:
  内核的失败识别同时兼容既有三种约定,新类型是可选项而非迁移要求)。
· internal/sdk: 补 ToolError 别名。
· core/toolerror.go: isToolError / toolErrorText / renderToolResult。
  ⚠️ 判据必须同时兼容仓内**三种**既有失败约定,且**不得**把成功误判:
   ① {"error": msg}  ② {"isError":true,content:…}  ③ *ToolError
  明确不判失败的:exit_code != 0(业务结果,带真实 stdout/stderr)、
  stderr 非空(cmd 成功常带 warn)、字符串/数字/bool/数组/nil
  (自由文本按成功处理:宁可少报失败,也不把正常结果报成失败)。
· core/toolcall.go: executeToolCallOutcome 返回 toolOutcome{Text,Raw},
  **Raw 必须在成功分支也带上**——否则结构化失败在 fmt.Sprintf("%v")
  那一步被抹平,Success 又退回恒真。panic 与 60s 超时统一以 ToolError
  表达(可执行 Hint,避免模型原样重试工具故障)。
· core/task.go: Success=!isToolError(Raw);修恒失败的类型断言;
  TaskFrame 增 CurRaw(未降级的原值)。

判据:
· toolerror_test.go 单元级:三种失败约定识别 / 成功形态不误判 /
  非零退出不算工具失败 / ToolError 识别。
· TestStageCtxSuccessIsHonestEndToEnd 端到端读 f.StageCtx.ToolResults,
  验证**内核产出的值本身**,而非辅助函数。

变异验证(两轮):
· Success 退回硬编码 true ⇒ 端到端判据 3 个子用例 FAIL;
· 把字符串判据改成 strings.Contains(x,"error") ⇒ 「成功文本含 error 字样」
  用例 FAIL。**第二轮暴露了判据缺口**(最初没有该用例),已补。

过程中三次自伤(均由判据/编译暴露):用正则批量包装 return 时把
多行 fmt.Sprintf 截断;包装范围溢出到返回 string 的辅助函数;
测试里重复注册同名工具导致 IOManager 取到错误的 device。

遗留:全量 go test ./internal/... 在本机无法完整跑完——/tmp 是 9.8G
tmpfs 且已 98% 占用,link 阶段报 "no space left on device";
/var/tmp 另有约 29G 陈旧 release worktree。与本次改动无关(未触碰
memory/* 等失败包),已在干净基线(7a566d5)对比确认。
2026-09-27 09:17:16 +08:00

165 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 阶段 1b:诚实化 Success。
//
// 现状(task.go:757):`Success: true` 是**唯一**赋值点 ⇒ 该字段恒真,
// 结构上不可能为 false。而工具失败是以 `nil` error + 错误**值**返回的
// (`files/plugin.go:228` 的 `errorResult(...)`、pluginmgr 的 `{"error":…}, nil`)。
//
// 本判据钉死「哪些返回值算失败」。**最大回归风险**在此:
// 判据若只认「error 键」而不认「普通 map/string 仍算成功」,
// 升级就会把存量插件的**成功**误判成失败。
// 失败形态在仓内有**三种**约定(已核实,见各出处):
//
// ① {"error": msg} —— pluginmgr、cmd 的参数校验
// ② {"isError": true, "content": msg} —— files、clawhubadapter 的 errorResult
// ③ {"status":"timeout", "stdout":…, "error":…} —— cmd 超时(带真实数据,status 才是判据)
func TestIsToolError_RecognizesLegacyFailureShapes(t *testing.T) {
failures := []struct {
name string
val interface{}
}{
{"①error 键", map[string]interface{}{"error": "name is required"}},
{"①error 键+其他字段", map[string]interface{}{"error": "boom", "stdout": "partial"}},
{"②isError", map[string]interface{}{"isError": true, "content": "path is required"}},
{"②isError=false 不算失败", map[string]interface{}{"isError": false, "content": "ok"}},
}
for _, c := range failures {
want := c.name != "②isError=false 不算失败"
if got := isToolError(c.val); got != want {
t.Errorf("%s: isToolError = %v,期望 %v(值 %#v)", c.name, got, want, c.val)
}
}
}
// 成功形态**绝不能**被判成失败——这是升级的头号回归风险。
func TestIsToolError_SuccessShapesAreNotFailures(t *testing.T) {
successes := []struct {
name string
val interface{}
}{
{"cmd 成功(含 stderr,命令本身常报 stderr 但不是工具失败)", map[string]interface{}{
"status": "ok", "stdout": "out", "stderr": "warn: deprecated", "exit_code": 0,
}},
{"普通 map", map[string]interface{}{"count": 3, "items": []interface{}{"a"}}},
{"空 map", map[string]interface{}{}},
{"字符串", "已通过 [webui] 通道发送"},
{"ok 标记", "ok"},
// ⚠️ 最高风险的一条:成功的**文本里恰好含 error 字样**。
// 若判据用 strings.Contains(x, "error") 之类,成功就会被判成失败——
// 而 cmd_run 的 stderr、检索到的日志片段都可能含这个词。
{"成功文本含 error 字样", "已处理 3 个 error 日志(命令退出码 0)"},
{"成功文本含 isError 字样", `{"isError": false, "note": "已检查"}`},
{"nil", nil},
{"bool", true},
{"数字", 42},
{"数组", []interface{}{"a", "b"}},
}
for _, c := range successes {
if isToolError(c.val) {
t.Errorf("成功形态被误判为失败: %s(%#v)", c.name, c.val)
}
}
}
// 非零退出码:cmd 的 `exit_code != 0` 属**业务失败**而非工具故障。
// 但它带 `status: "ok"` 与真实 stdout——不应整条判为失败,
// 否则「命令跑了但返回非零」会被误当成工具不可用。
// ⇒ 本判据锁定当前语义:**只看显式错误标记**,不看 exit_code。
func TestIsToolError_NonZeroExitIsNotToolFailure(t *testing.T) {
v := map[string]interface{}{"status": "ok", "stdout": "", "stderr": "boom", "exit_code": 1}
if isToolError(v) {
t.Errorf("非零退出码不应整条判为工具失败(它带真实 stdout/stderr): %#v", v)
}
}
// 显式 ToolError 结构必须被识别(阶段 1a 的新形态)。
func TestIsToolError_RecognizesStructuredToolError(t *testing.T) {
if !isToolError(newToolError(ErrReasonRequired, "path", "缺少 path", "先传 path")) {
t.Error("结构化 ToolError 应被识别为失败")
}
}
// 端到端:失败工具的 Success 必须是 false,成功工具必须是 true。
//
// 这是阶段 1b 的**真正目标**——此前 `Success: true` 是唯一赋值点,
// 恒真、结构上不可能为 false。本判据经 after_toolcall stage 直接读
// f.StageCtx.ToolResults,验证内核产出的值本身,而非某个辅助函数。
func TestStageCtxSuccessIsHonestEndToEnd(t *testing.T) {
cases := []struct {
name string
toolRet interface{}
wantSucc bool
wantHint string // 期望出现在回填文本里的片段
}{
{"成功返回 ok", "ok", true, ""},
{"成功返回结构化 map", map[string]interface{}{"status": "ok", "exit_code": 0}, true, ""},
{"①error 约定失败", map[string]interface{}{"error": "name is required"}, false, "name is required"},
{"②isError 约定失败", map[string]interface{}{"isError": true, "content": "path is required"}, false, "path is required"},
{"结构化 ToolError 失败", newToolError(ErrReasonRequired, "path", "缺少 path", "先传 path 参数"), false, "先传 path 参数"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "tool_ret", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}}
a, _ := newBatchAgent(t, sp)
// 覆盖设备工具的返回值为本用例的样本。
a.io.RegisterDevice(&retDevice{name: "retdev", toolName: "tool_ret", ret: c.toolRet})
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
if len(f.StageCtx.ToolResults) == 0 {
t.Fatal("StageCtx.ToolResults 为空")
}
tr := f.StageCtx.ToolResults[0]
if tr.Success != c.wantSucc {
t.Errorf("Success = %v,期望 %v(返回值 %#v)", tr.Success, c.wantSucc, c.toolRet)
}
if c.wantHint != "" {
// 结构化失败的 Hint 必须真的回填给模型,否则「看得懂真因」落空。
found := false
for _, m := range f.Msgs {
if m.Role == "tool" && strings.Contains(m.Content, c.wantHint) {
found = true
}
}
if !found {
t.Errorf("失败详情 %q 未回填给模型", c.wantHint)
}
}
})
}
}
// retDevice 是一个按预设值返回的测试设备。
type retDevice struct {
name string
toolName string
ret interface{}
}
func (d *retDevice) Name() string { return d.name }
func (d *retDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *retDevice) Description() string { return "ret test device" }
func (d *retDevice) Tools() []agentIO.ToolDef { return []agentIO.ToolDef{{Name: d.toolName}} }
func (d *retDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return d.ret, nil
}
func (d *retDevice) Start() error { return nil }
func (d *retDevice) Stop() error { return nil }
func (d *retDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *retDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }