mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
生产现象:单轮内 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 例)。
118 lines
4.1 KiB
Go
118 lines
4.1 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|