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 通过。
277 lines
9.5 KiB
Go
277 lines
9.5 KiB
Go
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 被缓存成了死指针")
|
||
}
|
||
}
|