mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 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
140 lines
5.4 KiB
Go
140 lines
5.4 KiB
Go
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)
|
||
}
|
||
}
|