Files
HomeAgent/internal/agent/core/spawn_replenish_test.go
JianFeeeee e6c1bfa6bf fix(spawn): 子 Agent 报告如实 + 跑满预算不再丢弃成果 + 提示词补执行纪律
三处修复,均来自生产日志实证(本机 systemd 实例,2026-10-08)。

## 1. 报告轮数在说谎(max_turns != 5 时必现)

生产日志:`spawn_child` 声明「最多 10 轮」,子 Agent 实际跑满 10 轮后被判定
「子 Agent 执行超时(超过 5 轮)」——**那个 5 是硬编码的**,与真实轮数无关。
排查时会被这个假数字带偏(我本人就先据此去查「为什么是 5」)。

改法:新增 `completed` / `reachedLimit` 两个标志如实分类收尾原因,
文案用 `maxTurns` 插值。顺带修两个边界:
- 模型返回**空文本**也算「回答完了」,此前会被误报成故障;
- 「跑满预算」不是「超时」,不再如此描述。

## 2. 跑满预算时丢弃全部中间成果

原实现直接回一句「超时」,子 Agent 前几轮已取到的工具结果**全部丢弃**。
父 Agent 拿到不可操作的信号只能重 spawn —— 生产日志里 `child_5→6→7` 正是
同一任务连试三次,每次重烧一整个上下文(也是实例内存偏高的成因之一)。

改法:新增 `salvageChildSummary()`,在**失败路径**上多做一次不带工具的调用
(`ToolChoice: "none"`),把已有成果落成文本并标注「可能不完整」。
成本权衡:多一次 LLM 调用 ≪ 父重 spawn 烧掉整个上下文。
正常完成的子任务**不会**触发它(有判据钉住,避免白花调用)。

## 3. 提示词缺「执行纪律」与「工具执行顺序」

子 Agent 的提示词原先只有 3 行,而父有 7 个章节。缺失的直接后果(生产日志):

    child_1 done: I'll start by searching for each school's admissions catalog information.
    child_3 done: 我将逐校用官方渠道核实。先并行检索四校的招生专业目录信息。

**它把「宣告计划」当成任务完成了**——因为退出判据只看模型有没有返回 tool_calls,
而「I'll start by…」之后就不再调工具。父的提示词里早有同类先例
(【记忆清理指令】:「必须实际调用…不能只回复文本」「不要只描述计划而不执行」),
子这里一句都没有。

另缺【工具执行顺序】:同轮多个 tool_call 默认**并行**,而子不知道
「依赖前一步结果的调用必须分两轮」,多步任务因此被塞进一轮。

★ 循环判据**未改动**(它是对的:模型确实不再要求工具了)——
靠提示词让模型不要过早停止调用,而不是放宽判据。

## 验证

- 新增 `spawn_replenish_test.go`:4 组判据覆盖 max_turns ∈ {1,5,10,14}、
  成果保留、正常完成不触发收尾、日志含轮次/预算/工具名
- **变异测试**:把轮数改回硬编码 5 / 去掉收尾提炼 / 删掉子日志 → 三个变异全部被杀
- `go test ./internal/agent/core/ -count=1` 绿;`-race` 绿

注:本提交含一行随 module 迁移的 import 路径更新(spawn.go 的 agentAPI 导入)。
2026-10-08 10:40:53 +08:00

