mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
fix(agent): 被拒绝/被跳过的工具没落 ToolResults —— call 与 result 失配
## 根因(4ab888c 的自检抓出来的)
4ab888c 在发送前加了配对自检,生产日志立刻指出真正来源:
★ dropped 1 unpaired tool message(s) before send (names: [qq_call_state])
顺着工具名查 context.json,脏帧是决定性的:
[42] tool_calls = [qq_call_state, output_send__qq]
tool_results = [output_send__qq] ← call_state 从未落盘
该帧自己的记录写了原因:qq_call_state 被 QQ 插件的来源权限策略拦下
(判定为"非所有者私聊")。
## 缺陷位置
stepToolBegin 的顺序是「先记 call,再判能不能执行」:
f.appendToolCallRecord(tc) // ← 先落 ToolCalls
if a.runStage(sdk.StageBeforeToolcall, …) {
f.Msgs 补一条 role=tool // ← 模型能看到
f.ToolIdx++
return outcomeContinue // ← 但没写 ToolResults
}
同一函数里还有一处同构分支(插件崩溃态跳过执行),同样只补 f.Msgs。
task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
两者都由 appendToolCallRecord 同步追加」—— 这个不变量在**提前返回路径**
上被违反了。
## 为什么这会变成会话级故障
ContextEvent{ToolCalls: 2, ToolResults: 1}
⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
⇒ 上游硬拒绝(HTTP 400):
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages…)
关键在于脏数据**持久化进 context.json**,之后每一轮都带着它,直到自检剔掉。
## 修法
新增 appendToolResultRecord(与 appendToolCallRecord 成对的公开入口),
在两处提前返回各补一行。
判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型确实
从 f.Msgs 看到了它。落 context 的那份必须与发给模型的一致。
并发路径(stepToolBatch)没有这个问题 —— runOneTool 只返回结果,
join 后的顺序收尾段无条件写 ToolResults(含被拒绝项)。
## 判据(2 条,2 次变异全部实测变红)
TestDeniedToolStillRecordsResult
注册一个真的会拒绝的 before_toolcall 钩子,跑完整一轮,
断言落进 context 的每个 call 都有 result,且拒绝原因文本一致。
变异:删掉拒绝分支那行 ⇒ "★ call call_deny_1 (denied_tool) 没有对应的
ToolResults"(实测红)
TestUnhealthyPluginSkipStillRecordsResult
钉住第二条同构分支。
变异:删掉跳过分支那行 ⇒ "★ 被跳过的工具 call_unhealthy_1 没落
ToolResults —— 同型失配,上游 400"(实测红)
两条都走真实链路(真的调 runStage / 真的置崩溃态),不是只测
appendToolResultRecord 这个函数本身。
## 顺带发现一个独立缺陷(未修,不在本次范围)
写第二条判据时发现:插件崩溃熔断**实际从不生效**。
recordCrash 标记 Unhealthy=true,但从不设置 LastReload
⇒ isHealthy 的冷却判据 time.Since(r.LastReload) > reloadCooldown
在 LastReload 为零值时恒为真(Since 是巨大的正数)
⇒ 判定"冷却期早已过去" ⇒ 立刻自愈
实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
紧接着 isHealthy 返回 true。
后果:task.go:1065/1135 的「插件崩溃态跳过执行」分支实际走不到。
这条应单独修并单独钉,本提交的判据用直达状态绕过它(注释已写明原因)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestDenied/TestUnhealthy 全绿
This commit is contained in:
@ -1115,6 +1115,10 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
result := denialResultText(tctx, tc.Name)
|
||||
f.ensureBatchAssistant()
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
// ★ 必须与 f.Msgs 同步落 context(见 appendToolResultRecord 的说明)。
|
||||
// 漏掉这行的实测后果:qq_call_state 被权限策略拒绝后,ContextEvent 里
|
||||
// 有 call 没有 result,每一轮都触发上游 400,直到发送前自检把它剔掉。
|
||||
f.appendToolResultRecord(tc.ID, tc.Name, result)
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
@ -1133,6 +1137,8 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
||||
f.ensureBatchAssistant()
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
// 同上:跳过执行也要落 result,否则 call/result 失配。
|
||||
f.appendToolResultRecord(tc.ID, tc.Name, result)
|
||||
f.ToolIdx++
|
||||
return outcomeContinue
|
||||
}
|
||||
@ -1192,6 +1198,41 @@ func (f *TaskFrame) appendToolCallRecord(tc agentAPI.ToolCall) {
|
||||
})
|
||||
}
|
||||
|
||||
// appendToolResultRecord 追加一条工具结果记录(与 appendToolCallRecord 成对)。
|
||||
//
|
||||
// ★ 为什么不变量必须由**每一次**调用 path 维护(2026-11-04 生产故障)
|
||||
//
|
||||
// task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
|
||||
// 两者都由 appendToolCallRecord 同步追加」。但"跳过执行"的三条提前返回
|
||||
// 分支只补了 f.Msgs(模型能看到),**没写 f.ToolResults**(落 context 的那份)。
|
||||
//
|
||||
// 后果不是"少一条记录"这么轻:
|
||||
//
|
||||
// stepToolBegin appendToolCallRecord(tc) ← 记了 call
|
||||
// before_toolcall 钩子拒绝 → return ← 没记 result
|
||||
// ⇒ ContextEvent{ ToolCalls: 2, ToolResults: 1 }
|
||||
// ⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
|
||||
// ⇒ 上游硬拒绝(HTTP 400):insufficient tool messages following tool_calls
|
||||
// ⇒ 脏数据持久化进 context.json,之后**每一轮**都带着它
|
||||
//
|
||||
// 实测(qq 插件的来源权限策略拒绝了 qq_call_state):
|
||||
//
|
||||
// [42] tool_calls=[qq_call_state, output_send__qq]
|
||||
// tool_results=[output_send__qq] ← call_state 的结果从未落盘
|
||||
//
|
||||
// 判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型
|
||||
// 确实看到了它(f.Msgs 里那条 tool 消息)。落 context 的必须与发给模型的一致。
|
||||
func (f *TaskFrame) appendToolResultRecord(callID, name, output string) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{
|
||||
CallID: callID,
|
||||
Name: name,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
|
||||
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
|
||||
|
||||
Reference in New Issue
Block a user