Files
HomeAgent/internal/agent/core/tooloop_test.go
JianFeeeee e218d0f100 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 例)。
2026-09-10 20:36:39 +08:00

118 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}