Files
HomeAgent/internal/agent/core/child/kinds.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

88 lines
4.5 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 child
// kinds.go 记录**当前全部子 agent 类型**及其迁移归属。
//
// # 为什么要把这份清单写进代码而不是只写在 plan.md
//
// 用户 2026-11-04 指出:"其他的哪几个子 agent 类型也要迁移"。
// 这个问题之所以要问,正是因为没有一份**唯一清单** ——
// 只能靠 grep `Spawn*` 反推,而反推会漏(工作式子叫 `runChildTask`,
// 前面没有 Spawn 前缀;转投驻留子是 `ensureOffloadResident`,
// 前面也没有 Spawn 前缀)。
//
// 所以清单必须在这里,且与 Kind 常量一一对应。新增类型时:
// 1. 在这里加一个 Kind + 一行说明
// 2. 实现 ChildKind
// 3. 装配期 Register
// 漏了第 2/3 步时 Register 不会报错但路由会走兜底 —— 由
// TestAllDeclaredKindsAreRegistered 钉住。
//
// # 四种类型的实际形态(2026-11-04 实测)
//
// 类型 入口 载体 独立IO 通道 子策略 上下文
// 主 agent New() *Agent(完整) 有 有 - 相关性
// 驻留子 SpawnResident 完整 *Agent + Start 有 有 有 RelevanceContext
// rtchild SpawnRTChild 完整 *Agent 不 Start 有 有 有 跨轮 history
// 工作式子 runChildTask goroutine + 局部msgs 无 无 无 局部 msgs
// 转投驻留子 ensureOffloadResident = 驻留子(offloadOwned=true 标记)
//
// ★ 迁移范围(用户裁定"注意其他的哪几个子 agent 类型也要迁移"):
//
// 必须迁(有独立生命周期与登记状态):驻留子、rtchild、工作式子
// 不需要独立 Kind(是驻留子的一个变体):转投驻留子
//
// 工作式子**不走骨架的 spawn**(它没有 io/通道/策略,也不需要登记进
// 通道归属表),但它的**执行循环**必须走共享的 runToolBatch ——
// 这是 ChildKind.RunTools 的职责,与"是否登记进 Registry"无关。
// 以下常量在 child.go 的 Kind 定义里已有 KindResident / KindRT,
// 这里只补尚未迁移的那几种的**规划标签**(迁移前不注册实现)。
const (
// KindWorker 是一次性工作式子 agent(spawn_child / runChildTask)。
//
// 与其余三种的结构差异(实测):
// · 无独立 IOManager(工具直接走父的 executeToolCall)
// · 无通道(不注册 inputch,父被显式剥掉 output_send__*)
// · 无子策略声明(不存在 declareChildPolicy 调用)
// · 上下文是**局部 msgs**,跑完即弃(父可按 taskID 取回结果,有界淘汰 20 条)
//
// ⇒ 它**不需要**进 Registry(没有通道归属要路由),但需要 ChildKind 的
// RunTools / BuildContext 两处语义。
KindWorker Kind = "worker"
// KindOffload 是 Kernel 为承接积压自动拉起的驻留子(offload.go)。
//
// ★ **它不是一种类型,是对驻留子的一种特殊调用。**
//
// 依据(offload.go:293 ensureOffloadResident):
//
// info, err := a.SpawnResident(ResidentOptions{
// ID: "offload-<unix>",
// InputChs: nil, // 不接插件输入,只由父转投
// AllowedOutputs: nil, // 完整输出授权(要能回发到原通道)
// TempPath: a.residentTempPath(id),
// OffloadOwned: true, // ← 唯一的“特殊”标在这里
// TaskPrompt: offloadTaskPrompt(),
// })
//
// 走的完全是驻留子那一条路(同样的 spawn / 同样的登记表 / 同样的 memory 装配),
// 差异只有两处,且都已在 ResidentOptions 上表达:
//
// ① OffloadOwned 布尔标记 —— 只影响“能否被自动复用 / 是否计入上限”,
// 不影响任何生命周期语义(不能拿去复用人工建的子,也不占用它们的名额)
// ② TaskPrompt 是分诊职责说明 —— 纯粹是提示词内容,不是机制
//
// ⇒ 因此**不应**为它新增 Kind。若新增,就得为它写一份 ChildKind,
// 那份实现会与驻留子的实现逐字重复(除了一个 bool),
// 而我们正要消灭“同一份逻辑写两遍、然后漂移”这个形态。
//
// 判据 TestOffloadIsNotItsOwnKind 钉住这条:DeclaredKinds() 里不得出现它。
)
// DeclaredKinds 返回**应当被注册**的全部类型标签(测试与装配自检用)。
//
// 不含 KindOffload(它是驻留子变体),不含主 agent(它不是子)。
func DeclaredKinds() []Kind {
return []Kind{KindResident, KindRT, KindWorker}
}