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 通过。
301 lines
10 KiB
Go
301 lines
10 KiB
Go
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
|
||
}
|