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

140 lines
5.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
// R1 判据:「只有 rtchild 可被划分 rt 通道」(用户裁定 R2)+ 通道侧能力校验。
//
// 设计见 plan.md 十一.三 F / 十一.七 R1。
//
// ## 这两条判据为什么必须成对存在
//
// 校验是**双向**的,任何一侧单独成立都不足以拦住"慢 agent 混进通话链路":
// · 只校验 agent 侧 ⇒ 一个没声明 CapRT 的普通通道被划进来,插件会把它
// 当成普通消息收(不知道"新值作废旧值"),rt 语义静默丢失;
// · 只校验通道侧 ⇒ 主 agent(实测往返 2–5s)能划 rt 通道,用户听到长静默。
//
// 所以下面两条各钉一侧,删掉任一侧的实现,对应判据必须变红。
import (
"errors"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// newRTTestAgent 造一个最小可用的 agent(带 io + 共享通道登记表)。
func newRTTestAgent(t *testing.T, realtime bool) *Agent {
t.Helper()
io := agentIO.NewIOManager()
a := New(AgentConfig{ID: "rt-test", IO: io})
if realtime {
a.AsRealtime()
}
return a
}
// registerRTChannel 往登记表里放一个 inputch(caps 决定它声明了什么能力)。
func registerRTChannel(t *testing.T, a *Agent, name string, caps agentIO.OutputCapability) {
t.Helper()
err := a.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: name,
Plugin: "test",
Capabilities: caps,
})
if err != nil {
t.Fatalf("注册 inputch %s: %v", name, err)
}
}
// TestAssignRealtimeRejectsNonRTAgent 钉住 agent 侧:
// 非 rtchild **不得**被划分 rt 通道。
//
// 删掉 AssignRealtime 里的 `if !a.isRealtime` 判断,本判据必红。
func TestAssignRealtimeRejectsNonRTAgent(t *testing.T) {
a := newRTTestAgent(t, false) // 普通 agent,不是 rtchild
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
err := a.AssignRealtime("voice")
if err == nil {
t.Fatalf("非 rtchild 竟然被划到 rt 通道 —— 「只有 rtchild 可被划分 rt 通道」未生效")
}
if !errors.Is(err, ErrAgentNotRealtime) {
t.Fatalf("错误类型不对:%v(应可用 errors.Is 匹配 ErrAgentNotRealtime,"+
"否则调用方只能靠字符串判断)", err)
}
// 配套断言:拒绝后 Owner 必须**未被写入**。
// 只返回错误但已经把 Owner 改了,同样是坏的(调用方看到报错,通道却已被占)。
ch, ok := a.io.ChannelRegistry().Lookup("voice")
if !ok {
t.Fatal("通道不见了")
}
if ch.Owner != "" {
t.Fatalf("划拨被拒但 Owner 已被写成 %q —— 拒绝路径必须无副作用", ch.Owner)
}
}
// TestAssignRealtimeRejectsChannelWithoutCapRT 钉住通道侧:
// 没有声明 CapRT 的通道**不得**被划作 rt 通道。
//
// 删掉 AssignRealtime 里的 ch.Capabilities.Supports(CapRT) 判断,本判据必红。
func TestAssignRealtimeRejectsChannelWithoutCapRT(t *testing.T) {
a := newRTTestAgent(t, true) // 确实是 rtchild
registerRTChannel(t, a, "plain", agentIO.CapText)
err := a.AssignRealtime("plain")
if err == nil {
t.Fatalf("未声明 CapRT 的通道竟被划成 rt 通道 —— 插件会把它当普通消息收,rt 语义静默丢失")
}
if !errors.Is(err, agentIO.ErrChannelNotRealtime) {
t.Fatalf("错误类型不对:%v(应匹配 ErrChannelNotRealtime)", err)
}
}
// TestAssignRealtimeAllowsPlainChannel 钉住计划原文的一条边界:
//
// 「标签不是准入门槛:**不带 rt 标签的普通通道同样可以划给 rt agent**」
//
// 即:rtchild 用普通通道是**允许**的 —— 只是那条通道不能承载 rt 流。
// 若有人"顺手"把普通 Assign 也加上 rt 校验,本判据会红。
func TestAssignRealtimeAllowsPlainChannel(t *testing.T) {
a := newRTTestAgent(t, true)
registerRTChannel(t, a, "cli", agentIO.CapText)
// 走**普通** Assign(不是 AssignRealtime):rtchild 划普通通道必须放行。
if err := a.io.AssignInputChannel("cli", "rt-test", 0); err != nil {
t.Fatalf("rtchild 划普通通道被拒 —— 违反「标签不是准入门槛」:%v", err)
}
ch, _ := a.io.ChannelRegistry().Lookup("cli")
if ch.Owner != "rt-test" {
t.Fatalf("Owner 未写入:%q", ch.Owner)
}
}
// TestAssignRealtimeSucceedsOnRTChannel 是正例:两个条件都满足时必须成功。
//
// ★ 只有负例判据时,"永远返回错误的实现"也能全绿 —— 正例是必需品。
func TestAssignRealtimeSucceedsOnRTChannel(t *testing.T) {
a := newRTTestAgent(t, true)
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
if err := a.AssignRealtime("voice"); err != nil {
t.Fatalf("rtchild 划 rt 通道失败:%v", err)
}
ch, _ := a.io.ChannelRegistry().Lookup("voice")
if ch.Owner != "rt-test" {
t.Fatalf("Owner = %q,期望 rt-test", ch.Owner)
}
// Capacity 必须是 0:rt 通道是**单槽覆盖**,不是"容量=1 的队列"
// (后者会让新输入等旧输入跑完,正是契约 1 禁止的,见 plan.md 十一.五)。
if ch.Capacity != 0 {
t.Fatalf("rt 通道的 Capacity = %d,必须为 0(单槽覆盖,不是队列)", ch.Capacity)
}
}
// TestAssignRealtimeRejectsUnknownChannel 引用未注册通道必须报错(不静默成功)。
func TestAssignRealtimeRejectsUnknownChannel(t *testing.T) {
a := newRTTestAgent(t, true)
if err := a.AssignRealtime("nope"); !errors.Is(err, agentIO.ErrInputChannelUnknown) {
t.Fatalf("未注册通道应报 ErrInputChannelUnknown,得到:%v", err)
}
}