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

@ -110,7 +110,11 @@ type TaskFrame struct {
CurTool agentAPI.ToolCall
CurToolPlugin string
CurResult string
Resp *agentAPI.CompletionResponse
// CurRaw 是本次执行的**未降级**返回值(interface{})。
// 存在理由:CurResult 是 string,结构化信息在此被抹平,导致 Success
// 无法诚实化、after_toolcall 的改写静默失效。
CurRaw interface{}
Resp *agentAPI.CompletionResponse
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
@ -747,14 +751,20 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
// 而工具步不一定走到下面的召回分支,所以不能等那里再解析。
turn := a.resolveTurnScenes(f, f.CurTool.Name)
result := a.executeToolCall(f.CurTool, f.OutputChannel, turn.Keys...)
outcome := a.executeToolCallOutcome(f.CurTool, f.OutputChannel, turn.Keys...)
result := outcome.Text
f.CurResult = result
f.CurRaw = outcome.Raw
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
// Success 此前是**唯一**赋值点且硬编码 true ⇒ 该字段恒真、结构上不可能为
// false。工具失败是以 nil error + 错误**值**返回的,所以判据必须看返回值。
// ⚠️ isToolError 必须同时覆盖存量插件的两种失败约定与「成功不误判」,
// 否则升级会把存量插件的成功判成失败(见 toolerror_test.go)。
f.StageCtx.ToolResults = []sdk.ToolResult{{
CallID: f.CurTool.ID, Name: f.CurTool.Name, Plugin: f.CurToolPlugin,
Success: true, Result: result,
Success: !isToolError(outcome.Raw), Result: outcome.Raw,
}}
f.Step = StepToolAfter
return outcomeContinue
@ -768,8 +778,20 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
a.runStage(sdk.StageAfterToolcall, f.StageCtx)
if len(f.StageCtx.ToolResults) > 0 {
if r, ok := f.StageCtx.ToolResults[0].Result.(string); ok {
// 此前是 `Result.(string)` 类型断言,而插件返回的多是 map ⇒ 断言几乎
// 恒失败,after_toolcall 阶段对结构化结果的改写**静默失效**。
// 改为:字符串就替换文本;结构化值则保留其原值并按契约渲染。
switch r := f.StageCtx.ToolResults[0].Result.(type) {
case string:
result = r
case nil:
// 插件清空结果:保持原样,不覆盖。
default:
f.CurRaw = r
result = toolErrorText(f.CurTool.Name, r)
if !isToolError(r) {
result = fmt.Sprintf("%v", r)
}
}
}
// 工具后处理:一次相关性过程,两个**正交**声明——