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 通过。
This commit is contained in:
JianFeeeee
2026-11-04 08:21:15 +08:00
parent 24b03e56e9
commit a02feaaff7
31 changed files with 3101 additions and 349 deletions

View File

@ -0,0 +1,300 @@
package core
// 阶段 1 判据:rtchild 的模型源必须走**候选链**(优先源 + 回退),不是裸 a.provider。
//
// ## 为什么这组判据必须走 SpawnRTChild
//
// 现有 TestPreferredProvider_* 有 6 条,全部用 `New(AgentConfig{...})` 直接构造
// agent,断言的是 `resolveProviders` 的排序结果。它们证明「配置被记住了」,
// 但证明不了「执行时用它了」。
//
// 而缺陷正在 SpawnRTChild 铺出来的那条路径上:carrier 的 PreferredProvider
// 是对的(候选链首位就是它),可 rtStream 当时调的是 `a.provider.ChatStream`,
// 绕过候选链 ⇒ 优先源一次都不被调用,且没有任何回退。
//
// ⇒ 本文件的每条判据都必须经 SpawnRTChild + 真实 PushRTInput 触发一轮。
// 这是 engineering-lessons.md §1.2「判据测接线之后 ≠ 测有没有接线」的直接应用。
import (
"context"
"errors"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// rtCountProvider 记录调用次数,并可选地返回错误。
type rtCountProvider struct {
name string
// failWith 非 nil 时 ChatStream 返回它(模拟源故障)。
failWith error
// content 是成功时吐的正文。
content string
mu sync.Mutex
streams int
muCalled sync.Mutex
}
func (p *rtCountProvider) Name() string { return p.name }
func (p *rtCountProvider) MaxContextTokens() int { return 8192 }
func (p *rtCountProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: p.content}, nil
}
func (p *rtCountProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.streams++
p.mu.Unlock()
if p.failWith != nil {
return nil, p.failWith
}
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: p.content}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtCountProvider) calls() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.streams
}
// rtProviderHost 造一个带 providerManager + 一对 rt 通道 + 输出设备的父 agent。
func rtProviderHost(t *testing.T, pm *agentAPI.ProviderManager, def string) *Agent {
t.Helper()
pio := agentIO.NewIOManager()
host := New(AgentConfig{
ID: "prov-host", IO: pio,
Provider: pm.Get(def), ProviderManager: pm,
})
if err := pio.RegisterDevice(&rtRecordingDevice{name: "pvout"}); err != nil {
t.Fatalf("注册输出设备: %v", err)
}
for _, n := range []string{"pvout", "pvin"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "provtest",
Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册通道 %s: %v", n, err)
}
}
return host
}
// rtWaitRound 等到 rtchild 至少跑完 rounds 轮(等状态,不等时间)。
func rtWaitRound(t *testing.T, host *Agent, id string, rounds uint64) {
t.Helper()
waitFor(t, "rtchild 跑够轮次", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.Rounds >= rounds
})
}
// TestRTChild_PreferredProviderIsUsed 钉住 D3 的正面:优先源必须真的被调用。
//
// 变红条件(已实测可红):rtStream 改回 `a.provider.ChatStream(...)`
// ⇒ fast 源 calls()==0、slow 源被调 ⇒ 本判据红。
func TestRTChild_PreferredProviderIsUsed(t *testing.T) {
pm := agentAPI.NewProviderManager()
slow := &rtCountProvider{name: "slow-default", content: "slow"}
fast := &rtCountProvider{name: "fast-rt", content: "fast"}
pm.Register("slow-default", slow)
pm.Register("fast-rt", fast)
if err := pm.SetDefault("slow-default"); err != nil {
t.Fatalf("SetDefault: %v", err)
}
host := rtProviderHost(t, pm, "slow-default")
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{
ID: "prov-rt", Input: "pvin", Output: "pvout",
PreferredProvider: "fast-rt",
})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
// 先证明「配置被记住了」(候选链首位是 fast-rt)——
// 这样如果后面失败,失败点唯一地指向"执行时没用它"。
carrier := host.rtChildOf(id).agent
if got := chainNames(carrier); len(got) == 0 || got[0] != "fast-rt" {
t.Fatalf("前置条件失败:carrier 候选链首位 = %v,期望 fast-rt", got)
}
if _, err := host.PushRTInput(id, "喂"); err != nil {
t.Fatalf("PushRTInput: %v", err)
}
rtWaitRound(t, host, id, 1)
if fast.calls() == 0 {
t.Fatalf("rtchild 声明 PreferredProvider=fast-rt 且候选链首位就是它,"+
"但 fast-rt 一次都没被调用(slow 被调 %d 次)—— "+
"rtStream 绕过了 resolveProviders", slow.calls())
}
if slow.calls() != 0 {
t.Fatalf("首选源健康时不该调用默认源:slow=%d fast=%d", slow.calls(), fast.calls())
}
}
// TestRTChild_ProviderFallsBackOnFailure 钉住「优先,不是独占」。
//
// 首选源故障时必须切到下一个源,而不是整个通话失败。
// 变红条件:streamWithFallback 只试第一个源就返回 ⇒ 红。
func TestRTChild_ProviderFallsBackOnFailure(t *testing.T) {
pm := agentAPI.NewProviderManager()
backup := &rtCountProvider{name: "backup-src", content: "backup"}
fast := &rtCountProvider{
name: "broken-fast",
failWith: errors.New("upstream 503"),
}
pm.Register("backup-src", backup)
pm.Register("broken-fast", fast)
if err := pm.SetDefault("backup-src"); err != nil {
t.Fatalf("SetDefault: %v", err)
}
host := rtProviderHost(t, pm, "backup-src")
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{
ID: "fb-rt", Input: "pvin", Output: "pvout",
PreferredProvider: "broken-fast", // 首选源是坏的
})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
if _, err := host.PushRTInput(id, "喂"); err != nil {
t.Fatalf("PushRTInput: %v", err)
}
rtWaitRound(t, host, id, 1)
if fast.calls() == 0 {
t.Fatalf("首选源 broken-fast 没被尝试过(回退逻辑没走候选链)")
}
if backup.calls() == 0 {
t.Fatalf("首选源故障后没有回退到 backup-src —— "+
"rt 变成零回退的单源,抖一次就断一通(broken=%d backup=%d)",
fast.calls(), backup.calls())
}
}
// TestRTChild_InterruptDoesNotFallback 钉住「取消不是可回退错误」。
//
// 契约 1:context.Canceled 的语义是「用户又说话了,上一轮作废」,
// 不是「这个源坏了」。若把它当可回退错误,用户一插话就会把新请求
// 打到第二个源上,而第一个源其实完全健康。
//
// 变红条件:streamWithFallback 去掉 context.Canceled 的特判 ⇒ 红。
func TestRTChild_InterruptDoesNotFallback(t *testing.T) {
// 直接单测 streamWithFallback:造一个「永远返回 Canceled」的源。
cancelSrc := &rtCountProvider{name: "cancel-src", failWith: context.Canceled}
backup := &rtCountProvider{name: "backup", content: "should-not-be-used"}
pm := agentAPI.NewProviderManager()
pm.Register("cancel-src", cancelSrc)
pm.Register("backup", backup)
if err := pm.SetDefault("cancel-src"); err != nil {
t.Fatalf("SetDefault: %v", err)
}
a := New(AgentConfig{
ID: "cancel-agent", Provider: pm.Get("cancel-src"), ProviderManager: pm,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := &agentAPI.CompletionRequest{Messages: nil}
providers := []agentAPI.Provider{cancelSrc, backup}
_, err := a.streamWithFallback(ctx, req, providers)
if !errors.Is(err, context.Canceled) {
t.Fatalf("取消应原样返回 context.Canceled,实际 = %v", err)
}
if backup.calls() != 0 {
t.Fatalf("取消被当成了可回退错误:backup 被调用 %d 次 —— "+
"用户插话会白打一个请求到备用源", backup.calls())
}
}
// TestRTChild_StreamFallbackDoesNotLeakModel 钉住「model 是 provider 的属性」。
//
// 见 callLLMWithFallback 的 2026-10-27 真机记录:回退时复用同一个 req,
// 会让备用源收到上一个源的 model,被网关 403 model_not_allowed 拒掉。
// 变红条件:streamWithFallback 直接改调用方的 req.Model ⇒ 红。
func TestRTChild_StreamFallbackDoesNotLeakModel(t *testing.T) {
seen := make(chan string, 4)
first := &modelProbeProvider{name: "first", failWith: errors.New("boom"), seen: seen}
second := &modelProbeProvider{name: "second", model: "second-model", seen: seen}
pm := agentAPI.NewProviderManager()
pm.Register("first", first)
pm.Register("second", second)
if err := pm.SetDefault("first"); err != nil {
t.Fatalf("SetDefault: %v", err)
}
a := New(AgentConfig{ID: "m", Provider: pm.Get("first"), ProviderManager: pm})
req := &agentAPI.CompletionRequest{Model: "original-model"}
if _, err := a.streamWithFallback(context.Background(), req,
[]agentAPI.Provider{first, second}); err != nil {
t.Fatalf("streamWithFallback: %v", err)
}
if req.Model != "original-model" {
t.Fatalf("调用方的 req.Model 被改写成 %q —— 回退不该污染请求", req.Model)
}
// 第二个源拿到的必须是**它自己的** model。
var gotSecond string
timeout := time.After(2 * time.Second)
for i := 0; i < 2; i++ {
select {
case m := <-seen:
gotSecond = m
case <-timeout:
t.Fatalf("只观测到 %d 次 ChatStream 调用", i)
}
}
if gotSecond != "second-model" {
t.Fatalf("回退源收到的 model = %q,期望 second-model(模型名串台)", gotSecond)
}
}
// modelProbeProvider 记录每次被调用时 req.Model 的值。
type modelProbeProvider struct {
name string
model string
failWith error
seen chan string
}
func (p *modelProbeProvider) Name() string { return p.name }
func (p *modelProbeProvider) Model() string { return p.model }
func (p *modelProbeProvider) Priority() int { return 0 }
func (p *modelProbeProvider) MaxContextTokens() int { return 8192 }
func (p *modelProbeProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "x"}, nil
}
func (p *modelProbeProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
select {
case p.seen <- req.Model:
default:
}
if p.failWith != nil {
return nil, p.failWith
}
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "ok"}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}