From e6c1bfa6bfc7fd78d94a09ee2d5ccef5c73e5502 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 8 Oct 2026 10:40:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(spawn):=20=E5=AD=90=20Agent=20=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=A6=82=E5=AE=9E=20+=20=E8=B7=91=E6=BB=A1=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E4=B8=8D=E5=86=8D=E4=B8=A2=E5=BC=83=E6=88=90=E6=9E=9C?= =?UTF-8?q?=20+=20=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=A1=A5=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E7=BA=AA=E5=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处修复,均来自生产日志实证(本机 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 导入)。 --- internal/agent/core/spawn.go | 132 ++++++++- internal/agent/core/spawn_replenish_test.go | 282 ++++++++++++++++++++ 2 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 internal/agent/core/spawn_replenish_test.go diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index f3b1664f..cca1968b 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -6,7 +6,7 @@ import ( "log" "strings" - agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api" ) // childTaskState 是一个子任务的生命周期状态。 @@ -86,6 +86,49 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) st // defaultChildMaxTurns 子 Agent 默认工具轮数(可被 spawn_child 的 max_turns 参数覆盖)。 const defaultChildMaxTurns = 5 +// childSystemPromptTemplate 是非驻留子 Agent 的系统提示词(%s = 任务描述)。 +// +// ★ 为什么必须显式写「执行纪律」与「工具执行顺序」两节: +// +// 此前这两节缺失,子只能拿到「你是子任务助手,请完成任务」这一句。实测(2026-10-08 +// 生产日志)子把「宣告计划」当成了任务完成 —— 交回父的结果大量是将来时: +// +// child_1 done: I'll start by searching for each school's admissions catalog information. +// child_3 done: 我将逐校用官方渠道核实。先并行检索四校的招生专业目录信息。 +// child_5 done: I'll start by reading the existing artifacts. +// +// 根因是退出判据只看「模型有没有返回 tool_calls」(spawn.go 的 `len(resp.ToolCalls) == 0`): +// 模型一输出「I'll start by...」就再无 tool_calls,循环立即判定完成。**该判据未改动** +// (它是对的:模型确实不再要求工具了),所以要靠提示词让模型**不要**在拿到结果前 +// 停止调用工具。 +// +// 父的提示词里早有同类先例(buildSystemPrompt 的【记忆清理指令】:「必须实际调用 +// memory_ 工具执行操作,不能只回复文本」「不要只描述计划而不执行」),子这里缺的正是它。 +// +// 第二缺失是【工具执行顺序】:同轮多个 tool_call **默认并行**,而子不知道 +// 「依赖前一步结果的调用必须分轮」。多步复杂任务(搜索 → 按结果抓页 → 解析) +// 因此被塞进同一轮,并行跑出无意义的组合。 +// +// 不把父的【输出规则】/【中断消息】/【记忆清理】/【可用技能】/人格那几节一并搬过来: +// 子被显式禁用了 output_send__*(结果回父而非直接回用户),也无需保留记忆或响应中断; +// 搬来只会白占提示词预算。 +const childSystemPromptTemplate = `你是 HomeAgent 的子任务助手。 +请完成以下任务。完成即可,无需保留记忆或查询历史。 + +【执行纪律】这是硬性要求,不是建议: +- **必须实际调用工具去执行**,不要只回复「我将要做什么」。宣告计划不等于完成任务; + 只要你还没把结果拿到手,就必须继续调用工具。 +- 只有当你**已经取得实际结果**(或确认无法取得)时,才用纯文本汇报结论; + 汇报内容必须是**已发生的事实**,而不是打算做的事。 +- 任务确实无法完成时,如实说明卡在哪一步、已取得了什么,不要谎报完成。 + +【工具执行顺序】同一条回复里的多个工具调用**默认并行执行**,不是依次执行: +- 若某个调用的参数**需要另一个调用的结果**,必须**分两轮** —— 先发前一个, + 看到结果后再发下一个。写在同一轮就等于假设了不存在的先后。 +- 互相独立的调用可以放在同一轮并行发出(这是被鼓励的,更快)。 + +任务: %s` + func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns int) { if a.provider == nil { log.Printf("[child] %s failed: no LLM provider configured", taskID) @@ -93,9 +136,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns } log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80)) - sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。 -请完成以下任务。完成即可,无需保留记忆或查询历史。 -任务: %s`, task) + sysPrompt := fmt.Sprintf(childSystemPromptTemplate, task) msgs := []agentAPI.Message{ {Role: "system", Content: sysPrompt}, @@ -120,7 +161,17 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns childTools = append(childTools, t) } + // 循环因何结束,供收尾文案如实描述(不要把「跑满预算」说成别的数字)。 + // + // 为什么需要它:此前收尾文案硬编码「超过 5 轮」,而 max_turns 是**模型可传** + // 的参数(1-30),于是 max_turns=10 的子 Agent 明明跑满 10 轮,报告却说 + // 「超过 5 轮」——日志在说谎,排查时会被这个假数字带偏。 + // + // completed 与会空内容分开记:模型返回了空文本也是一次「回答过了」, + // 不能与「循环中途 break」归为同一类(那会把可解释的空回答报成故障)。 var finalResult string + completed := false + reachedLimit := false for turn := 0; turn < maxTurns; turn++ { req := &agentAPI.CompletionRequest{ Messages: msgs, @@ -138,9 +189,15 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns if len(resp.ToolCalls) == 0 { finalResult = resp.Content + completed = true break } + if turn == maxTurns-1 { + // 本轮是最后一轮:它仍返回了工具调用,说明预算在此耗尽。 + reachedLimit = true + } + for _, ct := range resp.ToolCalls { var result string switch { @@ -151,13 +208,36 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns default: result = a.executeToolCall(ct, parentChannel) } + // 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里 + // 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、 + // 解析失败,还是被轮上限截断)——只能看到一句「超时」。 + log.Printf("[child] %s turn %d/%d: %s → %d 字节", taskID, turn+1, maxTurns, ct.Name, len(result)) msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}}) msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result}) } } if finalResult == "" { - finalResult = "子 Agent 执行超时(超过 5 轮)" + switch { + case completed: + // 模型确实回答完了,但内容是空的。如实说,不当作故障。 + finalResult = "子 Agent 已结束(模型未给出文本内容)" + case reachedLimit: + // 跑满预算 ≠ 白干。子 Agent 在前几轮已经拿到了一批工具结果 + // (实测 child_5 已跑完一串页面解析),直接丢弃它们会让父 Agent + // 只能重 spawn(日志里 child_5→6→7 就是同一任务连试三次,每次都 + // 重烧一整个上下文)。所以先做一次「不带工具的收尾调用」,把已有 + // 成果落成文本。仅在失败路径上多花一次 LLM 调用。 + log.Printf("[child] %s 跑满预算 maxTurns=%d,尝试收尾提炼已有成果", taskID, maxTurns) + if summary := a.salvageChildSummary(msgs, maxTurns); summary != "" { + finalResult = summary + } else { + finalResult = fmt.Sprintf("子 Agent 已达工具轮上限(%d 轮)仍未产出最终答复,任务未完成", maxTurns) + } + default: + // 既没拿到答复、也不是跑满预算:只可能是 Chat 出错后 break。 + finalResult = "子 Agent 未产出结果(执行中断)" + } } a.childMu.Lock() @@ -213,6 +293,48 @@ func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("【子任务 %s 已完成】结果已在上文提供(见先前的 child_result 工具结果),无需重复查询;请直接基于上文结果继续。", taskID) } +// childSalvagePrompt 是跑满预算后的收尾指令。 +// +// 强调「不得再请求调用工具」而不是仅靠不带 Tools 参数:provider 形态各异, +// 有的对“无工具但正文写了工具调用”仍会编造出 tool_calls,而此处已经不再执行 +// 任何工具,会直接落成空结果。 +const childSalvagePrompt = `你已用完工具调用轮次预算,不能再调用任何工具。 +请只根据上面已经取得的工具结果,直接给出**目前已知的结论与已收集到的数据**; +如有未完成的部分,明确列出“未查到/未完成”的项。不要请求调用工具,不要说“我将要去查”。` + +// salvageChildSummary 在子 Agent 跑满轮预算后,用一次**不带工具**的调用把 +// 已取得的中间成果落成文本。 +// +// 为什么值得多花一次调用:此前的行为是直接丢弃全部中间结果、只回一句“超时”, +// 父 Agent 拿到的是一个不可操作的信号,只能重 spawn——那会重烧一整个上下文, +// 比这多出来的**一次**调用贵得多。 +// +// 失败不抛错(外层只在失败路径上调用它):返回空串即回退到诚实的事实陈述。 +func (a *Agent) salvageChildSummary(msgs []agentAPI.Message, maxTurns int) string { + if a.provider == nil { + return "" + } + salvageMsgs := make([]agentAPI.Message, 0, len(msgs)+1) + salvageMsgs = append(salvageMsgs, msgs...) + salvageMsgs = append(salvageMsgs, agentAPI.Message{Role: "user", Content: childSalvagePrompt}) + + resp, err := a.provider.Chat(a.ctx, &agentAPI.CompletionRequest{ + Messages: salvageMsgs, + MaxTokens: 4096, + ToolChoice: "none", // 不给工具,强制走文本产出 + DisableThinking: !a.thinkingEnabled, + }) + if err != nil { + log.Printf("[child] 收尾提炼失败(回退为事实陈述): %v", err) + return "" + } + content := strings.TrimSpace(resp.Content) + if content == "" { + return "" + } + return fmt.Sprintf("(注:已达 %d 轮工具上限,以下为已取得成果的收尾提炼,可能不完整)\n%s", maxTurns, content) +} + func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string { if a.providerManager == nil { return "LLM 源管理器不可用" diff --git a/internal/agent/core/spawn_replenish_test.go b/internal/agent/core/spawn_replenish_test.go new file mode 100644 index 00000000..9c946d31 --- /dev/null +++ b/internal/agent/core/spawn_replenish_test.go @@ -0,0 +1,282 @@ +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("任务描述应位于模板末尾(否则会被后续章节挤到中间,读起来像背景而非指令)") + } +}