diff --git a/internal/agent/core/child/child.go b/internal/agent/core/child/child.go index b57ed036..a486e78a 100644 --- a/internal/agent/core/child/child.go +++ b/internal/agent/core/child/child.go @@ -1,44 +1,34 @@ -// Package child 是**子 agent 的类型抽象层**。 +package child + +// child.go 是子 agent 的**公共部分**与类型标签。 // -// # 为什么要有这个包(用户裁定 2026-11-04) +// # 这个包只做一件事 // -// 此前每新增一种子 agent,都要往 `internal/agent/core` 的同一个文件里追加代码: +// 把所有类型的子 agent 共有的、**最容易"改一处漏一处"**的生命周期收成一处: // -// agent.go 加一组类型专属字段(锁 + 表 + 专属状态),约 20 行/类型 -// inputroute.go 加一段"查我这张表 → 分流"的分支 -// toolcall.go 加若干 `case tc.Name == "<该类型的控制工具>":` -// 以及独立的 .go 放整份生命周期实现 +// 单一登记表(Registry) ← 替换掉 residents / rtChildren 两张表 +// 单一清理路径(Cleanup) ← 单数与复数共用,防"复数漏一步" +// 公共字段(Child:id/ctx/IO/入站通道) // -// 实测:新增一种子 agent 需要改 **18 个生产文件**。已经因此漂移过一次 -// (`StopRTChildren` 漏注销入站登记,见 docs/zh/engineering-lessons.md §2.3)。 +// # 为什么这里没有 ChildKind 接口 // -// # 抽象的形状 +// 曾设计过 `ChildKind`(Deliver / BuildContext / Emit / RunTools / Dispose / +// ParentTools),但在实现过程中发现它是**假抽象**: // -// 分成两半: +// · 各类型要用载体 Agent 的大半能力(provider / 工具执行 / 记忆 / 安全) +// · 要满足它就得以 `Entry` 为参数、在实现里做类型断言回 `*Agent` +// · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放 // -// **骨架(本包提供,只写一遍)** -// spawnChild 校验 → 去重 → 独立 ctx → 独立 IO(共享通道登记表 + 父视图回退) -// → 构造载体 → 绑定 → 声明子策略 → 登记 → 启动 -// stopChild 撤销策略 → 注销入站登记 → 取消 ctx → kind.Dispose -// stopAll 对全部子执行 stopChild -// -// **差异(由 ChildKind 实现,每种类型一份)** -// Deliver 输入策略:队列(不丢活)还是单槽(最新者胜) -// BuildContext 上下文策略:相关性裁剪 还是 跨轮 history + 窗口 -// Emit 输出策略(含异步批大小) -// RunTools 工具策略(并发批 还是 串行) -// Dispose 销毁语义(残余 keep/drop;通道释放还是悬空) -// ParentTools 暴露给父的控制工具 +// 实测结果是它零实现、零调用(26 个导出符号里 20 个无人使用)。 +// 按"不留技术债"的要求,接口与其配套的 Host / ToolSpec / ToolResult 已删除, +// 类型差异直接写在各自的文件里(rtchild.go / resident.go / spawn.go)。 // // # 已确认的边界(不要为了"更统一"而抹掉) // -// - 驻留子的 `DestroyResident` 有三种 ChannelDisposition(释放/保留/归还) -// - rtchild 的 rt 通道**销毁后悬空**,不归还(用户裁定,见 rt 的 Dispose 实现) -// - 驻留子 `child.Start()` 起 7 个 goroutine;rtchild **只起 1 个** rtLoop -// (rt 明确不要 schedulerLoop / interceptLoop / 记忆整理循环) -// -// 这三条是**语义差异**,属于 ChildKind 的职责,不属于骨架。 -package child +// - 驻留子的 `DestroyResident` 有三种 ChannelDisposition(释放/挂起/接回) +// - rtchild 的 rt 通道**销毁后悬空**,不归还 +// - 驻留子 `Start()` 起 7 个 goroutine;rtchild **只起 1 个** rtLoop +// - 工作式子**不进**登记表(无 io/通道,结果缓存与存活登记语义不同) import ( "context" @@ -50,32 +40,30 @@ import ( // Kind 是子 agent 的类型标签。 // -// 为什么用字符串而不是 iota 枚举:它要出现在状态面、日志、以及 -// ChannelRegistry 的归属判定里;字符串在日志里直接可读, -// 且新增类型不需要改一个中心 switch(这与包的目的正好一致)。 +// 用字符串而不是 iota:它要出现在状态面与日志里,字符串直接可读, +// 且新增类型不需要改一个中心 switch。 type Kind string const ( - // KindResident 是长期派驻的子 agent(有任务队列、有残余可回灌、不丢活)。 + // KindResident 是长期派驻的子 agent(任务队列、残余可回灌、不丢活)。 KindResident Kind = "resident" // KindRT 是实时通话子 agent(单槽覆盖、最新者胜、可饿死、丢现场)。 KindRT Kind = "rt" ) -// Child 是一个**已建立**的子 agent 实例的**公共部分**。 +// Child 是一个**已建立**的子 agent 实例的公共部分。 // // ★ 用法是**嵌入**:类型专属状态放在各实现自己的结构体里, // 公共部分靠嵌入提升字段: // // type rtChild struct { -// *child.Child // ID / Kind / Ctx / IO / InboundCh / 状态 / 轮次 +// *child.Child // ID / Kind / Ctx / IO / InboundCh / stopped // agent *Agent // 载体(类型专属,不进公共结构) // slot *rtSlot // } // // 为什么不把 agent *Agent 放进本结构:本包不能 import core(否则成环), -// 而 core 可以 import 本包。嵌入让公共部分与载体指针各归各位, -// 同时避免为此造一个“只有 ID 的假 Agent” —— 那种结构既不能用又会误导后来者。 +// 而 core 可以 import 本包。嵌入让公共部分与载体指针各归各位。 type Child struct { // ID 是登记表的键,也是通道 Owner 记的值。 // @@ -84,7 +72,7 @@ type Child struct { // 就会永远查不到(曾因写成 "rt/"+id 而让插件输入全打到父身上)。 ID string - // Kind 是本实例的类型标签。 + // Kind 是本实例的类型标签(Add 时由 Entry.Kind() 写入)。 Kind Kind // IO 是本子自己的 IOManager(独立输入入口 + 共享通道登记表 + 父视图回退)。 @@ -98,20 +86,16 @@ type Child struct { Cancel context.CancelFunc // InboundCh 是本子在父侧注册的入站通道名("child/"), - // stopChild 负责注销它。空 = 本类型不需要入站通道。 + // Cleanup 负责注销它。空 = 本类型不需要入站通道。 InboundCh string - // ParentID 是父的 agent id(策略撤销、日志用)。 + // ParentID 是父的 agent id(日志用)。 ParentID string // CreatedAt 是 Unix 纳秒,供状态面排序。 CreatedAt int64 - mu sync.Mutex - state State - rounds uint64 - // stopped 标记已 Stop。rtLoop 取到输入后先查它,防止 - // “Wake 信号先于 cancel 到达 select”时白跑一轮(D8)。 + mu sync.Mutex stopped bool } @@ -122,7 +106,6 @@ func (c *Child) Stop() { } c.mu.Lock() c.stopped = true - c.state = StateStopped c.mu.Unlock() if c.Cancel != nil { c.Cancel() @@ -139,61 +122,6 @@ func (c *Child) Stopped() bool { 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 ( @@ -201,57 +129,7 @@ var ( // // 为什么不允许"同名覆盖":登记表与通道 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" ) diff --git a/internal/agent/core/child/host.go b/internal/agent/core/child/host.go deleted file mode 100644 index 629142f2..00000000 --- a/internal/agent/core/child/host.go +++ /dev/null @@ -1,72 +0,0 @@ -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 -} diff --git a/internal/agent/core/child/kind.go b/internal/agent/core/child/kind.go deleted file mode 100644 index 02ad0362..00000000 --- a/internal/agent/core/child/kind.go +++ /dev/null @@ -1,95 +0,0 @@ -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 = "该类型不支持上下文压缩" diff --git a/internal/agent/core/child/kinds.go b/internal/agent/core/child/kinds.go index e2e47970..1e8645dd 100644 --- a/internal/agent/core/child/kinds.go +++ b/internal/agent/core/child/kinds.go @@ -1,87 +1,44 @@ package child -// kinds.go 记录**当前全部子 agent 类型**及其迁移归属。 +// kinds.go 记录**当前全部子 agent 类型**及其归属。 // -// # 为什么要把这份清单写进代码而不是只写在 plan.md +// # 为什么这份清单必须以文档形式留在代码里 // -// 用户 2026-11-04 指出:"其他的哪几个子 agent 类型也要迁移"。 -// 这个问题之所以要问,正是因为没有一份**唯一清单** —— -// 只能靠 grep `Spawn*` 反推,而反推会漏(工作式子叫 `runChildTask`, -// 前面没有 Spawn 前缀;转投驻留子是 `ensureOffloadResident`, -// 前面也没有 Spawn 前缀)。 +// 用户曾问"其他的哪几个子 agent 类型也要迁移"。这个问题的存在本身说明 +// 没有一份唯一清单 —— 只能靠 grep `Spawn*` 反推,而反推会漏: // -// 所以清单必须在这里,且与 Kind 常量一一对应。新增类型时: -// 1. 在这里加一个 Kind + 一行说明 -// 2. 实现 ChildKind -// 3. 装配期 Register -// 漏了第 2/3 步时 Register 不会报错但路由会走兜底 —— 由 -// TestAllDeclaredKindsAreRegistered 钉住。 +// 工作式子 入口叫 runChildTask,前面没有 Spawn 前缀 ⇒ grep 会漏 +// 转投驻留子 入口叫 ensureOffloadResident,同样没有前缀 ⇒ grep 会漏 // // # 四种类型的实际形态(2026-11-04 实测) // -// 类型 入口 载体 独立IO 通道 子策略 上下文 -// 主 agent New() *Agent(完整) 有 有 - 相关性 -// 驻留子 SpawnResident 完整 *Agent + Start 有 有 有 RelevanceContext -// rtchild SpawnRTChild 完整 *Agent 不 Start 有 有 有 跨轮 history -// 工作式子 runChildTask goroutine + 局部msgs 无 无 无 局部 msgs -// 转投驻留子 ensureOffloadResident = 驻留子(offloadOwned=true 标记) +// 类型 入口 载体 独立IO 通道 登记表 +// 主 agent New() *Agent(完整) 有 有 - +// 驻留子 SpawnResident 完整 *Agent + Start 有 有 children(KindResident) +// rtchild SpawnRTChild 完整 *Agent 不 Start 有 有 children(KindRT) +// 工作式子 runChildTask goroutine + 局部msgs 无 无 ✗(见下) +// 转投驻留子 ensureOffloadResident = 驻留子 有 有 children(KindResident) // -// ★ 迁移范围(用户裁定"注意其他的哪几个子 agent 类型也要迁移"): +// # 为什么这里没有 KindWorker 常量 // -// 必须迁(有独立生命周期与登记状态):驻留子、rtchild、工作式子 -// 不需要独立 Kind(是驻留子的一个变体):转投驻留子 +// 工作式子**不进** children 登记表,因此不需要类型标签: // -// 工作式子**不走骨架的 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-", - // InputChs: nil, // 不接插件输入,只由父转投 - // AllowedOutputs: nil, // 完整输出授权(要能回发到原通道) - // TempPath: a.residentTempPath(id), - // OffloadOwned: true, // ← 唯一的“特殊”标在这里 - // TaskPrompt: offloadTaskPrompt(), - // }) - // - // 走的完全是驻留子那一条路(同样的 spawn / 同样的登记表 / 同样的 memory 装配), - // 差异只有两处,且都已在 ResidentOptions 上表达: - // - // ① OffloadOwned 布尔标记 —— 只影响“能否被自动复用 / 是否计入上限”, - // 不影响任何生命周期语义(不能拿去复用人工建的子,也不占用它们的名额) - // ② TaskPrompt 是分诊职责说明 —— 纯粹是提示词内容,不是机制 - // - // ⇒ 因此**不应**为它新增 Kind。若新增,就得为它写一份 ChildKind, - // 那份实现会与驻留子的实现逐字重复(除了一个 bool), - // 而我们正要消灭“同一份逻辑写两遍、然后漂移”这个形态。 - // - // 判据 TestOffloadIsNotItsOwnKind 钉住这条:DeclaredKinds() 里不得出现它。 -) - -// DeclaredKinds 返回**应当被注册**的全部类型标签(测试与装配自检用)。 +// · 它没有 io、没有通道、没有子策略 ⇒ 不需要"按通道归属路由" +// · 它的 `childTasks` 是**已完成任务的幂等结果缓存**(child_result 要能 +// 重复查询 + 20 条有界淘汰),语义与"存活登记"完全不同 +// · 塞进 registry 会让 StopAll 去 Dispose 一个已经跑完的实例, +// 并把结果缓存一起清掉 ⇒ child_result 立刻变成"不存在", +// 模型会无限重试(实测单轮 35 次工具调用、持续 514 秒) // -// 不含 KindOffload(它是驻留子变体),不含主 agent(它不是子)。 -func DeclaredKinds() []Kind { - return []Kind{KindResident, KindRT, KindWorker} -} +// 曾定义过 `KindWorker` 常量,实测零使用,已删除(不留技术债)。 +// +// # 为什么转投驻留子不是独立类型 +// +// `ensureOffloadResident` 走的就是 `SpawnResident`,差异只有两处, +// 且都已在 ResidentOptions 上表达: +// +// ① OffloadOwned 布尔标记 —— 只影响"能否被自动复用 / 是否计入上限" +// ② TaskPrompt 是分诊职责说明 —— 纯粹是提示词内容,不是机制 +// +// 若为它新增类型,那份实现会与驻留子逐字重复(除了一个 bool), +// 而我们在消灭的正是"同一份逻辑写两遍、然后漂移"这个形态。 diff --git a/internal/agent/core/child/registry.go b/internal/agent/core/child/registry.go index baae0e46..88fbee3e 100644 --- a/internal/agent/core/child/registry.go +++ b/internal/agent/core/child/registry.go @@ -55,9 +55,15 @@ type Entry interface { // Kind 返回类型标签。 Kind() Kind - // Dispose 按本类型语义收尾(残余处置、通道释放或悬空)。 - // policy 只对支持残余处置的类型有意义,其余忽略。返回给父看的摘要。 - Dispose(policy ResidualPolicy) string + // Dispose 按本类型语义收尾(关闭记忆句柄、丢弃过期输入等)。 + // 返回给父看的摘要(可为空)。 + // + // ★ 签名里**没有**残余策略:残余处置(keep/drop)走的是另一条路径 + // (ApplyResidual / FinishResidentWith),它们在 Dispose 之前就已经把 + // 任务取走或丢弃了。曾在这里放过 `policy ResidualPolicy` 参数, + // 但两个实现都没读它 —— 那是误导性签名(看签名以为这里会处理残余), + // 按"不留技术债"删掉。 + Dispose() string } // Add 登记一个已构造好的实例。 @@ -158,8 +164,6 @@ func (r *Registry) Len() int { // StopDeps 是清理时需要的外部动作(由 core 注入,避免本包依赖 core 的类型)。 type StopDeps struct { - // Residual 是残余处置策略(只对支持的类型有意义)。 - Residual ResidualPolicy // RevokePolicy 撤销父为该子声明的主体策略。 // // ★ 必须注入:主体表以调用方给的名字为键,同名重建会继承旧策略, @@ -187,7 +191,7 @@ func (r *Registry) Cleanup(e Entry, deps StopDeps) { b.Stop() // ② 类型自己的收尾(残余/通道)。类型可能读 ctx,故排在 cancel 之后但 // 只读状态、不依赖 ctx 存活。 - if msg := e.Dispose(deps.Residual); msg != "" { + if msg := e.Dispose(); msg != "" { log.Printf("[child] %s (%s) disposed: %s", b.ID, e.Kind(), msg) } if deps.OnDispose != nil { diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 87b3fff3..ad2a525e 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -145,7 +145,7 @@ func (rc *residentChild) Kind() childpkg.Kind { return childpkg.KindResident } // // 这三种通道处置是驻留子独有的语义(ChannelDisposition), // 由父在销毁时决定,写在 `residentDisposition` 上供本方法读取。 -func (rc *residentChild) Dispose(policy childpkg.ResidualPolicy) string { +func (rc *residentChild) Dispose() string { // 关记忆句柄:temp 与子同生共死(连同目录一起丢弃)。 // // ★ 通道处置**不在这里**:它需要父的 sched / io(接回时要把暂存任务 diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index 59d37369..d8d29730 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -365,7 +365,7 @@ func (rc *rtChild) Kind() child.Kind { return child.KindRT } // agent 成为潜在持有方(routeInputByOwner 的 Owner=="" 分支会把输入 // 兜底给主 agent,静默改变语义)。悬空是**显式**状态,运维能一眼看出 // "这条 rt 通道的宿主没了"。 -func (rc *rtChild) Dispose(_ child.ResidualPolicy) string { +func (rc *rtChild) Dispose() string { // 契约 3:丢弃槽里未处理的输入,不转交。 if rc.slot != nil { _, _, _ = rc.slot.Take() @@ -799,16 +799,12 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) { return opts.ID, nil } -// rtResidualPolicy:rt **没有**残余回灌(契约 3)。 -const rtResidualPolicy = child.ResidualDrop - // rtStopDeps 组装骨架清理所需的注入动作。 // // ★ 这里是 rt 侧唯一需要知道"撤销策略 / 注销入站"的地方, // 而**执行**它们的是骨架的 Cleanup —— 单数与复数路径共用那一段。 func (a *Agent) rtStopDeps() child.StopDeps { return child.StopDeps{ - Residual: rtResidualPolicy, RevokePolicy: a.revokeChildPolicy, UnregisterInbound: func(ch string) { if a.io == nil { diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index 37246718..399cdbac 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -47,6 +47,90 @@ func (a *Agent) evictChildTasksLocked() { } } +// ── 工作式子的工具禁令(**单一事实来源**) ── +// +// 此前这条禁令写了两遍:一遍在定义侧(把工具从表里滤掉), +// 一遍在执行侧(模型硬调时拒绝)。两处的工具名单必须逐字一致 —— +// 任何一边落后就是一个真实缺口: +// +// 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试) +// 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**(模型确实会这么干) +// +// 现在只有下面这张表。定义侧走 workerToolAllowed, +// 执行侧走 workerToolReject,两者都从同一份 runnerForbidden 派生。 +// 判据 TestWorkerToolDenyListIsSingleSource 钉住"不许再长出第二份"。 + +// workerDenyRule 是一条工具禁令:匹配方式 + 给模型看的解释。 +// +// ★ 用结构体把"匹配"和"解释"绑在一起,避免上一版那种三处间接 +// (先查 reason 字符串、再按下标猜分类、再查 hint)。读代码时 +// 一条禁令的全部信息在一行里可见。 +type workerDenyRule struct { + class string // 给模型看的类别("输出工具" / "系统工具") + tool string // 精确匹配(prefix=false 时) + prefix string // 前缀匹配(prefix=true 时) + isPref bool + hint string // 为什么不行、该怎么做 +} + +// workerForbidden 是工作式子**唯一**的工具禁令表。 +// +// 为什么禁这些: +// +// 输出工具 结果回父(child_result),子直接对用户说话会与父的回复串台 +// spawn_child 子不能再 spawn 子(无限递归) +// plgreload 插件重载是内核级动作,子无权触发 +var workerForbidden = []workerDenyRule{ + { + class: "输出工具", prefix: "output_send__", isPref: true, + hint: "结果请直接作为最终答复返回,父会转达用户", + }, + { + class: "输出工具", tool: "output_list_channels", + hint: "结果请直接作为最终答复返回,父会转达用户", + }, + { + class: "系统工具", tool: "spawn_child", + hint: "子不能再 spawn 子(会无限递归);需要多步请在本次任务内完成", + }, + { + class: "系统工具", tool: "plgreload", + hint: "插件重载是内核级动作,只有父能触发", + }, +} + +// workerDenyRuleFor 返回命中的禁令(nil = 允许)。 +func workerDenyRuleFor(name string) *workerDenyRule { + for i := range workerForbidden { + r := &workerForbidden[i] + if r.isPref { + if strings.HasPrefix(name, r.prefix) { + return r + } + continue + } + if name == r.tool { + return r + } + } + return nil +} + +// workerToolAllowed 报告某个工具是否**允许**给工作式子用(定义侧过滤)。 +func workerToolAllowed(name string) bool { return workerDenyRuleFor(name) == nil } + +// workerToolReject 在执行侧拒绝被禁工具,返回给模型的**可执行**说明。 +// +// 返回空串表示放行。文案必须说明"为什么不行、该怎么做",否则模型会原样 +// 重试同一条被拒的调用(本仓有过"4 次 files_write 失败"的先例)。 +func workerToolReject(ct agentAPI.ToolCall) string { + r := workerDenyRuleFor(ct.Name) + if r == nil { + return "" + } + return fmt.Sprintf("子 Agent 不允许调用 %s: %s(%s)", r.class, ct.Name, r.hint) +} + func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string { task, _ := tc.Arguments["task"].(string) if task == "" { @@ -157,7 +241,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns continue } name, _ := fn["name"].(string) - if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" { + if !workerToolAllowed(name) { continue } childTools = append(childTools, t) @@ -217,17 +301,9 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns // ② 强制串行(NoBatch)—— 子历史上是串行的,改并发会改变它已上线 // 行为的时序假设,不在本次重构范围内(判据 TestWorkerToolsStaySerial 钉住) results := a.runToolBatch(a.ctx, resp.ToolCalls, batchOpts{ - Channel: parentChannel, - NoBatch: true, - ToolFilter: func(ct agentAPI.ToolCall) string { - switch { - case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels": - return fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name) - case ct.Name == "spawn_child" || ct.Name == "plgreload": - return fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name) - } - return "" - }, + Channel: parentChannel, + NoBatch: true, + ToolFilter: workerToolReject, }) // 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里 // 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、 diff --git a/internal/agent/core/workertool_test.go b/internal/agent/core/workertool_test.go new file mode 100644 index 00000000..c22432b5 --- /dev/null +++ b/internal/agent/core/workertool_test.go @@ -0,0 +1,116 @@ +package core + +// 判据:工作式子的工具禁令是**单一事实来源**。 +// +// ## 这条缺陷的原貌 +// +// 禁令曾写了两遍: +// +// 定义侧(spawn.go:160) 把禁的工具从 childTools 里滤掉 ⇒ 模型看不见 +// 执行侧(spawn.go:224) 模型硬调时拒绝 ⇒ 模型看得见也调不动 +// +// 两处的工具名单必须逐字一致。任何一边落后就是一个真实缺口: +// +// 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试) +// 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**(模型确实会这么干 —— +// 本仓 record 过"4 次 files_write 失败后模型改参数硬试") +// +// 现在两侧都从 workerToolAllowed 派生,这条判据钉住"不许再长出第二份"。 + +import ( + "os" + "strings" + "testing" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" +) + +// TestWorkerToolDenyListIsSingleSource 钉住:禁令只在 workerForbidden* 定义一次。 +// +// 变红条件:有人在别处又写一份字面量名单(如 `ct.Name == "spawn_child"`)⇒ 红。 +func TestWorkerToolDenyListIsSingleSource(t *testing.T) { + data, err := os.ReadFile("spawn.go") + if err != nil { + t.Fatalf("读 spawn.go: %v", err) + } + // ★ 必须**逐行剔除注释**再扫:第一版直接扫全文,结果把自己写在 + // 注释里的"上一版曾在这里重列过 `== "output_list_channels"`"当成了违规。 + // 判据误报会让人开始骗判据(改注释绕过),那比没有判据更糟。 + var code []string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + code = append(code, line) + } + src := strings.Join(code, "\n") + + for _, tool := range []string{"spawn_child", "plgreload", "output_list_channels"} { + needle := `== "` + tool + `"` + if strings.Contains(src, needle) { + t.Fatalf("spawn.go 里出现了第二份工具禁令:`%s`\n"+ + "禁令必须在 workerForbiddenTools 里定义一次,"+ + "定义侧与执行侧都从 workerToolAllowed 派生 —— "+ + "两处各写一遍时,任何一边落后就是缺口", needle) + } + } + if strings.Contains(src, `HasPrefix(name, "output_send__")`) || + strings.Contains(src, `HasPrefix(ct.Name, "output_send__")`) { + t.Fatal("spawn.go 里出现了第二份前缀禁令(output_send__)—— " + + "应统一走 workerToolAllowed") + } +} + +// TestWorkerToolAllowedMatchesReject 钉住两侧**语义一致**。 +// +// 这是上一条的行为版:名单同源还应满足"允许 ⇔ 不拒绝"。 +// 变红条件:两边判据不一致(比如一边禁 spawn_child、另一边漏了)。 +func TestWorkerToolAllowedMatchesReject(t *testing.T) { + cases := []struct { + tool string + allowed bool + }{ + {"output_send__qq", false}, + {"output_send__", false}, + {"output_list_channels", false}, + {"spawn_child", false}, + {"plgreload", false}, + {"cmd_run", true}, + {"files_read", true}, + {"knowledge_search", true}, + // 边界:前缀相似但不等价的必须放行(防"过度过滤")。 + {"output_list_channels_help", true}, + {"spawn_child_helper", true}, + } + for _, c := range cases { + got := workerToolAllowed(c.tool) + if got != c.allowed { + t.Errorf("workerToolAllowed(%q) = %v,期望 %v", c.tool, got, c.allowed) + } + rejected := workerToolReject(agentAPI.ToolCall{Name: c.tool}) != "" + if rejected == c.allowed { + t.Errorf("workerToolReject(%q) 与 workerToolAllowed 不一致:"+ + "allowed=%v 但 rejected=%v(两侧判据必须同源)", + c.tool, c.allowed, rejected) + } + } +} + +// TestWorkerToolRejectExplainsWhy 钉住拒绝文案**可执行**。 +// +// 本仓纪律:拒绝必须说明原因,否则模型会原样重试同一条被拒的调用 +// (有"4 次 files_write 失败"先例)。变红条件:返回空文案或只有工具名。 +func TestWorkerToolRejectExplainsWhy(t *testing.T) { + for _, tool := range []string{"output_send__qq", "spawn_child"} { + msg := workerToolReject(agentAPI.ToolCall{Name: tool}) + if msg == "" { + t.Fatalf("%s 被拒但没给文案", tool) + } + if len([]rune(msg)) < 20 { + t.Fatalf("%s 的拒绝文案太短(%q)—— 必须说明原因才能让模型换做法", tool, msg) + } + if !strings.Contains(msg, tool) { + t.Errorf("%s 的拒绝文案没提到工具名:%q", tool, msg) + } + } +}