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/* 等失败包),已在干净基线(ab1a17a)对比确认。
This commit is contained in:
JianFeeeee
2026-09-27 09:17:16 +08:00
parent 1a511b8b89
commit d55932bb53
7 changed files with 440 additions and 38 deletions

View File

@ -240,6 +240,41 @@ type ToolResult struct {
Result interface{} `json:"result"`
}
// ToolError 描述一次工具调用的失败原因。
//
// 存在的理由:失败若只表达为文本,模型无法定位到字段,只能原样重试
// (实测 cmd_run 失败率 34%~48%,全部源于同一个成因:参数被截断或
// JSON 写坏,工具却只回报 "command is required" 这类与真因无关的错)。
//
// ⚠️ 零值语义:插件**不必**改用本类型。内核的失败识别同时兼容既有三种约定
// ({"error":…}、{"isError":true,…}、显式 error 返回),见 core.isToolError。
// 本类型是给**新写**的工具用的可选项,不是迁移要求。
type ToolError struct {
// Field 是出错的参数字段名(参数校验失败时填)。
Field string `json:"field,omitempty"`
// Reason 是机器可读的原因码:required / type / unauthorized / timeout / not_found。
Reason string `json:"reason"`
// Detail 是人类可读的补充说明。
Detail string `json:"detail,omitempty"`
// Hint 是给模型的可执行指引(该改什么、不要重试什么)。
Hint string `json:"hint,omitempty"`
}
// Error 实现 error,便于工具同时走 (ToolError, error) 通道。
func (e *ToolError) Error() string {
if e == nil {
return ""
}
s := e.Reason
if e.Field != "" {
s = e.Field + ": " + s
}
if e.Detail != "" {
s += " (" + e.Detail + ")"
}
return s
}
// ToolDef describes a tool that the plugin exposes.
type ToolDef struct {
Name string `json:"name"`