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

73 lines
3.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
// 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
}