283 lines
11 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 (
"bytes"
"context"
"fmt"
"log"
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// captureLog 捕获 log 包在 fn 执行期间的全部输出。
//
// 注意:log 的输出目标是**全局**的,所以此 helper 不可与 t.Parallel() 同用。
// 本文件不并跑任何测试,故安全。
func captureLog(t *testing.T, fn func()) string {
t.Helper()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(prev)
fn()
return buf.String()
}
// scriptedChildProvider 按脚本逐轮返回:先给定批次的工具调用,最后给文本。
//
// 与 stubProvider 的区别:stub 每轮返回同一段文本(无法表达「先调工具、再收尾」
// 的多轮序列),而本轮修复的正确性**恰恰只在多轮序列里才显现**。
type scriptedChildProvider struct {
// toolTurns 是前 N 轮各自要返回的工具调用批次。
toolTurns [][]agentAPI.ToolCall
// finalText 是所有工具轮用尽后的文本答复(若为 "" 则永不返回文本,
// 用来构造「跑满预算仍未产出答复」这一路径)。
finalText string
// salvageText 是收尾调用(ToolChoice=none)的答复。
salvageText string
calls int
sawSalvage bool
}
func (p *scriptedChildProvider) Name() string { return "scripted" }
func (p *scriptedChildProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.calls++
// 收尾调用:子 Agent 跑满预算后补的那一次(ToolChoice=none)。
if req.ToolChoice == "none" {
p.sawSalvage = true
return &agentAPI.CompletionResponse{Content: p.salvageText}, nil
}
// 工具轮:按脚本发工具调用。
if p.calls <= len(p.toolTurns) {
return &agentAPI.CompletionResponse{ToolCalls: p.toolTurns[p.calls-1]}, nil
}
return &agentAPI.CompletionResponse{Content: p.finalText}, nil
}
func (p *scriptedChildProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk)
close(ch)
return ch, nil
}
func (p *scriptedChildProvider) MaxContextTokens() int { return 8192 }
// fakeToolCall 造一个指向不存在工具的工具调用。
//
// 用不存在的工具是**有意的**:本组测试只关心轮次与收尾逻辑,不该依赖任何真实
// 工具或插件;未知工具会走到「未注册」分支并返回一段文本,足以驱动循环继续。
func fakeToolCall(i int) agentAPI.ToolCall {
return agentAPI.ToolCall{
ID: fmt.Sprintf("call_%d", i),
Name: fmt.Sprintf("nonexistent_tool_%d", i),
Arguments: map[string]interface{}{},
}
}
// newSpawnAgent 造一个跑 runChildTask 所需的最小 Agent。
//
// io 必须接上:executeToolCallInner 会在设备工具授权闸上读 a.io(解引用 nil 会直接
// SIGSEGV)。用一个空 IOManager 即可——本组测试只用未知工具名,不会命中真实通道。
func newSpawnAgent(t *testing.T, p agentAPI.Provider) *Agent {
t.Helper()
a := New(AgentConfig{ID: "child-test", Provider: p})
a.io = agentIO.NewIOManager()
a.childTasks = make(map[string]*childTaskState)
return a
}
// runChildAndGet 登记任务、跑完 runChildTask,再取回结果。
//
// 必须通过同一注册路径:runChildTask 结尾会读 a.childTasks[taskID] 回写结果,
// 未登记就直接调用会让测试自己 nil 解引用(而非被测代码出错)。
func runChildAndGet(t *testing.T, a *Agent, provider agentAPI.Provider, taskID, task string, maxTurns int) string {
t.Helper()
a.provider = provider
a.childMu.Lock()
a.childTasks[taskID] = &childTaskState{running: true}
a.childMu.Unlock()
a.runChildTask(taskID, task, "cli", maxTurns)
a.childMu.Lock()
st := a.childTasks[taskID]
a.childMu.Unlock()
if st == nil {
t.Fatalf("任务 %s 未在注册表中(不应被淘汰或丢失)", taskID)
}
return st.result
}
// 跑满轮预算时,报告必须**如实说明实际轮数**,不能硬编码成别的数字。
//
// 这是本次修复的核心判据。生产实证:max_turns=10 的子 Agent 跑满 10 轮,
// 报告却写「超过 5 轮」——那个假数字会把排查引向错误方向(我曾据此去查
// 「为什么是 5」,而真实原因与 5 毫无关系)。
func TestChildReachingTurnLimitReportsActualMaxTurns(t *testing.T) {
for _, maxTurns := range []int{1, 5, 10, 14} {
t.Run(fmt.Sprintf("max_turns=%d", maxTurns), func(t *testing.T) {
// 每一轮都返回工具调用 ⇒ 永不产出最终文本 ⇒ 必然跑满预算。
turns := make([][]agentAPI.ToolCall, maxTurns)
for i := range turns {
turns[i] = []agentAPI.ToolCall{fakeToolCall(i)}
}
// salvage 也返回空 ⇒ 走「诚实陈述」分支
p := &scriptedChildProvider{toolTurns: turns, finalText: "", salvageText: ""}
a := newSpawnAgent(t, p)
got := runChildAndGet(t, a, p, "child_x", "test task", maxTurns)
// 判据一:必须出现**真实的**轮数。
if !strings.Contains(got, fmt.Sprintf("%d 轮", maxTurns)) {
t.Fatalf("max_turns=%d 的报告应含真实轮数,实际: %q", maxTurns, got)
}
// 判据二:绝不能出现硬编码的「5 轮」(除非 maxTurns 恰为 5)。
if maxTurns != 5 && strings.Contains(got, "5 轮") {
t.Fatalf("max_turns=%d 时报告出现了陈旧的「5 轮」: %q", maxTurns, got)
}
// 判据三:不得再谎称「执行超时」(它不是超时,是预算耗尽)。
if strings.Contains(got, "超时") {
t.Fatalf("跑满预算不是超时,不应如此描述: %q", got)
}
})
}
}
// 跑满预算时不得丢弃中间成果:应收尾提炼出已取得的内容。
//
// 丢弃的代价是父 Agent 只能重 spawn(生产日志里 child_5→6→7 是同一任务连试三次),
// 每次重烧一整个上下文——比多做一次收尾调用贵得多。
func TestChildReachingTurnLimitSalvagesPartialResults(t *testing.T) {
p := &scriptedChildProvider{
toolTurns: [][]agentAPI.ToolCall{{fakeToolCall(0)}, {fakeToolCall(1)}},
salvageText: "已查明:广州大学复试科目为数据结构;上海大学未取到正文。",
}
a := newSpawnAgent(t, p)
got := runChildAndGet(t, a, p, "child_s", "查两所高校", 2)
if !p.sawSalvage {
t.Fatal("跑满预算后应发起一次收尾调用(ToolChoice=none)")
}
if !strings.Contains(got, "广州大学复试科目为数据结构") {
t.Fatalf("收尾提炼的成果应被保留,实际: %q", got)
}
if !strings.Contains(got, "可能不完整") {
t.Fatalf("收尾结果必须标注可能不完整(否则父 Agent 会当成完整结论): %q", got)
}
}
// 正常完成(模型主动给文本)时,**不得**触发收尾调用。
//
// 否则每次成功的子任务都要多花一次 LLM 调用——那是纯浪费。
func TestChildNormalCompletionDoesNotSalvage(t *testing.T) {
p := &scriptedChildProvider{
toolTurns: [][]agentAPI.ToolCall{{fakeToolCall(0)}},
finalText: "任务完成:已写入 3 条记录。",
salvageText: "不应被调用",
}
a := newSpawnAgent(t, p)
got := runChildAndGet(t, a, p, "child_n", "写记录", 10)
if p.sawSalvage {
t.Fatal("正常完成时不应发起收尾调用(那是多余的 LLM 调用)")
}
if got != "任务完成:已写入 3 条记录。" {
t.Fatalf("正常结果应原样返回,实际: %q", got)
}
}
// 轮上限为 1:一轮就耗尽,收尾仍必须工作(边界)。
func TestChildMaxTurnsOne(t *testing.T) {
p := &scriptedChildProvider{
toolTurns: [][]agentAPI.ToolCall{{fakeToolCall(0)}},
salvageText: "部分结果",
}
a := newSpawnAgent(t, p)
got := runChildAndGet(t, a, p, "child_1t", "任务", 1)
if !strings.Contains(got, "1 轮") {
t.Fatalf("max_turns=1 应如实报告 1 轮,实际: %q", got)
}
}
// 子 Agent 的工具调用必须落日志——否则失败时无法判断倒在哪一步。
//
// 此前子侧全程静默,父内核日志里只有 started/done 两行,
// 无法区分「搜索无结果」「页面解析失败」还是「被轮上限截断」。
func TestChildToolCallsAreLogged(t *testing.T) {
p := &scriptedChildProvider{
toolTurns: [][]agentAPI.ToolCall{{fakeToolCall(0)}},
finalText: "done",
}
a := newSpawnAgent(t, p)
logs := captureLog(t, func() {
runChildAndGet(t, a, p, "child_log", "任务", 5)
})
if !strings.Contains(logs, "child_log") {
t.Fatalf("子 Agent 日志应带自己的 taskID,实际日志: %q", logs)
}
if !strings.Contains(logs, "turn 1/5") {
t.Fatalf("子 Agent 的工具调用应带轮次与预算,实际日志: %q", logs)
}
if !strings.Contains(logs, "nonexistent_tool_0") {
t.Fatalf("子 Agent 日志应含工具名,实际日志: %q", logs)
}
}
// 非驻留子的提示词必须包含「执行纪律」与「工具执行顺序」两节。
//
// 为什么需要这条判据:这两节缺失时子不会报错、也不会崩——它只是**静静地少干活**,
// 把「我打算做什么」当成完成任务交回父(生产日志:child_1/3/4/5 都交回将来时计划)。
// 这类退化没有其它可观测信号,只能靠判据钉住。
func TestChildSystemPromptEnforcesExecutionDiscipline(t *testing.T) {
rendered := fmt.Sprintf(childSystemPromptTemplate, "测试任务")
// 判据一:必须要求「实际调用工具」,且明确否定「只回复将要做」。
if !strings.Contains(rendered, "必须实际调用工具") {
t.Fatal("子提示词缺少「必须实际调用工具」的硬性要求(子会退化成只交回计划)")
}
if !strings.Contains(rendered, "宣告计划不等于完成任务") {
t.Fatal("子提示词必须显式否定「宣告计划即完成」这一行为")
}
// 判据二:必须说明同轮并行语义与「依赖结果时分轮」。
if !strings.Contains(rendered, "默认并行执行") {
t.Fatal("子提示词缺少「同轮默认并行」的说明")
}
if !strings.Contains(rendered, "分两轮") {
t.Fatal("子提示词必须告诉子:依赖前一步结果时要分两轮")
}
// 判据三:任务描述必须真的被插进去(模板参数位不能错位)。
if !strings.Contains(rendered, "任务: 测试任务") {
t.Fatal("任务描述未被正确插入模板")
}
// 判据四:不得把父专属的输出指引搬进来——子被禁用 output_send__*,
// 告知它「异步通道必须 output_send」是错误且白耗预算的指引。
if strings.Contains(rendered, "output_send__") {
t.Fatal("子提示词不应包含输出通道指引(子已被禁用 output_send__*,结果回父而非用户)")
}
}
// 子提示词模板必须正好一个 %s 占位(多了/少了都会让 Sprintf 出错或插错位置)。
func TestChildSystemPromptTemplateHasSingleVerbatimSlot(t *testing.T) {
if n := strings.Count(childSystemPromptTemplate, "%s"); n != 1 {
t.Fatalf("模板应有且仅有一个 %%s 占位,实际 %d 个", n)
}
if !strings.HasSuffix(childSystemPromptTemplate, "任务: %s") {
t.Fatal("任务描述应位于模板末尾(否则会被后续章节挤到中间,读起来像背景而非指令)")
}
}