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 例)。
89 lines
2.8 KiB
Go
89 lines
2.8 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
)
|
||
|
||
// child_result 必须幂等——这是 "任务已结束但核心循环不结束" 的根因修复。
|
||
//
|
||
// 子任务完成通知会写进持久上下文(formatMergedTimeline 每轮重新注入),
|
||
// 模型之后还会再查。若第二次查询返回 "不存在或已过期" 这种**永久失败信号**,
|
||
// 模型会认定任务未完成而无限重试/汇报(生产实测:单轮 35 次工具调用、
|
||
// 持续 514 秒)。
|
||
func TestChildResultIsIdempotent(t *testing.T) {
|
||
a := New(AgentConfig{ID: "t"})
|
||
|
||
a.childMu.Lock()
|
||
a.childTasks["child_1"] = &childTaskState{result: "任务完成:已创建 3 个日程", seq: 1}
|
||
a.childMu.Unlock()
|
||
|
||
call := func(id string) string {
|
||
return a.executeChildResultTool(agentAPI.ToolCall{
|
||
Name: "child_result",
|
||
Arguments: map[string]interface{}{"task_id": id},
|
||
})
|
||
}
|
||
|
||
first := call("child_1")
|
||
if !strings.Contains(first, "任务完成:已创建 3 个日程") {
|
||
t.Fatalf("首次查询应返回结果,实际: %q", first)
|
||
}
|
||
|
||
second := call("child_1")
|
||
if strings.Contains(second, "不存在") {
|
||
t.Fatalf("重复查询不能返回失败信号(会驱动模型无限重试),实际: %q", second)
|
||
}
|
||
if !strings.Contains(second, "已完成") {
|
||
t.Fatalf("重复查询应明确告知「已完成、结果已提供」,实际: %q", second)
|
||
}
|
||
|
||
// 只有从未创建过的 ID 才应报 "不存在"。
|
||
missing := call("child_999")
|
||
if !strings.Contains(missing, "不存在") {
|
||
t.Fatalf("未知 ID 应报不存在,实际: %q", missing)
|
||
}
|
||
}
|
||
|
||
// 运行中与已完成必须给出不同答复,否则模型无法判断该等还是该继续。
|
||
func TestChildResultRunningVsDone(t *testing.T) {
|
||
a := New(AgentConfig{ID: "t"})
|
||
|
||
a.childMu.Lock()
|
||
a.childTasks["child_run"] = &childTaskState{running: true}
|
||
a.childMu.Unlock()
|
||
|
||
got := a.executeChildResultTool(agentAPI.ToolCall{
|
||
Name: "child_result",
|
||
Arguments: map[string]interface{}{"task_id": "child_run"},
|
||
})
|
||
if !strings.Contains(got, "仍在运行中") {
|
||
t.Fatalf("运行中的任务应提示仍在运行,实际: %q", got)
|
||
}
|
||
}
|
||
|
||
// 保留的结果必须有界,不能随子任务数量无限增长。
|
||
func TestChildTaskRetentionBounded(t *testing.T) {
|
||
a := New(AgentConfig{ID: "t"})
|
||
|
||
a.childMu.Lock()
|
||
for i := 0; i < maxRetainedChildTasks*3; i++ {
|
||
a.childSeq++
|
||
a.childTasks[fmt.Sprintf("child_%d", i)] = &childTaskState{result: "r", seq: a.childSeq}
|
||
}
|
||
a.evictChildTasksLocked()
|
||
n := len(a.childTasks)
|
||
a.childMu.Unlock()
|
||
|
||
if n > maxRetainedChildTasks {
|
||
t.Fatalf("保留子任务数=%d,超过上限 %d", n, maxRetainedChildTasks)
|
||
}
|
||
// 淘汰应保留最新的:最早的那批必须已不在
|
||
if _, ok := a.childTasks["child_0"]; ok {
|
||
t.Fatal("淘汰应优先丢弃最旧的已完成任务")
|
||
}
|
||
}
|