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

219 lines
7.5 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
// R7 判据:rtchild 的上下文 = **普通上下文 + 上下文窗口**。
//
// 用户 2026-10-30 裁定:没有页、没有锚点、没有降级阶梯 —— 就是普通对话历史,
// 进模型前按窗口裁。本文件钉住三件事:
//
// 1. 历史**跨轮累积**(通话是一条连续对话,不能每轮失忆)
// 2. 超窗口时**从最旧丢**,system 与本轮输入永不丢
// 3. 裁剪**绝不拆开** assistant(tool_calls) 与其配对 tool 结果
// (切在中间上游直接 400,见 context.go:102 的生产事故)
import (
"context"
"sync"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// ── 纯函数判据:rtTrimToWindow ──
// TestRTTrim_KeepsSystemAndLatestInput 钉住:system 与本轮 user 输入永不裁。
//
// 这两条一丢,rt 就彻底坏了:
// - system 丢了 ⇒ 人格与【实时通话】约束消失(模型不再先说"稍等")
// - 本轮输入丢了 ⇒ 模型在回答上一轮的问题
func TestRTTrim_KeepsSystemAndLatestInput(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "你是助手"},
{Role: "user", Content: "旧问题"},
{Role: "assistant", Content: "旧回答"},
{Role: "user", Content: "本轮问题"},
}
// 窗口小到只装得下首尾两条。
out := rtTrimToWindow(msgs, 10)
if len(out) < 2 {
t.Fatalf("裁完只剩 %d 条,system 或本轮输入被丢了", len(out))
}
if out[0].Role != "system" || out[0].Content != "你是助手" {
t.Fatalf("system 被裁掉了:%+v", out[0])
}
if last := out[len(out)-1]; last.Role != "user" || last.Content != "本轮问题" {
t.Fatalf("本轮输入被裁掉了:%+v", last)
}
}
// TestRTTrim_DropsOldestFirst 钉住「从最旧丢」。
func TestRTTrim_DropsOldestFirst(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "最早的问题"},
{Role: "assistant", Content: "最早的回答"},
{Role: "user", Content: "最近的问题"},
{Role: "assistant", Content: "最近的回答"},
{Role: "user", Content: "本轮"},
}
out := rtTrimToWindow(msgs, 12)
for _, m := range out {
if m.Content == "最早的问题" {
t.Fatalf("最早的消息仍在(应从最旧丢):%+v", contentsOf(out))
}
}
}
// TestRTTrim_NeverSplitsToolPairs 是最重要的一条:
// assistant(tool_calls) 与其配对 tool 结果必须**整组进出**。
//
// 若实现按单条消息裁(而不是按组),这里会切出「有 tool_calls 但没有结果」
// 或「有 tool 结果但找不到调用」的残缺序列 —— 上游直接 400。
func TestRTTrim_NeverSplitsToolPairs(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
// ── 组 A:一次工具调用 + 两个配对结果 ──
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
{ID: "c1", Name: "t1"}, {ID: "c2", Name: "t2"},
}},
{Role: "tool", ToolCallID: "c1", Content: "结果1"},
{Role: "tool", ToolCallID: "c2", Content: "结果2"},
{Role: "user", Content: "中间问题"},
{Role: "assistant", Content: "中间回答"},
{Role: "user", Content: "本轮"},
}
// 用一个"刚好要丢掉一部分"的窗口,验证组不会被切开。
for _, window := range []int{20, 40, 60, 200} {
out := rtTrimToWindow(msgs, window)
assertToolPairsIntact(t, window, out)
}
}
// assertToolPairsIntact 断言 out 里每个 assistant(tool_calls) 的 ID 集合
// 与紧随其后的 tool 消息 ID 集合完全一致(不残缺、不多余)。
func assertToolPairsIntact(t *testing.T, window int, out []agentAPI.Message) {
t.Helper()
for i := 0; i < len(out); i++ {
m := out[i]
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
continue
}
want := map[string]bool{}
for _, tc := range m.ToolCalls {
want[tc.ID] = true
}
got := map[string]bool{}
for j := i + 1; j < len(out) && out[j].Role == "tool"; j++ {
got[out[j].ToolCallID] = true
}
if len(got) != len(want) {
t.Fatalf("window=%d: tool_call 与结果不配对(call=%v result=%v)—— 上游会 400\n序列:%+v",
window, want, got, contentsOf(out))
}
for id := range want {
if !got[id] {
t.Fatalf("window=%d: 调用 %s 没有配对结果 —— 上游会 400", window, id)
}
}
}
// 反向:不得出现没有前置 assistant 的孤儿 tool 消息。
for i, m := range out {
if m.Role != "tool" {
continue
}
if i == 0 {
t.Fatalf("window=%d: 序列以 tool 消息开头(孤儿结果)", window)
}
}
}
func contentsOf(msgs []agentAPI.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
s := m.Role + ":"
if m.Content != "" {
s += m.Content
}
if len(m.ToolCalls) > 0 {
s += "[calls]"
}
out = append(out, s)
}
return out
}
// TestRTTrim_ShortHistoryUntouched 钉住:本来就没超窗口时**一条都不删**。
//
// 这一条防"为了让窗口判据变绿而无脑裁剪"——那会把还装得下的历史白白丢掉。
func TestRTTrim_ShortHistoryUntouched(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "a"},
{Role: "assistant", Content: "b"},
{Role: "user", Content: "c"},
}
out := rtTrimToWindow(msgs, 100000)
if len(out) != len(msgs) {
t.Fatalf("窗口充裕却裁掉了消息:%v → %v", contentsOf(msgs), contentsOf(out))
}
}
// ── 集成判据:rtchild 跨轮累积上下文 ──
// rtMsgCountProvider 记录每次 ChatStream 收到的消息条数,
// 用来证明"历史真的被带进了下一轮"(而不是每轮失忆)。
type rtMsgCountProvider struct {
mu sync.Mutex
sizes []int
}
func (p *rtMsgCountProvider) Name() string { return "rtMsgCount" }
func (p *rtMsgCountProvider) MaxContextTokens() int { return 8192 }
func (p *rtMsgCountProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{}, nil
}
func (p *rtMsgCountProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.sizes = append(p.sizes, len(req.Messages))
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "回"}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtMsgCountProvider) snapshot() []int {
p.mu.Lock()
defer p.mu.Unlock()
return append([]int(nil), p.sizes...)
}
// TestRTChild_HistoryAccumulatesAcrossRounds 是「普通上下文」的核心判据:
// 第二轮发给模型的消息数必须**多于**第一轮(历史被带上了)。
//
// 若 buildRTContext 每轮只发 system+input(我最初的"无历史"最小版),
// 两轮都是 2 条 —— 本判据必红。
func TestRTChild_HistoryAccumulatesAcrossRounds(t *testing.T) {
p := &rtMsgCountProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "hist")
host.PushRTInput(id, "第一句")
waitFor(t, "第一轮完成", func() bool { return len(p.snapshot()) >= 1 })
host.PushRTInput(id, "第二句")
waitFor(t, "第二轮完成", func() bool { return len(p.snapshot()) >= 2 })
sizes := p.snapshot()
if sizes[1] <= sizes[0] {
t.Fatalf("第二轮消息数 %d 不多于第一轮 %d —— 上下文没有跨轮累积"+
"(rtchild 应是普通上下文,通话不能每轮失忆)", sizes[1], sizes[0])
}
// 第二轮 = system + (user+assistant) + user = 4 条(window 充裕时)
if sizes[1] < 4 {
t.Fatalf("第二轮只有 %d 条消息,期望 ≥4(system + 上一轮问答 + 本轮输入)", sizes[1])
}
}