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

277 lines
9.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
// 判据:rt 的输出调用是**异步**的(用户裁定 2026-11-04)。
//
// 「就是在正常输出的情况下套一个异步的壳,rtchild 的所有调用都应该是异步的」
//
// ## 这条缺陷的原貌
//
// rtEmit 曾直接 `dev.Execute(...)`,于是插件侧延迟(TTS / 网络编码 / 字幕渲染)
// **直接节流模型的 token 消费**:rtStream 每读一个 chunk 就等插件一次。
// 「不攒整段」(契约 2)的收益全被这里吃掉。
//
// ## 判据为什么必须测"时序关系"而不是"有没有 pump"
//
// 断言 `rc.pump != nil` 是结构判据,改回同步但保留字段时仍然绿
// (engineering-lessons.md §1.1)。所以这里测**可观测的时间差**:
// 模型吐完的时刻 必须早于 插件收完的时刻。
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// pumpSlowDevice 是一个**慢**的输出设备:每片 sleep,模拟 TTS / 网络编码。
type pumpSlowDevice struct {
name string
delay time.Duration
mu sync.Mutex
texts []string
// lastAt 是最后一次 Execute 完成的时刻(用于和"模型吐完"比较)。
lastAt atomic.Int64
calls atomic.Int64
}
func (d *pumpSlowDevice) Name() string { return d.name }
func (d *pumpSlowDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *pumpSlowDevice) Description() string { return "slow output" }
func (d *pumpSlowDevice) Tools() []agentIO.ToolDef { return nil }
func (d *pumpSlowDevice) Start() error { return nil }
func (d *pumpSlowDevice) Stop() error { return nil }
func (d *pumpSlowDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *pumpSlowDevice) OutputCapabilities() agentIO.OutputCapability {
return agentIO.CapText | agentIO.CapRT
}
func (d *pumpSlowDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
d.calls.Add(1)
time.Sleep(d.delay)
d.mu.Lock()
if s, ok := args["text"].(string); ok {
d.texts = append(d.texts, s)
}
d.mu.Unlock()
d.lastAt.Store(time.Now().UnixNano())
return "ok", nil
}
func (d *pumpSlowDevice) snapshot() []string {
d.mu.Lock()
defer d.mu.Unlock()
return append([]string(nil), d.texts...)
}
// burstProvider 一次性吐 n 片然后结束,并记录"吐完的时刻"。
type burstProvider struct {
n int
doneAt atomic.Int64
mu sync.Mutex
calls int
}
func (p *burstProvider) Name() string { return "burst" }
func (p *burstProvider) MaxContextTokens() int { return 8192 }
func (p *burstProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "x"}, nil
}
// ★ doneAt 必须记在**最后一片被消费之后**,而不是 ChatStream 返回时。
//
// 第一版把 channel 预填满再记时间戳 ⇒ doneAt 是"函数返回时刻",
// 与真正的"模型吐完"无关。于是变异验证(把 rtEmit 改回同步)**不会变红** ——
// 判据测的是一个恒真的量。这是 engineering-lessons.md §1.1
// 「结构判据不能替代质量判据」的又一例。
//
// 现在用无缓冲 channel + goroutine 逐片发送,最后一片**送出后**才记时间戳,
// 这样 doneAt 反映的是"消费侧真正读走最后一片"的时刻。
func (p *burstProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk)
go func() {
defer close(ch)
for i := 0; i < p.n; i++ {
select {
case <-ctx.Done():
return
case ch <- agentAPI.StreamChunk{Content: "片"}:
}
}
select {
case <-ctx.Done():
return
case ch <- agentAPI.StreamChunk{Done: true}:
}
// 最后一片(Done)已被消费方读走 ⇒ 这才是"模型吐完"的真实时刻。
p.doneAt.Store(time.Now().UnixNano())
}()
return ch, nil
}
// TestRTChild_OutputIsAsync 是本阶段的核心判据。
//
// 慢设备(每片 40ms)× 8 片 = 串行需 ≥320ms。
// 断言:模型"吐完"的时刻 早于 设备"收完"的时刻 —— 即生成没有被插件节流。
//
// 变红条件:rtEmit 改回同步 dev.Execute ⇒ 两个时刻几乎相等 ⇒ 红。
func TestRTChild_OutputIsAsync(t *testing.T) {
const chunks = 8
const delay = 40 * time.Millisecond
dev := &pumpSlowDevice{name: "slowout", delay: delay}
p := &burstProvider{n: chunks}
pio := agentIO.NewIOManager()
host := New(AgentConfig{ID: "async-host", IO: pio, Provider: p})
if err := pio.RegisterDevice(dev); err != nil {
t.Fatalf("注册慢设备: %v", err)
}
for _, n := range []string{"slowout", "slowin"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "asynctest",
Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册通道 %s: %v", n, err)
}
}
id, err := host.SpawnRTChild(RTChildOptions{ID: "a-rt", Input: "slowin", Output: "slowout"})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
defer host.StopRTChildren()
if _, err := host.PushRTInput(id, "说话"); err != nil {
t.Fatalf("PushRTInput: %v", err)
}
// 等设备收完最后一片(或用 pump 排空作为更快的前置)。
waitForWithin(t, "设备收完所有片", 10*time.Second, func() bool {
return len(dev.snapshot()) >= chunks
})
gen := p.doneAt.Load()
consumed := dev.lastAt.Load()
if gen == 0 || consumed == 0 {
t.Fatalf("时序采样缺失:gen=%d consumed=%d", gen, consumed)
}
if gen >= consumed {
t.Fatalf("模型吐完(%v) 不早于 插件收完(%v) —— 生成被插件节流了,"+
"说明输出调用仍是同步的(异步壳没生效)",
time.Unix(0, gen).Format("15:04:05.000"),
time.Unix(0, consumed).Format("15:04:05.000"))
}
// 顺带确认内容没丢、顺序没乱。
got := dev.snapshot()
if len(got) < chunks {
t.Fatalf("只收到 %d 片,期望 %d(异步不该丢片)", len(got), chunks)
}
}
// TestOutputPump_DropsOldestOnFull 钉住背压语义:满则丢**最旧**。
//
// 变红条件:改成阻塞 Push(退化为同步)或无限队列(吃内存)⇒ 红。
func TestOutputPump_DropsOldestOnFull(t *testing.T) {
// 设备永远阻塞,逼队列堆满。
block := make(chan struct{})
dev := &blockingDevice{name: "blk", gate: block}
pump := newOutputPump("blk", func() agentIO.Device { return dev })
defer func() { close(block); pump.Close() }()
// 推入远超容量的片数。
total := outputPumpQueueSize * 3
for i := 0; i < total; i++ {
pump.Push("x")
}
// 给消费 goroutine 一点时间卡在第一片上。
time.Sleep(20 * time.Millisecond)
depth, dropped := pump.Stats()
if dropped == 0 {
t.Fatalf("推入 %d 片(容量 %d)却没有丢弃 —— 队列是无界的?"+
"(depth=%d)", total, outputPumpQueueSize, depth)
}
if depth > outputPumpQueueSize {
t.Fatalf("队列深度 %d 超过上限 %d —— 背压没生效", depth, outputPumpQueueSize)
}
}
// blockingDevice 的 Execute 阻塞到 gate 关闭。
type blockingDevice struct {
name string
gate chan struct{}
}
func (d *blockingDevice) Name() string { return d.name }
func (d *blockingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *blockingDevice) Description() string { return "blocking" }
func (d *blockingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *blockingDevice) Start() error { return nil }
func (d *blockingDevice) Stop() error { return nil }
func (d *blockingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *blockingDevice) OutputCapabilities() agentIO.OutputCapability {
return agentIO.CapText | agentIO.CapRT
}
func (d *blockingDevice) Execute(string, map[string]interface{}) (interface{}, error) {
<-d.gate
return "ok", nil
}
// TestOutputPump_CloseDrains 钉住"最后半句不被吞"。
//
// 变红条件:Close 直接丢弃队列 ⇒ 红。
func TestOutputPump_CloseDrains(t *testing.T) {
dev := &pumpSlowDevice{name: "drain", delay: 5 * time.Millisecond}
pump := newOutputPump("drain", func() agentIO.Device { return dev })
const n = 5
for i := 0; i < n; i++ {
pump.Push("片")
}
pump.Close() // 必须等队列排空
if got := len(dev.snapshot()); got != n {
t.Fatalf("Close 后设备收到 %d 片,期望 %d —— 队列没排空,最后几句被吞了", got, n)
}
}
// TestOutputPump_ResolvesDeviceEachTime 钉住设备每片重新解析。
//
// 插件崩溃时设备会 Unregister,恢复后重新注册。缓存指针会一直指向死设备。
// 变红条件:把 resolve 的结果缓存下来 ⇒ 恢复后仍然丢 ⇒ 红。
func TestOutputPump_ResolvesDeviceEachTime(t *testing.T) {
var mu sync.Mutex
var cur agentIO.Device // nil 表示"当前没有设备"
pump := newOutputPump("flap", func() agentIO.Device {
mu.Lock()
defer mu.Unlock()
return cur
})
// ① 设备不存在时推入:不 panic、不阻塞。
pump.Push("早")
time.Sleep(10 * time.Millisecond)
// ② 设备出现后推入:必须能送达。
dev := &pumpSlowDevice{name: "flap", delay: 0}
mu.Lock()
cur = dev
mu.Unlock()
pump.Push("晚")
waitForWithin(t, "设备出现后输出送达", 3*time.Second, func() bool {
return len(dev.snapshot()) >= 1
})
pump.Close()
if got := dev.snapshot(); len(got) == 0 {
t.Fatal("设备恢复后仍收不到输出 —— resolve 被缓存成了死指针")
}
}