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

206 lines
5.9 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
// outputpump.go 是输出通道的**异步壳**。
//
// # 它是什么(用户裁定 2026-11-04)
//
// 「就是在正常输出的情况下套一个异步的壳,rtchild 的所有调用都应该是异步的」
//
// 所以它**不改输出的语义**(还是把 text 交给同一个 outputch 设备),
// 只是把"调用"这一步从调用方的 goroutine 里摘出去:
//
// rtStream 的流式循环: pump.Push(chunk) ← 立即返回
// ↓ 有界队列(满则丢最旧)
// pump 的消费 goroutine → dev.Execute(...)
//
// # 为什么必须异步(实测缺陷)
//
// 原 rtEmit 直接 `dev.Execute(...)`,于是**插件侧的延迟直接节流模型的生成**:
//
// 插件做 TTS / 网络编码 / 字幕渲染,慢 50ms 一片
// → rtStream 每读一个 chunk 就卡 50ms
// → 模型的下一个 token 迟 50ms 才开始消费
// → 用户听到的首字延迟被下游放大
//
// 「不攒整段」(契约 2)的收益全被这里吃掉。
//
// # 为什么用"丢最旧的"而不是"阻塞"或"无界"
//
// 阻塞 → 退化成现在的同步(等于没做)
// 无界 → 插件卡死会吃光内存
// 丢最旧 → 符合 rt 的"最新者胜"语义:过期的音频/字幕本来就不该播
//
// 丢弃必须**可观测**(dropped 计数 + 首次日志),否则"用户听不到最后几句"
// 会变成一个没有任何痕迹的现象。
//
// # 为什么不复用 IOManager.outputCh
//
// 那条通道是给 `output_send__*` 工具用的(`EmitOutputTo` → OutputEvent →
// 由 SDK 的 OutputChan 消费者取)。它的语义是"一条完整的输出事件",
// 而 rt 需要的是"逐 chunk 的增量流",且需要 per-child 的队列与丢弃统计。
// 混用会让 rt 的背压污染主通道。所以单独一个壳,但复用同一套纪律。
import (
"log"
"sync"
"time"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// outputPumpQueueSize 是每个 rtchild 输出队列的上限。
//
// 取值依据:rt 的 chunk 通常是几个到几十字节,64 片 ≈ 数百字节到数 KB,
// 足以吸收一次网络抖动;再大就只是在缓存"用户已经听不到"的过期内容。
const outputPumpQueueSize = 64
// outputPump 是一个输出通道的异步壳。
//
// ★ 生命周期:SpawnRTChild 时创建,StopRTChild 时 Close(排空后退出)。
type outputPump struct {
// resolve 每次**重新**解析设备。
//
// ★ 必须是闭包而不是缓存的 dev 指针:插件崩溃时 releasePluginChannels
// 会 UnregisterDevice,恢复后重新注册。缓存指针会一直指向死设备 ——
// 现象是"插件重启后 rt 再也说不出话",且日志里只有一行 execute 失败。
resolve func() agentIO.Device
mu sync.Mutex
q []string
notify chan struct{}
closing bool
wg sync.WaitGroup
// dropped 是队列满时丢弃的片数(可观测)。
dropped uint64
firstDrop sync.Once
name string // 输出通道名(日志用)
}
func newOutputPump(name string, resolve func() agentIO.Device) *outputPump {
p := &outputPump{
name: name,
resolve: resolve,
notify: make(chan struct{}, 1),
}
p.wg.Add(1)
go p.loop()
return p
}
// Push 投递一段增量文本,**立即返回**,不等待插件。
func (p *outputPump) Push(text string) {
if p == nil || text == "" {
return
}
p.mu.Lock()
if p.closing {
p.mu.Unlock()
return
}
p.q = append(p.q, text)
// 满了:丢最旧(rt 的语义是"最新者胜",过期内容不该再播)。
if len(p.q) > outputPumpQueueSize {
p.q = p.q[1:]
p.dropped++
p.mu.Unlock()
p.firstDrop.Do(func() {
log.Printf("[rtchild] output pump %q queue full (%d), dropping oldest chunks — "+
"插件消费慢于模型生成,用户可能听不到最早的一段", p.name, outputPumpQueueSize)
})
return
}
p.mu.Unlock()
// 非阻塞唤醒(电平语义,不计数)。
select {
case p.notify <- struct{}{}:
default:
}
}
// Close 关闭壳:**排空队列后**退出,避免把最后半句吞掉。
func (p *outputPump) Close() {
if p == nil {
return
}
p.mu.Lock()
if p.closing {
p.mu.Unlock()
return
}
p.closing = true
p.mu.Unlock()
select {
case p.notify <- struct{}{}:
default:
}
p.wg.Wait()
}
// Stats 返回(剩余队列深度,丢弃片数)—— 诊断与判据用。
func (p *outputPump) Stats() (depth int, dropped uint64) {
if p == nil {
return 0, 0
}
p.mu.Lock()
defer p.mu.Unlock()
return len(p.q), p.dropped
}
// loop 是唯一的消费者。
func (p *outputPump) loop() {
defer p.wg.Done()
for {
<-p.notify
for {
p.mu.Lock()
if len(p.q) == 0 {
closing := p.closing
p.mu.Unlock()
if closing {
return
}
break // 回外层等下一次唤醒
}
text := p.q[0]
p.q = p.q[1:]
p.mu.Unlock()
p.emit(text)
}
}
}
// emit 真正把一片交给设备。★ 每次重新解析设备(见 resolve 的注释)。
func (p *outputPump) emit(text string) {
dev := p.resolve()
if dev == nil {
// ★ 设备缺失必须留痕(一次性):插件崩溃期间会走到这里,
// 静默丢弃会让"用户听不到回应"变成一个没有任何线索的现象。
p.firstDrop.Do(func() {
log.Printf("[rtchild] output pump %q: 输出设备不存在(插件可能已崩溃/未注册),"+
"后续输出将被丢弃直到设备重新出现", p.name)
})
return
}
if _, err := dev.Execute(p.name, map[string]interface{}{"text": text}); err != nil {
log.Printf("[rtchild] output pump %q emit failed: %v", p.name, err)
}
}
// pumpWaitDrain 等一个 pump 排空(判据用,避免用 sleep)。
func pumpWaitDrain(p *outputPump, within time.Duration) bool {
if p == nil {
return true
}
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if depth, _ := p.Stats(); depth == 0 {
return true
}
time.Sleep(2 * time.Millisecond)
}
return false
}