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

301 lines
10 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
// 阶段 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
}