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

@ -0,0 +1,471 @@
package core
// R3–R6 判据:rtchild 生命周期 / 流式输出 / 打断截断 / toolcall 期不可打断。
//
// 每条判据都对应 plan.md 十一.七 的一个步骤,删掉被钉的实现必须变红。
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// ── 测试替身 ──
// rtBlockingProvider 的 ChatStream 会一直流式吐出 token,直到 ctx 被取消。
//
// 用它来验证"打断即截断":取消后流必须停在半路,且**不得重试**。
type rtBlockingProvider struct {
mu sync.Mutex
calls int // ChatStream 被调用次数(验证"不重试")
got int // 已发出的 chunk 数
// blockForever=true 时永不自然结束(只能靠 cancel 停)。
blockForever bool
}
func (p *rtBlockingProvider) Name() string { return "rtBlocking" }
func (p *rtBlockingProvider) MaxContextTokens() int { return 8192 }
func (p *rtBlockingProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "non-stream"}, nil
}
func (p *rtBlockingProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return // 被取消:流就地停止(这**就是**"就地截断")
case <-time.After(5 * time.Millisecond):
}
select {
case <-ctx.Done():
return
case ch <- agentAPI.StreamChunk{Content: "字"}:
p.mu.Lock()
p.got++
p.mu.Unlock()
}
if !p.blockForever && i >= 2 {
return
}
}
}()
return ch, nil
}
func (p *rtBlockingProvider) callCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls
}
// rtRecordingDevice 记录 outputch 收到的每一段文本。
type rtRecordingDevice struct {
mu sync.Mutex
texts []string
name string
}
func (d *rtRecordingDevice) Name() string { return d.name }
func (d *rtRecordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *rtRecordingDevice) Description() string { return "rt test output" }
func (d *rtRecordingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *rtRecordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
d.mu.Lock()
defer d.mu.Unlock()
if s, ok := args["text"].(string); ok {
d.texts = append(d.texts, s)
}
return "ok", nil
}
func (d *rtRecordingDevice) Start() error { return nil }
func (d *rtRecordingDevice) Stop() error { return nil }
func (d *rtRecordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *rtRecordingDevice) OutputCapabilities() agentIO.OutputCapability {
// ★ 声明 CapRT("我能承载 rt 流")。注意它仍然只收文本 ——
// CapRT 是"能承载实时流",不是"内核要产出音频"。
return agentIO.CapText | agentIO.CapRT
}
func (d *rtRecordingDevice) snapshot() []string {
d.mu.Lock()
defer d.mu.Unlock()
return append([]string(nil), d.texts...)
}
// newRTHost 造一个"父 agent + 注册好的 rt 输出设备"。
func newRTHost(t *testing.T, p agentAPI.Provider) (*Agent, *rtRecordingDevice) {
t.Helper()
pio := agentIO.NewIOManager()
host := New(AgentConfig{ID: "rt-host", IO: pio, Provider: p})
dev := &rtRecordingDevice{name: "voice"}
if err := pio.RegisterDevice(dev); err != nil {
t.Fatalf("注册 rt 输出设备: %v", err)
}
// rt 通道也必须能划(R1 的校验路径),这里走普通 Register + AssignRealtime。
//
// ★ 注册**一对** rt 通道("voice-in" + "voice"):rt 通道必须成对划拨
// (用户裁定 2026-10-30),所以 SpawnRTChild 要求两个名字都在登记表里。
// 测试统一用 rtSpawn 走"成对"路径(见下)。
for _, n := range []string{"voice", "voice-in"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "test", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道 %s: %v", n, err)
}
}
return host, dev
}
// rtSpawn 是测试里创建 rtchild 的**唯一入口**:自动补上成对的 Input。
//
// 为什么不改每个调用点:成对是 SpawnRTChild 的硬要求,但测试关心的是
// 各自的语义(生命周期/流式/上下文…),一个个补 `Input: "voice-in"`
// 只是噪声,且容易漏(漏了就是"创建失败 + 后续 nil 解引用")。
func rtSpawn(t *testing.T, host *Agent, id string) string {
t.Helper()
got, err := host.SpawnRTChild(RTChildOptions{
ID: id, Input: "voice-in", Output: "voice",
})
if err != nil {
t.Fatalf("创建 rtchild %s: %v", id, err)
}
return got
}
// ── R3:生命周期 ──
// TestRTChild_StopNoOrphans 钉住:Stop 之后实例出登记表、且不再产生输出。
func TestRTChild_StopNoOrphans(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "c1")
if got := host.RTChildren(); len(got) != 1 || got[0] != "c1" {
t.Fatalf("登记表 = %v,期望 [c1]", got)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("停止后登记表仍有 %v —— 留了孤儿", got)
}
// Stop 之后不得再有输出(流已被截断)。
before := len(dev.snapshot())
time.Sleep(60 * time.Millisecond)
after := len(dev.snapshot())
if after != before {
t.Fatalf("停止后仍在输出(%d → %d)—— 当前轮未被截断", before, after)
}
}
// TestRTChild_StopAllNoOrphans 钉住父退出时全部销毁(对应 StopRTChildren)。
func TestRTChild_StopAllNoOrphans(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
for i, id := range []string{"a", "b", "c"} {
rtSpawn(t, host, id)
_ = i
}
if got := len(host.RTChildren()); got != 3 {
t.Fatalf("登记了 %d 个,期望 3", got)
}
if n := host.StopRTChildren(); n != 3 {
t.Fatalf("StopRTChildren 返回 %d,期望 3", n)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("停止后仍有 %v", got)
}
}
// TestRTChild_InterruptDoesNotAffectOthers 是**用户裁定 R1 的核心判据**:
// 打断/停止 rtA 时,rtB 与主 agent 的 LLM 调用必须继续。
//
// 实现上靠"每个 rtchild 自己的 ctx"。若有人把 rtStream 里的
// `context.WithCancel(a.ctx)` 改回直接用 `a.ctx`,本判据必红。
func TestRTChild_InterruptDoesNotAffectOthers(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
idA := rtSpawn(t, host, "a")
idB := rtSpawn(t, host, "b")
// 让两个实例都跑起来(各自 block 在流上)。
if _, err := host.PushRTInput(idA, "你好 A"); err != nil {
t.Fatalf("投递 A: %v", err)
}
if _, err := host.PushRTInput(idB, "你好 B"); err != nil {
t.Fatalf("投递 B: %v", err)
}
waitFor(t, "两个实例都进入流式", func() bool { return p.callCount() >= 2 })
// 停 A。
if err := host.StopRTChild(idA); err != nil {
t.Fatalf("停止 A: %v", err)
}
time.Sleep(80 * time.Millisecond)
// B 必须仍然活着、仍在登记表里。
if _, ok := host.RTChildStats(idB); !ok {
t.Fatal("停 A 之后 B 不见了 —— 打断波及了其他实例(R1 被违反)")
}
// 父 agent 的 ctx 必须仍然可用(没被连带取消)。
select {
case <-host.ctx.Done():
t.Fatal("停 rtchild 把父 agent 的 ctx 也取消了 —— 严重影响主 agent")
default:
}
}
// ── R4:流式输出 ──
// TestRTChild_OutputIsStreamed 钉住契约 2:输出**逐片**外发,不攒整段。
//
// 若把 rtStream 改成"先收完整段再 emit",本判据会看到 1 次 emit 而非多次。
func TestRTChild_OutputIsStreamed(t *testing.T) {
p := &rtBlockingProvider{} // 非 blockForever:吐 3 片后自然结束
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "s")
if _, err := host.PushRTInput(id, "说点什么"); err != nil {
t.Fatalf("投递: %v", err)
}
waitFor(t, "收到多片输出", func() bool { return len(dev.snapshot()) >= 3 })
texts := dev.snapshot()
for _, s := range texts {
if len([]rune(s)) > 8 {
t.Fatalf("单片输出过长(%q)——这看起来是攒整段后一次吐出,不是流式", s)
}
}
if n := len(texts); n < 3 {
t.Fatalf("只收到 %d 片,期望 ≥3(流式逐片外发)", n)
}
}
// ── R5:打断即截断,且不重试 ──
// TestRTChild_InterruptTruncatesOutput 钉住契约 2 的后半:被打断时就地截断。
func TestRTChild_InterruptTruncatesOutput(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "t")
host.PushRTInput(id, "一直说")
waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
time.Sleep(50 * time.Millisecond)
frozen := len(dev.snapshot())
time.Sleep(80 * time.Millisecond)
if got := len(dev.snapshot()); got != frozen {
t.Fatalf("截断后仍在输出(%d → %d)", frozen, got)
}
}
// TestRTChild_NewInputWinsWithoutRetry 钉住契约 1 + "不重试":
// 新输入到来时,旧轮次被作废,**不会**为旧输入补跑一次。
//
// 若在打断后重试(break 改 continue 之类),callCount 会明显超过 2。
func TestRTChild_NewInputWinsWithoutRetry(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "n")
host.PushRTInput(id, "第一句")
waitFor(t, "第一轮开始", func() bool { return p.callCount() >= 1 })
// 新输入:应作废旧轮并用新输入重开。
if _, err := host.PushRTInput(id, "第二句"); err != nil {
t.Fatalf("投递第二句: %v", err)
}
waitFor(t, "第二轮开始", func() bool { return p.callCount() >= 2 })
// ★ 关键:不得为"第一句"补跑。给足够时间让任何错误的重试发生。
time.Sleep(120 * time.Millisecond)
if got := p.callCount(); got > 3 {
t.Fatalf("ChatStream 被调用 %d 次 —— 存在「为旧输入重试」(契约 1/3 禁止补偿)", got)
}
stats, _ := host.RTChildStats(id)
// lastSeq 必须是**最新**输入的那次(第二句)。
if stats.LastSeq != 2 {
t.Fatalf("LastSeq = %d,期望 2(第二轮必须基于最新输入)", stats.LastSeq)
}
}
// ── R6:toolcall 期间不可打断 ──
// rtToolProvider 第一轮返回一个工具调用,第二轮返回纯文本。
type rtToolProvider struct {
mu sync.Mutex
calls int
toolCh chan struct{} // 工具开始时关闭(测试用它对齐时序)
}
func (p *rtToolProvider) Name() string { return "rtTool" }
func (p *rtToolProvider) MaxContextTokens() int { return 8192 }
func (p *rtToolProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{}, nil
}
func (p *rtToolProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
n := p.calls
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 4)
if n == 1 {
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{{
ID: "c1", Name: "rt_slow_tool", Arguments: map[string]interface{}{},
}}}
ch <- agentAPI.StreamChunk{Done: true}
} else {
ch <- agentAPI.StreamChunk{Content: "好", Done: true}
}
close(ch)
return ch, nil
}
// TestRTChild_NotInterruptibleDuringToolExec 钉住 R5 的边界:
// 工具执行期间新输入**不得**打断它(副作用不可回滚)。
//
// ❗ 这一条**单独测不出真实缺陷**(实测过):设备工具经 `a.io.ExecuteTool`
// 执行,它内部传的是 `context.Background()`(toolcall.go:377),工具根本
// 收不到 roundCtx。于是"工具没被打断"在下面两种情况下都成立:
//
// · 守卫生效(我们主动不取消)
// · 守卫失效但取消也没效果(工具不消费 ctx)
//
// 所以真正钉住守卫的是下一个判据(suppressedCancels)。这里保留本判据,
// 因为它测的是**用户可见的行为**(工具跑完),与下面的实现细节互补。
func TestRTChild_NotInterruptibleDuringToolExec(t *testing.T) {
p := &rtToolProvider{}
host, _ := newRTHost(t, p)
var executed atomic.Int64
done := make(chan struct{}, 1)
if err := host.io.RegisterDevice(&rtToolDevice{
onExec: func() {
executed.Add(1)
time.Sleep(80 * time.Millisecond)
select {
case done <- struct{}{}:
default:
}
},
}); err != nil {
t.Fatalf("注册工具设备: %v", err)
}
id := rtSpawn(t, host, "tool")
host.PushRTInput(id, "调用工具")
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
if _, err := host.PushRTInput(id, "新的插话"); err != nil {
t.Fatalf("插话: %v", err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("工具执行期间被新输入打断 —— 违反 R5「除 toolcall 期间都可打断」的边界")
}
}
// TestRTChild_CriticalSectionSuppressesCancel 是 R6 守卫的**直接判据**:
// 工具执行期收到新输入时,内核必须**记录一次"故意未取消"**。
//
// 这是唯一能区分"守卫真的执行了"与"守卫失效但取消没效果"的判据。
// 变异验证:把 `if critical > 0 { suppressedCancels++ }` 改成直接 cancel,
// 本判据必红(因为 suppressedCancels 恒为 0)。
func TestRTChild_CriticalSectionSuppressesCancel(t *testing.T) {
p := &rtToolProvider{}
host, _ := newRTHost(t, p)
var executed atomic.Int64
done := make(chan struct{}, 1)
if err := host.io.RegisterDevice(&rtToolDevice{
onExec: func() {
executed.Add(1)
time.Sleep(100 * time.Millisecond) // 留出插话窗口
select {
case done <- struct{}{}:
default:
}
},
}); err != nil {
t.Fatalf("注册工具设备: %v", err)
}
id := rtSpawn(t, host, "guard")
host.PushRTInput(id, "调用工具")
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
// 工具执行期间推新输入:此时 critical > 0 ⇒ 必须抑制取消。
if _, err := host.PushRTInput(id, "工具期插话"); err != nil {
t.Fatalf("插话: %v", err)
}
waitFor(t, "抑制计数 +1", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.SuppressedCancels >= 1
})
<-done
}
// rtToolDevice 提供一个会阻塞的 rt_slow_tool。
type rtToolDevice struct {
name string
onExec func()
}
func (d *rtToolDevice) Name() string { return "rt-tool-dev" }
func (d *rtToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *rtToolDevice) Description() string { return "rt tool test device" }
func (d *rtToolDevice) Start() error { return nil }
func (d *rtToolDevice) Stop() error { return nil }
func (d *rtToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *rtToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *rtToolDevice) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{{
Name: "rt_slow_tool",
Description: "slow tool for rt test",
Parameters: map[string]interface{}{"type": "object"},
}}
}
func (d *rtToolDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if d.onExec != nil {
d.onExec()
}
return "tool done", nil
}
// TestRTChild_PromptRequiresPreCallNotice 钉住用户裁定 R4:
// 默认提示词必须要求"调用工具前先告知用户"。
func TestRTChild_PromptRequiresPreCallNotice(t *testing.T) {
if !strings.Contains(rtChildDefaultPrompt, "调用任何工具之前") {
t.Fatal("rtchild 默认提示词缺少「调用工具前先告知」约束(用户裁定 R4)")
}
if !strings.Contains(rtChildDefaultPrompt, "实时通话") {
t.Fatal("rtchild 默认提示词缺少【实时通话】段")
}
}