mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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 通过。
This commit is contained in:
257
internal/agent/core/child/child.go
Normal file
257
internal/agent/core/child/child.go
Normal file
@ -0,0 +1,257 @@
|
||||
// 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"
|
||||
)
|
||||
72
internal/agent/core/child/host.go
Normal file
72
internal/agent/core/child/host.go
Normal file
@ -0,0 +1,72 @@
|
||||
package child
|
||||
|
||||
// host.go 定义 ChildKind 实现所需的**最小宿主能力**。
|
||||
//
|
||||
// # 为什么需要它
|
||||
//
|
||||
// 类型实现(rt / resident)必须能用载体的能力(system prompt、窗口大小、
|
||||
// 输出设备、工具执行)。但如果它们直接调用 `*core.Agent`,就得反向 import core ⇒ 成环。
|
||||
//
|
||||
// 所以依赖方向固定为 **core → child**:core 实现 Host 接口,child 只认识接口。
|
||||
//
|
||||
// # 纪律:这个接口只增"骨架/类型共用的能力"
|
||||
//
|
||||
// 不要因为"某个类型顺手想用一下就加上"。每加一个方法,所有 Host 实现都要跟。
|
||||
// 类型专属能力应当留在该类型的实现里(用类型断言或构造期注入闭包)。
|
||||
// 判据 TestHostInterfaceIsMinimal 钉住方法集大小。
|
||||
|
||||
import (
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// Host 是子 agent 可用的载体能力。
|
||||
//
|
||||
// core.Agent 是唯一的生产实现(见 core/child_host.go)。
|
||||
type Host interface {
|
||||
// SystemPrompt 返回该载体的系统提示词(上下文装配用)。
|
||||
SystemPrompt() string
|
||||
|
||||
// ContextWindowTokens 返回上下文窗口(token)。窗口决定裁剪水位。
|
||||
ContextWindowTokens() int
|
||||
|
||||
// EmitText 把一段增量文本送到该子的输出通道。
|
||||
//
|
||||
// ★ 语义要求(用户裁定:rt 输出 = 标准异步输出路径):
|
||||
// 实现必须**异步**——插件侧延迟不得节流调用方的生成循环。
|
||||
EmitText(c *Child, text string) error
|
||||
|
||||
// RunToolBatch 执行一批工具,返回与入参**同序**的结果。
|
||||
//
|
||||
// ★ 实现必须让整批经过 before_toolcall / after_toolcall 插件钩子。
|
||||
// 历史上 rt 直调 executeToolCallOutcome,绕过了两个钩子 ⇒
|
||||
// 插件对 rt 的工具调用完全不可见(不能审计/拒绝/改参/脱敏)。
|
||||
RunToolBatch(c *Child, calls []agentAPI.ToolCall) []ToolResult
|
||||
|
||||
// Logf 记一行日志(带该子的 id 前缀由实现负责)。
|
||||
Logf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// Deliverer 由需要自定义输入投递的类型实现(骨架不需要,故不在 Host 上)。
|
||||
//
|
||||
// 单槽型(rt)用它在往槽里写值后取消当前轮(契约 1:新输入打断旧轮)。
|
||||
type Deliverer interface {
|
||||
// AfterPush 在输入已写入槽之后调用。seq 是本次写入的序号。
|
||||
//
|
||||
// ★ 为什么拆成两步(先写槽、再取消):只写槽不取消是错的 ——
|
||||
// 正在跑的轮次 block 在流里,不会回到循环顶部取新值,
|
||||
// 表现就是"等它说完",正是契约 1 禁止的。
|
||||
AfterPush(c *Child, seq uint64)
|
||||
}
|
||||
|
||||
// DeviceResolver 由能提供输出设备的 Host 实现(可选)。
|
||||
//
|
||||
// 单独成接口而不放进 Host:只有需要输出通道的类型才用它,
|
||||
// 放进 Host 会让不需要的类型也被迫实现。
|
||||
type DeviceResolver interface {
|
||||
// OutputDevice 按通道名解析设备。
|
||||
//
|
||||
// ★ 必须**每次重新解析**而不是缓存指针:插件崩溃时会被
|
||||
// UnregisterDevice,恢复后重新注册。缓存指针会一直指向死设备。
|
||||
OutputDevice(channel string) agentIO.Device
|
||||
}
|
||||
95
internal/agent/core/child/kind.go
Normal file
95
internal/agent/core/child/kind.go
Normal file
@ -0,0 +1,95 @@
|
||||
package child
|
||||
|
||||
// kind.go 定义 ChildKind —— 一种子 agent 类型的**全部差异点**。
|
||||
//
|
||||
// # 设计依据(2026-11-04 与用户对齐)
|
||||
//
|
||||
// 用户裁定的差异只有两条:
|
||||
//
|
||||
// ① 上下文管理策略
|
||||
// ② 工具异步调用(含输出通道异步调用)
|
||||
//
|
||||
// 实测这条判断是准确的。三种类型的**上下文管理**确实完全不同:
|
||||
//
|
||||
// 类型 上下文载体 裁剪方式 溢出处理
|
||||
// 主 agent RelevanceContext 相关性 topK + 冷上下文下沉 enforceContextBudget
|
||||
// 驻留子 RelevanceContext(轻量) 同 + 三层压缩协议 enforceContextBudget
|
||||
// + contextfull 上报
|
||||
// + 父驱动压缩
|
||||
// rtchild 跨轮 history 切片 按窗口丢最旧组(只裁视图) 无(视图裁完继续)
|
||||
// 工作式子 局部 msgs 无(跑完即弃) maxTurns 硬上限
|
||||
//
|
||||
// 而"输出通道异步调用"也一样:rt 逐 chunk、其余整段。
|
||||
//
|
||||
// ⇒ 所以接口的主轴就是这两条,其余(登记/停止/撤销策略/注销入站)全在骨架里。
|
||||
|
||||
import (
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// ChildKind 是一种子 agent 类型的完整差异实现。
|
||||
//
|
||||
// 实现者放在 core 包里(`*_child.go`),**不放本包**:
|
||||
// 实现需要载体 Agent 的全部能力(provider / 工具执行 / 记忆 / 安全),
|
||||
// 把它们抽象成接口会造出一个比 Agent 还大的假接口 —— 那是把复杂度
|
||||
// 换个地方放,不是消除。本包只提供接口、骨架与登记表。
|
||||
type ChildKind interface {
|
||||
// Kind 返回类型标签(与 kinds.go 的常量一致)。
|
||||
Kind() Kind
|
||||
|
||||
// ── ① 输入策略 ──
|
||||
|
||||
// Deliver 投递一条输入。
|
||||
//
|
||||
// 队列型(驻留子)返回 false 表示"交回默认路径"(DeliverRouted 进调度器)。
|
||||
// 单槽型(rt)自行覆盖并返回 true。
|
||||
Deliver(e Entry, evt *agentIO.InputEvent, isInterrupt bool) bool
|
||||
|
||||
// ── ② 上下文管理策略(本接口的主轴之一)──
|
||||
|
||||
// BuildContext 装配本轮送入模型的上下文视图。
|
||||
//
|
||||
// 必须遵守"视图与存储分离":返回的切片可以是裁剪后的视图,
|
||||
// 但**不得**修改本类型的长期存储(rt 的 history 永不裁剪)。
|
||||
BuildContext(e Entry, input string) []agentAPI.Message
|
||||
|
||||
// AppendHistory 把一轮产出写回长期存储。
|
||||
// 队列型(驻留子)用 RelevanceContext 承载,不需要它 ⇒ no-op。
|
||||
AppendHistory(e Entry, msgs ...agentAPI.Message)
|
||||
|
||||
// Compact 按本类型的上下文策略压缩。返回被处理的事件数。
|
||||
//
|
||||
// 只有驻留子实现了真正的压缩(摘要替换 + 失败回退截断)。
|
||||
// 不支持的类型返回 (0, ErrCompactUnsupported)。
|
||||
Compact(e Entry, keepRecent int) (int, error)
|
||||
|
||||
// ── ③ 工具与输出异步调用(主轴之二)──
|
||||
|
||||
// Emit 把一段增量文本送到输出通道。
|
||||
//
|
||||
// 异步批大小由实现决定:rt 逐 chunk、其余整段。**实现必须异步**,
|
||||
// 插件延迟不得节流调用方的生成循环。
|
||||
Emit(e Entry, text string)
|
||||
|
||||
// RunTools 执行一批工具,返回与入参**同序**的结果。
|
||||
//
|
||||
// 必须让整批经过 before_toolcall / after_toolcall 插件钩子。
|
||||
RunTools(e Entry, calls []agentAPI.ToolCall) []ToolResult
|
||||
|
||||
// ── ④ 销毁语义 ──
|
||||
|
||||
// Dispose 按本类型语义收尾。policy 只对支持残余处置的类型有意义。
|
||||
Dispose(e Entry, policy ResidualPolicy) string
|
||||
|
||||
// ── ⑤ 工具面 ──
|
||||
|
||||
// ParentTools 声明该类型暴露给**父模型**的控制工具。
|
||||
//
|
||||
// 定义与派发都从这一份派生 —— 这是消灭 toolcall.go 与 toolroute
|
||||
// 两份工具表不同步的落点。
|
||||
ParentTools() []ToolSpec
|
||||
}
|
||||
|
||||
// ErrCompactUnsupported 表示该类型不支持压缩。
|
||||
var ErrCompactUnsupported = "该类型不支持上下文压缩"
|
||||
87
internal/agent/core/child/kinds.go
Normal file
87
internal/agent/core/child/kinds.go
Normal file
@ -0,0 +1,87 @@
|
||||
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}
|
||||
}
|
||||
236
internal/agent/core/child/registry.go
Normal file
236
internal/agent/core/child/registry.go
Normal file
@ -0,0 +1,236 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user