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
This commit is contained in:
JianFeeeee
2026-10-30 17:00:47 +08:00
parent b850feaef2
commit eb1deab5dd
36 changed files with 5973 additions and 81 deletions

View File

@ -1447,3 +1447,159 @@ func (c *RelevanceContext) purgeToolResultDir(evtID string) {
log.Printf("[agent] purge toolresult dir %s: %v", p, err)
}
}
// ── 压缩支持(resident 的「压缩」= 摘要替换,不是截断丢弃) ──
// CompactPlan 是一次压缩的**只读计划**:描述要压掉哪一段、留下哪一段。
//
// 为什么先出计划再执行(而不是一步做完):
// 摘要需要调 LLM(可能几百毫秒到几十秒),期间不能持锁、也不能先把事件
// 从上下文里摘掉 —— 那样一旦摘要失败或取消,上下文就被破坏了一半。
// 所以顺序是:**取计划 → 无锁生成摘要 → 应用**。
type CompactPlan struct {
// Old 是即将被压掉的事件(按时间正序)。可能为空(没什么可压的)。
Old []*ContextEvent
// Keep 是保留的最近事件数(仅供日志/判据核对)。
Keep int
// Total 是压缩前的事件总数。
Total int
// PrefixText 是把 Old 渲染成供摘要用的纯文本。
PrefixText string
}
// PlanCompaction 生成压缩计划:保留最近 keepRecent 条,其余进 Old。
//
// 注意它**不改动任何状态**——调用方拿到计划后可以安全地去做慢操作(调 LLM),
// 失败了就当没发生过。
func (c *RelevanceContext) PlanCompaction(keepRecent int) CompactPlan {
if keepRecent < 1 {
keepRecent = 1
}
evts := c.snapshotEvents()
plan := CompactPlan{Keep: keepRecent, Total: len(evts)}
if len(evts) <= keepRecent {
return plan
}
cut := len(evts) - keepRecent
plan.Old = evts[:cut]
plan.PrefixText = renderEventsForSummary(plan.Old)
return plan
}
// ApplyCompaction 用一条**摘要事件**替换 Old 那一段。
//
// 语义(design §9「压成摘要 → 清处理表 → 子续用」):
// - 被压掉的 N 条不再存在于上下文(它们的**语义**留在摘要里)
// - 摘要作为一条 system 事件排在保留段的**前面**,子下一轮就能看到
//
// 为什么按"时间戳 + 事件指针"定位而不是按下标:
// 摘要生成期间(可能几十秒)上下文可能被 Append 追加新事件,下标会移。
// 这里用"Old 末尾那条事件"作为锚点:找到它当前的位置,删掉它之前的、
// 属于本次压缩的那些事件。
//
// 返回实际删掉的事件数。
func (c *RelevanceContext) ApplyCompaction(plan CompactPlan, summary string) int {
if len(plan.Old) == 0 {
return 0
}
dropSet := make(map[*ContextEvent]bool, len(plan.Old))
for _, e := range plan.Old {
dropSet[e] = true
}
// ★ 这里**不能**用 defer Unlock:本函数在中途(向量计算前)必须主动解锁,
// 而 defer 会在返回时再解一次 ⇒ "unlock of unlocked mutex"(实测 fatal)。
c.mu.Lock()
kept := make([]*ContextEvent, 0, len(c.events)+1)
removed := 0
inserted := false
for _, e := range c.events {
if dropSet[e] {
// 第一次遇到被压掉的事件时,就在这个位置插入摘要 —— 保持时间顺序
// (摘要代表"那段被压掉的内容",位置应在保留段之前)。
if !inserted && summary != "" {
kept = append(kept, newCompactionEvent(plan, summary))
inserted = true
}
removed++
continue
}
kept = append(kept, e)
}
if removed == 0 {
c.mu.Unlock()
return 0 // 期间上下文被别处改动过(如 Prune 已把它归档)—— 不重复处理
}
c.events = kept
c.dirty = true
// 把要算向量的那条事件带出锁外 —— 不能在这里算:
// c.mu 是**不可重入**的 sync.Mutex,而 computeVector 依赖 c.toolDefLookup
// 等字段(读锁内状态),在锁内再进出会死锁(实测 panic)。
// 向量计算还会调嵌入模型,持锁会拖住所有读者。
var toVector *ContextEvent
if inserted {
for _, e := range c.events {
if e.Source == compactionSource {
toVector = e
break
}
}
}
c.mu.Unlock()
if toVector != nil {
c.computeVector(toVector)
}
c.save()
return removed
}
// compactionSource 是压缩摘要事件的来源标识(判据与渲染据此识别它)。
const compactionSource = "system/compaction"
// newCompactionEvent 构造摘要事件。调用方须持锁(append 到 c.events)。
func newCompactionEvent(plan CompactPlan, summary string) *ContextEvent {
return &ContextEvent{
Timestamp: time.Now(),
Source: compactionSource,
Input: fmt.Sprintf("[压缩摘要] 以下是此前 %d 条对话的摘要(原文已不在上下文中):\n%s", len(plan.Old), summary),
// 摘要本身是"背景说明",不是用户说的话 ⇒ 不参与记忆写入,
// 否则每次压缩都会往图库里灌一条假对话。
NoMemory: true,
}
}
// renderEventsForSummary 把一批事件渲染成供 LLM 摘要的纯文本。
//
// 口径:**尽量保留事实**(谁说了什么、调用了什么工具得到什么结论),
// 但工具结果只留摘要(它们是体积大头,且摘要不需要原始 HTML/JSON)。
func renderEventsForSummary(evts []*ContextEvent) string {
var sb strings.Builder
for _, e := range evts {
if e == nil {
continue
}
if t := strings.TrimSpace(e.Input); t != "" {
fmt.Fprintf(&sb, "[用户] %s\n", truncateStr(t, compactionToolResultMaxRunes))
}
for _, tc := range e.ToolCalls {
fmt.Fprintf(&sb, "[工具调用] %s\n", tc.Name)
}
if len(e.ToolResults) > 0 {
parts := make([]string, 0, len(e.ToolResults))
for _, tr := range e.ToolResults {
parts = append(parts, fmt.Sprintf("%s→%s",
tr.Name, truncateStr(strings.TrimSpace(tr.Output), compactionToolResultMaxRunes)))
}
fmt.Fprintf(&sb, "[工具结果] %s\n", strings.Join(parts, ";"))
}
if r := strings.TrimSpace(e.Response); r != "" {
fmt.Fprintf(&sb, "[助手] %s\n", truncateStr(r, compactionToolResultMaxRunes))
}
}
return sb.String()
}
// compactionToolResultMaxRunes 是喂给摘要器时单条工具结果的字符上限。
const compactionToolResultMaxRunes = 400