Files
HomeAgent/internal/agent/core/spawn.go
JianFeeeee 637a3a3d3f refactor(agent): R4 工作式子语义落地 + 清除 R1–R3 遗留的死代码
## 1. 工作式子的工具禁令收成单一事实来源

禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧
(模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口:

  定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
  执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**

现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释"
绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject
都从它派生。

上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接,
属于新造的技术债,已重写为单表直查。

判据(均经变异验证):
  TestWorkerToolDenyListIsSingleSource  长出第二份名单 ⇒ 红(实测确认)
  TestWorkerToolAllowedMatchesReject    两侧判据漂移 ⇒ 红
  TestWorkerToolRejectExplainsWhy       拒绝文案不可执行 ⇒ 红

## 2. 删除零使用的抽象(不留技术债)

R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec /
ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound /
ChannelDisposition,实现过程中发现它们是假抽象:

  · 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent
  · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放
  · 实测:26 个导出符号里 20 个零使用、零实现

已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型"
这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。

child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry /
StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。

## 3. 修掉误导性签名

Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** ——
残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就
已经完成。看签名会以为这里处理残余,是误导。已去掉该参数,
并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。

## 验证

  go build ./...                                  PASS
  go vet ./...                                    PASS
  gofmt -l                                        干净
  go test ./internal/agent/core/ -count=1         ok 15.300s
  go test ./internal/agent/core/ -count=1 -race   ok 18.040s
  干净检出(git worktree)单独 build + vet + test  PASS
2026-11-04 08:35:23 +08:00

498 lines
20 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 (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// childTaskState 是一个子任务的生命周期状态。
//
// delivered 代替了早期的“读到即删”:完成通知会写进持久上下文
// (formatMergedTimeline 每轮重新注入),模型之后还会再查。读一次就删的
// 话,第二次查询返回“不存在或已过期”——那是一个**永远不会成功的可操作
// 信号**,模型只能一遍遍地重试/汇报,循环永不结束。
type childTaskState struct {
running bool
result string
delivered bool // 结果是否已交付过(用于幂等应答)
seq int64 // 完成顺序,用于有界淘汰
}
// maxRetainedChildTasks 是保留的已完成子任务上限(防结果无限占用内存)。
const maxRetainedChildTasks = 20
// evictChildTasksLocked 淘汰最旧的已完成子任务。调用方必须持有 childMu。
func (a *Agent) evictChildTasksLocked() {
for len(a.childTasks) > maxRetainedChildTasks {
oldestID := ""
var oldestSeq int64
for id, st := range a.childTasks {
if st.running {
continue
}
if oldestID == "" || st.seq < oldestSeq {
oldestID, oldestSeq = id, st.seq
}
}
if oldestID == "" {
return // 剩下全是运行中的,不淘汰
}
delete(a.childTasks, oldestID)
}
}
// ── 工作式子的工具禁令(**单一事实来源**) ──
//
// 此前这条禁令写了两遍:一遍在定义侧(把工具从表里滤掉),
// 一遍在执行侧(模型硬调时拒绝)。两处的工具名单必须逐字一致 ——
// 任何一边落后就是一个真实缺口:
//
// 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
// 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**(模型确实会这么干)
//
// 现在只有下面这张表。定义侧走 workerToolAllowed,
// 执行侧走 workerToolReject,两者都从同一份 runnerForbidden 派生。
// 判据 TestWorkerToolDenyListIsSingleSource 钉住"不许再长出第二份"。
// workerDenyRule 是一条工具禁令:匹配方式 + 给模型看的解释。
//
// ★ 用结构体把"匹配"和"解释"绑在一起,避免上一版那种三处间接
// (先查 reason 字符串、再按下标猜分类、再查 hint)。读代码时
// 一条禁令的全部信息在一行里可见。
type workerDenyRule struct {
class string // 给模型看的类别("输出工具" / "系统工具")
tool string // 精确匹配(prefix=false 时)
prefix string // 前缀匹配(prefix=true 时)
isPref bool
hint string // 为什么不行、该怎么做
}
// workerForbidden 是工作式子**唯一**的工具禁令表。
//
// 为什么禁这些:
//
// 输出工具 结果回父(child_result),子直接对用户说话会与父的回复串台
// spawn_child 子不能再 spawn 子(无限递归)
// plgreload 插件重载是内核级动作,子无权触发
var workerForbidden = []workerDenyRule{
{
class: "输出工具", prefix: "output_send__", isPref: true,
hint: "结果请直接作为最终答复返回,父会转达用户",
},
{
class: "输出工具", tool: "output_list_channels",
hint: "结果请直接作为最终答复返回,父会转达用户",
},
{
class: "系统工具", tool: "spawn_child",
hint: "子不能再 spawn 子(会无限递归);需要多步请在本次任务内完成",
},
{
class: "系统工具", tool: "plgreload",
hint: "插件重载是内核级动作,只有父能触发",
},
}
// workerDenyRuleFor 返回命中的禁令(nil = 允许)。
func workerDenyRuleFor(name string) *workerDenyRule {
for i := range workerForbidden {
r := &workerForbidden[i]
if r.isPref {
if strings.HasPrefix(name, r.prefix) {
return r
}
continue
}
if name == r.tool {
return r
}
}
return nil
}
// workerToolAllowed 报告某个工具是否**允许**给工作式子用(定义侧过滤)。
func workerToolAllowed(name string) bool { return workerDenyRuleFor(name) == nil }
// workerToolReject 在执行侧拒绝被禁工具,返回给模型的**可执行**说明。
//
// 返回空串表示放行。文案必须说明"为什么不行、该怎么做",否则模型会原样
// 重试同一条被拒的调用(本仓有过"4 次 files_write 失败"的先例)。
func workerToolReject(ct agentAPI.ToolCall) string {
r := workerDenyRuleFor(ct.Name)
if r == nil {
return ""
}
return fmt.Sprintf("子 Agent 不允许调用 %s: %s(%s)", r.class, ct.Name, r.hint)
}
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
task, _ := tc.Arguments["task"].(string)
if task == "" {
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
log.Printf("[spawn] task empty but arguments present: %s", truncateStr(string(b), 300))
}
return "请提供 task 参数"
}
maxTurns := 0
if v, ok := tc.Arguments["max_turns"].(float64); ok {
maxTurns = int(v)
}
if maxTurns < 1 {
maxTurns = defaultChildMaxTurns
}
if maxTurns > 30 {
maxTurns = 30
}
a.childMu.Lock()
a.childNextID++
taskID := fmt.Sprintf("child_%d", a.childNextID)
a.childMu.Unlock()
// parentChannel 由调用方(任务帧)传入:子任务完成通知要回到**发起这次
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
if parentChannel == "" || parentChannel == channelConsolidation {
parentChannel = "cli"
}
a.childMu.Lock()
a.childTasks[taskID] = &childTaskState{running: true}
a.childMu.Unlock()
go a.runChildTask(taskID, task, parentChannel, maxTurns)
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮)。完成后会自动通知你,届时用 child_result 查看输出即可(**只需查询一次**)", taskID, maxTurns)
}
// 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 = `你是 HologramAgent 的子任务助手。
请完成以下任务。完成即可,无需保留记忆或查询历史。
【执行纪律】这是硬性要求,不是建议:
- **必须实际调用工具去执行**,不要只回复「我将要做什么」。宣告计划不等于完成任务;
只要你还没把结果拿到手,就必须继续调用工具。
- 只有当你**已经取得实际结果**(或确认无法取得)时,才用纯文本汇报结论;
汇报内容必须是**已发生的事实**,而不是打算做的事。
- 任务确实无法完成时,如实说明卡在哪一步、已取得了什么,不要谎报完成。
【工具执行顺序】同一条回复里的多个工具调用**默认并行执行**,不是依次执行:
- 若某个调用的参数**需要另一个调用的结果**,必须**分两轮** —— 先发前一个,
看到结果后再发下一个。写在同一轮就等于假设了不存在的先后。
- 互相独立的调用可以放在同一轮并行发出(这是被鼓励的,更快)。
任务: %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)
return
}
log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80))
sysPrompt := fmt.Sprintf(childSystemPromptTemplate, task)
msgs := []agentAPI.Message{
{Role: "system", Content: sysPrompt},
{Role: "user", Content: task},
}
allTools := a.buildToolDefs()
childTools := make([]interface{}, 0, len(allTools))
for _, t := range allTools {
toolMap, ok := t.(map[string]interface{})
if !ok {
continue
}
fn, ok := toolMap["function"].(map[string]interface{})
if !ok {
continue
}
name, _ := fn["name"].(string)
if !workerToolAllowed(name) {
continue
}
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,
MaxTokens: 4096,
Tools: childTools,
ToolChoice: "auto",
DisableThinking: !a.thinkingEnabled,
}
// ★ 走与主 agent 同一套 provider 选择(而不是直接 a.provider.Chat)。
//
// 两件事因此成立:
// ① `core.agent.llm.child_provider` 对它生效(四类 agent 各自可配源)
// ② 多源回退对它生效 —— 此前它是唯一绕过回退链的地方:
// 默认源抖动一次,整条子任务就"执行失败",而主 agent 同刻能自动换源
resp, err := a.chatWithProviderChain(req)
if err != nil {
finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
break
}
if len(resp.ToolCalls) == 0 {
finalResult = resp.Content
completed = true
break
}
if turn == maxTurns-1 {
// 本轮是最后一轮:它仍返回了工具调用,说明预算在此耗尽。
reachedLimit = true
}
// ★★ 走共享的 runToolBatch(2026-11-04 重构):
//
// 原实现逐条直调 `executeToolCall`(= executeToolCallOutcome 的文本壳),
// 内无任何 runStage ⇒ 普通子的工具调用同样**跳过 before/after_toolcall**,
// 与 rt 同型缺陷(见 toolbatch.go 头注释的对照表)。
//
// 保留两处**子专属**的差异,通过 batchOpts 注入而不是各写一遍执行逻辑:
// ① 禁用输出工具与系统工具(子把结果回父,不直接回用户)
// ② 强制串行(NoBatch)—— 子历史上是串行的,改并发会改变它已上线
// 行为的时序假设,不在本次重构范围内(判据 TestWorkerToolsStaySerial 钉住)
results := a.runToolBatch(a.ctx, resp.ToolCalls, batchOpts{
Channel: parentChannel,
NoBatch: true,
ToolFilter: workerToolReject,
})
// 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里
// 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、
// 解析失败,还是被轮上限截断)——只能看到一句「超时」。
for _, r := range results {
log.Printf("[child] %s turn %d/%d: %s -> %d bytes",
taskID, turn+1, maxTurns, r.Name, len(r.Text))
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content,
ToolCalls: []agentAPI.ToolCall{{ID: r.CallID, Name: r.Name}}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: r.CallID, Content: r.Text})
}
}
if finalResult == "" {
switch {
case completed:
// 模型确实回答完了,但内容是空的。如实说,不当作故障。
finalResult = "子 Agent 已结束(模型未给出文本内容)"
case reachedLimit:
// 跑满预算 ≠ 白干。子 Agent 在前几轮已经拿到了一批工具结果
// (实测 child_5 已跑完一串页面解析),直接丢弃它们会让父 Agent
// 只能重 spawn(日志里 child_5→6→7 就是同一任务连试三次,每次都
// 重烧一整个上下文)。所以先做一次「不带工具的收尾调用」,把已有
// 成果落成文本。仅在失败路径上多花一次 LLM 调用。
log.Printf("[child] %s exhausted budget maxTurns=%d, attempting wrap-up distillation of results so far", 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()
if st := a.childTasks[taskID]; st != nil {
st.running = false
st.result = finalResult
a.childSeq++
st.seq = a.childSeq
}
a.evictChildTasksLocked()
a.childMu.Unlock()
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
notification := fmt.Sprintf("子任务 %s 已完成。请用 child_result 工具查看输出(只需查询一次;重复查询不会返回失败)。", taskID)
a.injectSelfChannel(selfInputMsg{
text: notification,
channel: parentChannel, // 回到父对话通道,正常处理(写入上下文 + emit 响应)
})
}
// executeChildResultTool 取回子任务结果。
//
// **幂等**:结果不会被“读到即删”,重复查询返回同一结果或一条明确提示。
// 这一点至关重要——完成通知会长期留在持久上下文里(formatMergedTimeline
// 每轮重新注入),如果重复查询返回“不存在”这种失败信号,模型会认定任务
// 未完成而无限重试(实测单轮 35 次工具调用、持续 514 秒)。
func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
taskID, _ := tc.Arguments["task_id"].(string)
if taskID == "" {
return "请提供 task_id 参数"
}
a.childMu.Lock()
st, ok := a.childTasks[taskID]
if !ok {
a.childMu.Unlock()
return fmt.Sprintf("子任务 %s 不存在:从未创建该 ID(请核对 spawn_child 返回的 ID 拼写)", taskID)
}
if st.running {
a.childMu.Unlock()
return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID)
}
first := !st.delivered
st.delivered = true
result := st.result
a.childMu.Unlock()
if first {
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
}
// 重复查询不是失败:明确告诉模型“任务已完成、结果已给过”,让它停止重试。
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] wrap-up distillation failed (falling back to factual summary): %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 源管理器不可用"
}
switch tc.Name {
case "llm_list_sources":
sources := a.providerManager.List()
if len(sources) == 0 {
return "没有可用的 LLM 源"
}
parts := []string{"可用 LLM 源:"}
for _, name := range sources {
mark := " "
if p := a.providerManager.Get(""); p != nil && p.Name() == name {
mark = "→"
}
parts = append(parts, fmt.Sprintf(" %s %s", mark, name))
}
return strings.Join(parts, "\n")
case "llm_set_source":
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "请提供源名称"
}
if err := a.providerManager.SetDefault(name); err != nil {
return fmt.Sprintf("切换失败: %v", err)
}
a.provider = a.providerManager.Get(name)
return fmt.Sprintf("已切换到 LLM 源: %s", name)
default:
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
}
}
// chatWithProviderChain 用本 agent 的 provider 链跑一次**非流式**请求。
//
// 优先级:自身 preferredProvider 提前 → 其余源按原顺序回退。
// 与主 agent 的 `callLLMWithFallback` 的区别是这里不重试、不切 active provider:
// 子任务是短生命周期,重试只会把一个已经抖动的时间窗拉长。
func (a *Agent) chatWithProviderChain(req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
providers := a.resolveProviders(req)
var lastErr error
for _, p := range providers {
resp, err := p.Chat(a.ctx, req)
if err == nil {
return resp, nil
}
lastErr = err
// 取消/超时不再试下一个:那是调用方主动结束,不是"这个源坏了"。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("没有可用的 LLM 源")
}
return nil, lastErr
}