fix(agent): 工具循环占位不再驱动重复发送,输出回执/子任务结果幂等

生产现象:单轮内 output_send__qq 被调用 34 次、持续 514 秒,直到 QQ 插件
自己的循环保险拒绝发送才停下(problem.md)。根因是多环节叠加,核心侧修四处:

1. 工具轮补位文案(process.go)
   通用占位「请根据以上工具结果继续。」对纯输出通道调用是错的:异步通道
   (qq/wechat)的回复只能经 output_send__* 交付,所以模型「已完成回复」的
   表达形式就是一个工具调用,紧随其后的「请继续」会被读成「还要再做一步」,
   而能做的「一步」恰好还是再发一条消息。
   改为按上一批工具的性质选文案:全部是 output_send__* 时补
   「若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。」
   同时每轮先移除旧占位再补一条,避免占位在 prompt 前缀里线性累积。
   (该占位是 zen 网关「最后一条必须是 user」的传输层附加物,HEAD 版本是
   无条件内联追加、从不移除。)

2. 输出成功回执(output.go)
   「已通过 [qq] 通道发送: map[status:sent]」这类富回执会被读成「这步成功,
   继续下一步」。成功改为只回极简标记。

3. proc 桥标量透传(internal/plugin/proc/plugin.go)
   插件返回 "ok" 时不再伪造 {status:sent} 覆盖插件真实返回值,否则只改
   output.go 不生效。

4. 子任务结果幂等(spawn.go)
   child_result 原先读到即删,而完成通知长期留在持久上下文里
   (formatMergedTimeline 每轮重新注入),第二次查询必然得到
   「不存在或已过期」这个永久失败信号,模型据此认为任务未完成而反复重试。
   改为保留结果 + delivered 标记,重复查询返回明确提示;结果按上限有界淘汰。

顺带:agent.go 去掉文档层显式向量器注入(TF-IDF 已内置为 fallback),
cmd/homed/main.go 同步 document.NewStore 的 tokenizer 参数。

测试:internal/agent/core/tooloop_test.go(5 例)、spawn_test.go(3 例)。
This commit is contained in:
JianFeeeee
2026-09-10 20:36:39 +08:00
parent b096e8ba2c
commit e218d0f100
8 changed files with 392 additions and 27 deletions

View File

@ -345,16 +345,30 @@ func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (inte
return nil, err // 真实失败上报,模型可感知并重试
}
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
return map[string]interface{}{"status": "ok"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil
if err := json.Unmarshal(raw, &res); err == nil {
if _, ok := res["status"]; !ok {
res["status"] = "sent"
}
return res, nil
}
if _, ok := res["status"]; !ok {
res["status"] = "sent"
// 插件返回的是标量(如 "ok")——**原样透传,不要伪造 status**。
//
// 为什么必须透传:核心 output.go 会把 map 结果格式化成富回执
// "已通过 [qq] 通道发送: map[status:sent]"),模型看到"发送成功 +
// 详情"会把这一步当成"上一步完成、继续下一步"的信号,形成
// output_send 回声循环。插件(如 qq刻意返回极简的 "ok" 就是为了
// 掐断这个信号;早期实现在这里把非 map 响应替换成 {status:sent}
// 等于把它又变回富回执——**只改插件永远修不掉这个循环**。
var scalar interface{}
if err := json.Unmarshal(raw, &scalar); err != nil {
return map[string]interface{}{"status": "ok"}, nil
}
return res, nil
return scalar, nil
}
// 编译期确认 Plugin 具备 registry 需要的启停形状。