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

203 lines
6.4 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
// R11 判据:待机零消耗 + 长时 e2e(契约 4 / 契约 1 的持续正确性)。
//
// 契约 4:无输入即静默,**不烧轮次、不烧 token、不轮询**
//
// "不轮询"这条容易被忽略但同样重要:轮询会烧 CPU,且在静默期产生
// 无意义的唤醒。实现上 rtLoop 必须 block 在 Wake() 上(见 rtchild.go)。
import (
"context"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtCountingProvider 统计调用次数与总 token 量(近似:正文长度)。
type rtCountingProvider struct {
mu sync.Mutex
calls int
chars int
}
func (p *rtCountingProvider) Name() string { return "rtCounting" }
func (p *rtCountingProvider) MaxContextTokens() int { return 8192 }
func (p *rtCountingProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *rtCountingProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "答"}
p.mu.Lock()
p.chars++
p.mu.Unlock()
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtCountingProvider) stats() (int, int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls, p.chars
}
// TestRTChild_IdleZeroTokens 钉住契约 4:**静默期零模型调用**。
//
// 若 rtLoop 改成轮询(select 带 default 或定时 tick),本判据会看到
// calls > 0 —— 那意味着空转烧钱。
func TestRTChild_IdleZeroTokens(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "idle")
// 给 rtLoop 足够时间"如果它要空转的话"必然已经转了。
time.Sleep(300 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 300ms 期间模型被调用 %d 次 —— 待机必须零消耗(契约 4)", calls)
}
// 槽也必须仍是空的(没有凭空产生输入)。
if st, ok := host.RTChildStats(id); !ok {
t.Fatal("实例不在登记表")
} else if st.Pending {
t.Fatal("静默期槽里出现了待处理输入")
} else if st.Rounds != 0 {
t.Fatalf("静默期跑了 %d 轮", st.Rounds)
}
// 再等一段,确认不是"刚启动还没轮到"。
time.Sleep(200 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 500ms 累计调用 %d 次", calls)
}
}
// TestRTChild_IdleStopsWhenInputArrives 是配套正例:来输入必须**真的**跑起来。
//
// 只有"静默零调用"这一半的判定条件是坏的 —— "永不调用模型"的实现也能通过,
// 但那是死进程。正例是必需品。
func TestRTChild_IdleStopsWhenInputArrives(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "wakeup")
time.Sleep(100 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默期不该有调用,得到 %d", calls)
}
host.PushRTInput(id, "你好")
waitFor(t, "输入后跑了一轮", func() bool {
calls, _ := p.stats()
return calls >= 1
})
}
// TestRTChild_E2E_Sustained 是长时 e2e:连续灌入输入,验证
//
// · 每一批都被处理(不丢活)
// · 无 goroutine 泄漏 / 无 panic
// · history 持续增长(普通上下文)
// · 全程 -race 干净(由调用方加 -race)
//
// 用 -short 跳过:默认用例集不该被一段秒级压测拖慢。
func TestRTChild_E2E_Sustained(t *testing.T) {
if testing.Short() {
t.Skip("长时 e2e;-short 跳过")
}
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "e2e")
// 定速灌入(模拟插件攒批后的节奏)。总数取 60:足够暴露累积问题,
// 又不至于让默认用例集跑太久。
const total = 60
for i := 0; i < total; i++ {
host.PushRTInput(id, "第 N 句话")
// 给 rtLoop 一点时间消费;不追求"每条都跑完",要的是压力。
if i%10 == 0 {
time.Sleep(5 * time.Millisecond)
}
}
// 收尾:等最后一条被处理。
waitFor(t, "所有输入都被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
tr, ok := host.RTTranscript(id)
if !ok {
t.Fatal("取不到 transcript")
}
if len(tr) == 0 {
t.Fatal("跑了多轮但 history 为空 —— 普通上下文没在累积")
}
calls, _ := p.stats()
if calls == 0 {
t.Fatal("全程零模型调用 —— rtLoop 没工作")
}
t.Logf("e2e: 灌入 %d 条,模型调用 %d 次,history %d 条", total, calls, len(tr))
// 关闭必须干净。
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := len(host.RTChildren()); got != 0 {
t.Fatalf("停止后仍有 %d 个实例", got)
}
}
// TestRTChild_RapidInputCollapsesToLatest 钉住契约 1+3 的**联合语义**:
// 输入到达得比处理快时,只有最新一条会被处理,其余被覆盖丢弃。
//
// 这不是缺陷,是设计:rt 场景下"用户刚说的话"才是当前态,旧语音已经过期。
// 若有人把 rtSlot 改成队列(让每条都跑),本判据会看到 rounds > 1 —— 那时
// 通话会变成"逐条追赶",用户说完很久机器才反应到,违反实时性。
//
// ★ 同时钉住 dropped 计数器:它是"打断真的发生了"的唯一可观测凭据。
func TestRTChild_RapidInputCollapsesToLatest(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "collapse")
time.Sleep(30 * time.Millisecond)
// 一口气推 50 条(比 rtLoop 处理得快得多)。
const burst = 50
for i := 0; i < burst; i++ {
host.PushRTInput(id, "连发")
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, ok := host.RTChildStats(id)
if !ok {
t.Fatal("实例不存在")
}
if st.Rounds > 3 {
t.Fatalf("连发 %d 条却跑了 %d 轮 —— rtSlot 退化成了队列(契约 1/3:最新者胜、不排队)",
burst, st.Rounds)
}
if st.Dropped == 0 {
t.Fatal("连发 50 条但 dropped=0 —— 覆盖没有发生,或计数器没接线")
}
if st.Dropped+st.Rounds < burst/2 {
t.Fatalf("dropped=%d rounds=%d,合计远小于连发数 %d —— 有输入凭空消失了(既没被处理也没计入丢弃)",
st.Dropped, st.Rounds, burst)
}
}