mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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 导入)。
This commit is contained in:
@ -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 源管理器不可用"
|
||||
|
||||
Reference in New Issue
Block a user