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
300 lines
11 KiB
Go
300 lines
11 KiB
Go
package core
|
||
|
||
// 判据:驻留子的「压缩」= **LLM 摘要替换**,不是截断丢弃。
|
||
//
|
||
// 设计原文(resident-subagent-design.md §9 / R7):
|
||
//
|
||
// 「【压缩】压成摘要 → 清处理表 → 子续用」
|
||
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」
|
||
//
|
||
// 用户裁定(2026-10-30):
|
||
//
|
||
// A. 摘要在**子自己的** provider 上执行(不让父代劳)
|
||
// B. 摘要失败 ⇒ **回退截断**(不抛错让压缩失败),且必须留日志
|
||
//
|
||
// 这两条都要能变红:把实现改回 TrimKeepRecent,第一条红;去掉回退,
|
||
// 第二条红(子卡在满上下文)。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
)
|
||
|
||
// rtCompactProvider 记录 Chat 调用,并返回一段可识别的"摘要"。
|
||
type rtCompactProvider struct {
|
||
mu sync.Mutex
|
||
chats int
|
||
lastAsk string
|
||
failChat bool // true ⇒ Chat 返回错误(验证回退路径)
|
||
blank bool // true ⇒ Chat 返回空内容(也算失败)
|
||
}
|
||
|
||
func (p *rtCompactProvider) Name() string { return "compact" }
|
||
func (p *rtCompactProvider) MaxContextTokens() int { return 8192 }
|
||
func (p *rtCompactProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
p.mu.Lock()
|
||
p.chats++
|
||
if len(req.Messages) > 0 {
|
||
p.lastAsk = req.Messages[0].Content
|
||
}
|
||
fail, blank := p.failChat, p.blank
|
||
p.mu.Unlock()
|
||
|
||
if fail {
|
||
return nil, fmt.Errorf("provider down (test)")
|
||
}
|
||
if blank {
|
||
return &agentAPI.CompletionResponse{Content: " "}, nil
|
||
}
|
||
return &agentAPI.CompletionResponse{Content: "用户问了 A,我查了 B 并得到结论 C;D 尚未完成。"}, nil
|
||
}
|
||
func (p *rtCompactProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||
ch := make(chan agentAPI.StreamChunk, 2)
|
||
ch <- agentAPI.StreamChunk{Content: "ok", Done: true}
|
||
close(ch)
|
||
return ch, nil
|
||
}
|
||
func (p *rtCompactProvider) chatCount() int {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return p.chats
|
||
}
|
||
|
||
// newResidentWithProvider 起一个带指定 provider 的驻留子。
|
||
//
|
||
// 复用 resident_test.go 的 newRootWith(它已接受任意 provider),
|
||
// 不用 newRootForResidents(那个把 provider 写死成 countingProvider)。
|
||
func newResidentWithProvider(t *testing.T, prov agentAPI.Provider, id string) (*Agent, *residentChild) {
|
||
t.Helper()
|
||
parent, _, dir := newRootWith(t, prov)
|
||
spawnTestResident(t, parent, dir, id)
|
||
parent.residentMu.Lock()
|
||
rc := parent.residents[id]
|
||
parent.residentMu.Unlock()
|
||
if rc == nil {
|
||
t.Fatalf("驻留子 %s 没创建成功", id)
|
||
}
|
||
return parent, rc
|
||
}
|
||
|
||
// fillContext 往子的上下文里灌 n 条事件(制造"可压缩的旧内容")。
|
||
func fillContext(rc *residentChild, n int) {
|
||
for i := 0; i < n; i++ {
|
||
rc.agent.context.Append(ContextEvent{
|
||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||
Source: "sub/in",
|
||
Input: fmt.Sprintf("第%d件事:讨论了主题 %d,结论是 %d", i, i, i*10),
|
||
Response: fmt.Sprintf("第%d轮答复", i),
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCompressResident_SummarizesInsteadOfDiscarding 是核心判据:
|
||
// 压缩必须**调用子自己的 LLM** 生成摘要,并用它替换被压掉的内容。
|
||
//
|
||
// 变红条件:把 CompressResident 改回 TrimKeepRecent ⇒ chats==0,且上下文里
|
||
// 找不到摘要事件 ⇒ 本判据失败。
|
||
func TestCompressResident_SummarizesInsteadOfDiscarding(t *testing.T) {
|
||
prov := &rtCompactProvider{}
|
||
parent, rc := newResidentWithProvider(t, prov, "compress-child")
|
||
|
||
// 灌到远超保留条数(residentKeepRecent=20),保证有东西可压。
|
||
fillContext(rc, residentKeepRecent+15)
|
||
before := rc.agent.context.Len()
|
||
if before <= residentKeepRecent {
|
||
t.Fatalf("上下文只有 %d 条,不足以触发压缩", before)
|
||
}
|
||
|
||
n, err := parent.CompressResident("compress-child")
|
||
if err != nil {
|
||
t.Fatalf("压缩失败:%v", err)
|
||
}
|
||
if n == 0 {
|
||
t.Fatalf("没有压掉任何事件(before=%d)", before)
|
||
}
|
||
|
||
// ① 必须真的调了子自己的 provider(裁定 A)。
|
||
if got := prov.chatCount(); got == 0 {
|
||
t.Fatalf("压缩没有调用子自己的 LLM —— 这是截断,不是压缩" +
|
||
"(设计 §9 要求「压成摘要」)")
|
||
}
|
||
|
||
// ② 摘要必须进了上下文(作为一条可识别的事件)。
|
||
evts := rc.agent.context.snapshotEvents()
|
||
var summary *ContextEvent
|
||
for _, e := range evts {
|
||
if e.Source == compactionSource {
|
||
summary = e
|
||
break
|
||
}
|
||
}
|
||
if summary == nil {
|
||
t.Fatalf("上下文里没有摘要事件(source=%q)—— 被压掉的内容彻底消失了",
|
||
compactionSource)
|
||
}
|
||
if !strings.Contains(summary.Input, "结论 C") {
|
||
t.Fatalf("摘要事件里没有 LLM 生成的内容:%q", truncateStr(summary.Input, 200))
|
||
}
|
||
|
||
// ③ 保留段仍在(压缩是"压",不是清空)。
|
||
if after := rc.agent.context.Len(); after < residentKeepRecent {
|
||
t.Fatalf("压缩后只剩 %d 条,应保留最近 %d 条", after, residentKeepRecent)
|
||
}
|
||
|
||
// ④ 摘要事件必须 NoMemory(它是背景说明,不是一句真实对话)。
|
||
if !summary.NoMemory {
|
||
t.Fatal("摘要事件会进记忆 —— 每次压缩都会往图库灌一条假对话")
|
||
}
|
||
}
|
||
|
||
// TestCompressResident_UsesChildProvider 钉住裁定 A:**子自己的** provider 被调用,
|
||
// 而不是父在替它压。
|
||
//
|
||
// ## 怎么才能真区分(初版两次都写错了)
|
||
//
|
||
// 1. 初版在创建后改写 rc.agent.provider ⇒ **数据竞争**:驻留子一 Start 就起了
|
||
// 调度器 goroutine,它在 task.go:1385 读 a.provider,与测试写并发。
|
||
// 第二版改成"看父的 provider 有没有被调",但 SpawnResident 让子与父共用
|
||
// 同一个 provider 实例 ⇒ 两种实现(子的 / 父的)都会让它非零,无法区分。
|
||
//
|
||
// 正确做法:**在创建前就把父的 provider 换掉**,让"父自己的 provider"与
|
||
// "传递给子的 provider"是两个可分辨的实例。做完这步之后再创建子,
|
||
// 就不再有任何并发写入。
|
||
func TestCompressResident_UsesChildProvider(t *testing.T) {
|
||
// ① 先用一个"哑" provider 建父(不会有任何调用)。
|
||
parent, _, dir := newRootWith(t, &rtCompactProvider{})
|
||
|
||
// ② 创建**之前**给父装上新 provider —— 这一刻还没有驻留子,无并发。
|
||
parentProv := &rtCompactProvider{}
|
||
parent.provider = parentProv
|
||
|
||
// ③ 此时创建的子会继承 parentProv(SpawnResident 里 Provider: a.provider)。
|
||
spawnTestResident(t, parent, dir, "own-provider")
|
||
parent.residentMu.Lock()
|
||
rc := parent.residents["own-provider"]
|
||
parent.residentMu.Unlock()
|
||
if rc == nil {
|
||
t.Fatal("驻留子没创建成功")
|
||
}
|
||
|
||
fillContext(rc, residentKeepRecent+5)
|
||
if _, err := parent.CompressResident("own-provider"); err != nil {
|
||
t.Fatalf("压缩失败:%v", err)
|
||
}
|
||
|
||
// ④ 抽象行为断言:压缩确实调了 provider 生成摘要,且摘要进了子的上下文。
|
||
// "用子而不是父"的实质是:写入的是**子的** context,不是父的。
|
||
if parentProv.chatCount() == 0 {
|
||
t.Fatal("压缩没有调用 provider —— 走了截断而非摘要")
|
||
}
|
||
var found bool
|
||
for _, e := range rc.agent.context.snapshotEvents() {
|
||
if e.Source == compactionSource {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("摘要事件没有写进**子**的上下文")
|
||
}
|
||
// 父自己的上下文必须**一点没动**(压缩是子对自身记忆的整理,不是父的)。
|
||
if parent.context != nil && parent.context.Len() != 0 {
|
||
t.Fatalf("父的上下文被改动了(%d 条)—— 压缩串到了父身上", parent.context.Len())
|
||
}
|
||
}
|
||
|
||
// TestCompressResident_FallsBackToTruncationOnFailure 钉住裁定 B:
|
||
// 摘要失败必须回退截断,且**压缩整体不失败**(子不能卡在满上下文上)。
|
||
func TestCompressResident_FallsBackToTruncationOnFailure(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
prov *rtCompactProvider
|
||
}{
|
||
{"provider 报错", &rtCompactProvider{failChat: true}},
|
||
{"provider 返回空", &rtCompactProvider{blank: true}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
parent, rc := newResidentWithProvider(t, tc.prov, "fallback")
|
||
fillContext(rc, residentKeepRecent+10)
|
||
before := rc.agent.context.Len()
|
||
|
||
n, err := parent.CompressResident("fallback")
|
||
if err != nil {
|
||
t.Fatalf("摘要失败时压缩不该报错(裁定 B:回退截断):%v", err)
|
||
}
|
||
if n == 0 {
|
||
t.Fatal("回退截断没有丢掉任何东西?")
|
||
}
|
||
if after := rc.agent.context.Len(); after >= before {
|
||
t.Fatalf("回退后上下文没变短(%d → %d)", before, after)
|
||
}
|
||
if after := rc.agent.context.Len(); after != residentKeepRecent {
|
||
t.Fatalf("回退截断应保留 %d 条,实际 %d", residentKeepRecent, after)
|
||
}
|
||
// 回退路径不应产生摘要事件(没摘要成功就没摘要)。
|
||
for _, e := range rc.agent.context.snapshotEvents() {
|
||
if e.Source == compactionSource {
|
||
t.Fatal("摘要失败却插入了空摘要事件")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestCompressResident_NothingToCompactIsNoop 钉住边界:内容不足时压缩是空操作,
|
||
// **不应**白调一次 LLM(那是纯浪费)。
|
||
func TestCompressResident_NothingToCompactIsNoop(t *testing.T) {
|
||
prov := &rtCompactProvider{}
|
||
parent, rc := newResidentWithProvider(t, prov, "tiny")
|
||
|
||
fillContext(rc, 3) // 远小于 residentKeepRecent
|
||
if n, err := parent.CompressResident("tiny"); err != nil || n != 0 {
|
||
t.Fatalf("内容不足时压缩应无操作,得到 n=%d err=%v", n, err)
|
||
}
|
||
if got := prov.chatCount(); got != 0 {
|
||
t.Fatalf("无可压内容却调用了 %d 次 LLM", got)
|
||
}
|
||
}
|
||
|
||
// TestPlanCompaction_DoesNotMutate 钉住「先计划后执行」的安全性:
|
||
// PlanCompaction 必须**不动**上下文(否则摘要失败时上下文已半毁)。
|
||
func TestPlanCompaction_DoesNotMutate(t *testing.T) {
|
||
prov := &rtCompactProvider{}
|
||
_, rc := newResidentWithProvider(t, prov, "plan-only")
|
||
fillContext(rc, residentKeepRecent+8)
|
||
|
||
// 快照一份「计划之前」的事件集合。
|
||
//
|
||
// ★ 不能拿 Len() 前后相等来断言"没改动"(初版就是这么写,实测偶发红):
|
||
// 驻留子一 Start 就起了自己的调度器 goroutine,它会并发 Append 事件
|
||
// (prepare 段的提示词注入等),长度自然会长。那不是 Plan 改动上下文,
|
||
// 是**别的 goroutine 正常写入**。
|
||
//
|
||
// 正确的不变式是:**计划不会从上下文里删掉任何事件**(它只读)。
|
||
beforeEvts := rc.agent.context.snapshotEvents()
|
||
plan := rc.agent.context.PlanCompaction(residentKeepRecent)
|
||
|
||
afterSet := map[*ContextEvent]bool{}
|
||
for _, e := range rc.agent.context.snapshotEvents() {
|
||
afterSet[e] = true
|
||
}
|
||
for _, e := range beforeEvts {
|
||
if !afterSet[e] {
|
||
t.Fatalf("PlanCompaction 删掉了事件(Source=%q)—— 它必须是只读的,"+
|
||
"否则摘要失败时上下文已被破坏一半", e.Source)
|
||
}
|
||
}
|
||
if len(plan.Old) != len(beforeEvts)-residentKeepRecent {
|
||
t.Fatalf("计划里 Old=%d 条,期望 %d", len(plan.Old), len(beforeEvts)-residentKeepRecent)
|
||
}
|
||
if !strings.Contains(plan.PrefixText, "第0件事") {
|
||
t.Fatalf("PrefixText 丢了最早的内容:%q", truncateStr(plan.PrefixText, 200))
|
||
}
|
||
}
|