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

258 lines
8.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 是**子 agent 的类型抽象层**。
//
// # 为什么要有这个包(用户裁定 2026-11-04)
//
// 此前每新增一种子 agent,都要往 `internal/agent/core` 的同一个文件里追加代码:
//
// agent.go 加一组类型专属字段(锁 + 表 + 专属状态),约 20 行/类型
// inputroute.go 加一段"查我这张表 → 分流"的分支
// toolcall.go 加若干 `case tc.Name == "<该类型的控制工具>":`
// 以及独立的 <type>.go 放整份生命周期实现
//
// 实测:新增一种子 agent 需要改 **18 个生产文件**。已经因此漂移过一次
// (`StopRTChildren` 漏注销入站登记,见 docs/zh/engineering-lessons.md §2.3)。
//
// # 抽象的形状
//
// 分成两半:
//
// **骨架(本包提供,只写一遍)**
// spawnChild 校验 → 去重 → 独立 ctx → 独立 IO(共享通道登记表 + 父视图回退)
// → 构造载体 → 绑定 → 声明子策略 → 登记 → 启动
// stopChild 撤销策略 → 注销入站登记 → 取消 ctx → kind.Dispose
// stopAll 对全部子执行 stopChild
//
// **差异(由 ChildKind 实现,每种类型一份)**
// Deliver 输入策略:队列(不丢活)还是单槽(最新者胜)
// BuildContext 上下文策略:相关性裁剪 还是 跨轮 history + 窗口
// Emit 输出策略(含异步批大小)
// RunTools 工具策略(并发批 还是 串行)
// Dispose 销毁语义(残余 keep/drop;通道释放还是悬空)
// ParentTools 暴露给父的控制工具
//
// # 已确认的边界(不要为了"更统一"而抹掉)
//
// - 驻留子的 `DestroyResident` 有三种 ChannelDisposition(释放/保留/归还)
// - rtchild 的 rt 通道**销毁后悬空**,不归还(用户裁定,见 rt 的 Dispose 实现)
// - 驻留子 `child.Start()` 起 7 个 goroutine;rtchild **只起 1 个** rtLoop
// (rt 明确不要 schedulerLoop / interceptLoop / 记忆整理循环)
//
// 这三条是**语义差异**,属于 ChildKind 的职责,不属于骨架。
package child
import (
"context"
"errors"
"sync"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// Kind 是子 agent 的类型标签。
//
// 为什么用字符串而不是 iota 枚举:它要出现在状态面、日志、以及
// ChannelRegistry 的归属判定里;字符串在日志里直接可读,
// 且新增类型不需要改一个中心 switch(这与包的目的正好一致)。
type Kind string
const (
// KindResident 是长期派驻的子 agent(有任务队列、有残余可回灌、不丢活)。
KindResident Kind = "resident"
// KindRT 是实时通话子 agent(单槽覆盖、最新者胜、可饿死、丢现场)。
KindRT Kind = "rt"
)
// Child 是一个**已建立**的子 agent 实例的**公共部分**。
//
// ★ 用法是**嵌入**:类型专属状态放在各实现自己的结构体里,
// 公共部分靠嵌入提升字段:
//
// type rtChild struct {
// *child.Child // ID / Kind / Ctx / IO / InboundCh / 状态 / 轮次
// agent *Agent // 载体(类型专属,不进公共结构)
// slot *rtSlot
// }
//
// 为什么不把 agent *Agent 放进本结构:本包不能 import core(否则成环),
// 而 core 可以 import 本包。嵌入让公共部分与载体指针各归各位,
// 同时避免为此造一个“只有 ID 的假 Agent” —— 那种结构既不能用又会误导后来者。
type Child struct {
// ID 是登记表的键,也是通道 Owner 记的值。
//
// ★ 必须与载体 Agent 的 ID **逐字相同**:AssignRealtime 会把通道 Owner
// 记成 carrier.id,而路由按 entry.Owner 在本表里查 —— 两处各拼一次名字
// 就会永远查不到(曾因写成 "rt/"+id 而让插件输入全打到父身上)。
ID string
// Kind 是本实例的类型标签。
Kind Kind
// IO 是本子自己的 IOManager(独立输入入口 + 共享通道登记表 + 父视图回退)。
IO *agentIO.IOManager
// Ctx / Cancel 是本子**自己的**取消域。
//
// 必须是 WithCancel(parentCtx) 派生:父停则子停(不留孤儿),
// 但单独 cancel 只影响本子,不波及父与兄弟。
Ctx context.Context
Cancel context.CancelFunc
// InboundCh 是本子在父侧注册的入站通道名("child/<id>"),
// stopChild 负责注销它。空 = 本类型不需要入站通道。
InboundCh string
// ParentID 是父的 agent id(策略撤销、日志用)。
ParentID string
// CreatedAt 是 Unix 纳秒,供状态面排序。
CreatedAt int64
mu sync.Mutex
state State
rounds uint64
// stopped 标记已 Stop。rtLoop 取到输入后先查它,防止
// “Wake 信号先于 cancel 到达 select”时白跑一轮(D8)。
stopped bool
}
// Stop 原子地标记停止并取消本子的 ctx。可重复调用(幂等)。
func (c *Child) Stop() {
if c == nil {
return
}
c.mu.Lock()
c.stopped = true
c.state = StateStopped
c.mu.Unlock()
if c.Cancel != nil {
c.Cancel()
}
}
// Stopped 报告是否已被 Stop。
func (c *Child) Stopped() bool {
if c == nil {
return true
}
c.mu.Lock()
defer c.mu.Unlock()
return c.stopped
}
// State 是子 agent 的运行态。
type State string
const (
StateRunning State = "running"
StateContextFull State = "contextfull"
StateStopped State = "stopped"
)
// String 让状态能直接进日志与状态面。
func (s State) String() string { return string(s) }
// SetState 设置运行态(并发安全)。
func (c *Child) SetState(s State) {
if c == nil {
return
}
c.mu.Lock()
c.state = s
c.mu.Unlock()
}
// State 返回当前运行态。
func (c *Child) State() State {
if c == nil {
return StateStopped
}
c.mu.Lock()
defer c.mu.Unlock()
if c.state == "" {
return StateRunning
}
return c.state
}
// BumpRound 累加轮次(可观测性用)。
func (c *Child) BumpRound() {
if c == nil {
return
}
c.mu.Lock()
c.rounds++
c.mu.Unlock()
}
// Rounds 返回已跑轮次。
func (c *Child) Rounds() uint64 {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
return c.rounds
}
// ── 错误 ──
var (
// ErrChildExists 表示同名子已存在。
//
// 为什么不允许"同名覆盖":登记表与通道 Owner 都以 id 为键,
// 覆盖会让上一代的残留(通道归属、策略、留档)被当成这一代的。
// 使用者必须先显式 stop。
ErrChildExists = errors.New("同名子 agent 已存在")
// ErrChildNotFound 表示 id 未登记。
ErrChildNotFound = errors.New("子 agent 不存在")
// ErrUnknownKind 表示 Kind 没有注册实现。
ErrUnknownKind = errors.New("子 agent 类型未注册")
)
// 差异点接口见 kind.go。
// ToolResult 是一条工具执行结果。
type ToolResult struct {
CallID string
Name string
Text string
Raw interface{}
Denied bool
}
// ToolSpec 是父控制工具的定义与派发。
type ToolSpec struct {
Name string
Description string
Parameters map[string]interface{}
Required []string
// Handler 接收父发起的调用。c 是**被操作的子**(可能为 nil,如 list 动作)。
Handler func(c *Child, args map[string]interface{}) string
// Batchable 声明该工具在父侧是否可并发执行。
Batchable bool
// NeedsChild 为 true 时骨架会先按 args["id"] 查出子再传 Handler。
NeedsChild bool
}
// ResidualPolicy 是销毁时对未处理输入的处置(驻留子用)。
type ResidualPolicy string
const (
// ResidualKeep 把残余回灌给父(不丢活)。
ResidualKeep ResidualPolicy = "keep"
// ResidualDrop 丢弃残余。
ResidualDrop ResidualPolicy = "drop"
)
// ChannelDisposition 是销毁时对通道归属的处置(驻留子用)。
type ChannelDisposition string
const (
// ChannelRelease 注销通道登记。
ChannelRelease ChannelDisposition = "release"
// ChannelKeep 保留登记,Owner 仍指向已销毁的 id(悬空)。
ChannelKeep ChannelDisposition = "keep"
// ChannelHandBack 归还父(Owner 清空或改指父)。
ChannelHandBack ChannelDisposition = "handback"
)