mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。
## 1. 抽出 child 包(类型抽象 + 单一登记表)
新增 internal/agent/core/child/:
child.go Kind / Child(嵌入用)/ State / 残余与通道处置
registry.go 单一 Registry + Entry 接口 + Cleanup 骨架
kind.go ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
kinds.go 四种子 agent 的清单与迁移状态
此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。
现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。
## 2. rt 的输出改为异步(用户的原始诉求)
rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。
新增 outputpump.go(输出通道的异步壳):
· Push 立即返回,交付由独立消费 goroutine 完成
· 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
· 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
· Close 排空后退出,不吞最后半句
· 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)
## 3. 工具钩子统一(本轮最大发现)
实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。
⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。
新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。
## 判据(均经变异验证)
TestRTChild_OutputIsAsync 改回同步 ⇒ 红(实测确认)
TestOutputPump_DropsOldestOnFull 改阻塞/无界 ⇒ 红
TestOutputPump_CloseDrains 丢弃队列 ⇒ 红
TestOutputPump_ResolvesDeviceEachTime 缓存指针 ⇒ 红
TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
TestRTChild_StageCanDenyTool 同上
TestWorkerToolsGoThroughPluginStages 同上(变异实测确认)
TestWorkerToolsStaySerial 改成并发 ⇒ 红
TestNoChildTypeTablesInCore 源码扫描,禁类型专属表回潮
TestRTChild_HasGraphMemory 等 5 条 rt 接入图记忆
## 顺带修复
· rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
· rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。
## 保留的语义(逐项确认仍在)
驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
三种通道处置:ChannelRelease / Suspend / Takeover
残余 keep/drop + harvestResidentMemory
child.Start() 的 7 个 goroutine
验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
296 lines
11 KiB
Go
296 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)
|
||
rc := parent.residentOf(id)
|
||
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")
|
||
rc := parent.residentOf("own-provider")
|
||
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))
|
||
}
|
||
}
|