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

433 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
// 判据:**插件 → rtchild 的通路**。
//
// 这是本轮发现的最后一个真实缺口(比 R10 的 notify_parent 更关键):
//
// 插件只能调 Inject* 系列(SDK 里没有任何 rtchild 接口),
// 它们全部汇到 IOManager.deliverInput → routeInputByOwner。
// 而初版 routeInputByOwner 只认 a.residents(驻留子)——
// rt 通道的 Owner 是 rtchild id,查不到就**兜底给主 agent 处理**。
//
// 后果:插件的语音/画面输入永远打在父身上,rtchild 的轮次永远是 0。
// 这正是 resident-subagent-design.md 记的那个现场故障的同型形态
// (「子挂着 inputch=[timer],timer 的输入却打在父身上」)。
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// registerRTChannelWithOwner 注册一个 rt 通道并划给指定 rtchild。
func registerRTChannelWithOwner(t *testing.T, host *Agent, name, ownerID string) {
t.Helper()
reg := host.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{
Name: name, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道: %v", err)
}
if err := reg.Assign(name, ownerID, 0); err != nil {
t.Fatalf("划给 %s: %v", ownerID, err)
}
}
// TestRTChannel_PluginInputReachesRTSlot 是这条通路的**核心判据**:
// 插件往 rt 通道注入输入,必须进 rtSlot 并真的跑起一轮。
//
// 若 routeInputByOwner 不识别 rtchild,输入会被兜底给主 agent 处理
// (宿主没有调度器,于是什么都不会发生)—— 本判据会超时变红。
func TestRTChannel_PluginInputReachesRTSlot(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 先注册一对 rt 通道;**划拨由 SpawnRTChild 自己做**(成对划拨)。
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "voice-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
// 插件注入:完全走真实路径(IOManager → deliverInput → router)。
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "用户说话了"})
waitFor(t, "rtchild 被插件输入唤起", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.Rounds >= 1
})
calls, _ := p.stats()
if calls == 0 {
t.Fatal("插件输入到达了 rtSlot 但没跑模型")
}
// 内容必须真的进入 history(不是空跑)。
tr, _ := host.RTTranscript(id)
if len(tr) == 0 {
t.Fatal("跑了一轮但 history 为空")
}
}
// TestRTChannel_PluginInputIsLatestWins 钉住:插件连发时也走**单槽覆盖**语义,
// 不会因为"走了路由"就退化成排队。
func TestRTChannel_PluginInputIsLatestWins(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "burst-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
time.Sleep(30 * time.Millisecond)
const burst = 40
for i := 0; i < burst; i++ {
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "连发语音"})
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, _ := host.RTChildStats(id)
// ★ 判据取**不变式**而不是"跑了多少轮"。
//
// 初版断言 rounds <= 3,在 -race 下稳定失败(实测跑了 13 轮):
// 竞态插桩让 emit 变慢,40 条在 rtLoop 起轮之前全部到达,于是
// 每完成一轮就立刻被下一条唤起 —— 那不是"队列退化",而是**正常的
// 最新者胜**(每一轮用的都是当时最新的一条,没有一条在等前一条跑完)。
//
// 真正该钉的是:**每条输入要么被处理过、要么被覆盖丢弃**,
// 不存在"既没处理也没记录丢弃"的第三条路。
if st.Dropped == 0 && st.Rounds < burst {
t.Fatalf("连发 %d 条:dropped=0 且只跑了 %d 轮 —— "+
"有输入凭空消失了(既没被处理,也没计入覆盖丢弃)", burst, st.Rounds)
}
// 槽最终必须被清空(没有积压 —— 这是"不排队"的直接证据)。
if st.Pending {
t.Fatal("连发结束后槽里仍有待处理输入 —— rt 通道在排队")
}
}
// TestRTChannel_NoOwnerStillFallsBackToMain 钉住边界:
// rt 通道**未划拨**时输入仍归父处理(不静默丢)。
//
// 这条防"为了让 rt 通路工作而把所有 rt 输入都截走"——那会让未分配的
// rt 通道输入凭空消失。
func TestRTChannel_NoOwnerStillFallsBackToMain(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 注册但**不划拨**(Owner == "")—— 这条判据测的是路由的兜底分支,
// 与 SpawnRTChild 的成对划拨无关,所以直接登记一条裸通道。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "unassigned", Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册: %v", err)
}
// 路由必须返回 false(不接管),让父的队列收下它。
if handled := host.routeInputByOwner(&agentIO.InputEvent{
Source: "voice-plugin", Type: "text", OutputChannel: "unassigned",
Payload: map[string]interface{}{"content": "无人认领"},
}, false); handled {
t.Fatal("未划拨的 rt 通道输入被截走了 —— 会静默丢输入")
}
}
// TestRTChannel_SDKRealtimeBecomesCapRT 钉住 SDK → 内核的**唯一一次**能力位转换:
// 插件在 ChannelDef 里声明 Realtime=true,登记后通道必须带上 CapRT,
// 从而**能**被 AssignRealtime 划拨。
//
// 若去掉 Register 里的映射,插件就无法登记 rt 通道 ——
// rtchild 永远拿不到插件的语音输入(整条链路断在第一步)。
func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 插件侧声明(走 SDK 的 ChannelDef.Realtime)。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "sdk-rt", Plugin: "voice",
Def: agentIO.ChannelDef{Realtime: true},
}); err != nil {
t.Fatalf("注册: %v", err)
}
ch, ok := host.io.ChannelRegistry().Lookup("sdk-rt")
if !ok {
t.Fatal("通道未登记")
}
if !ch.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("Def.Realtime=true 但 Capabilities 没有 CapRT(= %v)—— "+
"插件声明的 rt 通道无法被划拨", ch.Capabilities)
}
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
rc := host.rtChildren[id]
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
}
}
// ── 成对划拨 / 主 agent 无 rt 能力 / 销毁后悬空 ──
//
// 用户裁定(2026-10-30):
// 「主 agent 创建 rtchild,将 rt 通道划给 rtchild,开始工作
// (rt 通道必须**成对**划给子 agent)」
// 「主 agent 没有 rt 能力,rt 通道在 rtchild 销毁后应当**悬空**」
// registerRTPair 注册一对 rt 通道(input + output),供 Spawn 划拨。
func registerRTPair(t *testing.T, host *Agent, in, out string) {
t.Helper()
reg := host.io.ChannelRegistry()
for _, n := range []string{in, out} {
if err := reg.Register(agentIO.InputChannel{
Name: n, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道 %s: %v", n, err)
}
}
}
// TestSpawnRTChild_AssignsPairedChannels 钉住"成对划拨":
// SpawnRTChild 必须把 input 与 output **两条**都划给 rtchild,而不是只划一条。
//
// 变红条件:去掉 SpawnRTChild 里的 AssignRealtime 调用 ⇒ Owner 仍是 "" ⇒ 红。
func TestSpawnRTChild_AssignsPairedChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "paired", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
ch, ok := reg.Lookup(n)
if !ok {
t.Fatalf("通道 %s 未登记", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— "+
"rt 通道没有成对划给 rtchild", n, ch.Owner, id)
}
}
}
// TestSpawnRTChild_RejectsMissingHalf 钉住成对的**强制性**:
// 只给 input 或只给 output 都必须被拒(否则 rtchild 半残且不报错)。
func TestSpawnRTChild_RejectsMissingHalf(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in2", "voice2")
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-in", Output: "voice2"}); err == nil {
t.Fatal("只给 outputch 竟然创建成功 —— rtchild 会永远听不到用户")
}
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-out", Input: "voice-in2"}); err == nil {
t.Fatal("只给 inputch 竟然创建成功 —— rtchild 的输出无处可去")
}
// 被拒后**不得**在登记表里留下半残实例。
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("创建被拒但登记表里有 %v", got)
}
}
// TestSpawnRTChild_RejectsChannelOwnedByMainAgent 钉住「主 agent 没有 rt 能力」:
// 一条归属于主 agent 的通道不能被拿来当 rt 通道。
func TestSpawnRTChild_RejectsChannelOwnedByMainAgent(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in3", "voice3")
// 把 input 划给主 agent 自己(模拟"主 agent 占着这条通道")。
if err := host.io.ChannelRegistry().Assign("voice-in3", string(host.id), 0); err != nil {
t.Fatalf("划给主 agent: %v", err)
}
_, err := host.SpawnRTChild(RTChildOptions{ID: "steal", Input: "voice-in3", Output: "voice3"})
if err == nil {
t.Fatal("主 agent 自己占着的通道被划成 rt 通道 —— 主 agent 不持有 rt 能力")
}
}
// TestStopRTChild_ChannelsBecomeDangling 钉住用户裁定:
// rtchild 销毁后通道**悬空**(Owner 仍指向已销毁的 id),**不归还为未分配**。
//
// 为什么必须悬空而不是归还:
// - 归还为 "" ⇒ routeInputByOwner 会把它兜底给主 agent,而主 agent
// 没有 rt 能力 ⇒ 一条 rt 通道静默变成普通通道(不可查的坏状态)
// - 悬空 ⇒ 路由查不到该 owner,走既有"owner 无匹配 agent"兜底**并留日志**
// (可查的坏状态)
func TestStopRTChild_ChannelsBecomeDangling(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in4", "voice4")
id, err := host.SpawnRTChild(RTChildOptions{ID: "dangling", Input: "voice-in4", Output: "voice4"})
if err != nil {
t.Fatalf("创建: %v", err)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in4", "voice4"} {
ch, ok := reg.Lookup(n)
if !ok {
continue // 注销也算一种处理;但插件声明的 rt 通道不该被注销
}
if ch.Owner == "" {
t.Fatalf("通道 %s 被归还为未分配 —— 主 agent 没有 rt 能力,"+
"归还会让它静默兜底到主 agent(应保持悬空)", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望悬空保留 %q", n, ch.Owner, id)
}
}
}
// ── rt_children 工具面(主 agent 创建 rtchild 的唯一入口) ──
// TestRTChildrenTool_ListEmpty 钉住空态提示可读。
func TestRTChildrenTool_ListEmpty(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(got, "没有实时子 agent") {
t.Fatalf("空态提示不对:%q", got)
}
}
// TestRTChildrenTool_CreateRequiresBothChannels 钉住成对要求,
// 且失败提示必须**列出可用的 rt 通道**(否则模型只能瞎猜名字反复重试)。
func TestRTChildrenTool_CreateRequiresBothChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// 只给 input:必须拒,并列出可用通道。
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "c1", "inputch": "voice-in",
},
})
if !strings.Contains(got, "必须成对") {
t.Fatalf("只给 inputch 未被拒:%q", got)
}
if !strings.Contains(got, "voice-in") || !strings.Contains(got, "voice") {
t.Fatalf("失败提示没有列出可用 rt 通道(模型无法自我修正):%q", got)
}
}
// TestRTChildrenTool_FullLifecycle 是**端到端**:模型视角走一遍
// create → list → send → stop(这正是"主 agent 创建 rtchild 并开始工作")。
func TestRTChildrenTool_FullLifecycle(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// ① create(成对)
out := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "call-1",
"inputch": "voice-in", "outputch": "voice",
},
})
if !strings.Contains(out, "已创建") {
t.Fatalf("create 失败:%q", out)
}
// 通道必须已成对划给它。
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
if ch, _ := reg.Lookup(n); ch.Owner != "call-1" {
t.Fatalf("通道 %s 的 Owner = %q,期望 call-1", n, ch.Owner)
}
}
// ② list 能看到它
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(out, "call-1") {
t.Fatalf("list 里看不到刚创建的实例:%q", out)
}
if !strings.Contains(out, "打断") {
t.Fatalf("list 提示没有说明 rt 的打断语义:%q", out)
}
// ③ send:打断式注入,真的跑起来
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "send", "id": "call-1", "text": "用户说:你好",
},
})
if !strings.Contains(out, "打断") {
t.Fatalf("send 没说明打断语义:%q", out)
}
waitFor(t, "rtchild 跑了一轮", func() bool {
st, ok := host.RTChildStats("call-1")
return ok && st.Rounds >= 1
})
// ④ stop:回收并交接
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "stop", "id": "call-1"},
})
if !strings.Contains(out, "已回收") {
t.Fatalf("stop 失败:%q", out)
}
if !strings.Contains(out, "rt_transcript") {
t.Fatalf("stop 没有告诉模型用 rt_transcript 取回原文:%q", out)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("回收后登记表仍有 %v", got)
}
}
// TestRTChildrenTool_UnknownAction 钉住未知 action 响亮报错并列出可用值。
func TestRTChildrenTool_UnknownAction(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "destroy"},
})
if !strings.Contains(got, "未知 action") || !strings.Contains(got, "list") {
t.Fatalf("未知 action 提示不可操作:%q", got)
}
}
// TestRTChildrenTool_NotVisibleToChild 钉住工具是**父专属**的:
// rtchild 自己不该看到 rt_children(否则它能无限自我复制)。
func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
})
id := rtSpawn(t, host, "no-recurse")
rc := host.rtChildren[id]
if rc == nil {
t.Fatal("实例不在登记表")
}
if _, ok := rtToolNames(rc.agent.buildToolDefs())["rt_children"]; ok {
t.Fatal("rtchild 自己能看到 rt_children —— 会形成无界自我复制")
}
}