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

85 lines
2.7 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 的类型分叉不能再长回来**(源码扫描,与
// TestAgentCoreHoldsNoPolicySemantics 同型)。
//
// ## 为什么要这条
//
// 本仓头号缺陷形态是「机制写对了、判据全绿、生产里长歪」。子 agent 的
// 分叉尤其容易回潮:每加一种类型,往 inputroute 加一段、
// 往 agent 加一张表、往 toolcall 加几个 case —— 每次都很"顺手"。
//
// 实测代价(2026-11-04):新增一种子 agent 要改 18 个生产文件,
// 并已因复制而漂移过一次(StopRTChildren 漏注销入站登记,
// engineering-lessons.md §2.3)。
//
// 这条判据扫**源码文本**,禁止这些字段/调用在 core 里重新出现。
// 它不是风格洁癖:`a.rtChildren` 一旦回来,说明有人绕过统一登记表
// 又建了一张表 —— 那正是要消灭的形态。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// forbiddenChildSplits 是**已并入统一登记表**、不得再出现的类型专属入口。
//
// 每一条都对应一次真实的迁移:
//
// rtMu / rtChildren → children child.Registry(R2)
// residentMu / residents → 同上(R3)
//
// 注意:`rtClosedTranscripts` **不在此列** —— 它是"已回收实例的留档",
// 不属于登记表(实例已经不在了),有独立的 rtArchiveMu 保护。
var forbiddenChildSplits = []string{
".rtChildren[",
".rtMu.Lock()",
".residentMu.Lock()",
".residents[",
}
// TestNoChildTypeTablesInCore 钉住:core 里不得再有按类型的专用登记表。
//
// 变红条件:把任一类型改回自己的 map + 锁 ⇒ 红。
func TestNoChildTypeTablesInCore(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatalf("glob: %v", err)
}
if len(files) == 0 {
t.Fatal("没有扫到任何 .go 文件 —— 判据跑错了目录?")
}
var hits []string
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := string(data)
for _, bad := range forbiddenChildSplits {
if strings.Contains(src, bad) {
// 允许出现在注释里(迁移说明需要提到旧字段名),
// 所以逐行判:只有"非注释行含它"才算违规。
for i, line := range strings.Split(src, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue
}
if strings.Contains(line, bad) {
hits = append(hits, f+":"+itoa(i+1)+" "+trimmed)
}
}
}
}
}
if len(hits) > 0 {
t.Fatalf("子 agent 的类型专属登记表又出现了(应走 children 统一登记表):\n %s",
strings.Join(hits, "\n "))
}
}