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

@ -0,0 +1,117 @@
package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。
func appendPlaceholder(msgs []agentAPI.Message, replyOnly bool) []agentAPI.Message {
msgs = dropContinuationPlaceholders(msgs)
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{Role: "user", Content: continuationFor(replyOnly)})
}
return msgs
}
func countPlaceholders(msgs []agentAPI.Message) int {
n := 0
for _, m := range msgs {
if isContinuationPlaceholder(m) {
n++
}
}
return n
}
// 占位是核心插入的传输层附加物,不是用户发言——它不能随轮次线性累积。
//
// 旧实现每轮无条件追加而从不移除,跑 N 轮 prompt 里就叠了 N 条一模一样的
// “继续”,把前缀上下文(含记忆注入)往后挤。
func TestPlaceholderDoesNotAccumulate(t *testing.T) {
msgs := []agentAPI.Message{{Role: "user", Content: "用户请求"}}
const rounds = 20
for turn := 0; turn < rounds; turn++ {
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: "调用工具"})
msgs = append(msgs, agentAPI.Message{Role: "tool", Content: "结果"})
// 交替普通工具轮 / 纯发送轮,确保两种文案都参与去重。
msgs = appendPlaceholder(msgs, turn%2 == 1)
if n := countPlaceholders(msgs); n != 1 {
t.Fatalf("第 %d 轮后占位数=%d期望恰好 1 条(旧实现会累积到 %d 条)", turn+1, n, turn+1)
}
}
// 末尾那一轮是纯发送轮,留下的应是“允许收尾”的文案。
if last := msgs[len(msgs)-1]; last.Content != replyDeliveredPlaceholder {
t.Fatalf("最后应是回复已交付的文案,实际: %q", last.Content)
}
}
// 首轮 system/真实用户输入结尾不补位:补了会覆盖实际用户输入。
func TestPlaceholderNotAppendedOnFirstTurn(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "系统说明"},
{Role: "user", Content: "真实用户输入"},
}
got := appendPlaceholder(msgs, false)
if len(got) != 2 {
t.Fatalf("首轮不应补位,得到 %d 条: %+v", len(got), got)
}
if got[1].Content != "真实用户输入" {
t.Fatalf("真实用户输入被覆盖: %q", got[1].Content)
}
}
// 内容相近的真实用户消息不能被当作占位删掉。
func TestDropOnlyExactPlaceholder(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "user", Content: continuationPlaceholder + "补充"},
{Role: "user", Content: replyDeliveredPlaceholder + "补充"},
{Role: "user", Content: continuationPlaceholder},
}
got := dropContinuationPlaceholders(msgs)
if len(got) != 2 {
t.Fatalf("只应删掉精确匹配的那条,得到 %d 条: %+v", len(got), got)
}
}
// 纯输出通道调用之后的补位不能再是「请继续」。
//
// 异步通道的回复只能经 output_send__* 交付,所以模型「已完成回复」的形式就是
// 一个工具调用;紧跟一句「请继续」会被读成「还要再做一步」,而能做的
// 「一步」恰好还是再发一条消息。(生产实测:单轮 34 次发送、514 秒)
func TestContinuationForReplyDoesNotPushToContinue(t *testing.T) {
reply := continuationFor(true)
if reply == continuationPlaceholder {
t.Fatal("回复已交付后不应再补「请继续」,会驱动重复发送")
}
if !strings.Contains(reply, "纯文本") || !strings.Contains(reply, "结束") {
t.Fatalf("应明确告知可返回纯文本收尾,实际: %q", reply)
}
if got := continuationFor(false); got != continuationPlaceholder {
t.Fatalf("普通工具轮补位应保持不变,实际: %q", got)
}
}
// 只有真正的发送动作算「交付回复」_help 是查询用法。
func TestIsOutputDeliveryTool(t *testing.T) {
cases := map[string]bool{
"output_send__qq": true,
"output_send__webui": true,
"output_send__qq_help": false,
"output_list_channels": false,
"cmd_run": false,
"qq_get_message": false,
}
for name, want := range cases {
if got := isOutputDeliveryTool(name); got != want {
t.Errorf("isOutputDeliveryTool(%q) = %v, want %v", name, got, want)
}
}
}