Files
HomeAgent/internal/agent/core/spawn.go
JianFeeeee eb1deab5dd feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)

第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。

- R1  CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2  rtSlot 单槽覆盖(latest-wins,从不排队)
- R3  独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4  ChatStream 逐块 → outputch(rt).Execute(text)
- R5  打断 = 原地截断,不重试
- R6  工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7  普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9  CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)

刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。

## 四类 agent 各自可配模型源

core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。

★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。

## 驻留子残余任务四态 + 通道三态

残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。

通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。

一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。

## 修复"手写字段白名单"丢值(7 处)

同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。

- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
  原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)

## 压缩偏离设计

CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。

并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。

## 判据

新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
  每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
  "在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿

## 判据自身的坑(两次偶发红,都不是产品缺陷)

1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
   → "暂存 0 条"。修法:cancel + waitSchedulerStopped。
   (offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
   靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
2026-10-30 17:00:47 +08:00

406 lines
16 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)
}
}
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 strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
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
}
for _, ct := range resp.ToolCalls {
var result string
switch {
case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
case ct.Name == "spawn_child" || ct.Name == "plgreload":
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
default:
result = a.executeToolCall(ct, parentChannel)
}
// 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里
// 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、
// 解析失败,还是被轮上限截断)——只能看到一句「超时」。
log.Printf("[child] %s turn %d/%d: %s -> %d bytes", 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 == "" {
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
}