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

237 lines
7.1 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
// registry.go 是**单一子 agent 登记表**与生命周期骨架。
//
// # 它替换了什么
//
// 此前 core.Agent 上有**两张**互不知情的表:
//
// residentMu + residents map[string]*residentChild
// rtMu + rtChildren map[string]*rtChild
//
// 于是每加一种类型就多一张表、一把锁、一段路由分支。实测新增一种子 agent
// 需要改 18 个生产文件(见 plan.md §D4)。
//
// 现在只有一把锁、一张表,类型差异全部下沉到 Entry 实现。
//
// # 骨架的职责边界
//
// 骨架只做**所有类型都一样**的事,这些事恰恰是最容易"改一处漏一处"的:
//
// 销毁:标记停止 + cancel → 类型 Dispose → 撤销子策略 → 注销入站登记
//
// 差异(输入投递/上下文/输出/工具/残余/通道处置/父工具)一律不进骨架。
//
// ★ 历史教训:`StopRTChild`(单数)注销入站登记,`StopRTChildren`(复数)漏了,
// 残留随 create/destroy 单调累积且只能重启清(engineering-lessons.md §2.3)。
// 现在单数与复数是**同一个函数**,这个形态在结构上不可能复现。
import (
"log"
"sort"
"sync"
"time"
)
// Registry 是子 agent 登记表。
//
// 零值可用(lazy init),不需要构造函数。
type Registry struct {
mu sync.Mutex
byID map[string]Entry
order uint64
}
// Entry 是一个已建立的子 agent 实例。
//
// ★ 登记表存的是**实例**而不是"类型 → 实现"的映射:
// 每个实例自带它的类型行为(rt 实例就是 *rtChild,驻留子实例就是 *residentChild)。
// 这样骨架不需要一张全局 kind 表去反查,也不会出现"实例存在但 kind 没注册"的
// 半残状态。类型清单(kinds.go)只用于装配自检与文档。
type Entry interface {
// Base 返回公共部分(id / ctx / 状态)。不可为 nil。
Base() *Child
// Kind 返回类型标签。
Kind() Kind
// Dispose 按本类型语义收尾(残余处置、通道释放或悬空)。
// policy 只对支持残余处置的类型有意义,其余忽略。返回给父看的摘要。
Dispose(policy ResidualPolicy) string
}
// Add 登记一个已构造好的实例。
//
// ★ 返回 error 而不是静默覆盖:同名覆盖会让上一代的残留
// (通道 Owner、主体策略、留档)被当成这一代的。
func (r *Registry) Add(e Entry) error {
if e == nil || e.Base() == nil || e.Base().ID == "" {
return ErrChildNotFound
}
b := e.Base()
r.mu.Lock()
defer r.mu.Unlock()
if r.byID == nil {
r.byID = map[string]Entry{}
}
if _, dup := r.byID[b.ID]; dup {
return ErrChildExists
}
r.order++
if b.CreatedAt == 0 {
b.CreatedAt = time.Now().UnixNano()
}
b.Kind = e.Kind()
r.byID[b.ID] = e
return nil
}
// Get 按 id 查询。
func (r *Registry) Get(id string) (Entry, bool) {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.byID[id]
return e, ok
}
// Base 按 id 查公共部分(路由热路径用,少一层类型断言)。
func (r *Registry) Base(id string) (*Child, bool) {
e, ok := r.Get(id)
if !ok {
return nil, false
}
return e.Base(), true
}
// Remove 摘出登记表并返回被摘的实例(不存在返回 nil)。
//
// ★ 先把条目摘掉再返回:调用方拿到指针后做清理(撤销策略、注销通道),
// 期间新输入不会再路由到它。顺序不能反 —— 否则清理期间仍有输入进来。
func (r *Registry) Remove(id string) Entry {
r.mu.Lock()
defer r.mu.Unlock()
e := r.byID[id]
delete(r.byID, id)
return e
}
// All 返回全部实例快照(按 id 排序,便于断言与展示稳定)。
func (r *Registry) All() []Entry {
r.mu.Lock()
out := make([]Entry, 0, len(r.byID))
for _, e := range r.byID {
out = append(out, e)
}
r.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Base().ID < out[j].Base().ID })
return out
}
// IDs 返回全部 id(排序)。
func (r *Registry) IDs() []string {
all := r.All()
out := make([]string, len(all))
for i, e := range all {
out[i] = e.Base().ID
}
return out
}
// ByKind 返回某类型的全部实例。
func (r *Registry) ByKind(k Kind) []Entry {
all := r.All()
out := make([]Entry, 0, len(all))
for _, e := range all {
if e.Kind() == k {
out = append(out, e)
}
}
return out
}
// Len 返回登记数量。
func (r *Registry) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.byID)
}
// StopDeps 是清理时需要的外部动作(由 core 注入,避免本包依赖 core 的类型)。
type StopDeps struct {
// Residual 是残余处置策略(只对支持的类型有意义)。
Residual ResidualPolicy
// RevokePolicy 撤销父为该子声明的主体策略。
//
// ★ 必须注入:主体表以调用方给的名字为键,同名重建会继承旧策略,
// 且表会随 create/destroy 单调增长(engineering-lessons.md §3.2)。
RevokePolicy func(childID string)
// OnDispose 在类型 Dispose **之后**调用,供需要父资源(调度器/IO)的
// 类型收尾(如驻留子把接回的暂存任务转入父队列)。
OnDispose func(e Entry)
// UnregisterInbound 注销该子在父侧的入站 inputch。
//
// ★ 这一步曾经在"复数路径"上漏过 —— 就是本包要消灭的形态。
UnregisterInbound func(channelName string)
}
// Cleanup 对一个已摘出登记表的实例执行**骨架级**清理。
//
// 单数与复数路径**共用**它,这是"单数改了复数漏了"那个缺陷的结构性预防。
func (r *Registry) Cleanup(e Entry, deps StopDeps) {
if e == nil || e.Base() == nil {
return
}
b := e.Base()
// ① 先标记停止 + cancel:让正在跑的轮次在下一个安全点退出。
// 必须在 Dispose 之前 —— Dispose 要看当前状态决定残余处置。
b.Stop()
// ② 类型自己的收尾(残余/通道)。类型可能读 ctx,故排在 cancel 之后但
// 只读状态、不依赖 ctx 存活。
if msg := e.Dispose(deps.Residual); msg != "" {
log.Printf("[child] %s (%s) disposed: %s", b.ID, e.Kind(), msg)
}
if deps.OnDispose != nil {
deps.OnDispose(e)
}
// ③ 撤销主体策略。
if deps.RevokePolicy != nil {
deps.RevokePolicy(b.ID)
}
// ④ 注销入站登记("child/<id>")。
if deps.UnregisterInbound != nil && b.InboundCh != "" {
deps.UnregisterInbound(b.InboundCh)
}
log.Printf("[child] %s (%s) stopped", b.ID, e.Kind())
}
// StopOne 停止一个子(单数语义:不存在时返回错误)。
func (r *Registry) StopOne(id string, deps StopDeps) error {
e := r.Remove(id)
if e == nil {
return ErrChildNotFound
}
r.Cleanup(e, deps)
return nil
}
// StopAll 停止某类型(kind 为空表示全部)的子,返回停止数量。
//
// ★ 与 StopOne **共用 Cleanup**,不是复制一份循环体。
func (r *Registry) StopAll(kind Kind, deps StopDeps) int {
all := r.All()
var targets []Entry
for _, e := range all {
if kind == "" || e.Kind() == kind {
targets = append(targets, e)
}
}
for _, e := range targets {
r.Remove(e.Base().ID)
r.Cleanup(e, deps)
}
if len(targets) > 0 {
log.Printf("[child] stopped %d instance(s) (kind=%q)", len(targets), kind)
}
return len(targets)
}