Files
HomeAgent/internal/agent/core/rtchild_graph_test.go
JianFeeeee a02feaaff7 refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 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 通过。
2026-11-04 08:21:15 +08:00

202 lines
7.1 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
// 阶段 R3 判据:rtchild 的**图记忆接入**(参照驻留子的轻量内核形态)。
//
// ## 为什么这组判据必须存在
//
// 接入前 rt 的 carrier 既没有 Memory 也没有 LightMemory ⇒ `graphMem() == nil`
// ⇒ memory_recall / memory_commit 这类图记忆共同面工具在 rt 里**全部不可达**。
// 而 rt 恰恰需要它:通话里用户会提到人、地点、约定、偏好,那些正是图记忆
// 该记住的东西;rt 的 history 只在**这一通电话**内有效,通话结束就只剩留档。
//
// 不接图记忆 ⇒ 每通电话都从零开始。这不是"少一个优化",是丢失能力。
//
// ## 判据为什么要走 SpawnRTChild
//
// 与 provider 那组同理(engineering-lessons.md §1.2):直构 Agent 会让判据假绿。
// 装配(有哪些句柄、可写还是只读)是在 SpawnRTChild 里决定的。
import (
"path/filepath"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// rtGraphHost 造一个带真实图记忆库的父 agent。
func rtGraphHost(t *testing.T) (*Agent, string) {
t.Helper()
dir := t.TempDir()
db, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("打开父图库: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
pio := agentIO.NewIOManager()
host := New(AgentConfig{
ID: "g-host", IO: pio, Memory: db, DataDir: dir,
})
if err := pio.RegisterDevice(&rtRecordingDevice{name: "gout"}); err != nil {
t.Fatalf("注册输出设备: %v", err)
}
for _, n := range []string{"gout", "gin"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "gtest",
Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册通道 %s: %v", n, err)
}
}
return host, dir
}
// TestRTChild_HasGraphMemory 是核心判据:rt 必须拿到图记忆共同面。
//
// 变红条件:去掉 SpawnRTChild 里的 LightMemory 装配 ⇒ graphMem() == nil ⇒ 红。
func TestRTChild_HasGraphMemory(t *testing.T) {
host, _ := rtGraphHost(t)
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{
ID: "g-rt", Input: "gin", Output: "gout",
TempPath: filepath.Join(t.TempDir(), "rt.db"),
})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
carrier := host.rtChildOf(id).agent
if carrier.graphMem() == nil {
t.Fatal("rtchild 的 graphMem() == nil —— 图记忆没有接入," +
"memory_recall / memory_commit 在通话里全部不可达")
}
// 必须是轻量内核形态:整理面保持 nil(否则 rt 会把记忆整理流水线也拉起来)。
if carrier.memory != nil {
t.Fatalf("rtchild 拿到了完整图记忆库(memory != nil)—— " +
"那会让记忆整理面(块/媒体/蒸馏)对 rt 可达,与「轻量内核」形态不符")
}
}
// TestRTChild_GraphMemoryIsReadOnlyByDefault 钉住「默认只读」。
//
// 理由:一次通话几十轮高频写入会把图谱灌满噪声
// (与 rtvoice 插件给 inputch 标 NoMemory 同一条考虑)。
// 变红条件:把 AllowGraphWrite 默认成 true ⇒ 红。
func TestRTChild_GraphMemoryIsReadOnlyByDefault(t *testing.T) {
host, _ := rtGraphHost(t)
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{
ID: "ro-rt", Input: "gin", Output: "gout",
TempPath: filepath.Join(t.TempDir(), "rt.db"),
})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
lm, ok := host.rtChildOf(id).agent.graphMem().(*memory.LightMemory)
if !ok {
t.Fatalf("rtchild 的图记忆不是 *memory.LightMemory(轻量内核形态)")
}
if lm.AllowWrite() {
t.Fatal("rtchild 默认允许写图记忆 —— 通话高频写入会把图谱灌满噪声")
}
}
// TestRTChild_GraphWriteWhenEnabled 钉住显式开启后确实可写。
//
// 否则"能开"就只是个没被读取的字段(本仓已有过多例死字段)。
func TestRTChild_GraphWriteWhenEnabled(t *testing.T) {
host, _ := rtGraphHost(t)
defer host.StopRTChildren()
tempPath := filepath.Join(t.TempDir(), "rw.db")
id, err := host.SpawnRTChild(RTChildOptions{
ID: "rw-rt", Input: "gin", Output: "gout",
TempPath: tempPath,
AllowGraphWrite: true,
})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
lm, ok := host.rtChildOf(id).agent.graphMem().(*memory.LightMemory)
if !ok {
t.Fatal("rtchild 的图记忆不是 *memory.LightMemory")
}
if !lm.AllowWrite() {
t.Fatal("AllowGraphWrite=true 但 LightMemory 仍拒绝写 —— 开关没被读取")
}
if lm.Temp() == nil {
t.Fatal("允许写但没有 temp 实例 —— 写入会无处落地")
}
// 真写一条,证明链路通(不是只有 bool 对)。
if _, _, err := lm.Commit(nil, "s1", 1); err != nil {
t.Fatalf("允许写却 Commit 失败: %v", err)
}
}
// TestRTChild_GraphMemoryHandlesAreClosed 钉住「不泄漏 SQLite 句柄」。
//
// 变红条件:Dispose 里不关 light/mainRO ⇒ 句柄随 create/stop 累积。
// 做法:连续建停多轮,断言 temp 文件可被重新创建/删除(Windows 之外
// 更可靠的判据是 light/mainRO 字段被置 nil)。
func TestRTChild_GraphMemoryHandlesAreClosed(t *testing.T) {
host, _ := rtGraphHost(t)
defer host.StopRTChildren()
for i := 0; i < 3; i++ {
id := "cycle-rt"
rc := host.rtChildOf(id)
if rc != nil {
t.Fatalf("第 %d 轮:上一轮的 rtchild 仍在登记表里", i)
}
_, err := host.SpawnRTChild(RTChildOptions{
ID: id, Input: "gin", Output: "gout",
TempPath: filepath.Join(t.TempDir(), "cycle.db"),
AllowGraphWrite: true,
})
if err != nil {
t.Fatalf("第 %d 轮 SpawnRTChild: %v", i, err)
}
rc = host.rtChildOf(id)
if rc == nil {
t.Fatalf("第 %d 轮:创建后取不到实例", i)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("第 %d 轮 StopRTChild: %v", i, err)
}
if rc.light != nil || rc.mainRO != nil {
t.Fatalf("第 %d 轮停止后图记忆句柄未释放(light=%v mainRO=%v)—— "+
"会随 create/stop 累积泄漏", i, rc.light != nil, rc.mainRO != nil)
}
}
}
// TestRTChild_NoGraphMemoryWhenParentHasNone 钉住降级不 panic。
//
// 父没有图记忆(独立工具/单测常见)时,rt 应当照常创建,
// 只是 graphMem() == nil。变红条件:装配处对 nil 解引用 ⇒ 红。
func TestRTChild_NoGraphMemoryWhenParentHasNone(t *testing.T) {
pio := agentIO.NewIOManager()
host := New(AgentConfig{ID: "nog-host", IO: pio}) // 无 Memory
if err := pio.RegisterDevice(&rtRecordingDevice{name: "nout"}); err != nil {
t.Fatalf("注册输出设备: %v", err)
}
for _, n := range []string{"nout", "nin"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "ntest", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册通道 %s: %v", n, err)
}
}
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{ID: "n-rt", Input: "nin", Output: "nout"})
if err != nil {
t.Fatalf("父无图记忆时 SpawnRTChild 不该失败: %v", err)
}
if gm := host.rtChildOf(id).agent.graphMem(); gm != nil {
t.Fatalf("父无图记忆,子却拿到了 graphMem()=%v", gm)
}
}