Files
HomeAgent/internal/agent/core/resident_compress_test.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

300 lines
11 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
// 判据:驻留子的「压缩」= **LLM 摘要替换**,不是截断丢弃。
//
// 设计原文(resident-subagent-design.md §9 / R7):
//
// 「【压缩】压成摘要 → 清处理表 → 子续用」
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」
//
// 用户裁定(2026-10-30):
//
// A. 摘要在**子自己的** provider 上执行(不让父代劳)
// B. 摘要失败 ⇒ **回退截断**(不抛错让压缩失败),且必须留日志
//
// 这两条都要能变红:把实现改回 TrimKeepRecent,第一条红;去掉回退,
// 第二条红(子卡在满上下文)。
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtCompactProvider 记录 Chat 调用,并返回一段可识别的"摘要"。
type rtCompactProvider struct {
mu sync.Mutex
chats int
lastAsk string
failChat bool // true ⇒ Chat 返回错误(验证回退路径)
blank bool // true ⇒ Chat 返回空内容(也算失败)
}
func (p *rtCompactProvider) Name() string { return "compact" }
func (p *rtCompactProvider) MaxContextTokens() int { return 8192 }
func (p *rtCompactProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.chats++
if len(req.Messages) > 0 {
p.lastAsk = req.Messages[0].Content
}
fail, blank := p.failChat, p.blank
p.mu.Unlock()
if fail {
return nil, fmt.Errorf("provider down (test)")
}
if blank {
return &agentAPI.CompletionResponse{Content: " "}, nil
}
return &agentAPI.CompletionResponse{Content: "用户问了 A,我查了 B 并得到结论 C;D 尚未完成。"}, nil
}
func (p *rtCompactProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "ok", Done: true}
close(ch)
return ch, nil
}
func (p *rtCompactProvider) chatCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.chats
}
// newResidentWithProvider 起一个带指定 provider 的驻留子。
//
// 复用 resident_test.go 的 newRootWith(它已接受任意 provider),
// 不用 newRootForResidents(那个把 provider 写死成 countingProvider)。
func newResidentWithProvider(t *testing.T, prov agentAPI.Provider, id string) (*Agent, *residentChild) {
t.Helper()
parent, _, dir := newRootWith(t, prov)
spawnTestResident(t, parent, dir, id)
parent.residentMu.Lock()
rc := parent.residents[id]
parent.residentMu.Unlock()
if rc == nil {
t.Fatalf("驻留子 %s 没创建成功", id)
}
return parent, rc
}
// fillContext 往子的上下文里灌 n 条事件(制造"可压缩的旧内容")。
func fillContext(rc *residentChild, n int) {
for i := 0; i < n; i++ {
rc.agent.context.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "sub/in",
Input: fmt.Sprintf("第%d件事:讨论了主题 %d,结论是 %d", i, i, i*10),
Response: fmt.Sprintf("第%d轮答复", i),
})
}
}
// TestCompressResident_SummarizesInsteadOfDiscarding 是核心判据:
// 压缩必须**调用子自己的 LLM** 生成摘要,并用它替换被压掉的内容。
//
// 变红条件:把 CompressResident 改回 TrimKeepRecent ⇒ chats==0,且上下文里
// 找不到摘要事件 ⇒ 本判据失败。
func TestCompressResident_SummarizesInsteadOfDiscarding(t *testing.T) {
prov := &rtCompactProvider{}
parent, rc := newResidentWithProvider(t, prov, "compress-child")
// 灌到远超保留条数(residentKeepRecent=20),保证有东西可压。
fillContext(rc, residentKeepRecent+15)
before := rc.agent.context.Len()
if before <= residentKeepRecent {
t.Fatalf("上下文只有 %d 条,不足以触发压缩", before)
}
n, err := parent.CompressResident("compress-child")
if err != nil {
t.Fatalf("压缩失败:%v", err)
}
if n == 0 {
t.Fatalf("没有压掉任何事件(before=%d)", before)
}
// ① 必须真的调了子自己的 provider(裁定 A)。
if got := prov.chatCount(); got == 0 {
t.Fatalf("压缩没有调用子自己的 LLM —— 这是截断,不是压缩" +
"(设计 §9 要求「压成摘要」)")
}
// ② 摘要必须进了上下文(作为一条可识别的事件)。
evts := rc.agent.context.snapshotEvents()
var summary *ContextEvent
for _, e := range evts {
if e.Source == compactionSource {
summary = e
break
}
}
if summary == nil {
t.Fatalf("上下文里没有摘要事件(source=%q)—— 被压掉的内容彻底消失了",
compactionSource)
}
if !strings.Contains(summary.Input, "结论 C") {
t.Fatalf("摘要事件里没有 LLM 生成的内容:%q", truncateStr(summary.Input, 200))
}
// ③ 保留段仍在(压缩是"压",不是清空)。
if after := rc.agent.context.Len(); after < residentKeepRecent {
t.Fatalf("压缩后只剩 %d 条,应保留最近 %d 条", after, residentKeepRecent)
}
// ④ 摘要事件必须 NoMemory(它是背景说明,不是一句真实对话)。
if !summary.NoMemory {
t.Fatal("摘要事件会进记忆 —— 每次压缩都会往图库灌一条假对话")
}
}
// TestCompressResident_UsesChildProvider 钉住裁定 A:**子自己的** provider 被调用,
// 而不是父在替它压。
//
// ## 怎么才能真区分(初版两次都写错了)
//
// 1. 初版在创建后改写 rc.agent.provider ⇒ **数据竞争**:驻留子一 Start 就起了
// 调度器 goroutine,它在 task.go:1385 读 a.provider,与测试写并发。
// 第二版改成"看父的 provider 有没有被调",但 SpawnResident 让子与父共用
// 同一个 provider 实例 ⇒ 两种实现(子的 / 父的)都会让它非零,无法区分。
//
// 正确做法:**在创建前就把父的 provider 换掉**,让"父自己的 provider"与
// "传递给子的 provider"是两个可分辨的实例。做完这步之后再创建子,
// 就不再有任何并发写入。
func TestCompressResident_UsesChildProvider(t *testing.T) {
// ① 先用一个"哑" provider 建父(不会有任何调用)。
parent, _, dir := newRootWith(t, &rtCompactProvider{})
// ② 创建**之前**给父装上新 provider —— 这一刻还没有驻留子,无并发。
parentProv := &rtCompactProvider{}
parent.provider = parentProv
// ③ 此时创建的子会继承 parentProv(SpawnResident 里 Provider: a.provider)。
spawnTestResident(t, parent, dir, "own-provider")
parent.residentMu.Lock()
rc := parent.residents["own-provider"]
parent.residentMu.Unlock()
if rc == nil {
t.Fatal("驻留子没创建成功")
}
fillContext(rc, residentKeepRecent+5)
if _, err := parent.CompressResident("own-provider"); err != nil {
t.Fatalf("压缩失败:%v", err)
}
// ④ 抽象行为断言:压缩确实调了 provider 生成摘要,且摘要进了子的上下文。
// "用子而不是父"的实质是:写入的是**子的** context,不是父的。
if parentProv.chatCount() == 0 {
t.Fatal("压缩没有调用 provider —— 走了截断而非摘要")
}
var found bool
for _, e := range rc.agent.context.snapshotEvents() {
if e.Source == compactionSource {
found = true
break
}
}
if !found {
t.Fatal("摘要事件没有写进**子**的上下文")
}
// 父自己的上下文必须**一点没动**(压缩是子对自身记忆的整理,不是父的)。
if parent.context != nil && parent.context.Len() != 0 {
t.Fatalf("父的上下文被改动了(%d 条)—— 压缩串到了父身上", parent.context.Len())
}
}
// TestCompressResident_FallsBackToTruncationOnFailure 钉住裁定 B:
// 摘要失败必须回退截断,且**压缩整体不失败**(子不能卡在满上下文上)。
func TestCompressResident_FallsBackToTruncationOnFailure(t *testing.T) {
for _, tc := range []struct {
name string
prov *rtCompactProvider
}{
{"provider 报错", &rtCompactProvider{failChat: true}},
{"provider 返回空", &rtCompactProvider{blank: true}},
} {
t.Run(tc.name, func(t *testing.T) {
parent, rc := newResidentWithProvider(t, tc.prov, "fallback")
fillContext(rc, residentKeepRecent+10)
before := rc.agent.context.Len()
n, err := parent.CompressResident("fallback")
if err != nil {
t.Fatalf("摘要失败时压缩不该报错(裁定 B:回退截断):%v", err)
}
if n == 0 {
t.Fatal("回退截断没有丢掉任何东西?")
}
if after := rc.agent.context.Len(); after >= before {
t.Fatalf("回退后上下文没变短(%d → %d)", before, after)
}
if after := rc.agent.context.Len(); after != residentKeepRecent {
t.Fatalf("回退截断应保留 %d 条,实际 %d", residentKeepRecent, after)
}
// 回退路径不应产生摘要事件(没摘要成功就没摘要)。
for _, e := range rc.agent.context.snapshotEvents() {
if e.Source == compactionSource {
t.Fatal("摘要失败却插入了空摘要事件")
}
}
})
}
}
// TestCompressResident_NothingToCompactIsNoop 钉住边界:内容不足时压缩是空操作,
// **不应**白调一次 LLM(那是纯浪费)。
func TestCompressResident_NothingToCompactIsNoop(t *testing.T) {
prov := &rtCompactProvider{}
parent, rc := newResidentWithProvider(t, prov, "tiny")
fillContext(rc, 3) // 远小于 residentKeepRecent
if n, err := parent.CompressResident("tiny"); err != nil || n != 0 {
t.Fatalf("内容不足时压缩应无操作,得到 n=%d err=%v", n, err)
}
if got := prov.chatCount(); got != 0 {
t.Fatalf("无可压内容却调用了 %d 次 LLM", got)
}
}
// TestPlanCompaction_DoesNotMutate 钉住「先计划后执行」的安全性:
// PlanCompaction 必须**不动**上下文(否则摘要失败时上下文已半毁)。
func TestPlanCompaction_DoesNotMutate(t *testing.T) {
prov := &rtCompactProvider{}
_, rc := newResidentWithProvider(t, prov, "plan-only")
fillContext(rc, residentKeepRecent+8)
// 快照一份「计划之前」的事件集合。
//
// ★ 不能拿 Len() 前后相等来断言"没改动"(初版就是这么写,实测偶发红):
// 驻留子一 Start 就起了自己的调度器 goroutine,它会并发 Append 事件
// (prepare 段的提示词注入等),长度自然会长。那不是 Plan 改动上下文,
// 是**别的 goroutine 正常写入**。
//
// 正确的不变式是:**计划不会从上下文里删掉任何事件**(它只读)。
beforeEvts := rc.agent.context.snapshotEvents()
plan := rc.agent.context.PlanCompaction(residentKeepRecent)
afterSet := map[*ContextEvent]bool{}
for _, e := range rc.agent.context.snapshotEvents() {
afterSet[e] = true
}
for _, e := range beforeEvts {
if !afterSet[e] {
t.Fatalf("PlanCompaction 删掉了事件(Source=%q)—— 它必须是只读的,"+
"否则摘要失败时上下文已被破坏一半", e.Source)
}
}
if len(plan.Old) != len(beforeEvts)-residentKeepRecent {
t.Fatalf("计划里 Old=%d 条,期望 %d", len(plan.Old), len(beforeEvts)-residentKeepRecent)
}
if !strings.Contains(plan.PrefixText, "第0件事") {
t.Fatalf("PrefixText 丢了最早的内容:%q", truncateStr(plan.PrefixText, 200))
}
}