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

175 lines
6.0 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
// 判据:四类 agent 可**各自**配置优先模型源,默认用总默认源。
//
// 用户 2026-10-30 要求:「支持为主 agent、子 agent、驻留式子 agent、rt agent
// 可各自配置模型源,默认使用总模型源(default)」。
//
// ## 语义是"优先"而不是"独占"
//
// 该源排在候选链**最前**,其余源仍作回退。硬锁定会让"首选源挂了"直接变成
// "这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
//
// ## 判据怎么才算有效
//
// 不能只断言"配置生效了"——那样"只留一个源"(破坏回退)也会绿。
// 所以成对断言:
// ① 优先源排在第一位(配置生效)
// ② 其余源仍在候选链里(回退未被破坏)
import (
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// newPMWith 造一个有若干源的 manager,默认源是 first。
func newPMWith(t *testing.T, names ...string) *agentAPI.ProviderManager {
t.Helper()
pm := agentAPI.NewProviderManager()
for _, n := range names {
pm.Register(n, &stubProvider{name: n})
}
if len(names) > 0 {
if err := pm.SetDefault(names[0]); err != nil {
t.Fatalf("SetDefault: %v", err)
}
}
return pm
}
// chainNames 取本 agent 当前候选链的名字序列。
func chainNames(a *Agent) []string {
req := &agentAPI.CompletionRequest{}
ps := a.resolveProviders(req)
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, p.Name())
}
return out
}
// TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 是核心判据。
func TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks(t *testing.T) {
pm := newPMWith(t, "default-src", "fast-src", "cheap-src")
a := New(AgentConfig{
ID: "rt", Provider: pm.Get("default-src"),
ProviderManager: pm, PreferredProvider: "fast-src",
})
got := chainNames(a)
if len(got) == 0 || got[0] != "fast-src" {
t.Fatalf("候选链首位 = %v,期望 fast-src 在最前(配置未生效)", got)
}
// ★ 关键:其余源必须仍在链上(否则回退被破坏)。
if len(got) != 3 {
t.Fatalf("候选链只剩 %v —— 优先源不该挤掉其它源(回退链被破坏)", got)
}
seen := map[string]bool{}
for _, n := range got {
seen[n] = true
}
for _, n := range []string{"default-src", "fast-src", "cheap-src"} {
if !seen[n] {
t.Errorf("候选链缺少 %q:%v", n, got)
}
}
}
// TestPreferredProvider_DefaultUsesGlobalDefault 钉住「默认用总模型源」。
func TestPreferredProvider_DefaultUsesGlobalDefault(t *testing.T) {
pm := newPMWith(t, "default-src", "other-src")
a := New(AgentConfig{
ID: "main", Provider: pm.Get("default-src"), ProviderManager: pm,
// PreferredProvider 留空 ⇒ 用总默认源
})
if got := chainNames(a); len(got) == 0 || got[0] != "default-src" {
t.Fatalf("未配置优先源时首位 = %v,期望总默认源 default-src", got)
}
}
// TestPreferredProvider_UnknownNameFallsBackLoudly 钉住配置写错的语义:
// 回退到默认源(不静默失效),且**不影响可用性**。
func TestPreferredProvider_UnknownNameFallsBackLoudly(t *testing.T) {
pm := newPMWith(t, "default-src", "other-src")
a := New(AgentConfig{
ID: "typo", Provider: pm.Get("default-src"), ProviderManager: pm,
PreferredProvider: "no-such-source", // 拼错
})
got := chainNames(a)
if len(got) == 0 || got[0] != "default-src" {
t.Fatalf("拼错的优先源应回退到默认源,实际首位 = %v", got)
}
if len(got) != 2 {
t.Fatalf("回退后候选链 = %v,期望两个源都在", got)
}
}
// TestPreferredProvider_EachAgentIndependent 钉住"各自可配":
// 同进程内三个 agent 配不同源,互不影响。
func TestPreferredProvider_EachAgentIndependent(t *testing.T) {
pm := newPMWith(t, "shared-default", "for-main", "for-rt", "for-resident")
mainA := New(AgentConfig{ID: "main", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-main"})
rtA := New(AgentConfig{ID: "rt", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-rt"})
resA := New(AgentConfig{ID: "res", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-resident"})
for _, tc := range []struct {
who string
a *Agent
want string
}{
{"主 agent", mainA, "for-main"},
{"rt agent", rtA, "for-rt"},
{"驻留子", resA, "for-resident"},
} {
if got := chainNames(tc.a); len(got) == 0 || got[0] != tc.want {
t.Errorf("%s 的首位 = %v,期望 %q(各自配置未隔离)", tc.who, got, tc.want)
}
}
}
// TestPreferredProvider_InheritedByChildAgents 钉住继承链:
// 子 agent 未显式配置时**继承父的**优先源(而不是掉回总默认)。
func TestPreferredProvider_InheritedByChildAgents(t *testing.T) {
pm := newPMWith(t, "shared-default", "parent-pref")
parent, _, dir := newRootWith(t, pm.Get("shared-default"))
parent.providerManager = pm
parent.preferredProvider = "parent-pref"
spawnTestResident(t, parent, dir, "inh")
rc := parent.residentOf("inh")
if rc == nil {
t.Fatal("驻留子未创建")
}
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "parent-pref" {
t.Fatalf("驻留子未继承父的优先源,首位 = %v", got)
}
}
// TestPreferredProvider_RTChildOwnSource 钉住 rtchild 可**独立**配源。
//
// 这条对通话场景最实际:rt 的首字预算是 500ms 级,而不同源往返差 2–5 倍。
func TestPreferredProvider_RTChildOwnSource(t *testing.T) {
pm := newPMWith(t, "shared-default", "parent-pref", "rt-fast")
parent, _ := newRTHost(t, pm.Get("shared-default"))
parent.providerManager = pm
parent.preferredProvider = "parent-pref"
id, err := parent.SpawnRTChild(RTChildOptions{
ID: "rt-own", Input: "voice-in", Output: "voice",
PreferredProvider: "rt-fast",
})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
rc := parent.rtChildOf(id)
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "rt-fast" {
t.Fatalf("rtchild 未使用自己配置的源,首位 = %v", got)
}
}