mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 08:17:32 +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
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package core
|
||
|
||
// R7b 判据:裁剪只是**视图**,原文永不丢弃;回收时把全文交回主 agent。
|
||
//
|
||
// 用户 2026-10-30 裁定:
|
||
//
|
||
// 「老内容不再进入 LLM,但是**原文保留**,rtchild 回收时依旧向主 agent
|
||
// 展示全部上下文。」
|
||
//
|
||
// 这两件事必须分开,混在一起就会静默丢数据:
|
||
//
|
||
// 视图(进 LLM) —— 按窗口裁 ← 判据 1
|
||
// 存储(history) —— 全文永不丢 ← 判据 2
|
||
// 回收交接 —— 交**全文** ← 判据 3
|
||
//
|
||
// 删掉任一条实现,对应判据必红。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
)
|
||
|
||
// rtLongHistoryProvider 每轮回复一句固定文本,用于累积历史。
|
||
type rtLongHistoryProvider struct {
|
||
mu sync.Mutex
|
||
reqs [][]agentAPI.Message // 每次请求的完整消息(拷一份)
|
||
}
|
||
|
||
func (p *rtLongHistoryProvider) Name() string { return "rtLongHistory" }
|
||
func (p *rtLongHistoryProvider) MaxContextTokens() int { return 700 } // > system(444),否则 system 一条就超窗、裁无可裁
|
||
func (p *rtLongHistoryProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
return &agentAPI.CompletionResponse{}, nil
|
||
}
|
||
func (p *rtLongHistoryProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||
p.mu.Lock()
|
||
p.reqs = append(p.reqs, append([]agentAPI.Message(nil), req.Messages...))
|
||
p.mu.Unlock()
|
||
ch := make(chan agentAPI.StreamChunk, 2)
|
||
ch <- agentAPI.StreamChunk{Content: strings.Repeat("答", 40)}
|
||
ch <- agentAPI.StreamChunk{Done: true}
|
||
close(ch)
|
||
return ch, nil
|
||
}
|
||
func (p *rtLongHistoryProvider) rounds() int {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return len(p.reqs)
|
||
}
|
||
func (p *rtLongHistoryProvider) lastReq() []agentAPI.Message {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if len(p.reqs) == 0 {
|
||
return nil
|
||
}
|
||
return p.reqs[len(p.reqs)-1]
|
||
}
|
||
|
||
// TestRTChild_ViewIsTrimmedButHistoryIsNot 是同一条判据的两半,必须成对断言:
|
||
//
|
||
// ① 进 LLM 的视图**确实被裁**(旧内容不再进模型)
|
||
// ② 存储的 history **一条不少**(原文保留)
|
||
//
|
||
// 只断言 ① ⇒ "每轮清空历史"也能通过(那不是裁,是丢)
|
||
// 只断言 ② ⇒ "从不裁"也能通过(那会撑爆窗口)
|
||
func TestRTChild_ViewIsTrimmedButHistoryIsNot(t *testing.T) {
|
||
p := &rtLongHistoryProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
id := rtSpawn(t, host, "trim")
|
||
|
||
// 跑多轮,故意让历史远超窗口(MaxContextTokens=120,每轮产出 ~40 字)。
|
||
const rounds = 6
|
||
for i := 0; i < rounds; i++ {
|
||
host.PushRTInput(id, strings.Repeat("问", 30))
|
||
want := (i + 1) * 2
|
||
// ★ 必须等 **transcript 条数**,不能等 p.rounds()。
|
||
//
|
||
// p.rounds() 在 ChatStream **被调用时**就自增(此时请求刚构建完),
|
||
// 而 assistant 那条消息是**流结束后**才追加进 history 的。
|
||
// 等 rounds 的话第 N 轮请求一发出就放行,随即读 transcript 会少一条
|
||
// (实测 11 vs 12)—— 这是判据自己的竞态,不是产品缺陷。
|
||
waitFor(t, "本轮完成", func() bool {
|
||
tr, ok := host.RTTranscript(id)
|
||
return ok && len(tr) >= want
|
||
})
|
||
}
|
||
|
||
// ② 存储侧:6 轮 ⇒ 每轮 user+assistant 两条,共 12 条,**一条都不能少**。
|
||
transcript, ok := host.RTTranscript(id)
|
||
if !ok {
|
||
t.Fatal("取不到 transcript")
|
||
}
|
||
if len(transcript) != rounds*2 {
|
||
t.Fatalf("history 有 %d 条,期望 %d 条 —— 原文被裁掉了(用户裁定:原文保留)",
|
||
len(transcript), rounds*2)
|
||
}
|
||
// 最早那一轮的原话必须还在。
|
||
if !strings.Contains(transcript[0].Content, "问") {
|
||
t.Fatalf("最早的一轮不见了:%+v", transcript[0])
|
||
}
|
||
|
||
// ① 视图侧:最后一次请求的消息数必须**明显少于** history(说明确实裁了)。
|
||
last := p.lastReq()
|
||
if len(last) >= len(transcript)+1 { // +1 = 本轮 user 之前已在 history 里
|
||
t.Logf("视图 %d 条 / 历史 %d 条:窗口未触发裁剪(内容可能还不够大)", len(last), len(transcript))
|
||
}
|
||
viewTokens := 0
|
||
for _, m := range last {
|
||
viewTokens += rtMsgTokens(m)
|
||
}
|
||
// 允许一定估算偏差(+20%),但视图必须**明显小于**完整历史。
|
||
fullTokens := 0
|
||
for _, m := range append([]agentAPI.Message{{Role: "system", Content: rtChildDefaultPrompt}}, transcript...) {
|
||
fullTokens += rtMsgTokens(m)
|
||
}
|
||
if viewTokens > 700*12/10 {
|
||
t.Fatalf("视图估算 %d token,远超窗口 700 —— 裁剪没生效(旧内容仍在进模型)", viewTokens)
|
||
}
|
||
if fullTokens > 700 && viewTokens >= fullTokens*3/4 {
|
||
t.Fatalf("视图 %d token 与完整历史 %d token 相当 —— 没有真正裁掉旧内容",
|
||
viewTokens, fullTokens)
|
||
}
|
||
}
|
||
|
||
// TestRTChild_HistorySurvivesInputSupersede 钉住:被打断的轮次,**已说出口的部分**
|
||
// 也要留在 history 里(它确实被用户听到了,下一轮不能假装没发生)。
|
||
func TestRTChild_HistorySurvivesInputSupersede(t *testing.T) {
|
||
p := &rtLongHistoryProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
id := rtSpawn(t, host, "super")
|
||
host.PushRTInput(id, "第一问")
|
||
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
|
||
host.PushRTInput(id, "第二问")
|
||
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
|
||
|
||
tr, _ := host.RTTranscript(id)
|
||
if len(tr) < 4 {
|
||
t.Fatalf("两轮之后 history 只有 %d 条,期望 ≥4(每轮 user+assistant)", len(tr))
|
||
}
|
||
}
|
||
|
||
// TestFormatRTTranscript_PreservesDialogueFully 钉住渲染口径:
|
||
// user/assistant **逐字保留**;工具结果按上限截断(体积大头,复盘只需结论)。
|
||
func TestFormatRTTranscript_PreservesDialogueFully(t *testing.T) {
|
||
longToolResult := strings.Repeat("x", rtTranscriptToolMaxRunes*3)
|
||
msgs := []agentAPI.Message{
|
||
{Role: "user", Content: "帮我查一下明天的天气"},
|
||
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "c1", Name: "weather_query", Arguments: map[string]interface{}{"city": "北京"}},
|
||
}},
|
||
{Role: "tool", ToolCallID: "c1", Content: longToolResult},
|
||
{Role: "assistant", Content: "明天北京晴,18-26 度。"},
|
||
}
|
||
out := FormatRTTranscript(msgs)
|
||
|
||
for _, want := range []string{
|
||
"帮我查一下明天的天气",
|
||
"weather_query",
|
||
"city=北京",
|
||
"明天北京晴,18-26 度。",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Fatalf("交接文本缺少 %q\n---\n%s", want, out)
|
||
}
|
||
}
|
||
// 工具结果被截断(不把整页塞回去),但仍有内容。
|
||
if strings.Contains(out, strings.Repeat("x", rtTranscriptToolMaxRunes+50)) {
|
||
t.Fatal("工具结果未按上限截断 —— 会把体积大头整段塞回主 agent 上下文")
|
||
}
|
||
if !strings.Contains(out, "xxx") {
|
||
t.Fatal("工具结果被整段丢掉了 —— 复盘需要看到结论")
|
||
}
|
||
}
|
||
|
||
// TestCloseRTChild_PushesSignalNotBody 是**交接协议**的判据(用户裁定 2026-10-30):
|
||
//
|
||
// 回收走「contextfull 式中断」—— 只推**信号**(规模),正文留着等主 agent
|
||
// 按需拉取,由它自己选保留哪些。
|
||
//
|
||
// 若把 CloseRTChild 改回"直接把全文 injectSelfChannel 进主 agent 上下文",
|
||
// 本判据必红:正文动辄几十万字符,全灌进去会顶爆父的上下文。
|
||
func TestCloseRTChild_PushesSignalNotBody(t *testing.T) {
|
||
p := &rtLongHistoryProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
id := rtSpawn(t, host, "close")
|
||
host.PushRTInput(id, "第一句原话")
|
||
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
|
||
host.PushRTInput(id, "第二句原话")
|
||
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
|
||
|
||
before, _ := host.RTTranscript(id)
|
||
n, err := host.CloseRTChild(id)
|
||
if err != nil {
|
||
t.Fatalf("回收失败:%v", err)
|
||
}
|
||
if n != len(before) {
|
||
t.Fatalf("交接条数 %d,实际 %d", n, len(before))
|
||
}
|
||
if _, ok := host.RTChildStats(id); ok {
|
||
t.Fatal("回收后实例仍在登记表里")
|
||
}
|
||
|
||
// ★ 核心断言:父侧收到的是**中断信号**(level=L4,source=rtchild/<id>),
|
||
// 且 payload 里**没有**通话正文 —— 正文是要去拉的东西,不该被推进上下文。
|
||
//
|
||
// 注意这里查的是**调度器的中断登记**而不是 selfInputCh:
|
||
// 交接走的是 contextfull 同款 L4(raiseVector),不是自循环消息。
|
||
snap := host.DumpScheduler()
|
||
if len(snap.InterruptQueues[LevelCritical]) == 0 {
|
||
t.Fatalf("没有产生 L4 中断 —— 交接必须走 contextfull 式中断,实际快照:%+v", snap)
|
||
}
|
||
|
||
// 原文必须仍可**按需取回**(回收之后也能取)。
|
||
after, ok := host.RTTranscript(id)
|
||
if !ok {
|
||
t.Fatal("回收后取不到原文 —— 「回收时向主 agent 展示全部上下文」断链")
|
||
}
|
||
if len(after) != len(before) {
|
||
t.Fatalf("回收后原文 %d 条,回收前 %d 条", len(after), len(before))
|
||
}
|
||
}
|
||
|
||
// TestRTTranscript_ToolServesAllContentAndPages 钉住 rt_transcript 工具:
|
||
// 它必须能取回全部原文(分段),这是主 agent"自行选择保留内容"的前提。
|
||
func TestRTTranscript_ToolServesAllContentAndPages(t *testing.T) {
|
||
p := &rtLongHistoryProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
id := rtSpawn(t, host, "tool")
|
||
const rounds = 3
|
||
for i := 0; i < rounds; i++ {
|
||
host.PushRTInput(id, fmt.Sprintf("第%d句原话", i))
|
||
want := i + 1
|
||
waitFor(t, "本轮完成", func() bool { return p.rounds() >= want })
|
||
}
|
||
host.CloseRTChild(id)
|
||
|
||
// 第一段:默认 limit。
|
||
out := host.executeRTTranscript(agentAPI.ToolCall{
|
||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id},
|
||
})
|
||
if !strings.Contains(out, "共 ") {
|
||
t.Fatalf("工具输出没有规模信息:%s", truncateStr(out, 200))
|
||
}
|
||
if !strings.Contains(out, "第0句原话") {
|
||
t.Fatalf("第一段缺少最早的原话:%s", truncateStr(out, 300))
|
||
}
|
||
// 分段续读:offset 应能取到后面的内容(用 limit=1 逼出分页)。
|
||
page2 := host.executeRTTranscript(agentAPI.ToolCall{
|
||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id, "offset": 1, "limit": 1},
|
||
})
|
||
if !strings.Contains(page2, "1-2") {
|
||
t.Fatalf("分页参数未生效(应返回第 1-2 条):%s", truncateStr(page2, 200))
|
||
}
|
||
// 未知 id 必须明确报错(不静默返回空)。
|
||
bad := host.executeRTTranscript(agentAPI.ToolCall{
|
||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": "nope"},
|
||
})
|
||
if !strings.Contains(bad, "不存在") {
|
||
t.Fatalf("未知 id 未明确报错:%s", bad)
|
||
}
|
||
}
|
||
|
||
// TestCloseRTChild_EmptyHistoryNoSpam 钉住:没有历史时不推中断(别污染主 agent)。
|
||
func TestCloseRTChild_EmptyHistoryNoSpam(t *testing.T) {
|
||
p := &rtLongHistoryProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
id := rtSpawn(t, host, "empty")
|
||
n, err := host.CloseRTChild(id)
|
||
if err != nil {
|
||
t.Fatalf("回收失败:%v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("空历史返回 %d 条", n)
|
||
}
|
||
time.Sleep(30 * time.Millisecond)
|
||
select {
|
||
case msg := <-host.selfInputCh:
|
||
t.Fatalf("空历史也推了信号,污染主 agent:%q", truncateStr(msg.text, 80))
|
||
default:
|
||
}
|
||
}
|