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 通过。
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
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
|
||
}
|