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:
@ -9,6 +9,7 @@ import (
|
||||
|
||||
agentPkg "github.com/JianFeeeee/HologramAgent/internal/agent"
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core/child"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
@ -60,32 +61,29 @@ type Agent struct {
|
||||
// 与 kernelSource 同类:都是“这个 agent 在层级/类型上是谁”的不可变标记。
|
||||
isRealtime bool
|
||||
|
||||
// 驻留子(父侧):登记表 + 子侧钩子。
|
||||
residentMu sync.Mutex
|
||||
residents map[string]*residentChild
|
||||
// children 是**全部类型**的子 agent 的唯一登记表(重构 R1 起)。
|
||||
//
|
||||
// ## 为什么只有一张表
|
||||
//
|
||||
// 此前 residents 与 rtChildren 各自一张表 + 一把锁,每加一种类型就
|
||||
// 多一张表、一把锁、一段路由分支。实测新增一种子 agent 要改 18 个生产
|
||||
// 文件,并已因复制而漂移过一次(StopRTChildren 漏注销入站登记)。
|
||||
//
|
||||
// 现在类型差异下沉到 child.Entry / 各类型的 Dispose,骨架
|
||||
// (单数/复数停止、撤销策略、注销入站)只写一遍。
|
||||
children child.Registry
|
||||
|
||||
// rtchild(实时子 agent,plan.md 十一):登记表 + 单槽输入。
|
||||
// rt 的实例登记已并入 children(统一登记表)。
|
||||
//
|
||||
// ★ 为何与 residents 分开而不复用:两者的语义**相反**:
|
||||
// residents:有任务队列、有残余可回灌、父可 pull 状态面(不丢活)
|
||||
// rtChildren:无队列(单槽覆盖)、无残余、最新者胜(低延迟)
|
||||
// 混在一张表里会让"该不该转残余"这类判断变成条件分叉。
|
||||
rtMu sync.Mutex
|
||||
rtChildren map[string]*rtChild
|
||||
// rtClosedTranscripts 是**已回收** rtchild 的上下文留档(id → 全文)。
|
||||
// 这里只剩**已回收实例的上下文留档**:它不是登记表的一部分
|
||||
// (实例已经不在了),而是"推信号 + 拉状态"交接协议的拉侧数据源。
|
||||
//
|
||||
// 为什么必须留档:交接是「推信号 + 拉状态」两步(见 CloseRTChild),
|
||||
// 推完中断后实例已出登记表 —— 若不留档,主 agent 收到"可调用 rt_transcript
|
||||
// 取回原文"的提示时,原文已经取不到了。
|
||||
//
|
||||
// ★ 键是 id,所以**同名复用会撞键**。串线的真因在读侧:RTTranscript 曾
|
||||
// "先查留档、命中即返回",于是新实例读到的永远是上一通已回收的原文
|
||||
// (2026-10-30 实测复现)。现在改为**在跑实例优先**(见 RTTranscript)。
|
||||
//
|
||||
// ★ 刻意**不**在 Spawn 时 delete 这份留档:留档存在的意义就是"回收后
|
||||
// 主 agent 按需取回原文",提前删掉等于让推了信号却拉不到正文。
|
||||
// 同名键只在下一次 CloseRTChild 写入时被自然覆写。
|
||||
// ★ 键是调用方(模型)给的 id,所以同名复用会撞键。串线的真因在**读侧**
|
||||
// (RTTranscript 曾"先查留档、命中即返回"),现在按"在跑实例优先"。
|
||||
// 刻意**不**在 Spawn 时删留档:提前删掉等于让"推了信号却拉不到正文"。
|
||||
rtArchiveMu sync.Mutex
|
||||
rtClosedTranscripts map[string][]agentAPI.Message
|
||||
|
||||
// rtSlot 是**本 agent 作为 rtchild 时**的输入槽(作为父时无用)。
|
||||
rtSlot *rtSlot
|
||||
// rtMaxTools 是本 agent 作为 rtchild 时的工具批数上限(0 = 不限)。
|
||||
|
||||
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)
|
||||
}
|
||||
84
internal/agent/core/childkind_test.go
Normal file
84
internal/agent/core/childkind_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
// 判据:**子 agent 的类型分叉不能再长回来**(源码扫描,与
|
||||
// TestAgentCoreHoldsNoPolicySemantics 同型)。
|
||||
//
|
||||
// ## 为什么要这条
|
||||
//
|
||||
// 本仓头号缺陷形态是「机制写对了、判据全绿、生产里长歪」。子 agent 的
|
||||
// 分叉尤其容易回潮:每加一种类型,往 inputroute 加一段、
|
||||
// 往 agent 加一张表、往 toolcall 加几个 case —— 每次都很"顺手"。
|
||||
//
|
||||
// 实测代价(2026-11-04):新增一种子 agent 要改 18 个生产文件,
|
||||
// 并已因复制而漂移过一次(StopRTChildren 漏注销入站登记,
|
||||
// engineering-lessons.md §2.3)。
|
||||
//
|
||||
// 这条判据扫**源码文本**,禁止这些字段/调用在 core 里重新出现。
|
||||
// 它不是风格洁癖:`a.rtChildren` 一旦回来,说明有人绕过统一登记表
|
||||
// 又建了一张表 —— 那正是要消灭的形态。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// forbiddenChildSplits 是**已并入统一登记表**、不得再出现的类型专属入口。
|
||||
//
|
||||
// 每一条都对应一次真实的迁移:
|
||||
//
|
||||
// rtMu / rtChildren → children child.Registry(R2)
|
||||
// residentMu / residents → 同上(R3)
|
||||
//
|
||||
// 注意:`rtClosedTranscripts` **不在此列** —— 它是"已回收实例的留档",
|
||||
// 不属于登记表(实例已经不在了),有独立的 rtArchiveMu 保护。
|
||||
var forbiddenChildSplits = []string{
|
||||
".rtChildren[",
|
||||
".rtMu.Lock()",
|
||||
".residentMu.Lock()",
|
||||
".residents[",
|
||||
}
|
||||
|
||||
// TestNoChildTypeTablesInCore 钉住:core 里不得再有按类型的专用登记表。
|
||||
//
|
||||
// 变红条件:把任一类型改回自己的 map + 锁 ⇒ 红。
|
||||
func TestNoChildTypeTablesInCore(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("没有扫到任何 .go 文件 —— 判据跑错了目录?")
|
||||
}
|
||||
var hits []string
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", f, err)
|
||||
}
|
||||
src := string(data)
|
||||
for _, bad := range forbiddenChildSplits {
|
||||
if strings.Contains(src, bad) {
|
||||
// 允许出现在注释里(迁移说明需要提到旧字段名),
|
||||
// 所以逐行判:只有"非注释行含它"才算违规。
|
||||
for i, line := range strings.Split(src, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, bad) {
|
||||
hits = append(hits, f+":"+itoa(i+1)+" "+trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("子 agent 的类型专属登记表又出现了(应走 children 统一登记表):\n %s",
|
||||
strings.Join(hits, "\n "))
|
||||
}
|
||||
}
|
||||
@ -191,10 +191,8 @@ func TestP3_ChannelNameSlashIsIllegalForOutputGate(t *testing.T) {
|
||||
// residentOf 取父登记表里的驻留子(不存在则 fatal)。
|
||||
func residentOf(t *testing.T, parent *Agent, id string) *Agent {
|
||||
t.Helper()
|
||||
parent.residentMu.Lock()
|
||||
rc, ok := parent.residents[id]
|
||||
parent.residentMu.Unlock()
|
||||
if !ok || rc == nil || rc.agent == nil {
|
||||
rc := parent.residentOf(id)
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", id)
|
||||
}
|
||||
return rc.agent
|
||||
|
||||
@ -29,9 +29,8 @@ func (a *Agent) routeInputByOwner(evt *agentIO.InputEvent, isInterrupt bool) boo
|
||||
return false // 未分配 / 归自己 ⇒ 本内核处理
|
||||
}
|
||||
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[entry.Owner]
|
||||
a.residentMu.Unlock()
|
||||
// 驻留子从**统一登记表**取(不再是专用表 + 专用锁)。
|
||||
rc := a.residentOf(entry.Owner)
|
||||
|
||||
// ★ rtchild 分支:rt 通道的输入必须进 **rtSlot**(单槽覆盖),
|
||||
// 而不是走 rc.agent.io.DeliverRouted(那会进四级中断阶梯)。
|
||||
@ -41,10 +40,8 @@ func (a *Agent) routeInputByOwner(evt *agentIO.InputEvent, isInterrupt bool) boo
|
||||
// 语音/画面输入会永远打在父身上(rtchild 的轮次永远是 0)。
|
||||
//
|
||||
// 判据 TestRTChannel_PluginInputReachesRTSlot 钉它。
|
||||
a.rtMu.Lock()
|
||||
rt := a.rtChildren[entry.Owner]
|
||||
a.rtMu.Unlock()
|
||||
if rt != nil {
|
||||
// rt 实例从**统一登记表**取(不再是专用表 + 专用锁)。
|
||||
if rt := a.rtChildOf(entry.Owner); rt != nil {
|
||||
// 不区分 isInterrupt:rt 没有中断阶梯,所有输入都是"覆盖式最新值"。
|
||||
// 这正是 plan.md 十一.三 B 说的"rt 输入不进四级中断阶梯"的落点。
|
||||
rt.slot.Push(evt)
|
||||
|
||||
@ -19,7 +19,7 @@ func TestResident_InputchRoutingIsExclusive(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spawnTestResident(t, parent, dir, "r-route", "sub/in")
|
||||
child := parent.residents["r-route"].agent
|
||||
child := parent.residentOf("r-route").agent
|
||||
|
||||
before := parent.DumpScheduler().Stats.Enqueued
|
||||
// 插件往"已划给子"的 inputch 投输入
|
||||
@ -29,14 +29,14 @@ func TestResident_InputchRoutingIsExclusive(t *testing.T) {
|
||||
if child.DumpScheduler().Stats.Executed > 0 {
|
||||
return true
|
||||
}
|
||||
return parent.residents["r-route"].info().TableSize > 0
|
||||
return parent.residentOf("r-route").info().TableSize > 0
|
||||
})
|
||||
if got := parent.DumpScheduler().Stats.Enqueued; got != before {
|
||||
t.Fatalf("划给子的 inputch,父不应再入队(before=%d after=%d)", before, got)
|
||||
}
|
||||
// 轮次必须真的涨:此前 info() 根本没填 Rounds ⇒ 父永远读到 0
|
||||
// (现场:子处理表已有 2 条,轮次却显示 0,被误判成"子没干活")。
|
||||
if got := parent.residents["r-route"].info().Rounds; got <= 0 {
|
||||
if got := parent.residentOf("r-route").info().Rounds; got <= 0 {
|
||||
t.Fatalf("子处理的轮次应 > 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import (
|
||||
|
||||
"runtime/debug"
|
||||
|
||||
childpkg "github.com/JianFeeeee/HologramAgent/internal/agent/core/child"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
@ -229,9 +230,7 @@ func (a *Agent) offloadPendingTasks(opts OffloadOptions) int {
|
||||
//
|
||||
// 用排队语义(isInterrupt=false):转投的是"待办工作",不是"打断子"。
|
||||
func (a *Agent) forwardInputToResident(residentID string, evt *agentIO.InputEvent) error {
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[residentID]
|
||||
a.residentMu.Unlock()
|
||||
rc := a.residentOf(residentID)
|
||||
if rc == nil || rc.agent == nil || rc.agent.io == nil {
|
||||
return fmt.Errorf("驻留子 %s 不存在或不可用", residentID)
|
||||
}
|
||||
@ -252,20 +251,20 @@ func (a *Agent) forwardInputToResident(residentID string, evt *agentIO.InputEven
|
||||
// 复用规则:优先复用"内核为转投而建"且仍 running、还没满的驻留子;
|
||||
// 都不可用时(在 MaxResidents 内)新建一个。
|
||||
func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
|
||||
a.residentMu.Lock()
|
||||
// 复用候选从统一登记表里挑"内核为转投而建"且仍 running 的。
|
||||
var reusable []string
|
||||
for id, rc := range a.residents {
|
||||
if rc == nil || !rc.offloadOwned {
|
||||
for _, e := range a.children.ByKind(childpkg.KindResident) {
|
||||
rc, ok := e.(*residentChild)
|
||||
if !ok || !rc.offloadOwned {
|
||||
continue
|
||||
}
|
||||
rc.mu.Lock()
|
||||
state := rc.state
|
||||
rc.mu.Unlock()
|
||||
if state == "running" {
|
||||
reusable = append(reusable, id)
|
||||
reusable = append(reusable, e.Base().ID)
|
||||
}
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
|
||||
// 复用:按 id 稳定排序后取第一个,避免每次挑到不同的子(可预测性)。
|
||||
if len(reusable) > 0 {
|
||||
@ -274,14 +273,12 @@ func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
|
||||
}
|
||||
|
||||
// 计数:只为转投而建的子是否已达上限(人工建的子不计入)。
|
||||
a.residentMu.Lock()
|
||||
owned := 0
|
||||
for _, rc := range a.residents {
|
||||
if rc != nil && rc.offloadOwned {
|
||||
for _, e := range a.children.ByKind(childpkg.KindResident) {
|
||||
if rc, ok := e.(*residentChild); ok && rc.offloadOwned {
|
||||
owned++
|
||||
}
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
if owned >= opts.MaxResidents {
|
||||
return "", fmt.Errorf("转投专用驻留子已达上限 %d", opts.MaxResidents)
|
||||
}
|
||||
|
||||
@ -448,9 +448,7 @@ func TestResidentInheritsParentSystemPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[info.ID]
|
||||
root.residentMu.Unlock()
|
||||
rc := root.residentOf(info.ID)
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatal("驻留子不可用")
|
||||
}
|
||||
@ -480,9 +478,7 @@ func TestResidualKeepReturnsTasksToParent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
child := root.residents[info.ID].agent
|
||||
root.residentMu.Unlock()
|
||||
child := root.residentOf(info.ID).agent
|
||||
|
||||
// ★ 必须先停住子的调度器,再入队 —— 否则它不是确定性测试。
|
||||
//
|
||||
@ -548,9 +544,7 @@ func TestResidualDropNotifiesSyncCaller(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
child := root.residents[info.ID].agent
|
||||
root.residentMu.Unlock()
|
||||
child := root.residentOf(info.ID).agent
|
||||
|
||||
// ★ 同 TestResidualKeepReturnsTasksToParent:先停住子调度器再入队。
|
||||
// 否则子可能先把这条取走执行,于是"残余任务"为 0 条,断言失效。
|
||||
|
||||
205
internal/agent/core/outputpump.go
Normal file
205
internal/agent/core/outputpump.go
Normal file
@ -0,0 +1,205 @@
|
||||
package core
|
||||
|
||||
// outputpump.go 是输出通道的**异步壳**。
|
||||
//
|
||||
// # 它是什么(用户裁定 2026-11-04)
|
||||
//
|
||||
// 「就是在正常输出的情况下套一个异步的壳,rtchild 的所有调用都应该是异步的」
|
||||
//
|
||||
// 所以它**不改输出的语义**(还是把 text 交给同一个 outputch 设备),
|
||||
// 只是把"调用"这一步从调用方的 goroutine 里摘出去:
|
||||
//
|
||||
// rtStream 的流式循环: pump.Push(chunk) ← 立即返回
|
||||
// ↓ 有界队列(满则丢最旧)
|
||||
// pump 的消费 goroutine → dev.Execute(...)
|
||||
//
|
||||
// # 为什么必须异步(实测缺陷)
|
||||
//
|
||||
// 原 rtEmit 直接 `dev.Execute(...)`,于是**插件侧的延迟直接节流模型的生成**:
|
||||
//
|
||||
// 插件做 TTS / 网络编码 / 字幕渲染,慢 50ms 一片
|
||||
// → rtStream 每读一个 chunk 就卡 50ms
|
||||
// → 模型的下一个 token 迟 50ms 才开始消费
|
||||
// → 用户听到的首字延迟被下游放大
|
||||
//
|
||||
// 「不攒整段」(契约 2)的收益全被这里吃掉。
|
||||
//
|
||||
// # 为什么用"丢最旧的"而不是"阻塞"或"无界"
|
||||
//
|
||||
// 阻塞 → 退化成现在的同步(等于没做)
|
||||
// 无界 → 插件卡死会吃光内存
|
||||
// 丢最旧 → 符合 rt 的"最新者胜"语义:过期的音频/字幕本来就不该播
|
||||
//
|
||||
// 丢弃必须**可观测**(dropped 计数 + 首次日志),否则"用户听不到最后几句"
|
||||
// 会变成一个没有任何痕迹的现象。
|
||||
//
|
||||
// # 为什么不复用 IOManager.outputCh
|
||||
//
|
||||
// 那条通道是给 `output_send__*` 工具用的(`EmitOutputTo` → OutputEvent →
|
||||
// 由 SDK 的 OutputChan 消费者取)。它的语义是"一条完整的输出事件",
|
||||
// 而 rt 需要的是"逐 chunk 的增量流",且需要 per-child 的队列与丢弃统计。
|
||||
// 混用会让 rt 的背压污染主通道。所以单独一个壳,但复用同一套纪律。
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// outputPumpQueueSize 是每个 rtchild 输出队列的上限。
|
||||
//
|
||||
// 取值依据:rt 的 chunk 通常是几个到几十字节,64 片 ≈ 数百字节到数 KB,
|
||||
// 足以吸收一次网络抖动;再大就只是在缓存"用户已经听不到"的过期内容。
|
||||
const outputPumpQueueSize = 64
|
||||
|
||||
// outputPump 是一个输出通道的异步壳。
|
||||
//
|
||||
// ★ 生命周期:SpawnRTChild 时创建,StopRTChild 时 Close(排空后退出)。
|
||||
type outputPump struct {
|
||||
// resolve 每次**重新**解析设备。
|
||||
//
|
||||
// ★ 必须是闭包而不是缓存的 dev 指针:插件崩溃时 releasePluginChannels
|
||||
// 会 UnregisterDevice,恢复后重新注册。缓存指针会一直指向死设备 ——
|
||||
// 现象是"插件重启后 rt 再也说不出话",且日志里只有一行 execute 失败。
|
||||
resolve func() agentIO.Device
|
||||
|
||||
mu sync.Mutex
|
||||
q []string
|
||||
notify chan struct{}
|
||||
closing bool
|
||||
wg sync.WaitGroup
|
||||
|
||||
// dropped 是队列满时丢弃的片数(可观测)。
|
||||
dropped uint64
|
||||
firstDrop sync.Once
|
||||
name string // 输出通道名(日志用)
|
||||
}
|
||||
|
||||
func newOutputPump(name string, resolve func() agentIO.Device) *outputPump {
|
||||
p := &outputPump{
|
||||
name: name,
|
||||
resolve: resolve,
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
p.wg.Add(1)
|
||||
go p.loop()
|
||||
return p
|
||||
}
|
||||
|
||||
// Push 投递一段增量文本,**立即返回**,不等待插件。
|
||||
func (p *outputPump) Push(text string) {
|
||||
if p == nil || text == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.closing {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.q = append(p.q, text)
|
||||
// 满了:丢最旧(rt 的语义是"最新者胜",过期内容不该再播)。
|
||||
if len(p.q) > outputPumpQueueSize {
|
||||
p.q = p.q[1:]
|
||||
p.dropped++
|
||||
p.mu.Unlock()
|
||||
p.firstDrop.Do(func() {
|
||||
log.Printf("[rtchild] output pump %q queue full (%d), dropping oldest chunks — "+
|
||||
"插件消费慢于模型生成,用户可能听不到最早的一段", p.name, outputPumpQueueSize)
|
||||
})
|
||||
return
|
||||
}
|
||||
p.mu.Unlock()
|
||||
// 非阻塞唤醒(电平语义,不计数)。
|
||||
select {
|
||||
case p.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭壳:**排空队列后**退出,避免把最后半句吞掉。
|
||||
func (p *outputPump) Close() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.closing {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closing = true
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case p.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
// Stats 返回(剩余队列深度,丢弃片数)—— 诊断与判据用。
|
||||
func (p *outputPump) Stats() (depth int, dropped uint64) {
|
||||
if p == nil {
|
||||
return 0, 0
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.q), p.dropped
|
||||
}
|
||||
|
||||
// loop 是唯一的消费者。
|
||||
func (p *outputPump) loop() {
|
||||
defer p.wg.Done()
|
||||
for {
|
||||
<-p.notify
|
||||
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.q) == 0 {
|
||||
closing := p.closing
|
||||
p.mu.Unlock()
|
||||
if closing {
|
||||
return
|
||||
}
|
||||
break // 回外层等下一次唤醒
|
||||
}
|
||||
text := p.q[0]
|
||||
p.q = p.q[1:]
|
||||
p.mu.Unlock()
|
||||
|
||||
p.emit(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit 真正把一片交给设备。★ 每次重新解析设备(见 resolve 的注释)。
|
||||
func (p *outputPump) emit(text string) {
|
||||
dev := p.resolve()
|
||||
if dev == nil {
|
||||
// ★ 设备缺失必须留痕(一次性):插件崩溃期间会走到这里,
|
||||
// 静默丢弃会让"用户听不到回应"变成一个没有任何线索的现象。
|
||||
p.firstDrop.Do(func() {
|
||||
log.Printf("[rtchild] output pump %q: 输出设备不存在(插件可能已崩溃/未注册),"+
|
||||
"后续输出将被丢弃直到设备重新出现", p.name)
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, err := dev.Execute(p.name, map[string]interface{}{"text": text}); err != nil {
|
||||
log.Printf("[rtchild] output pump %q emit failed: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pumpWaitDrain 等一个 pump 排空(判据用,避免用 sleep)。
|
||||
func pumpWaitDrain(p *outputPump, within time.Duration) bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if depth, _ := p.Stats(); depth == 0 {
|
||||
return true
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
276
internal/agent/core/outputpump_test.go
Normal file
276
internal/agent/core/outputpump_test.go
Normal file
@ -0,0 +1,276 @@
|
||||
package core
|
||||
|
||||
// 判据:rt 的输出调用是**异步**的(用户裁定 2026-11-04)。
|
||||
//
|
||||
// 「就是在正常输出的情况下套一个异步的壳,rtchild 的所有调用都应该是异步的」
|
||||
//
|
||||
// ## 这条缺陷的原貌
|
||||
//
|
||||
// rtEmit 曾直接 `dev.Execute(...)`,于是插件侧延迟(TTS / 网络编码 / 字幕渲染)
|
||||
// **直接节流模型的 token 消费**:rtStream 每读一个 chunk 就等插件一次。
|
||||
// 「不攒整段」(契约 2)的收益全被这里吃掉。
|
||||
//
|
||||
// ## 判据为什么必须测"时序关系"而不是"有没有 pump"
|
||||
//
|
||||
// 断言 `rc.pump != nil` 是结构判据,改回同步但保留字段时仍然绿
|
||||
// (engineering-lessons.md §1.1)。所以这里测**可观测的时间差**:
|
||||
// 模型吐完的时刻 必须早于 插件收完的时刻。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// pumpSlowDevice 是一个**慢**的输出设备:每片 sleep,模拟 TTS / 网络编码。
|
||||
type pumpSlowDevice struct {
|
||||
name string
|
||||
delay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
// lastAt 是最后一次 Execute 完成的时刻(用于和"模型吐完"比较)。
|
||||
lastAt atomic.Int64
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (d *pumpSlowDevice) Name() string { return d.name }
|
||||
func (d *pumpSlowDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *pumpSlowDevice) Description() string { return "slow output" }
|
||||
func (d *pumpSlowDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *pumpSlowDevice) Start() error { return nil }
|
||||
func (d *pumpSlowDevice) Stop() error { return nil }
|
||||
func (d *pumpSlowDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *pumpSlowDevice) OutputCapabilities() agentIO.OutputCapability {
|
||||
return agentIO.CapText | agentIO.CapRT
|
||||
}
|
||||
|
||||
func (d *pumpSlowDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
d.calls.Add(1)
|
||||
time.Sleep(d.delay)
|
||||
d.mu.Lock()
|
||||
if s, ok := args["text"].(string); ok {
|
||||
d.texts = append(d.texts, s)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.lastAt.Store(time.Now().UnixNano())
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func (d *pumpSlowDevice) snapshot() []string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]string(nil), d.texts...)
|
||||
}
|
||||
|
||||
// burstProvider 一次性吐 n 片然后结束,并记录"吐完的时刻"。
|
||||
type burstProvider struct {
|
||||
n int
|
||||
doneAt atomic.Int64
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *burstProvider) Name() string { return "burst" }
|
||||
func (p *burstProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *burstProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "x"}, nil
|
||||
}
|
||||
|
||||
// ★ doneAt 必须记在**最后一片被消费之后**,而不是 ChatStream 返回时。
|
||||
//
|
||||
// 第一版把 channel 预填满再记时间戳 ⇒ doneAt 是"函数返回时刻",
|
||||
// 与真正的"模型吐完"无关。于是变异验证(把 rtEmit 改回同步)**不会变红** ——
|
||||
// 判据测的是一个恒真的量。这是 engineering-lessons.md §1.1
|
||||
// 「结构判据不能替代质量判据」的又一例。
|
||||
//
|
||||
// 现在用无缓冲 channel + goroutine 逐片发送,最后一片**送出后**才记时间戳,
|
||||
// 这样 doneAt 反映的是"消费侧真正读走最后一片"的时刻。
|
||||
func (p *burstProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for i := 0; i < p.n; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- agentAPI.StreamChunk{Content: "片"}:
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- agentAPI.StreamChunk{Done: true}:
|
||||
}
|
||||
// 最后一片(Done)已被消费方读走 ⇒ 这才是"模型吐完"的真实时刻。
|
||||
p.doneAt.Store(time.Now().UnixNano())
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRTChild_OutputIsAsync 是本阶段的核心判据。
|
||||
//
|
||||
// 慢设备(每片 40ms)× 8 片 = 串行需 ≥320ms。
|
||||
// 断言:模型"吐完"的时刻 早于 设备"收完"的时刻 —— 即生成没有被插件节流。
|
||||
//
|
||||
// 变红条件:rtEmit 改回同步 dev.Execute ⇒ 两个时刻几乎相等 ⇒ 红。
|
||||
func TestRTChild_OutputIsAsync(t *testing.T) {
|
||||
const chunks = 8
|
||||
const delay = 40 * time.Millisecond
|
||||
|
||||
dev := &pumpSlowDevice{name: "slowout", delay: delay}
|
||||
p := &burstProvider{n: chunks}
|
||||
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{ID: "async-host", IO: pio, Provider: p})
|
||||
if err := pio.RegisterDevice(dev); err != nil {
|
||||
t.Fatalf("注册慢设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"slowout", "slowin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "asynctest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "a-rt", Input: "slowin", Output: "slowout"})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
defer host.StopRTChildren()
|
||||
|
||||
if _, err := host.PushRTInput(id, "说话"); err != nil {
|
||||
t.Fatalf("PushRTInput: %v", err)
|
||||
}
|
||||
|
||||
// 等设备收完最后一片(或用 pump 排空作为更快的前置)。
|
||||
waitForWithin(t, "设备收完所有片", 10*time.Second, func() bool {
|
||||
return len(dev.snapshot()) >= chunks
|
||||
})
|
||||
|
||||
gen := p.doneAt.Load()
|
||||
consumed := dev.lastAt.Load()
|
||||
if gen == 0 || consumed == 0 {
|
||||
t.Fatalf("时序采样缺失:gen=%d consumed=%d", gen, consumed)
|
||||
}
|
||||
if gen >= consumed {
|
||||
t.Fatalf("模型吐完(%v) 不早于 插件收完(%v) —— 生成被插件节流了,"+
|
||||
"说明输出调用仍是同步的(异步壳没生效)",
|
||||
time.Unix(0, gen).Format("15:04:05.000"),
|
||||
time.Unix(0, consumed).Format("15:04:05.000"))
|
||||
}
|
||||
|
||||
// 顺带确认内容没丢、顺序没乱。
|
||||
got := dev.snapshot()
|
||||
if len(got) < chunks {
|
||||
t.Fatalf("只收到 %d 片,期望 %d(异步不该丢片)", len(got), chunks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputPump_DropsOldestOnFull 钉住背压语义:满则丢**最旧**。
|
||||
//
|
||||
// 变红条件:改成阻塞 Push(退化为同步)或无限队列(吃内存)⇒ 红。
|
||||
func TestOutputPump_DropsOldestOnFull(t *testing.T) {
|
||||
// 设备永远阻塞,逼队列堆满。
|
||||
block := make(chan struct{})
|
||||
dev := &blockingDevice{name: "blk", gate: block}
|
||||
pump := newOutputPump("blk", func() agentIO.Device { return dev })
|
||||
defer func() { close(block); pump.Close() }()
|
||||
|
||||
// 推入远超容量的片数。
|
||||
total := outputPumpQueueSize * 3
|
||||
for i := 0; i < total; i++ {
|
||||
pump.Push("x")
|
||||
}
|
||||
// 给消费 goroutine 一点时间卡在第一片上。
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
depth, dropped := pump.Stats()
|
||||
if dropped == 0 {
|
||||
t.Fatalf("推入 %d 片(容量 %d)却没有丢弃 —— 队列是无界的?"+
|
||||
"(depth=%d)", total, outputPumpQueueSize, depth)
|
||||
}
|
||||
if depth > outputPumpQueueSize {
|
||||
t.Fatalf("队列深度 %d 超过上限 %d —— 背压没生效", depth, outputPumpQueueSize)
|
||||
}
|
||||
}
|
||||
|
||||
// blockingDevice 的 Execute 阻塞到 gate 关闭。
|
||||
type blockingDevice struct {
|
||||
name string
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func (d *blockingDevice) Name() string { return d.name }
|
||||
func (d *blockingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *blockingDevice) Description() string { return "blocking" }
|
||||
func (d *blockingDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *blockingDevice) Start() error { return nil }
|
||||
func (d *blockingDevice) Stop() error { return nil }
|
||||
func (d *blockingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *blockingDevice) OutputCapabilities() agentIO.OutputCapability {
|
||||
return agentIO.CapText | agentIO.CapRT
|
||||
}
|
||||
func (d *blockingDevice) Execute(string, map[string]interface{}) (interface{}, error) {
|
||||
<-d.gate
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// TestOutputPump_CloseDrains 钉住"最后半句不被吞"。
|
||||
//
|
||||
// 变红条件:Close 直接丢弃队列 ⇒ 红。
|
||||
func TestOutputPump_CloseDrains(t *testing.T) {
|
||||
dev := &pumpSlowDevice{name: "drain", delay: 5 * time.Millisecond}
|
||||
pump := newOutputPump("drain", func() agentIO.Device { return dev })
|
||||
|
||||
const n = 5
|
||||
for i := 0; i < n; i++ {
|
||||
pump.Push("片")
|
||||
}
|
||||
pump.Close() // 必须等队列排空
|
||||
|
||||
if got := len(dev.snapshot()); got != n {
|
||||
t.Fatalf("Close 后设备收到 %d 片,期望 %d —— 队列没排空,最后几句被吞了", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputPump_ResolvesDeviceEachTime 钉住设备每片重新解析。
|
||||
//
|
||||
// 插件崩溃时设备会 Unregister,恢复后重新注册。缓存指针会一直指向死设备。
|
||||
// 变红条件:把 resolve 的结果缓存下来 ⇒ 恢复后仍然丢 ⇒ 红。
|
||||
func TestOutputPump_ResolvesDeviceEachTime(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var cur agentIO.Device // nil 表示"当前没有设备"
|
||||
|
||||
pump := newOutputPump("flap", func() agentIO.Device {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return cur
|
||||
})
|
||||
|
||||
// ① 设备不存在时推入:不 panic、不阻塞。
|
||||
pump.Push("早")
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// ② 设备出现后推入:必须能送达。
|
||||
dev := &pumpSlowDevice{name: "flap", delay: 0}
|
||||
mu.Lock()
|
||||
cur = dev
|
||||
mu.Unlock()
|
||||
pump.Push("晚")
|
||||
waitForWithin(t, "设备出现后输出送达", 3*time.Second, func() bool {
|
||||
return len(dev.snapshot()) >= 1
|
||||
})
|
||||
|
||||
pump.Close()
|
||||
if got := dev.snapshot(); len(got) == 0 {
|
||||
t.Fatal("设备恢复后仍收不到输出 —— resolve 被缓存成了死指针")
|
||||
}
|
||||
}
|
||||
@ -142,9 +142,7 @@ func TestPreferredProvider_InheritedByChildAgents(t *testing.T) {
|
||||
parent.preferredProvider = "parent-pref"
|
||||
|
||||
spawnTestResident(t, parent, dir, "inh")
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents["inh"]
|
||||
parent.residentMu.Unlock()
|
||||
rc := parent.residentOf("inh")
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子未创建")
|
||||
}
|
||||
@ -169,9 +167,7 @@ func TestPreferredProvider_RTChildOwnSource(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild: %v", err)
|
||||
}
|
||||
parent.rtMu.Lock()
|
||||
rc := parent.rtChildren[id]
|
||||
parent.rtMu.Unlock()
|
||||
rc := parent.rtChildOf(id)
|
||||
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "rt-fast" {
|
||||
t.Fatalf("rtchild 未使用自己配置的源,首位 = %v", got)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
childpkg "github.com/JianFeeeee/HologramAgent/internal/agent/core/child"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
@ -107,22 +108,83 @@ type ResidentInfo struct {
|
||||
}
|
||||
|
||||
type residentChild struct {
|
||||
id string
|
||||
agent *Agent
|
||||
light *memory.LightMemory
|
||||
mainRO *memory.GraphDB
|
||||
tempPath string
|
||||
dir string
|
||||
inputChs []string
|
||||
allowed []string
|
||||
createdAt time.Time
|
||||
// ★ 公共部分(id / Kind / ctx / IO / 入站通道 / 状态 / 轮次 / stopped)
|
||||
// 来自 childpkg.Child 的**嵌入**:骨架统一管理生命周期,类型只管差异。
|
||||
*childpkg.Child
|
||||
|
||||
agent *Agent
|
||||
light *memory.LightMemory
|
||||
mainRO *memory.GraphDB
|
||||
tempPath string
|
||||
dir string
|
||||
inputChs []string
|
||||
allowed []string
|
||||
// offloadOwned 标记这是内核为转投拉起的子(见 offload.go)。
|
||||
offloadOwned bool
|
||||
|
||||
// mu 只保护**驻留子特有的**状态(不与 childpkg.Child 的 mu 混用:
|
||||
// 两把锁各自保护自己的字段,避免一处加锁跨到另一处的字段)。
|
||||
mu sync.Mutex
|
||||
state string // running | contextfull | stopped
|
||||
}
|
||||
|
||||
// ── childpkg.Entry 实现(骨架通过它管理生命周期) ──
|
||||
|
||||
// Base 返回公共部分。
|
||||
func (rc *residentChild) Base() *childpkg.Child { return rc.Child }
|
||||
|
||||
// Kind 返回类型标签。
|
||||
func (rc *residentChild) Kind() childpkg.Kind { return childpkg.KindResident }
|
||||
|
||||
// Dispose 是驻留子的销毁语义:残余处置 + 通道处置 + 记忆句柄释放。
|
||||
//
|
||||
// ## 与 rt 的关键差别(不能统一)
|
||||
//
|
||||
// rt :无残余(未处理输入是过期状态,丢弃);通道**悬空**不归还
|
||||
// 驻留子 :残余 keep/drop(不丢活);通道按父的决定 **三选一**释放/挂起/接回
|
||||
//
|
||||
// 这三种通道处置是驻留子独有的语义(ChannelDisposition),
|
||||
// 由父在销毁时决定,写在 `residentDisposition` 上供本方法读取。
|
||||
func (rc *residentChild) Dispose(policy childpkg.ResidualPolicy) string {
|
||||
// 关记忆句柄:temp 与子同生共死(连同目录一起丢弃)。
|
||||
//
|
||||
// ★ 通道处置**不在这里**:它需要父的 sched / io(接回时要把暂存任务
|
||||
// 转进父的队列),而 residentChild 只有子自己的载体。所以那一段留在
|
||||
// Agent 侧(teardownResidentWith),由骨架的 OnDispose 钩子调用 ——
|
||||
// 强行搬进来会让本类型持有对父的隐式反向引用,比现在更糟。
|
||||
if rc.light != nil {
|
||||
_ = rc.light.Close()
|
||||
rc.light = nil
|
||||
}
|
||||
if rc.mainRO != nil {
|
||||
_ = rc.mainRO.Close()
|
||||
rc.mainRO = nil
|
||||
}
|
||||
if rc.dir != "" && strings.Contains(rc.tempPath, rc.Base().ID) {
|
||||
_ = os.RemoveAll(rc.dir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// residentEntryOf 按 id 取驻留子(含"是否存在")。
|
||||
//
|
||||
// 统一走 children 登记表,再做一次类型断言 —— 断言失败说明该 id 属于
|
||||
// 别的类型(如 rtchild),对调用方而言同样"不是驻留子"。
|
||||
func (a *Agent) residentEntryOf(id string) (*residentChild, bool) {
|
||||
e, ok := a.children.Get(id)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
rc, ok := e.(*residentChild)
|
||||
return rc, ok
|
||||
}
|
||||
|
||||
// residentOf 按 id 取驻留子(不存在返回 nil)。
|
||||
func (a *Agent) residentOf(id string) *residentChild {
|
||||
rc, _ := a.residentEntryOf(id)
|
||||
return rc
|
||||
}
|
||||
|
||||
// mainGraph 返回父自己的完整图记忆库(驻留子的受限句柄由它派生)。
|
||||
func (a *Agent) mainGraph() *memory.GraphDB { return a.memory }
|
||||
|
||||
@ -139,15 +201,9 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
return ResidentInfo{}, fmt.Errorf("父没有图记忆,无法为驻留子提供主库只读视图")
|
||||
}
|
||||
|
||||
a.residentMu.Lock()
|
||||
if a.residents == nil {
|
||||
a.residents = map[string]*residentChild{}
|
||||
if _, dup := a.children.Get(opts.ID); dup {
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在(%w)", opts.ID, childpkg.ErrChildExists)
|
||||
}
|
||||
if _, dup := a.residents[opts.ID]; dup {
|
||||
a.residentMu.Unlock()
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在", opts.ID)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
|
||||
// ① 轻量内核的记忆装配:主库**受限句柄**(结构上写不进)+ 自己的 temp 实例。
|
||||
// temp 目录由内核创建(调用方只给路径)——与子同生共死,销毁时整目录丢弃。
|
||||
@ -224,11 +280,21 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
})
|
||||
|
||||
rc := &residentChild{
|
||||
id: opts.ID, agent: child, light: light, mainRO: mainRO,
|
||||
Child: &childpkg.Child{
|
||||
ID: opts.ID,
|
||||
Kind: childpkg.KindResident,
|
||||
IO: childIO,
|
||||
// 驻留子的 ctx 用**子自己载体的**(它已 Start,有自己的调度循环)。
|
||||
// 与 rt 不同:rt 的 carrier 不 Start,ctx 由骨架单独派生。
|
||||
Ctx: child.ctx,
|
||||
Cancel: child.cancel,
|
||||
ParentID: string(a.id),
|
||||
},
|
||||
agent: child, light: light, mainRO: mainRO,
|
||||
tempPath: opts.TempPath, dir: filepath.Dir(opts.TempPath),
|
||||
inputChs: append([]string(nil), opts.InputChs...),
|
||||
allowed: append([]string(nil), opts.AllowedOutputs...),
|
||||
createdAt: time.Now(), state: "running",
|
||||
inputChs: append([]string(nil), opts.InputChs...),
|
||||
allowed: append([]string(nil), opts.AllowedOutputs...),
|
||||
state: "running",
|
||||
offloadOwned: opts.OffloadOwned,
|
||||
}
|
||||
|
||||
@ -271,9 +337,13 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
// ⑤ 子的 contextfull → 父侧的**内核级事件**(L4,带子标识)。
|
||||
child.onContextFull = func() { a.handleChildContextFull(rc) }
|
||||
|
||||
a.residentMu.Lock()
|
||||
a.residents[opts.ID] = rc
|
||||
a.residentMu.Unlock()
|
||||
if err := a.children.Add(rc); err != nil {
|
||||
child.Stop()
|
||||
_ = light.Close()
|
||||
_ = mainRO.Close()
|
||||
_ = os.RemoveAll(filepath.Dir(opts.TempPath))
|
||||
return ResidentInfo{}, fmt.Errorf("登记驻留子 %s 失败: %w", opts.ID, err)
|
||||
}
|
||||
|
||||
child.Start()
|
||||
|
||||
@ -318,16 +388,14 @@ func (a *Agent) DestroyResident(id string) error {
|
||||
|
||||
// DestroyResidentWith 按父的决定处置通道后销毁驻留子。
|
||||
func (a *Agent) DestroyResidentWith(id string, disp ChannelDisposition) error {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
if ok {
|
||||
delete(a.residents, id)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
e := a.children.Remove(id)
|
||||
if e == nil {
|
||||
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
a.teardownResidentWith(rc, disp, nil)
|
||||
rc, _ := e.(*residentChild)
|
||||
if rc != nil {
|
||||
a.teardownResidentWith(rc, disp, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -385,9 +453,7 @@ const (
|
||||
// 一旦取走,不再调 ApplyResidual 就等于把它们丢了。控制面工具走 ApplyResidual
|
||||
// (取+处置一步完成)以避免这个误用。
|
||||
func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) {
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc := a.residentOf(id)
|
||||
if rc == nil || rc.agent == nil {
|
||||
return nil, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -435,9 +501,7 @@ func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(
|
||||
// 若拆成两次调用,中间窗口里通道可能已被别人接管,暂存任务就跑到了
|
||||
// 一个跟它无关的 agent 手里。**一次定完,没有窗口。**
|
||||
func (a *Agent) FinishResidentWith(id string, policy ResidualPolicy, disp ChannelDisposition, onDestroy func(rc *residentChild) error) (n int, msg string, err error) {
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc := a.residentOf(id)
|
||||
if rc == nil || rc.agent == nil {
|
||||
return 0, "", fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -481,9 +545,7 @@ func (a *Agent) ApplyResidual(id string, policy ResidualPolicy) (int, string, er
|
||||
// 否则"取完到销毁之间"子还会收新任务,那些任务的结果随销毁丢失。
|
||||
//
|
||||
// 保留它是为了 TakeResidual 的配对语义(先取后自己决定),以及既有判据。
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc := a.residentOf(id)
|
||||
if rc == nil || rc.agent == nil {
|
||||
return 0, "", fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -608,7 +670,7 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
// 不撤销的后果:主体表以**调用方给的名字**为键,回收后同名重建是常规
|
||||
// 操作 ⇒ 新一代用上一代父留的约束运行;且表随 create/destroy 单调增长。
|
||||
// 与 rtchild 留档串线(rtClosedTranscripts)同一形态。
|
||||
a.revokeChildPolicy(rc.id)
|
||||
a.revokeChildPolicy(rc.ID)
|
||||
|
||||
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
|
||||
if rc.light != nil {
|
||||
@ -618,7 +680,7 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
_ = rc.mainRO.Close()
|
||||
}
|
||||
// temp 与子同生共死:连同目录一起丢弃(回收/销毁都不保留)。
|
||||
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
|
||||
if rc.dir != "" && strings.Contains(rc.tempPath, rc.ID) {
|
||||
_ = os.RemoveAll(rc.dir)
|
||||
}
|
||||
// 处置划入的 inputch(按父的决定:释放 / 挂起 / 接回)。
|
||||
@ -629,11 +691,11 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
// 接回父:通道归父,并把该通道上的暂存任务交出。
|
||||
drained, err := reg.AssignAndDrainStash(ch, string(a.id), 0)
|
||||
if err != nil {
|
||||
log.Printf("[resident] %s 接回通道 %s 失败: %v", rc.id, ch, err)
|
||||
log.Printf("[resident] %s 接回通道 %s 失败: %v", rc.ID, ch, err)
|
||||
continue
|
||||
}
|
||||
if len(drained) > 0 {
|
||||
log.Printf("[resident] %s 接回通道 %s,释放暂存任务 %d 条", rc.id, ch, len(drained))
|
||||
log.Printf("[resident] %s 接回通道 %s,释放暂存任务 %d 条", rc.ID, ch, len(drained))
|
||||
if onDrained != nil {
|
||||
onDrained(drained)
|
||||
} else {
|
||||
@ -651,7 +713,7 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
// 且日志明确写出"已挂起",让运维能查到它没有在跑。
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
log.Printf("[resident] %s 通道 %s 已挂起(暂存 %d 条,等接回时释放)",
|
||||
rc.id, ch, reg.StashLen(ch))
|
||||
rc.ID, ch, reg.StashLen(ch))
|
||||
default: // ChannelRelease
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
@ -663,36 +725,32 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
// 次数单调累积脏数据。实测:destroy 后 child/<id> 仍挂在根 agent 名下,
|
||||
// 而外部没有任何工具能单独注销 inputch,只能重启 homed 清。
|
||||
// 注意用纯函数算名字,不要再走 residentInboundChannel(会重新登记)。
|
||||
reg.Unregister(inboundChannelName(rc.id))
|
||||
reg.Unregister(inboundChannelName(rc.ID))
|
||||
}
|
||||
}
|
||||
|
||||
// StopResidents 销毁全部驻留子(父退出时必须;不留孤儿)。
|
||||
// ★ 与 DestroyResident(单数)共用 teardownResident —— 两者都由骨架的
|
||||
// Cleanup 驱动撤销策略/注销入站,本函数只补通道处置那一段(需要父的 sched)。
|
||||
func (a *Agent) StopResidents() int {
|
||||
a.residentMu.Lock()
|
||||
all := make([]*residentChild, 0, len(a.residents))
|
||||
for _, rc := range a.residents {
|
||||
all = append(all, rc)
|
||||
}
|
||||
a.residents = map[string]*residentChild{}
|
||||
a.residentMu.Unlock()
|
||||
for _, rc := range all {
|
||||
a.teardownResident(rc)
|
||||
all := a.children.ByKind(childpkg.KindResident)
|
||||
for _, e := range all {
|
||||
a.children.Remove(e.Base().ID)
|
||||
if rc, ok := e.(*residentChild); ok {
|
||||
a.teardownResident(rc)
|
||||
}
|
||||
}
|
||||
return len(all)
|
||||
}
|
||||
|
||||
// Residents 返回登记表的快照(按 id 排序,便于断言与展示稳定)。
|
||||
func (a *Agent) Residents() []ResidentInfo {
|
||||
a.residentMu.Lock()
|
||||
all := make([]*residentChild, 0, len(a.residents))
|
||||
for _, rc := range a.residents {
|
||||
all = append(all, rc)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
out := make([]ResidentInfo, 0, len(all))
|
||||
for _, rc := range all {
|
||||
out = append(out, rc.info())
|
||||
entries := a.children.ByKind(childpkg.KindResident)
|
||||
out := make([]ResidentInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if rc, ok := e.(*residentChild); ok {
|
||||
out = append(out, rc.info())
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
@ -700,9 +758,7 @@ func (a *Agent) Residents() []ResidentInfo {
|
||||
|
||||
// ResidentTable 是父**查看**子的 inputch 处理表(pull,不打断子)。
|
||||
func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc, ok := a.residentEntryOf(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -712,9 +768,7 @@ func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) {
|
||||
// SendToResident 是父的"发送消息":经输出通道寻址到该子的 inputch,
|
||||
// 对子而言是 **L4 中断**(取消当前状态 + 插入新消息)。
|
||||
func (a *Agent) SendToResident(id, text string) error {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc, ok := a.residentEntryOf(id)
|
||||
if !ok {
|
||||
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -749,9 +803,7 @@ func (a *Agent) SendToResident(id, text string) error {
|
||||
// 为什么先出计划再执行:摘要要调 LLM(可能几十秒),期间不能持锁、也不能
|
||||
// 先把事件摘掉 —— 那样一旦失败/取消,上下文就被破坏了一半。
|
||||
func (a *Agent) CompressResident(id string) (int, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc, ok := a.residentEntryOf(id)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -810,16 +862,14 @@ func (a *Agent) harvestResidentMemory(rc *residentChild, keep ...func([]InputchR
|
||||
if len(selected) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+rc.id, 0); err != nil {
|
||||
if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+rc.ID, 0); err != nil {
|
||||
return 0, fmt.Errorf("合入主记忆失败: %w", err)
|
||||
}
|
||||
return len(selected), nil
|
||||
}
|
||||
|
||||
func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
rc, ok := a.residentEntryOf(id)
|
||||
if !ok {
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
@ -848,7 +898,7 @@ func (a *Agent) handleChildContextFull(rc *residentChild) {
|
||||
rc.mu.Lock()
|
||||
rc.state = "contextfull"
|
||||
rc.mu.Unlock()
|
||||
a.raiseVector(VectorChildContextFull, vectorArgs{ChildID: rc.id})
|
||||
a.raiseVector(VectorChildContextFull, vectorArgs{ChildID: rc.ID})
|
||||
}
|
||||
|
||||
// checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。
|
||||
@ -990,9 +1040,9 @@ func (rc *residentChild) info() ResidentInfo {
|
||||
// 每次状态轮询为每个子算一次,成本可忽略(驻留子数量是个位数)。
|
||||
sc := rc.agent.schedulerStatus()
|
||||
info := ResidentInfo{
|
||||
ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...),
|
||||
ID: rc.ID, State: state, InputChs: append([]string(nil), rc.inputChs...),
|
||||
AllowedOutputs: append([]string(nil), rc.allowed...),
|
||||
ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table),
|
||||
ContextFull: full, CreatedAt: time.Unix(0, rc.CreatedAt), TableSize: len(table),
|
||||
SchedReady: sc.ReadyQueueDepth,
|
||||
SchedPending: sc.PendingInterrupts,
|
||||
SchedStack: sc.SuspendStack,
|
||||
|
||||
@ -74,9 +74,7 @@ func newResidentWithProvider(t *testing.T, prov agentAPI.Provider, id string) (*
|
||||
t.Helper()
|
||||
parent, _, dir := newRootWith(t, prov)
|
||||
spawnTestResident(t, parent, dir, id)
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents[id]
|
||||
parent.residentMu.Unlock()
|
||||
rc := parent.residentOf(id)
|
||||
if rc == nil {
|
||||
t.Fatalf("驻留子 %s 没创建成功", id)
|
||||
}
|
||||
@ -176,9 +174,7 @@ func TestCompressResident_UsesChildProvider(t *testing.T) {
|
||||
|
||||
// ③ 此时创建的子会继承 parentProv(SpawnResident 里 Provider: a.provider)。
|
||||
spawnTestResident(t, parent, dir, "own-provider")
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents["own-provider"]
|
||||
parent.residentMu.Unlock()
|
||||
rc := parent.residentOf("own-provider")
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子没创建成功")
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ func TestResident_InheritsParentOutputChannels(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
child := parent.residents["r-out"].agent
|
||||
child := parent.residentOf("r-out").agent
|
||||
|
||||
// ① 看得见:修复前这里是 0(childIO 空壳)
|
||||
if caps := child.io.GetChannelCapabilities("fakeout"); caps == 0 {
|
||||
@ -138,7 +138,7 @@ func TestResident_DefaultOutputsAreFull(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
child := parent.residents["r-full"].agent
|
||||
child := parent.residentOf("r-full").agent
|
||||
if out := child.executeOutputSendTool(outputSendTool("anywhere", "默认授权")); out != "ok" {
|
||||
t.Fatalf("默认应完整授权,得到 %q", out)
|
||||
}
|
||||
|
||||
@ -117,7 +117,7 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
}
|
||||
|
||||
// 子的轻量内核:有共同面、没有整理面。
|
||||
c1 := parent.residents["child-1"]
|
||||
c1 := parent.residentOf("child-1")
|
||||
if c1.agent.memory != nil {
|
||||
t.Fatal("驻留子不该有记忆整理面")
|
||||
}
|
||||
@ -181,7 +181,7 @@ func osStat(path string) (interface{}, error) {
|
||||
func TestResident_DeliveryBothDirections(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
child := parent.residentOf("child-1").agent
|
||||
|
||||
// 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。
|
||||
if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil {
|
||||
@ -207,7 +207,7 @@ func TestResident_DeliveryBothDirections(t *testing.T) {
|
||||
func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
child := parent.residentOf("child-1").agent
|
||||
|
||||
// 说明:create 会把任务提示词作为**第一条输入**投给子("create 即开工"),
|
||||
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
|
||||
@ -255,7 +255,7 @@ func TestResident_ContextFullAndDispositions(t *testing.T) {
|
||||
// ① contextfull:把子的**积累上下文**(a.context,不是拼好的消息)灌到超过窗口 90%。
|
||||
// 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
rc := parent.residents["child-1"]
|
||||
rc := parent.residentOf("child-1")
|
||||
child := rc.agent
|
||||
child.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(), Source: "sub/in",
|
||||
@ -364,7 +364,7 @@ func TestResident_E2EAndStress(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text",
|
||||
parent.residentOf(id).agent.io.InjectInput("sub/"+id+"/in", "text",
|
||||
map[string]interface{}{"content": msg})
|
||||
}
|
||||
}
|
||||
@ -383,7 +383,7 @@ func TestResident_E2EAndStress(t *testing.T) {
|
||||
|
||||
// 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3)。
|
||||
for _, id := range ids {
|
||||
parent.residents[id].agent.notifyParent("压力汇报 " + id)
|
||||
parent.residentOf(id).agent.notifyParent("压力汇报 " + id)
|
||||
}
|
||||
waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool {
|
||||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents)
|
||||
@ -442,7 +442,7 @@ func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||
provider := &captureProvider{}
|
||||
parent, _, dir := newRootWith(t, provider)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
child := parent.residentOf("child-1").agent
|
||||
|
||||
if !child.isLightKernel() {
|
||||
t.Fatal("驻留子必须被识别为轻量内核")
|
||||
|
||||
@ -141,10 +141,7 @@ func TestFinishResidentIsAtomic(t *testing.T) {
|
||||
if _, _, err := root.FinishResident(info.ID, ResidualKeep, nil); err != nil {
|
||||
t.Fatalf("FinishResident 失败: %v", err)
|
||||
}
|
||||
|
||||
root.residentMu.Lock()
|
||||
_, still := root.residents[info.ID]
|
||||
root.residentMu.Unlock()
|
||||
still := root.residentOf(info.ID) != nil
|
||||
if still {
|
||||
t.Fatal("★ FinishResident 之后子仍在登记表 —— 销毁没被纳入同一事务," +
|
||||
"取残余到销毁之间的窗口仍存在")
|
||||
|
||||
@ -26,9 +26,7 @@ import (
|
||||
// 可控。
|
||||
func pushToChild(t *testing.T, root *Agent, childID, channel, text string) {
|
||||
t.Helper()
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[childID]
|
||||
root.residentMu.Unlock()
|
||||
rc := root.residentOf(childID)
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", childID)
|
||||
}
|
||||
@ -75,9 +73,7 @@ func spawnQuietResident(t *testing.T, root *Agent, dir, id string, inputChs ...s
|
||||
// 这里复用它的做法:cancel + 等静止,不用任何自定义标志位。)
|
||||
func parkChild(t *testing.T, root *Agent, childID string) {
|
||||
t.Helper()
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[childID]
|
||||
root.residentMu.Unlock()
|
||||
rc := root.residentOf(childID)
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", childID)
|
||||
}
|
||||
@ -262,9 +258,7 @@ func TestResidualDrop_StillNotifiesSyncCaller(t *testing.T) {
|
||||
info := spawnQuietResident(t, root, dir, "drop1", "qq")
|
||||
|
||||
respCh := make(chan *agentIO.OutputEvent, 1)
|
||||
root.rtMu.Lock()
|
||||
root.rtMu.Unlock() // 只是保持锁序一致(无实际作用)
|
||||
child := root.residents[info.ID]
|
||||
child := root.residentOf(info.ID)
|
||||
child.agent.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||||
Source: "cli", OutputChannel: "qq", Payload: map[string]interface{}{"content": "答我"},
|
||||
ResponseCh: respCh,
|
||||
|
||||
@ -43,7 +43,9 @@ import (
|
||||
"sync"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/agent/core/child"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
@ -280,19 +282,35 @@ func (s *rtSlot) Wake() <-chan struct{} { return s.wake }
|
||||
|
||||
// rtChild 是一个 rtchild 实例(通话场景的实时子 agent)。
|
||||
//
|
||||
// 它**不进** `a.residents`(那是驻留子的登记表,语义是"有任务队列、有残余"),
|
||||
// 它**不是驻留子**(驻留子的语义是"有任务队列、有残余",与 rt 相反),
|
||||
// 而是自己一张轻表(见 Agent.rtChildren)。
|
||||
type rtChild struct {
|
||||
id string
|
||||
agent *Agent // 跑 rt 轮次的载体(已标记 isRealtime)
|
||||
// ★ 公共部分(id / Kind / ctx / IO / 入站通道 / 状态 / 轮次 / stopped)
|
||||
// 来自 child.Child 的**嵌入**:骨架统一管理生命周期,类型只管差异。
|
||||
// 嵌入而非命名成员:所有 rc.Base().ID 这类访问都能简化成 rc.ID。
|
||||
*child.Child
|
||||
|
||||
agent *Agent // 跑 rt 轮次的载体(已标记 isRealtime)
|
||||
// pump 是输出通道的**异步壳**(用户裁定 2026-11-04:rt 的所有调用都异步)。
|
||||
//
|
||||
// 它把"调用 outputch 设备"从 rtStream 的流式循环里摘出去:
|
||||
// Push 立即返回,插件侧延迟不再节流模型生成。
|
||||
pump *outputPump
|
||||
// light / mainRO 是本实例的图记忆句柄(参照驻留子)。
|
||||
//
|
||||
// ★ 必须在 Dispose 里关闭:它们是 SQLite 句柄,不关就随每次
|
||||
// create/stop 累积泄漏。驻留子那边由 teardownResident 关,
|
||||
// rt 这边原先没有图记忆,所以从未需要处理 —— 接入后必须补。
|
||||
light *memory.LightMemory
|
||||
mainRO *memory.GraphDB
|
||||
slot *rtSlot // 唯一输入入口:单槽覆盖
|
||||
input string // inputch(rt) 的名字(采集侧灌入;成对的一半)
|
||||
output string // outputch(rt) 的设备名(内核把文本写进去)
|
||||
|
||||
// round 是本轮序号(单调递增),seq 是本轮所基于的输入 seq。
|
||||
//
|
||||
// ★ seq 是**可验证凭据**(plan.md 十一.四 空白 3):输出回传它,
|
||||
// 判据才能证明"这一轮是用最新输入跑的",而不是旧输入的残余。
|
||||
// ★ seq 是**可验证凭据**:输出回传它,判据才能证明"这一轮是用最新输入跑的",
|
||||
// 而不是旧输入的残余。
|
||||
mu sync.Mutex
|
||||
round uint64
|
||||
lastSeq uint64
|
||||
@ -301,34 +319,90 @@ type rtChild struct {
|
||||
// nil = 没有正在跑的轮次。
|
||||
curCancel context.CancelFunc
|
||||
// critical > 0 表示正在执行工具(不可打断,R5)。
|
||||
// 用计数而非 bool:一轮里可能串行执行多个工具。
|
||||
// 用计数而非 bool:一轮里可能并发执行多个工具。
|
||||
critical int
|
||||
// suppressedCancels 统计「因处于工具执行期而**故意没取消**」的次数。
|
||||
//
|
||||
// ★ 为什么需要这个计数器(否则 R6 的守卫是**不可验证**的):
|
||||
// 设备工具执行经 `a.io.ExecuteTool`(toolcall.go:377),它内部传的是
|
||||
// `context.Background()` —— 工具**根本收不到** roundCtx。于是"工具不被
|
||||
// 中途掐断"这件事在两条路径上都成立,判据无法区分:
|
||||
// 设备工具执行经 `a.io.ExecuteTool`,它内部传的是 context.Background()
|
||||
// —— 工具**根本收不到** roundCtx。于是"工具不被中途掐断"这件事在两条
|
||||
// 路径上都成立,判据无法区分:
|
||||
// · 守卫生效(我们主动不取消)
|
||||
// · 守卫失效但取消也没效果(工具不消费 ctx)
|
||||
// 这个计数器把"守卫真的被执行了"变成可观测事实(判据
|
||||
// TestRTChild_CriticalSectionSuppressesCancel 钉它)。
|
||||
suppressedCancels uint64
|
||||
|
||||
// history 是跨轮累积的对话消息(普通上下文,plan.md 十一.四 修订后)。
|
||||
// history 是跨轮累积的对话消息(普通上下文)。
|
||||
//
|
||||
// 它**就是** rtchild 的全部上下文状态:一条通话是一条连续对话,
|
||||
// 每轮把「本轮 user 输入 + 模型产出 + 工具结果」追加进去,
|
||||
// 进模型前按窗口裁(见 buildRTContext / rtTrimToWindow)。
|
||||
//
|
||||
// 与另外两类子 agent 的差别只在**生命周期**:
|
||||
// 普通子 —— 局部 msgs,跑完即弃
|
||||
// 与另外几类子 agent 的差别只在**生命周期**:
|
||||
// 工作式子 —— 局部 msgs,跑完即弃
|
||||
// rtchild —— 同一份 msgs 跨轮保留(通话不能每轮失忆)
|
||||
//
|
||||
// 由 rtLoop 单 goroutine 串行改,锁只用于让外部读取(stats/判据)安全。
|
||||
history []agentAPI.Message
|
||||
// stopped 标记本实例已被 Stop(避免 Stop 后再启 loop)。
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// ── child.Entry 实现(骨架通过它管理生命周期) ──
|
||||
|
||||
// Base 返回公共部分。
|
||||
func (rc *rtChild) Base() *child.Child { return rc.Child }
|
||||
|
||||
// Kind 返回类型标签。
|
||||
func (rc *rtChild) Kind() child.Kind { return child.KindRT }
|
||||
|
||||
// Dispose 是 rt 的销毁语义:**无残余**(契约 3)。
|
||||
//
|
||||
// 与驻留子的关键差别:驻留子可 keep/drop 残余并回灌父,
|
||||
// rt 的未处理输入是过期状态,回灌只会污染主管线(计划 §七)。
|
||||
//
|
||||
// ★ 通道**悬空不归还**(用户裁定 2026-10-30):
|
||||
// 主 agent 不持有 rt 能力,把它"归还为未分配"等于让一个没有 rtSlot 的
|
||||
// agent 成为潜在持有方(routeInputByOwner 的 Owner=="" 分支会把输入
|
||||
// 兜底给主 agent,静默改变语义)。悬空是**显式**状态,运维能一眼看出
|
||||
// "这条 rt 通道的宿主没了"。
|
||||
func (rc *rtChild) Dispose(_ child.ResidualPolicy) string {
|
||||
// 契约 3:丢弃槽里未处理的输入,不转交。
|
||||
if rc.slot != nil {
|
||||
_, _, _ = rc.slot.Take()
|
||||
}
|
||||
// ★ 先排空输出队列再退出:Close 内部 wg.Wait() 会等消费 goroutine
|
||||
// 把剩余片发完。不排空会把"最后半句"吞掉 —— 用户听到话说到一半就断了。
|
||||
if rc.pump != nil {
|
||||
rc.pump.Close()
|
||||
rc.pump = nil
|
||||
}
|
||||
// 图记忆句柄必须关(SQLite 句柄,不关会随 create/stop 累积泄漏)。
|
||||
//
|
||||
// ★ 顺序:先关 temp(LightMemory 内部持有它),再关主库受限句柄。
|
||||
// 反了会让 LightMemory.Close 去关一个已关的 main(它只关 temp,
|
||||
// 但保持"子先于父"的顺序与本仓其它所有关闭路径一致)。
|
||||
if rc.light != nil {
|
||||
_ = rc.light.Close()
|
||||
rc.light = nil
|
||||
}
|
||||
if rc.mainRO != nil {
|
||||
_ = rc.mainRO.Close()
|
||||
rc.mainRO = nil
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rtChildOf 按 id 取 rt 实例(不存在返回 nil)。
|
||||
//
|
||||
// 统一走 children 登记表,再做一次类型断言 —— 断言失败说明该 id 属于
|
||||
// 别的类型(如驻留子),对调用方而言同样"不是 rtchild",返回 nil 是对的。
|
||||
func (a *Agent) rtChildOf(id string) *rtChild {
|
||||
e, ok := a.children.Get(id)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rc, _ := e.(*rtChild)
|
||||
return rc
|
||||
}
|
||||
|
||||
// RTChildOptions 是创建一个 rtchild 的参数。
|
||||
@ -368,6 +442,32 @@ type RTChildOptions struct {
|
||||
// 与 `spawn_child` 的 max_turns 同义,但默认值必须是 0(不限):
|
||||
// rt 是**常驻对话**而非一次性任务,跑满就结束是错的。
|
||||
MaxTools int
|
||||
// TempPath 是它 temp 图记忆的存储路径(空 = 不建 temp,只读父的主库)。
|
||||
//
|
||||
// ## 为什么 rt 也要有图记忆(用户裁定 2026-11-04)
|
||||
//
|
||||
// 参照驻留子(`SpawnResident` 的 LightMemory 装配):驻留子是
|
||||
// 「轻量内核 = 传统上下文 + 图记忆共同面」,rt 原先**零图记忆**
|
||||
// (carrier 既没有 Memory 也没有 LightMemory ⇒ graphMem() == nil),
|
||||
// 于是 memory_recall / memory_commit 这类共同面工具在 rt 里全部不可达。
|
||||
//
|
||||
// 但 rt 恰恰需要它:通话里用户会提到人、地点、约定、偏好,
|
||||
// 那些正是图记忆该记住的东西;而 rt 的 history 只在**这一通电话**内有效,
|
||||
// 通话一结束就只剩留档(父按需拉取)。不接图记忆 ⇒ 每通电话都从零开始。
|
||||
//
|
||||
// ## 为什么走 LightMemory 而不是直接给父的 *GraphDB
|
||||
//
|
||||
// 与驻留子同一理由:子写进去的东西必须与子同生共死、可被父采收,
|
||||
// 而不能直接污染主库。给了 TempPath 就是 temp 实例(读写、销毁时丢弃),
|
||||
// 同时**始终**带主库的受限只读句柄 ⇒ 子读得到父的全部记忆。
|
||||
TempPath string
|
||||
// AllowGraphWrite 声明是否允许写图记忆(默认 false = 只读)。
|
||||
//
|
||||
// 默认只读的理由:rt 是通话场景,一次通话几十轮高频写入会把图谱灌满
|
||||
// 噪声(与 rtvoice 插件给 inputch 标 NoMemory 同一条考虑)。
|
||||
// 需要「通话中记下用户说的事」时由父显式开 true。
|
||||
AllowGraphWrite bool
|
||||
|
||||
// AllowedTools / DenyTools 是**父为这个 rtchild 声明**的工具策略。
|
||||
//
|
||||
// 与驻留子同理(见 ResidentOptions.AllowedTools)。rt 流不禁工具(用户裁定 R3),
|
||||
@ -395,13 +495,10 @@ const rtChildDefaultPrompt = `你正在与用户实时通话。
|
||||
|
||||
// RTChildren 返回全部 rtchild 的 id(按 id 排序,便于断言与展示稳定)。
|
||||
func (a *Agent) RTChildren() []string {
|
||||
a.rtMu.Lock()
|
||||
defer a.rtMu.Unlock()
|
||||
out := make([]string, 0, len(a.rtChildren))
|
||||
for id := range a.rtChildren {
|
||||
out = append(out, id)
|
||||
out := make([]string, 0, 4)
|
||||
for _, e := range a.children.ByKind(child.KindRT) {
|
||||
out = append(out, e.Base().ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@ -447,15 +544,9 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
a.rtMu.Lock()
|
||||
if a.rtChildren == nil {
|
||||
a.rtChildren = map[string]*rtChild{}
|
||||
if _, dup := a.children.Get(opts.ID); dup {
|
||||
return "", fmt.Errorf("rtchild %s 已存在(%w)", opts.ID, child.ErrChildExists)
|
||||
}
|
||||
if _, dup := a.rtChildren[opts.ID]; dup {
|
||||
a.rtMu.Unlock()
|
||||
return "", fmt.Errorf("rtchild %s 已存在", opts.ID)
|
||||
}
|
||||
a.rtMu.Unlock()
|
||||
|
||||
// ① 独立 ctx:**必须**从父的 ctx 派生(父停则子停,不留孤儿),
|
||||
// 但**单独可取消**(打断 rtA 不波及父与 rtB)。见上面的注释。
|
||||
@ -474,11 +565,55 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
sysPrompt = rtChildDefaultPrompt
|
||||
}
|
||||
|
||||
// ★ 图记忆装配(用户裁定 2026-11-04:参照驻留子接入)。
|
||||
//
|
||||
// 形态与驻留子**逐项对齐**(resident.go 的 SpawnResident ①):
|
||||
//
|
||||
// 主库受限只读句柄 memory.OpenGraphDBReadOnly(a.mainGraph().Path())
|
||||
// temp 实例(可选) memory.NewLightMemory(ro, TempPath, AllowGraphWrite)
|
||||
//
|
||||
// 三处差异是刻意的,且都有理由:
|
||||
// ① TempPath 可为空:rt 常常只读(通话中不写记忆),此时不开 temp,
|
||||
// 只给受限只读句柄 —— 驻留子必须有 temp(它是长期干活的)。
|
||||
// ② AllowGraphWrite 默认 false:一次通话几十轮高频写入会把图谱灌满噪声
|
||||
// (与 rtvoice 插件给 inputch 标 NoMemory 同一条考虑)。
|
||||
// ③ 失败**必须回滚**:与驻留子同样拒绝创建一个"记忆装配半残"的执行体。
|
||||
var (
|
||||
rtLight *memory.LightMemory
|
||||
rtMainRO *memory.GraphDB
|
||||
)
|
||||
if gm := a.mainGraph(); gm != nil {
|
||||
ro, err := memory.OpenGraphDBReadOnly(gm.Path())
|
||||
if err != nil {
|
||||
cancel()
|
||||
return "", fmt.Errorf("rtchild 打开主库受限句柄失败: %w", err)
|
||||
}
|
||||
rtMainRO = ro
|
||||
light, err := memory.NewLightMemory(ro, opts.TempPath, opts.AllowGraphWrite)
|
||||
if err != nil {
|
||||
_ = ro.Close()
|
||||
cancel()
|
||||
return "", fmt.Errorf("rtchild 装配图记忆失败: %w", err)
|
||||
}
|
||||
rtLight = light
|
||||
}
|
||||
// 统一回滚:任何后续失败都要把这些句柄关掉,否则每失败一次泄一个
|
||||
// SQLite 句柄(驻留子那边用逐点 Close;这里集中成一个闭包,
|
||||
// 因为它有 4 个失败点,逐点写必然漏一处)。
|
||||
rollbackMem := func() {
|
||||
if rtLight != nil {
|
||||
_ = rtLight.Close()
|
||||
}
|
||||
if rtMainRO != nil {
|
||||
_ = rtMainRO.Close()
|
||||
}
|
||||
}
|
||||
|
||||
carrier := New(AgentConfig{
|
||||
// ★ 必须**逐字等于**登记表 key(opts.ID)。
|
||||
//
|
||||
// 曾经写成 "rt/"+opts.ID:于是 AssignRealtime 把通道 Owner 记成 "rt/xxx",
|
||||
// 而 routeInputByOwner 按 entry.Owner 去 a.rtChildren 里查(key 是 "xxx")
|
||||
// 而 routeInputByOwner 按 entry.Owner 去登记表里查(key 是 "xxx")
|
||||
// ⇒ **永远查不到** ⇒ 插件的 rt 输入被兜底给主 agent。
|
||||
// 这是"两个地方各拼一次名字"的典型故障(与 resident 的 inboundChannelName 同型)。
|
||||
ID: types.AgentID(opts.ID),
|
||||
@ -501,8 +636,12 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
// 不继承的后果与主 agent 同型:`securityDecisionFor` 恒走 nil ⇒ 恒放行。
|
||||
// 而它比主 agent 更危险 —— 通话场景下它一边听用户说话一边执行工具,
|
||||
// 没有任何人工复核的窗口。
|
||||
Security: a.security,
|
||||
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
|
||||
// ★ 图记忆共同面(与驻留子同一个字段、同一套语义):
|
||||
// 给了 LightMemory ⇒ graphMemoryOf 用它,且 a.memory 保持 nil
|
||||
// ⇒ 记忆整理面(块/媒体/流水线)全部不可达,只有 Recall/Commit 可用。
|
||||
LightMemory: rtLight,
|
||||
Security: a.security,
|
||||
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
|
||||
})
|
||||
// ★ 不调 carrier.Start():那会起 schedulerLoop / interceptLoop / 3 个记忆
|
||||
// 整理循环。rt 要的是"不吃队列、无轮询",这些 goroutine 对它是**反向**的。
|
||||
@ -544,9 +683,31 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
}
|
||||
|
||||
rc := &rtChild{
|
||||
id: opts.ID, agent: carrier, slot: carrier.rtSlot,
|
||||
Child: &child.Child{
|
||||
ID: opts.ID,
|
||||
Kind: child.KindRT,
|
||||
IO: childIO,
|
||||
Ctx: ctx,
|
||||
Cancel: cancel,
|
||||
// 入站通道:回调里投递用;Stop 时由骨架注销(单数/复数共用同一段)。
|
||||
InboundCh: rtInboundCh,
|
||||
ParentID: string(a.id),
|
||||
},
|
||||
agent: carrier, slot: carrier.rtSlot,
|
||||
light: rtLight, mainRO: rtMainRO,
|
||||
input: opts.Input, output: opts.Output,
|
||||
}
|
||||
// ★ 输出异步壳(用户裁定:rt 的所有调用都异步)。
|
||||
//
|
||||
// resolve 是闭包而不是缓存的设备指针:插件崩溃时 releasePluginChannels
|
||||
// 会注销设备,恢复后重新注册 —— 缓存指针会一直指向死设备,
|
||||
// 现象是"插件重启后 rt 再也说不出话"。
|
||||
rc.pump = newOutputPump(opts.Output, func() agentIO.Device {
|
||||
if a.io == nil {
|
||||
return nil
|
||||
}
|
||||
return a.io.GetDevice(opts.Output)
|
||||
})
|
||||
|
||||
// ★ 成对划拨 rt 通道(这是"主 agent 把 rt 通道划给 rtchild"的**唯一**落点)。
|
||||
//
|
||||
@ -570,7 +731,14 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// rollback 是**唯一**的失败回滚点:通道归属恢复 + 图记忆句柄释放。
|
||||
//
|
||||
// ★ 为什么把两者合成一个闭包而不逐个失败点各写一遍:
|
||||
// 本函数有 4 个失败出口(两个 AssignRealtime、declareChildPolicy、
|
||||
// children.Add)。逐点挂"关 memory 句柄"必然漏一处 —— 漏了就是
|
||||
// 每失败一次泄一个 SQLite 句柄(且只在失败路径上,平时看不出来)。
|
||||
rollback := func() {
|
||||
rollbackMem()
|
||||
reg := a.io.ChannelRegistry()
|
||||
if reg == nil {
|
||||
return
|
||||
@ -603,8 +771,11 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
return "", fmt.Errorf("为 rtchild %s 声明安全策略失败(拒绝创建一个无约束的子执行体): %w", opts.ID, err)
|
||||
}
|
||||
|
||||
a.rtMu.Lock()
|
||||
a.rtChildren[opts.ID] = rc
|
||||
if err := a.children.Add(rc); err != nil {
|
||||
cancel()
|
||||
rollback()
|
||||
return "", fmt.Errorf("登记 rtchild %s 失败: %w", opts.ID, err)
|
||||
}
|
||||
// ★ 这里**不**清留档(与一个曾被写错一版的修复相反,记录以示后人)。
|
||||
//
|
||||
// 我一度在这里加过 `delete(a.rtClosedTranscripts, opts.ID)`,理由是
|
||||
@ -621,7 +792,6 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
//
|
||||
// 现在的语义:留档只在**下一次 CloseRTChild 写同名键**时被覆写
|
||||
// (自然淘汰),在那之前一直可取。
|
||||
a.rtMu.Unlock()
|
||||
|
||||
go carrier.rtLoop(rc)
|
||||
|
||||
@ -629,98 +799,54 @@ 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 {
|
||||
return
|
||||
}
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
reg.Unregister(ch)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// StopRTChild 停止一个 rtchild 并清出登记表。
|
||||
//
|
||||
// 契约 3:**无残余回灌** —— 槽里未处理的那条输入直接丢弃(它是过期状态,
|
||||
// 回灌给父只会污染主管线,见计划 §七「插件那边的当前态默认不转交」)。
|
||||
// 回灌给父只会污染主管线)。
|
||||
//
|
||||
// ★ 通道**销毁后悬空**(用户裁定 2026-10-30):不归还为空。
|
||||
// 主 agent 不持有 rt 能力,归还为空会让 routeInputByOwner 的 Owner==""
|
||||
// 分支把输入兜底给主 agent,静默改变语义。悬空是**显式**状态,可日志诊断。
|
||||
// 只注销本内核自建的入站登记(child/<id>),不碰插件声明的 rt 通道。
|
||||
func (a *Agent) StopRTChild(id string) error {
|
||||
a.rtMu.Lock()
|
||||
rc, ok := a.rtChildren[id]
|
||||
if ok {
|
||||
delete(a.rtChildren, id)
|
||||
}
|
||||
a.rtMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("rtchild %s 不存在", id)
|
||||
}
|
||||
// ★ 撤销父为它声明的主体策略(与 StopRTChild 同时)。
|
||||
//
|
||||
// 与 resident 同理:主体表以调用方给的名字为键,同名重建会继承旧策略。
|
||||
// 而且若不撤销,`rt_children create id=x` → `stop x` → `create id=x`
|
||||
// 这个循环会让策略表持续增长且语义错误。
|
||||
a.revokeChildPolicy(id)
|
||||
rc.mu.Lock()
|
||||
rc.stopped = true
|
||||
rc.mu.Unlock()
|
||||
|
||||
// 取消自己的 ctx ⇒ rtLoop 在下一个安全点退出、当前 ChatStream 被截断。
|
||||
rc.agent.cancel()
|
||||
// 契约 3:无残余。清空槽(丢弃未处理输入,不转交)。
|
||||
_, _, _ = rc.slot.Take()
|
||||
if a.io != nil {
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
// ★ rt 通道**销毁后悬空**(用户裁定 2026-10-30),**不归还为空**。
|
||||
//
|
||||
// 为什么不归还:
|
||||
// · 主 agent **不持有** rt 能力 ⇒ 把它"归还为未分配"等于让一个
|
||||
// 没有 rtSlot 的 agent 成为潜在持有方(routeInputByOwner 的
|
||||
// Owner=="" 分支会把输入兜底给主 agent,静默改变语义)
|
||||
// · 悬空(Owner 仍指向已销毁的 id)是**显式**状态:路由查不到
|
||||
// 该 owner ⇒ 走既有的"owner 无匹配 agent"兜底 + 留日志,
|
||||
// 运维能一眼看出"这条 rt 通道的宿主没了"
|
||||
//
|
||||
// 换言之:悬空 = 可用日志诊断的坏状态;归还 = 静默改路由的坏状态。
|
||||
// 前者可查,后者不可查。
|
||||
//
|
||||
// 只注销入站登记(那是本内核自己建的 child/<id>,不是插件声明的 rt 通道)。
|
||||
reg.Unregister(inboundChannelName("rt/" + id))
|
||||
if err := a.children.StopOne(id, a.rtStopDeps()); err != nil {
|
||||
if errors.Is(err, child.ErrChildNotFound) {
|
||||
return fmt.Errorf("rtchild %s 不存在", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
log.Printf("[rtchild] %s stopped", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopRTChildren 停止全部 rtchild(父退出时必须;不留孤儿)。
|
||||
//
|
||||
// ★ 必须与 StopRTChild(单数)**行为对称**:那个会注销入站登记
|
||||
// (child/rt-<id>),本函数曾漏了这一步。
|
||||
//
|
||||
// 漏的后果与驻留子那边记录的是同一个形态:登记表是共享的、按 name 全局唯一,
|
||||
// 残留随 create/destroy 次数**单调累积**,而外部没有任何工具能单独注销
|
||||
// 一条 inputch —— 只能重启 homed 清。
|
||||
// ★ 与 StopRTChild(单数)**共用骨架的 Cleanup**:此前两条路径各写一遍,
|
||||
// 复数漏了注销入站登记,残留随 create/destroy 单调累积且只能重启清
|
||||
// (engineering-lessons.md §2.3)。现在"漏一步"在结构上不可能发生。
|
||||
func (a *Agent) StopRTChildren() int {
|
||||
a.rtMu.Lock()
|
||||
all := make([]*rtChild, 0, len(a.rtChildren))
|
||||
for _, rc := range a.rtChildren {
|
||||
all = append(all, rc)
|
||||
}
|
||||
a.rtChildren = map[string]*rtChild{}
|
||||
a.rtMu.Unlock()
|
||||
for _, rc := range all {
|
||||
rc.mu.Lock()
|
||||
rc.stopped = true
|
||||
rc.mu.Unlock()
|
||||
// ★ 撤销主体策略(必须与单数路径对称)。
|
||||
//
|
||||
// 这里曾漏过一步(入站登记未注销),与今天修的 StopRTChildren
|
||||
// 不对称是同一类缺陷形态 —— 所以两处必须逐字对齐。
|
||||
a.revokeChildPolicy(rc.id)
|
||||
rc.agent.cancel()
|
||||
_, _, _ = rc.slot.Take()
|
||||
// 注销入站登记(与 StopRTChild 的单数路径逐字一致)。
|
||||
//
|
||||
// 不碰插件声明的 rt 通道本身(那两条销毁后**悬空**,用户裁定):
|
||||
// 这里只摘本内核自己建的 child/rt-<id>。
|
||||
if a.io != nil {
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
reg.Unregister(inboundChannelName("rt/" + rc.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(all) > 0 {
|
||||
log.Printf("[rtchild] stopped all %d instance(s)", len(all))
|
||||
}
|
||||
return len(all)
|
||||
return a.children.StopAll(child.KindRT, a.rtStopDeps())
|
||||
}
|
||||
|
||||
// PushRTInput 投递一条 rt 输入(**覆盖式**,返回本次 seq)。
|
||||
@ -729,9 +855,7 @@ func (a *Agent) StopRTChildren() int {
|
||||
// 后者进四级中断阶梯(会被 preemptCooldown=2s 降级为入队),
|
||||
// 本函数直接覆盖单槽(最新者胜,永不排队)。见 plan.md 十一.三 B。
|
||||
func (a *Agent) PushRTInput(id, text string) (uint64, error) {
|
||||
a.rtMu.Lock()
|
||||
rc := a.rtChildren[id]
|
||||
a.rtMu.Unlock()
|
||||
rc := a.rtChildOf(id)
|
||||
if rc == nil {
|
||||
return 0, fmt.Errorf("rtchild %s 不存在", id)
|
||||
}
|
||||
@ -781,9 +905,7 @@ type RTChildStats struct {
|
||||
|
||||
// RTChildStats 返回该实例的状态快照。
|
||||
func (a *Agent) RTChildStats(id string) (RTChildStats, bool) {
|
||||
a.rtMu.Lock()
|
||||
rc := a.rtChildren[id]
|
||||
a.rtMu.Unlock()
|
||||
rc := a.rtChildOf(id)
|
||||
if rc == nil {
|
||||
return RTChildStats{}, false
|
||||
}
|
||||
@ -824,7 +946,7 @@ func (a *Agent) RTChildStats(id string) (RTChildStats, bool) {
|
||||
func (a *Agent) rtLoop(rc *rtChild) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[rtchild] %s loop panic recovered: %v\n%s", rc.id, r, debug.Stack())
|
||||
log.Printf("[rtchild] %s loop panic recovered: %v\n%s", rc.ID, r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
|
||||
@ -909,7 +1031,7 @@ func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) {
|
||||
// 每轮进 LLM 前看一看:若这期间已有更新的输入进了槽,本轮就不必再跑了
|
||||
// (新的一轮马上会用那条输入重开)。这是契约 1 在循环内的落点。
|
||||
if a.rtSuperseded(rc, seq) {
|
||||
log.Printf("[rtchild] %s round %d superseded by newer input before LLM", rc.id, round)
|
||||
log.Printf("[rtchild] %s round %d superseded by newer input before LLM", rc.ID, round)
|
||||
return
|
||||
}
|
||||
|
||||
@ -933,7 +1055,7 @@ func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) {
|
||||
// 工具批数上限(0 = 不限)。rt 是常驻对话,默认不限;
|
||||
// 给了上限是为了让调用方能约束"一次插话最多查几步"。
|
||||
if a.rtMaxTools > 0 && turns >= a.rtMaxTools {
|
||||
log.Printf("[rtchild] %s round %d reached max tool turns %d", rc.id, round, a.rtMaxTools)
|
||||
log.Printf("[rtchild] %s round %d reached max tool turns %d", rc.ID, round, a.rtMaxTools)
|
||||
return
|
||||
}
|
||||
turns++
|
||||
@ -942,17 +1064,35 @@ func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls,
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
// ★ 工具执行期间**不可打断**(R5 / plan.md 十一.三 A 的临界区):
|
||||
// 副作用不可回滚,打断只会让"决定执行但未执行"变成尴尬状态。
|
||||
a.rtEnterCritical(rc)
|
||||
out := a.executeToolCallOutcome(tc, rc.output)
|
||||
a.rtExitCritical(rc)
|
||||
// ★ 工具执行期间**不可打断**(R5 / plan.md 十一.三 A 的临界区):
|
||||
// 副作用不可回滚,打断只会让"决定执行但未执行"变成尴尬状态。
|
||||
//
|
||||
// ★★ 走共享的 runToolBatch(2026-11-04 重构):
|
||||
//
|
||||
// 原实现是逐条直调 `executeToolCallOutcome`,它内部**没有任何 runStage**
|
||||
// ⇒ rt 的工具调用对插件完全不可见:不能审计、不能拒绝、不能改参、不能脱敏。
|
||||
// 而 README 承诺的 before_toolcall「审计、拒绝、改参」在这条路径上不成立。
|
||||
//
|
||||
// 现在整批经过 before/after_toolcall,且按声明并发(rt 的工具互不依赖)。
|
||||
// 临界区改为**覆盖整批**:语义不变(副作用仍不可被打断),
|
||||
// 但不再需要"每个工具进一次"的计数抖动。
|
||||
a.rtEnterCritical(rc)
|
||||
results := a.runToolBatch(roundCtx, resp.ToolCalls, batchOpts{
|
||||
Channel: rc.output,
|
||||
// rt 无场景(它不吃 RelevanceContext)。
|
||||
Scenes: nil,
|
||||
})
|
||||
a.rtExitCritical(rc)
|
||||
|
||||
log.Printf("[rtchild] %s round %d tool %s -> %d bytes",
|
||||
rc.id, round, tc.Name, len(out.Text))
|
||||
// 按**索引顺序**落消息:并发只作用于执行,落消息必须保序,
|
||||
// 否则 assistant(tool_calls) 与 tool 结果的配对会被上游 400 拒绝。
|
||||
for i, r := range results {
|
||||
if i < len(resp.ToolCalls) {
|
||||
log.Printf("[rtchild] %s round %d tool %s -> %d bytes",
|
||||
rc.ID, round, r.Name, len(r.Text))
|
||||
}
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "tool", ToolCallID: tc.ID, Content: out.Text,
|
||||
Role: "tool", ToolCallID: r.CallID, Content: r.Text,
|
||||
})
|
||||
}
|
||||
// 这一批(assistant(tool_calls) + 配对 tool 结果)是**不可分割的一组**,
|
||||
@ -995,12 +1135,28 @@ func (a *Agent) rtStream(ctx context.Context, rc *rtChild, round uint64, msgs []
|
||||
// ctx 由调用方(rtRunOnce)按**整轮**提供,本函数不再自己派生:
|
||||
// 否则工具执行期间 curCancel 已被清空,R6 的临界区守卫会变成死代码
|
||||
// (见 rtRunOnce 里 roundCtx 的注释)。
|
||||
ch, err := a.provider.ChatStream(ctx, req)
|
||||
//
|
||||
// ★ 走**候选链**而不是裸 a.provider(2026-11-04 修):
|
||||
//
|
||||
// 原实现是 `a.provider.ChatStream(...)`,即只认本 agent 构造时拿到的那一个源。
|
||||
// 后果有两层,都不是“少一个优化”而是**功能失效**:
|
||||
//
|
||||
// ① PreferredProvider 完全无效。SpawnRTChild 把它写进了 carrier
|
||||
// (注释写着“rt 可以有自己更快的源”),配置项 core.agent.llm.resident_provider
|
||||
// 的显示名就是「实时子 agent 模型源」—— 但 rtStream 从不读它。
|
||||
// 实测:carrier 候选链首位已是 fast-rt,实际被调用的仍是 default 源。
|
||||
// ② 零回退。单源裸调用意味着该源抖动一次,通话就断一次 ——
|
||||
// 而多源回退链正是本仓为此建立的机制。
|
||||
//
|
||||
// 判据 TestRTChild_PreferredProviderIsUsed / _ProviderFallsBackOnFailure /
|
||||
// _InterruptDoesNotFallback 钉住这三条。
|
||||
providers := a.resolveProviders(req)
|
||||
ch, err := a.streamWithFallback(ctx, req, providers)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, false
|
||||
}
|
||||
log.Printf("[rtchild] %s round %d stream connect failed: %v", rc.id, round, err)
|
||||
log.Printf("[rtchild] %s round %d stream connect failed: %v", rc.ID, round, err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@ -1035,6 +1191,66 @@ func (a *Agent) rtStream(ctx context.Context, rc *rtChild, round uint64, msgs []
|
||||
}
|
||||
}
|
||||
|
||||
// streamWithFallback 在候选链上逐个尝试**建立**流(拿到 channel 即成功)。
|
||||
//
|
||||
// ## 与 callLLMWithFallback 的差别(刻意不同,不是偷工减料)
|
||||
//
|
||||
// ① 不重试同一个源:rt 的首字预算是 500ms 级,而 callLLMWithFallback
|
||||
// 的瞬时错误重试要等 2s —— 对通话那是灾难(用户听到 2 秒静默)。
|
||||
// 换源通常比等同一个源恢复快,所以这里直接下一个。
|
||||
// ② 不切换 active provider:a.provider 是 carrier 的长期字段,
|
||||
// 改写它会污染**后续所有轮次**的选择(rt 是长期实例,不是一次性调用)。
|
||||
// ③ 不改写 req.Model(除逐次浅拷贝的临时值):model 是 provider 的属性,
|
||||
// 不是请求的共享字段 —— 复用同一个 req 会让回退源收到上一个源
|
||||
// 的 model,实测会被网关以 403 model_not_allowed 拒掉(见
|
||||
// callLLMWithFallback 的 2026-10-27 真机记录)。这里每次尝试用浅拷贝。
|
||||
//
|
||||
// ## 取消为什么**不**换源
|
||||
//
|
||||
// context.Canceled 在 rt 里的含义是「用户又说话了,上一轮作废」(契约 1),
|
||||
// 不是「这个源坏了」。若把它当可回退错误,用户一插话就会把新请求打到
|
||||
// 第二个源上,而第一个源其实完全健康 —— 判据
|
||||
// TestRTChild_InterruptDoesNotFallback 钉住这条。
|
||||
func (a *Agent) streamWithFallback(ctx context.Context, req *agentAPI.CompletionRequest, providers []agentAPI.Provider) (<-chan agentAPI.StreamChunk, error) {
|
||||
if len(providers) == 0 {
|
||||
// resolveProviders 在 manager 为空时会兜底成 [a.provider],
|
||||
// 走到这里说明连 a.provider 都是 nil(装配错误)。
|
||||
return nil, fmt.Errorf("rtchild 没有可用的 provider")
|
||||
}
|
||||
var lastErr error
|
||||
for i, p := range providers {
|
||||
if i > 0 {
|
||||
log.Printf("[rtchild] stream fallback: trying provider %q (#%d/%d, previous error: %v)",
|
||||
p.Name(), i, len(providers)-1, lastErr)
|
||||
}
|
||||
// 每次尝试一份浅拷贝,只改 Model:不污染调用方的 req,
|
||||
// 也不让上一个源的 model 泄给下一个源。
|
||||
attempt := *req
|
||||
if rp, ok := p.(agentAPI.RoutableProvider); ok {
|
||||
if m := rp.Model(); m != "" {
|
||||
attempt.Model = m
|
||||
}
|
||||
}
|
||||
|
||||
ch, err := p.ChatStream(ctx, &attempt)
|
||||
if err == nil {
|
||||
if i > 0 && a.providerManager != nil {
|
||||
// 首选源失败但回退成功:清掉它的不可用标记,避免它
|
||||
// 因为一次抖动被永久降级。
|
||||
a.providerManager.ResetAvailability(p.Name())
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
// 取消/超时:用户中断(或整轮被停),**不换源**。
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
log.Printf("[rtchild] provider %q stream failed: %v", p.Name(), err)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// rtFlushToolCalls 把累积的 tool call 过滤掉 nil。
|
||||
func rtFlushToolCalls(acc []*agentAPI.ToolCall) []agentAPI.ToolCall {
|
||||
out := make([]agentAPI.ToolCall, 0, len(acc))
|
||||
@ -1051,9 +1267,20 @@ func rtFlushToolCalls(acc []*agentAPI.ToolCall) []agentAPI.ToolCall {
|
||||
// ★ 内核只产出**文本**(R6)。这里不判断设备是否 CapAudio,也不做 TTS:
|
||||
// 插件拿到 text 后怎么呈现(文字/语音/字幕)完全是它的事。
|
||||
func (a *Agent) rtEmit(rc *rtChild, text string) {
|
||||
if text == "" {
|
||||
if text == "" || rc == nil {
|
||||
return
|
||||
}
|
||||
// ★ 走异步壳(用户裁定 2026-11-04:rt 的所有调用都异步)。
|
||||
//
|
||||
// 原实现直接 `dev.Execute(...)`,插件侧延迟(TTS/网络编码/字幕渲染)
|
||||
// **直接节流模型的 token 消费** —— 「不攒整段」的收益全被吃掉。
|
||||
// pump.Push 立即返回,交付由 pump 的消费 goroutine 负责。
|
||||
if rc.pump != nil {
|
||||
rc.pump.Push(text)
|
||||
return
|
||||
}
|
||||
// 兜底(pump 未建时,如某些判据直接构造 rtChild):保持原同步行为,
|
||||
// 但必须留痕 —— 否则"异步没生效"会静默。
|
||||
if a.io == nil {
|
||||
return
|
||||
}
|
||||
@ -1062,7 +1289,7 @@ func (a *Agent) rtEmit(rc *rtChild, text string) {
|
||||
return
|
||||
}
|
||||
if _, err := dev.Execute(rc.output, map[string]interface{}{"text": text}); err != nil {
|
||||
log.Printf("[rtchild] %s emit to %s failed: %v", rc.id, rc.output, err)
|
||||
log.Printf("[rtchild] %s emit to %s failed: %v (pump 未建,走了同步兜底)", rc.ID, rc.output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1327,9 +1554,7 @@ const rtTranscriptToolMaxRunes = 500
|
||||
//
|
||||
// 返回交接了多少条消息(0 表示没有历史)。
|
||||
func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
a.rtMu.Lock()
|
||||
rc := a.rtChildren[id]
|
||||
a.rtMu.Unlock()
|
||||
rc := a.rtChildOf(id)
|
||||
if rc == nil {
|
||||
return 0, fmt.Errorf("rtchild %s 不存在", id)
|
||||
}
|
||||
@ -1357,12 +1582,12 @@ func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
// ★★ 同名键在这里**自然覆写**(2026-10-30):留档以 id 为键,同名回收
|
||||
// 会替换掉上一次的留档。串线问题不在写侧而在**读侧**(见 RTTranscript
|
||||
// 的"在跑实例优先"),所以这里不需要额外清理动作。
|
||||
a.rtMu.Lock()
|
||||
a.rtArchiveMu.Lock()
|
||||
if a.rtClosedTranscripts == nil {
|
||||
a.rtClosedTranscripts = map[string][]agentAPI.Message{}
|
||||
}
|
||||
a.rtClosedTranscripts[id] = transcript
|
||||
a.rtMu.Unlock()
|
||||
a.rtArchiveMu.Unlock()
|
||||
|
||||
a.raiseVector(VectorRTChildClosed, vectorArgs{RTID: id, RTCount: n, RTChars: chars})
|
||||
log.Printf("[rtchild] %s closed: %d messages (%d chars) kept for on-demand transcript", id, n, chars)
|
||||
@ -1383,10 +1608,10 @@ func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
// 两条都保留,缺一条就会在某个时序下拿不到原文。二者的先后由
|
||||
// "实例还在不在" 决定,而不是由"留档里有没有" 决定。
|
||||
func (a *Agent) RTTranscript(id string) ([]agentAPI.Message, bool) {
|
||||
a.rtMu.Lock()
|
||||
rc := a.rtChildren[id]
|
||||
rc := a.rtChildOf(id)
|
||||
a.rtArchiveMu.Lock()
|
||||
archived := a.rtClosedTranscripts[id]
|
||||
a.rtMu.Unlock()
|
||||
a.rtArchiveMu.Unlock()
|
||||
|
||||
if rc != nil {
|
||||
rc.mu.Lock()
|
||||
@ -1507,12 +1732,7 @@ const rtTranscriptDefaultLimit int = 40
|
||||
func (a *Agent) executeRTChildren(tc agentAPI.ToolCall) string {
|
||||
switch action := strArg(tc, "action"); action {
|
||||
case "", "list":
|
||||
a.rtMu.Lock()
|
||||
ids := make([]string, 0, len(a.rtChildren))
|
||||
for id := range a.rtChildren {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
a.rtMu.Unlock()
|
||||
ids := a.RTChildren()
|
||||
if len(ids) == 0 {
|
||||
return "当前没有实时子 agent(rtchild)。"
|
||||
}
|
||||
|
||||
201
internal/agent/core/rtchild_graph_test.go
Normal file
201
internal/agent/core/rtchild_graph_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package core
|
||||
|
||||
// 阶段 R3 判据:rtchild 的**图记忆接入**(参照驻留子的轻量内核形态)。
|
||||
//
|
||||
// ## 为什么这组判据必须存在
|
||||
//
|
||||
// 接入前 rt 的 carrier 既没有 Memory 也没有 LightMemory ⇒ `graphMem() == nil`
|
||||
// ⇒ memory_recall / memory_commit 这类图记忆共同面工具在 rt 里**全部不可达**。
|
||||
// 而 rt 恰恰需要它:通话里用户会提到人、地点、约定、偏好,那些正是图记忆
|
||||
// 该记住的东西;rt 的 history 只在**这一通电话**内有效,通话结束就只剩留档。
|
||||
//
|
||||
// 不接图记忆 ⇒ 每通电话都从零开始。这不是"少一个优化",是丢失能力。
|
||||
//
|
||||
// ## 判据为什么要走 SpawnRTChild
|
||||
//
|
||||
// 与 provider 那组同理(engineering-lessons.md §1.2):直构 Agent 会让判据假绿。
|
||||
// 装配(有哪些句柄、可写还是只读)是在 SpawnRTChild 里决定的。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
)
|
||||
|
||||
// rtGraphHost 造一个带真实图记忆库的父 agent。
|
||||
func rtGraphHost(t *testing.T) (*Agent, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开父图库: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{
|
||||
ID: "g-host", IO: pio, Memory: db, DataDir: dir,
|
||||
})
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: "gout"}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"gout", "gin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "gtest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
return host, dir
|
||||
}
|
||||
|
||||
// TestRTChild_HasGraphMemory 是核心判据:rt 必须拿到图记忆共同面。
|
||||
//
|
||||
// 变红条件:去掉 SpawnRTChild 里的 LightMemory 装配 ⇒ graphMem() == nil ⇒ 红。
|
||||
func TestRTChild_HasGraphMemory(t *testing.T) {
|
||||
host, _ := rtGraphHost(t)
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: "g-rt", Input: "gin", Output: "gout",
|
||||
TempPath: filepath.Join(t.TempDir(), "rt.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
|
||||
carrier := host.rtChildOf(id).agent
|
||||
if carrier.graphMem() == nil {
|
||||
t.Fatal("rtchild 的 graphMem() == nil —— 图记忆没有接入," +
|
||||
"memory_recall / memory_commit 在通话里全部不可达")
|
||||
}
|
||||
// 必须是轻量内核形态:整理面保持 nil(否则 rt 会把记忆整理流水线也拉起来)。
|
||||
if carrier.memory != nil {
|
||||
t.Fatalf("rtchild 拿到了完整图记忆库(memory != nil)—— " +
|
||||
"那会让记忆整理面(块/媒体/蒸馏)对 rt 可达,与「轻量内核」形态不符")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_GraphMemoryIsReadOnlyByDefault 钉住「默认只读」。
|
||||
//
|
||||
// 理由:一次通话几十轮高频写入会把图谱灌满噪声
|
||||
// (与 rtvoice 插件给 inputch 标 NoMemory 同一条考虑)。
|
||||
// 变红条件:把 AllowGraphWrite 默认成 true ⇒ 红。
|
||||
func TestRTChild_GraphMemoryIsReadOnlyByDefault(t *testing.T) {
|
||||
host, _ := rtGraphHost(t)
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: "ro-rt", Input: "gin", Output: "gout",
|
||||
TempPath: filepath.Join(t.TempDir(), "rt.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
lm, ok := host.rtChildOf(id).agent.graphMem().(*memory.LightMemory)
|
||||
if !ok {
|
||||
t.Fatalf("rtchild 的图记忆不是 *memory.LightMemory(轻量内核形态)")
|
||||
}
|
||||
if lm.AllowWrite() {
|
||||
t.Fatal("rtchild 默认允许写图记忆 —— 通话高频写入会把图谱灌满噪声")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_GraphWriteWhenEnabled 钉住显式开启后确实可写。
|
||||
//
|
||||
// 否则"能开"就只是个没被读取的字段(本仓已有过多例死字段)。
|
||||
func TestRTChild_GraphWriteWhenEnabled(t *testing.T) {
|
||||
host, _ := rtGraphHost(t)
|
||||
defer host.StopRTChildren()
|
||||
|
||||
tempPath := filepath.Join(t.TempDir(), "rw.db")
|
||||
id, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: "rw-rt", Input: "gin", Output: "gout",
|
||||
TempPath: tempPath,
|
||||
AllowGraphWrite: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
lm, ok := host.rtChildOf(id).agent.graphMem().(*memory.LightMemory)
|
||||
if !ok {
|
||||
t.Fatal("rtchild 的图记忆不是 *memory.LightMemory")
|
||||
}
|
||||
if !lm.AllowWrite() {
|
||||
t.Fatal("AllowGraphWrite=true 但 LightMemory 仍拒绝写 —— 开关没被读取")
|
||||
}
|
||||
if lm.Temp() == nil {
|
||||
t.Fatal("允许写但没有 temp 实例 —— 写入会无处落地")
|
||||
}
|
||||
// 真写一条,证明链路通(不是只有 bool 对)。
|
||||
if _, _, err := lm.Commit(nil, "s1", 1); err != nil {
|
||||
t.Fatalf("允许写却 Commit 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_GraphMemoryHandlesAreClosed 钉住「不泄漏 SQLite 句柄」。
|
||||
//
|
||||
// 变红条件:Dispose 里不关 light/mainRO ⇒ 句柄随 create/stop 累积。
|
||||
// 做法:连续建停多轮,断言 temp 文件可被重新创建/删除(Windows 之外
|
||||
// 更可靠的判据是 light/mainRO 字段被置 nil)。
|
||||
func TestRTChild_GraphMemoryHandlesAreClosed(t *testing.T) {
|
||||
host, _ := rtGraphHost(t)
|
||||
defer host.StopRTChildren()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
id := "cycle-rt"
|
||||
rc := host.rtChildOf(id)
|
||||
if rc != nil {
|
||||
t.Fatalf("第 %d 轮:上一轮的 rtchild 仍在登记表里", i)
|
||||
}
|
||||
_, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: id, Input: "gin", Output: "gout",
|
||||
TempPath: filepath.Join(t.TempDir(), "cycle.db"),
|
||||
AllowGraphWrite: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 轮 SpawnRTChild: %v", i, err)
|
||||
}
|
||||
rc = host.rtChildOf(id)
|
||||
if rc == nil {
|
||||
t.Fatalf("第 %d 轮:创建后取不到实例", i)
|
||||
}
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("第 %d 轮 StopRTChild: %v", i, err)
|
||||
}
|
||||
if rc.light != nil || rc.mainRO != nil {
|
||||
t.Fatalf("第 %d 轮停止后图记忆句柄未释放(light=%v mainRO=%v)—— "+
|
||||
"会随 create/stop 累积泄漏", i, rc.light != nil, rc.mainRO != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_NoGraphMemoryWhenParentHasNone 钉住降级不 panic。
|
||||
//
|
||||
// 父没有图记忆(独立工具/单测常见)时,rt 应当照常创建,
|
||||
// 只是 graphMem() == nil。变红条件:装配处对 nil 解引用 ⇒ 红。
|
||||
func TestRTChild_NoGraphMemoryWhenParentHasNone(t *testing.T) {
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{ID: "nog-host", IO: pio}) // 无 Memory
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: "nout"}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"nout", "nin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "ntest", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
defer host.StopRTChildren()
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "n-rt", Input: "nin", Output: "nout"})
|
||||
if err != nil {
|
||||
t.Fatalf("父无图记忆时 SpawnRTChild 不该失败: %v", err)
|
||||
}
|
||||
if gm := host.rtChildOf(id).agent.graphMem(); gm != nil {
|
||||
t.Fatalf("父无图记忆,子却拿到了 graphMem()=%v", gm)
|
||||
}
|
||||
}
|
||||
300
internal/agent/core/rtchild_provider_test.go
Normal file
300
internal/agent/core/rtchild_provider_test.go
Normal file
@ -0,0 +1,300 @@
|
||||
package core
|
||||
|
||||
// 阶段 1 判据:rtchild 的模型源必须走**候选链**(优先源 + 回退),不是裸 a.provider。
|
||||
//
|
||||
// ## 为什么这组判据必须走 SpawnRTChild
|
||||
//
|
||||
// 现有 TestPreferredProvider_* 有 6 条,全部用 `New(AgentConfig{...})` 直接构造
|
||||
// agent,断言的是 `resolveProviders` 的排序结果。它们证明「配置被记住了」,
|
||||
// 但证明不了「执行时用它了」。
|
||||
//
|
||||
// 而缺陷正在 SpawnRTChild 铺出来的那条路径上:carrier 的 PreferredProvider
|
||||
// 是对的(候选链首位就是它),可 rtStream 当时调的是 `a.provider.ChatStream`,
|
||||
// 绕过候选链 ⇒ 优先源一次都不被调用,且没有任何回退。
|
||||
//
|
||||
// ⇒ 本文件的每条判据都必须经 SpawnRTChild + 真实 PushRTInput 触发一轮。
|
||||
// 这是 engineering-lessons.md §1.2「判据测接线之后 ≠ 测有没有接线」的直接应用。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// rtCountProvider 记录调用次数,并可选地返回错误。
|
||||
type rtCountProvider struct {
|
||||
name string
|
||||
// failWith 非 nil 时 ChatStream 返回它(模拟源故障)。
|
||||
failWith error
|
||||
// content 是成功时吐的正文。
|
||||
content string
|
||||
|
||||
mu sync.Mutex
|
||||
streams int
|
||||
muCalled sync.Mutex
|
||||
}
|
||||
|
||||
func (p *rtCountProvider) Name() string { return p.name }
|
||||
func (p *rtCountProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func (p *rtCountProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: p.content}, nil
|
||||
}
|
||||
|
||||
func (p *rtCountProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.streams++
|
||||
p.mu.Unlock()
|
||||
|
||||
if p.failWith != nil {
|
||||
return nil, p.failWith
|
||||
}
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: p.content}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *rtCountProvider) calls() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.streams
|
||||
}
|
||||
|
||||
// rtProviderHost 造一个带 providerManager + 一对 rt 通道 + 输出设备的父 agent。
|
||||
func rtProviderHost(t *testing.T, pm *agentAPI.ProviderManager, def string) *Agent {
|
||||
t.Helper()
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{
|
||||
ID: "prov-host", IO: pio,
|
||||
Provider: pm.Get(def), ProviderManager: pm,
|
||||
})
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: "pvout"}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"pvout", "pvin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "provtest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// rtWaitRound 等到 rtchild 至少跑完 rounds 轮(等状态,不等时间)。
|
||||
func rtWaitRound(t *testing.T, host *Agent, id string, rounds uint64) {
|
||||
t.Helper()
|
||||
waitFor(t, "rtchild 跑够轮次", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && st.Rounds >= rounds
|
||||
})
|
||||
}
|
||||
|
||||
// TestRTChild_PreferredProviderIsUsed 钉住 D3 的正面:优先源必须真的被调用。
|
||||
//
|
||||
// 变红条件(已实测可红):rtStream 改回 `a.provider.ChatStream(...)`
|
||||
// ⇒ fast 源 calls()==0、slow 源被调 ⇒ 本判据红。
|
||||
func TestRTChild_PreferredProviderIsUsed(t *testing.T) {
|
||||
pm := agentAPI.NewProviderManager()
|
||||
slow := &rtCountProvider{name: "slow-default", content: "slow"}
|
||||
fast := &rtCountProvider{name: "fast-rt", content: "fast"}
|
||||
pm.Register("slow-default", slow)
|
||||
pm.Register("fast-rt", fast)
|
||||
if err := pm.SetDefault("slow-default"); err != nil {
|
||||
t.Fatalf("SetDefault: %v", err)
|
||||
}
|
||||
|
||||
host := rtProviderHost(t, pm, "slow-default")
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: "prov-rt", Input: "pvin", Output: "pvout",
|
||||
PreferredProvider: "fast-rt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
|
||||
// 先证明「配置被记住了」(候选链首位是 fast-rt)——
|
||||
// 这样如果后面失败,失败点唯一地指向"执行时没用它"。
|
||||
carrier := host.rtChildOf(id).agent
|
||||
if got := chainNames(carrier); len(got) == 0 || got[0] != "fast-rt" {
|
||||
t.Fatalf("前置条件失败:carrier 候选链首位 = %v,期望 fast-rt", got)
|
||||
}
|
||||
|
||||
if _, err := host.PushRTInput(id, "喂"); err != nil {
|
||||
t.Fatalf("PushRTInput: %v", err)
|
||||
}
|
||||
rtWaitRound(t, host, id, 1)
|
||||
|
||||
if fast.calls() == 0 {
|
||||
t.Fatalf("rtchild 声明 PreferredProvider=fast-rt 且候选链首位就是它,"+
|
||||
"但 fast-rt 一次都没被调用(slow 被调 %d 次)—— "+
|
||||
"rtStream 绕过了 resolveProviders", slow.calls())
|
||||
}
|
||||
if slow.calls() != 0 {
|
||||
t.Fatalf("首选源健康时不该调用默认源:slow=%d fast=%d", slow.calls(), fast.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_ProviderFallsBackOnFailure 钉住「优先,不是独占」。
|
||||
//
|
||||
// 首选源故障时必须切到下一个源,而不是整个通话失败。
|
||||
// 变红条件:streamWithFallback 只试第一个源就返回 ⇒ 红。
|
||||
func TestRTChild_ProviderFallsBackOnFailure(t *testing.T) {
|
||||
pm := agentAPI.NewProviderManager()
|
||||
backup := &rtCountProvider{name: "backup-src", content: "backup"}
|
||||
fast := &rtCountProvider{
|
||||
name: "broken-fast",
|
||||
failWith: errors.New("upstream 503"),
|
||||
}
|
||||
pm.Register("backup-src", backup)
|
||||
pm.Register("broken-fast", fast)
|
||||
if err := pm.SetDefault("backup-src"); err != nil {
|
||||
t.Fatalf("SetDefault: %v", err)
|
||||
}
|
||||
|
||||
host := rtProviderHost(t, pm, "backup-src")
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: "fb-rt", Input: "pvin", Output: "pvout",
|
||||
PreferredProvider: "broken-fast", // 首选源是坏的
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
|
||||
if _, err := host.PushRTInput(id, "喂"); err != nil {
|
||||
t.Fatalf("PushRTInput: %v", err)
|
||||
}
|
||||
rtWaitRound(t, host, id, 1)
|
||||
|
||||
if fast.calls() == 0 {
|
||||
t.Fatalf("首选源 broken-fast 没被尝试过(回退逻辑没走候选链)")
|
||||
}
|
||||
if backup.calls() == 0 {
|
||||
t.Fatalf("首选源故障后没有回退到 backup-src —— "+
|
||||
"rt 变成零回退的单源,抖一次就断一通(broken=%d backup=%d)",
|
||||
fast.calls(), backup.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_InterruptDoesNotFallback 钉住「取消不是可回退错误」。
|
||||
//
|
||||
// 契约 1:context.Canceled 的语义是「用户又说话了,上一轮作废」,
|
||||
// 不是「这个源坏了」。若把它当可回退错误,用户一插话就会把新请求
|
||||
// 打到第二个源上,而第一个源其实完全健康。
|
||||
//
|
||||
// 变红条件:streamWithFallback 去掉 context.Canceled 的特判 ⇒ 红。
|
||||
func TestRTChild_InterruptDoesNotFallback(t *testing.T) {
|
||||
// 直接单测 streamWithFallback:造一个「永远返回 Canceled」的源。
|
||||
cancelSrc := &rtCountProvider{name: "cancel-src", failWith: context.Canceled}
|
||||
backup := &rtCountProvider{name: "backup", content: "should-not-be-used"}
|
||||
|
||||
pm := agentAPI.NewProviderManager()
|
||||
pm.Register("cancel-src", cancelSrc)
|
||||
pm.Register("backup", backup)
|
||||
if err := pm.SetDefault("cancel-src"); err != nil {
|
||||
t.Fatalf("SetDefault: %v", err)
|
||||
}
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "cancel-agent", Provider: pm.Get("cancel-src"), ProviderManager: pm,
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req := &agentAPI.CompletionRequest{Messages: nil}
|
||||
providers := []agentAPI.Provider{cancelSrc, backup}
|
||||
_, err := a.streamWithFallback(ctx, req, providers)
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("取消应原样返回 context.Canceled,实际 = %v", err)
|
||||
}
|
||||
if backup.calls() != 0 {
|
||||
t.Fatalf("取消被当成了可回退错误:backup 被调用 %d 次 —— "+
|
||||
"用户插话会白打一个请求到备用源", backup.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_StreamFallbackDoesNotLeakModel 钉住「model 是 provider 的属性」。
|
||||
//
|
||||
// 见 callLLMWithFallback 的 2026-10-27 真机记录:回退时复用同一个 req,
|
||||
// 会让备用源收到上一个源的 model,被网关 403 model_not_allowed 拒掉。
|
||||
// 变红条件:streamWithFallback 直接改调用方的 req.Model ⇒ 红。
|
||||
func TestRTChild_StreamFallbackDoesNotLeakModel(t *testing.T) {
|
||||
seen := make(chan string, 4)
|
||||
first := &modelProbeProvider{name: "first", failWith: errors.New("boom"), seen: seen}
|
||||
second := &modelProbeProvider{name: "second", model: "second-model", seen: seen}
|
||||
|
||||
pm := agentAPI.NewProviderManager()
|
||||
pm.Register("first", first)
|
||||
pm.Register("second", second)
|
||||
if err := pm.SetDefault("first"); err != nil {
|
||||
t.Fatalf("SetDefault: %v", err)
|
||||
}
|
||||
a := New(AgentConfig{ID: "m", Provider: pm.Get("first"), ProviderManager: pm})
|
||||
|
||||
req := &agentAPI.CompletionRequest{Model: "original-model"}
|
||||
if _, err := a.streamWithFallback(context.Background(), req,
|
||||
[]agentAPI.Provider{first, second}); err != nil {
|
||||
t.Fatalf("streamWithFallback: %v", err)
|
||||
}
|
||||
|
||||
if req.Model != "original-model" {
|
||||
t.Fatalf("调用方的 req.Model 被改写成 %q —— 回退不该污染请求", req.Model)
|
||||
}
|
||||
// 第二个源拿到的必须是**它自己的** model。
|
||||
var gotSecond string
|
||||
timeout := time.After(2 * time.Second)
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case m := <-seen:
|
||||
gotSecond = m
|
||||
case <-timeout:
|
||||
t.Fatalf("只观测到 %d 次 ChatStream 调用", i)
|
||||
}
|
||||
}
|
||||
if gotSecond != "second-model" {
|
||||
t.Fatalf("回退源收到的 model = %q,期望 second-model(模型名串台)", gotSecond)
|
||||
}
|
||||
}
|
||||
|
||||
// modelProbeProvider 记录每次被调用时 req.Model 的值。
|
||||
type modelProbeProvider struct {
|
||||
name string
|
||||
model string
|
||||
failWith error
|
||||
seen chan string
|
||||
}
|
||||
|
||||
func (p *modelProbeProvider) Name() string { return p.name }
|
||||
func (p *modelProbeProvider) Model() string { return p.model }
|
||||
func (p *modelProbeProvider) Priority() int { return 0 }
|
||||
func (p *modelProbeProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *modelProbeProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "x"}, nil
|
||||
}
|
||||
func (p *modelProbeProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
select {
|
||||
case p.seen <- req.Model:
|
||||
default:
|
||||
}
|
||||
if p.failWith != nil {
|
||||
return nil, p.failWith
|
||||
}
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: "ok"}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
@ -47,7 +47,7 @@ func TestRTChild_AskparentToolPresent(t *testing.T) {
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "ask")
|
||||
rc := host.rtChildren[id]
|
||||
rc := host.rtChildOf(id)
|
||||
if rc == nil {
|
||||
t.Fatal("实例不在登记表")
|
||||
}
|
||||
@ -85,7 +85,7 @@ func TestRTChild_NotifyParentIsWired(t *testing.T) {
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "wire")
|
||||
rc := host.rtChildren[id]
|
||||
rc := host.rtChildOf(id)
|
||||
if rc == nil || rc.agent.notifyParent == nil {
|
||||
t.Fatal("rtchild 的 notifyParent 未接线 —— notify_parent 工具会永远失败")
|
||||
}
|
||||
@ -123,7 +123,7 @@ func TestRTChild_ToolFaceNotNarrowedByRT(t *testing.T) {
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "face")
|
||||
rc := host.rtChildren[id]
|
||||
rc := host.rtChildOf(id)
|
||||
|
||||
all := rtToolNames(rc.agent.buildToolDefs())
|
||||
kept := rtToolNames(rtStripOutputTools(rc.agent.buildToolDefs()))
|
||||
|
||||
@ -108,16 +108,12 @@ func TestRTTranscript_PrefersLiveInstanceOverArchive(t *testing.T) {
|
||||
rtSpawn(t, host, id)
|
||||
|
||||
// ② 再注入同 id 的陈旧留档 —— "活实例与留档共存"的那一瞬。
|
||||
host.rtMu.Lock()
|
||||
if host.rtClosedTranscripts == nil { // 懒初始化:没人回收过就该是 nil
|
||||
host.rtClosedTranscripts = map[string][]agentAPI.Message{}
|
||||
}
|
||||
host.rtClosedTranscripts[id] = []agentAPI.Message{
|
||||
{Role: "user", Content: "上一通的陈旧原话"},
|
||||
}
|
||||
host.rtMu.Unlock()
|
||||
|
||||
// ③ 让活实例真的说一句(否则 history 为空,判据会假绿)。
|
||||
host.PushRTInput(id, "正在说的这句")
|
||||
waitFor(t, "活实例第一轮完成", func() bool { return p.rounds() >= 1 })
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
|
||||
|
||||
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
|
||||
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
|
||||
rc := host.rtChildren[id]
|
||||
rc := host.rtChildOf(id)
|
||||
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
|
||||
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
|
||||
}
|
||||
@ -429,7 +429,7 @@ func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
|
||||
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
|
||||
})
|
||||
id := rtSpawn(t, host, "no-recurse")
|
||||
rc := host.rtChildren[id]
|
||||
rc := host.rtChildOf(id)
|
||||
if rc == nil {
|
||||
t.Fatal("实例不在登记表")
|
||||
}
|
||||
|
||||
@ -206,22 +206,38 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
||||
reachedLimit = true
|
||||
}
|
||||
|
||||
for _, ct := range resp.ToolCalls {
|
||||
var result string
|
||||
switch {
|
||||
case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
|
||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||
default:
|
||||
result = a.executeToolCall(ct, parentChannel)
|
||||
}
|
||||
// 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里
|
||||
// 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、
|
||||
// 解析失败,还是被轮上限截断)——只能看到一句「超时」。
|
||||
log.Printf("[child] %s turn %d/%d: %s -> %d bytes", taskID, turn+1, maxTurns, ct.Name, len(result))
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
|
||||
// ★★ 走共享的 runToolBatch(2026-11-04 重构):
|
||||
//
|
||||
// 原实现逐条直调 `executeToolCall`(= executeToolCallOutcome 的文本壳),
|
||||
// 内无任何 runStage ⇒ 普通子的工具调用同样**跳过 before/after_toolcall**,
|
||||
// 与 rt 同型缺陷(见 toolbatch.go 头注释的对照表)。
|
||||
//
|
||||
// 保留两处**子专属**的差异,通过 batchOpts 注入而不是各写一遍执行逻辑:
|
||||
// ① 禁用输出工具与系统工具(子把结果回父,不直接回用户)
|
||||
// ② 强制串行(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 ""
|
||||
},
|
||||
})
|
||||
// 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里
|
||||
// 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、
|
||||
// 解析失败,还是被轮上限截断)——只能看到一句「超时」。
|
||||
for _, r := range results {
|
||||
log.Printf("[child] %s turn %d/%d: %s -> %d bytes",
|
||||
taskID, turn+1, maxTurns, r.Name, len(r.Text))
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content,
|
||||
ToolCalls: []agentAPI.ToolCall{{ID: r.CallID, Name: r.Name}}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: r.CallID, Content: r.Text})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
256
internal/agent/core/toolbatch.go
Normal file
256
internal/agent/core/toolbatch.go
Normal file
@ -0,0 +1,256 @@
|
||||
package core
|
||||
|
||||
// toolbatch.go 是**唯一**的工具批执行实现(骨架级基础设施)。
|
||||
//
|
||||
// # 它解决什么(实测缺陷,2026-11-04)
|
||||
//
|
||||
// 全仓有**三**条工具执行路径,其中两条绕过了插件的 before/after_toolcall:
|
||||
//
|
||||
// 路径 before_toolcall after_toolcall 并发
|
||||
// 主 agent ✅ ✅ ✅ stepToolBatch
|
||||
// 驻留子 ✅ ✅ ✅ 同主 agent(走 runInputTask)
|
||||
// rtchild ❌ ❌ ❌ 直调 executeToolCallOutcome
|
||||
// 工作式子 ❌ ❌ ❌ 直调 executeToolCall
|
||||
//
|
||||
// 实测依据:`StageBeforeToolcall` / `StageAfterToolcall` 全仓只出现在
|
||||
// task.go:1022 / 1079 / 1194,三处都在 runOneTool / stepToolAfter 里;
|
||||
// 而 `executeToolCallOutcome` 内部**没有任何 runStage 调用**。
|
||||
//
|
||||
// 后果:rt 与普通子的工具调用对插件**完全不可见** —— 不能审计、不能拒绝、
|
||||
// 不能改参、不能脱敏。README 承诺的 before_toolcall「审计、拒绝、改参」
|
||||
// 在这两条路径上不成立。
|
||||
//
|
||||
// # 为什么不做成"让它们复用 TaskFrame"
|
||||
//
|
||||
// TaskFrame 承载 7 个 Step + 挂起/恢复/中断栈 + 场景解析 + 上下文预算,
|
||||
// 那是为「可抢占、现场可恢复」设计的。rt 明确不要这些(见 rtchild.go 的
|
||||
// 743 行注释),工作式子也不要(它是一次性局部循环)。
|
||||
// 复用 TaskFrame 会让两边都被拖进对方的复杂度里。
|
||||
//
|
||||
// ⇒ 抽出**最小公共核**:批并发 + 两个钩子 + 顺序落结果。
|
||||
// 三种类型都用它,差异通过参数传入。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// batchOpts 是批执行的可变部分。其余全部共享。
|
||||
type batchOpts struct {
|
||||
// Channel 是本次批量要回的通道(进 StageContext.Extra,插件据此判权限)。
|
||||
Channel string
|
||||
// Scenes 是本轮的场景键(rt / 工作式子传 nil)。
|
||||
Scenes []string
|
||||
// StageCtx 是基底 StageContext;内部按 idx 拆分(Extra 逐份复制)。
|
||||
// nil 时用一份空基底(仍然带 comefrom 戳)。
|
||||
StageCtx *sdk.StageContext
|
||||
// NoBatch 为 true 时强制串行(工作式子历史上是逐条串行)。
|
||||
NoBatch bool
|
||||
// ToolFilter 可在执行前改写/拒绝单个工具(工作式子用它禁 output_send__*)。
|
||||
//
|
||||
// 返回非空字符串表示"拒绝并以此作为结果",不回落到真实执行。
|
||||
ToolFilter func(tc agentAPI.ToolCall) string
|
||||
}
|
||||
|
||||
// batchResult 是单条工具的结果。
|
||||
type batchResult struct {
|
||||
CallID string
|
||||
Name string
|
||||
Text string
|
||||
Raw interface{}
|
||||
Denied bool
|
||||
}
|
||||
|
||||
// runToolBatch 执行一批工具,返回与 calls **同序**的结果。
|
||||
//
|
||||
// ## 为什么必须同序
|
||||
//
|
||||
// assistant(tool_calls) 与其后的 tool 结果消息必须相邻且一一对应。
|
||||
// 按完成顺序落消息会被上游以 400 拒绝(insufficient tool messages)。
|
||||
// 所以并发只作用于"执行",落结果一律按索引顺序。
|
||||
//
|
||||
// ## 并发条件
|
||||
//
|
||||
// 走与主 agent 相同的判据(batchRunnable):批内 >1、全部声明 ParallelSafe、
|
||||
// 不含同通道多次输出。这里额外允许 opts.NoBatch 强制串行 ——
|
||||
// 工作式子历史上是串行的,改成并发会改变它已上线行为的时序假设,
|
||||
// 不在本次重构范围内(判据 TestWorkerToolsStaySerial 钉住)。
|
||||
func (a *Agent) runToolBatch(ctx context.Context, calls []agentAPI.ToolCall, opts batchOpts) []batchResult {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 预建每份 StageContext:Extra 必须逐份复制(插件会写它,共享即竞争)。
|
||||
ctxs := make([]sdk.StageContext, len(calls))
|
||||
for i := range calls {
|
||||
// ★ 不复制 StageContext 整体:它内含 mutex(go vet 会报
|
||||
// "assignment copies lock value")。只逐字段取真正需要的那两项,
|
||||
// 与 task.go 的 buildToolContexts 同一写法。
|
||||
var extra map[string]interface{}
|
||||
var noMemory bool
|
||||
if opts.StageCtx != nil {
|
||||
extra = opts.StageCtx.Extra
|
||||
noMemory = opts.StageCtx.NoMemory
|
||||
}
|
||||
ctxs[i] = sdk.StageContext{
|
||||
Extra: copyExtraMap(extra),
|
||||
NoMemory: noMemory,
|
||||
}
|
||||
if ctxs[i].Extra == nil {
|
||||
ctxs[i].Extra = map[string]interface{}{}
|
||||
}
|
||||
if opts.Channel != "" {
|
||||
ctxs[i].Extra["output_channel"] = opts.Channel
|
||||
}
|
||||
// ★ 工具调用帧必须带来源标识(comefrom):通道插件的权限门靠它区分
|
||||
// "用户请求" 与 "agent 内部委派"。缺了它,子 agent 的工具调用会被按
|
||||
// **用户**权限模型判定 ⇒ 合法工作全被拦(comefrom.go 有 2026-10-16 实证)。
|
||||
//
|
||||
// 子 agent 是内部委派 ⇒ comeFromInternal。主 agent 传 comeFromUser
|
||||
// 由调用方在 StageCtx 里已标好(这里只在缺失时补保守值)。
|
||||
if _, ok := ctxs[i].Extra["comefrom"]; !ok {
|
||||
stampComeFrom(ctxs[i].Extra, comeFromInternal)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]batchResult, len(calls))
|
||||
|
||||
runOne := func(i int) {
|
||||
tc := calls[i]
|
||||
res := batchResult{CallID: tc.ID, Name: tc.Name}
|
||||
|
||||
// ① 类型级过滤(工作式子禁 output_send__* / spawn_child 等)。
|
||||
if opts.ToolFilter != nil {
|
||||
if deny := opts.ToolFilter(tc); deny != "" {
|
||||
res.Text = deny
|
||||
res.Denied = true
|
||||
results[i] = res
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tctx := &ctxs[i]
|
||||
tctx.ToolCalls = []sdk.ToolCall{{
|
||||
ID: tc.ID, Name: tc.Name,
|
||||
Plugin: a.resolveToolPlugin(tc.Name), Arguments: tc.Arguments,
|
||||
}}
|
||||
tctx.ToolResults = nil
|
||||
|
||||
// ② before_toolcall —— **这一步就是本文件存在的理由**。
|
||||
if a.runStage(sdk.StageBeforeToolcall, tctx) {
|
||||
res.Text = denialResultText(tctx, tc.Name)
|
||||
res.Denied = true
|
||||
results[i] = res
|
||||
return
|
||||
}
|
||||
// 插件可以改参(before_toolcall 的契约之一)。
|
||||
tc.Arguments = tctx.ToolCalls[0].Arguments
|
||||
|
||||
// ③ 插件崩溃态:不执行(与主 agent 一致)。
|
||||
if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
|
||||
if !a.pluginHealth.isHealthy(pluginName) {
|
||||
res.Text = pluginNameCrashedText(pluginName)
|
||||
res.Denied = true
|
||||
results[i] = res
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 真实执行。
|
||||
out := a.executeToolCallOutcome(tc, opts.Channel, opts.Scenes...)
|
||||
res.Text = out.Text
|
||||
res.Raw = out.Raw
|
||||
tctx.ToolResults = []sdk.ToolResult{{
|
||||
CallID: tc.ID, Name: tc.Name,
|
||||
Success: !isToolError(out.Raw), Result: out.Raw,
|
||||
}}
|
||||
|
||||
// ⑤ after_toolcall —— 插件可脱敏/改写结果。
|
||||
a.runStage(sdk.StageAfterToolcall, tctx)
|
||||
if len(tctx.ToolResults) > 0 {
|
||||
switch r := tctx.ToolResults[0].Result.(type) {
|
||||
case string:
|
||||
res.Text = r
|
||||
case nil:
|
||||
// 插件清空:保持原样,不覆盖。
|
||||
default:
|
||||
res.Raw = r
|
||||
res.Text = toolErrorText(tc.Name, r)
|
||||
if !isToolError(r) {
|
||||
res.Text = fmt.Sprintf("%v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
results[i] = res
|
||||
}
|
||||
|
||||
// 串行 or 并发。
|
||||
if opts.NoBatch || !a.batchParallelSafe(calls) {
|
||||
for i := range calls {
|
||||
runOne(i)
|
||||
}
|
||||
return results
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := range calls {
|
||||
wg.Add(1)
|
||||
go func(idx int) { defer wg.Done(); runOne(idx) }(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// batchParallelSafe 判断这批工具能否并发(与主 agent 的 batchRunnable 同判据)。
|
||||
func (a *Agent) batchParallelSafe(calls []agentAPI.ToolCall) bool {
|
||||
if len(calls) <= 1 {
|
||||
return false
|
||||
}
|
||||
chans := map[string]bool{}
|
||||
for _, tc := range calls {
|
||||
if !a.toolParallelSafe(tc.Name) {
|
||||
return false
|
||||
}
|
||||
if isOutputDeliveryTool(tc.Name) {
|
||||
ch := trimOutputPrefix(tc.Name)
|
||||
if chans[ch] {
|
||||
return false // 同通道多次输出必须保序
|
||||
}
|
||||
chans[ch] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sortBatchByIndex 是给调用方落消息用的辅助:按原始索引排(防御性)。
|
||||
//
|
||||
// 现有实现本来就按索引写 results,这里提供它是因为"按完成顺序落消息"
|
||||
// 是本仓踩过的坑,留一个显式函数让纪律可见。
|
||||
func sortBatchByIndex(rs []batchResult, order []string) []batchResult {
|
||||
if len(order) == 0 {
|
||||
return rs
|
||||
}
|
||||
idx := map[string]int{}
|
||||
for i, id := range order {
|
||||
idx[id] = i
|
||||
}
|
||||
out := append([]batchResult(nil), rs...)
|
||||
sort.SliceStable(out, func(i, j int) bool { return idx[out[i].CallID] < idx[out[j].CallID] })
|
||||
return out
|
||||
}
|
||||
|
||||
// 以下两个小工具避免在热路径里引 fmt 的多次分配。
|
||||
func pluginNameCrashedText(p string) string {
|
||||
return "插件 " + p + " 处于崩溃状态,已跳过执行,等待自动恢复重载"
|
||||
}
|
||||
|
||||
func trimOutputPrefix(name string) string {
|
||||
const p = "output_send__"
|
||||
if len(name) > len(p) {
|
||||
return name[len(p):]
|
||||
}
|
||||
return name
|
||||
}
|
||||
434
internal/agent/core/toolstage_test.go
Normal file
434
internal/agent/core/toolstage_test.go
Normal file
@ -0,0 +1,434 @@
|
||||
package core
|
||||
|
||||
// 判据:**四种子 agent 类型都必须经过 before/after_toolcall 插件钩子**。
|
||||
//
|
||||
// ## 这条缺陷的实测依据(2026-11-04)
|
||||
//
|
||||
// `StageBeforeToolcall` / `StageAfterToolcall` 全仓只出现在
|
||||
// task.go:1022 / 1079 / 1194,三处都在 runOneTool / stepToolAfter 里。
|
||||
// 而 rtchild 与工作式子直调 `executeToolCallOutcome`,它内部**没有任何 runStage**。
|
||||
//
|
||||
// ⇒ rt 与普通子的工具调用对插件完全不可见:不能审计、不能拒绝、不能改参、不能脱敏。
|
||||
//
|
||||
// ## 为什么判据要放在"真实路径"上
|
||||
//
|
||||
// 直接测 `runToolBatch` 会绿——但那正是 engineering-lessons.md §1.2 说的
|
||||
// "测接线之后,不测有没有接线"。所以:
|
||||
//
|
||||
// rt → 经 SpawnRTChild + 插件吐 tool_call + 真实 rtLoop
|
||||
// 工作式子 → 经 runChildTask 的真实循环
|
||||
//
|
||||
// 变红方式:把对应路径改回直调 executeToolCallOutcome ⇒ 钩子不触发 ⇒ 红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
// stageRecorder 记录插件钩子被触发的次数与工具名。
|
||||
type stageRecorder struct {
|
||||
mu sync.Mutex
|
||||
before []string
|
||||
after []string
|
||||
denyTool string // 非空时 before_toolcall 拒绝该工具
|
||||
rewriteTo string // 非空时 after_toolcall 把结果改写成它
|
||||
}
|
||||
|
||||
func (r *stageRecorder) snapBefore() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.before...)
|
||||
}
|
||||
|
||||
func (r *stageRecorder) snapAfter() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.after...)
|
||||
}
|
||||
|
||||
// stageHostFor 造一个带 stage 钩子的 StageHost 替身并挂到 agent 上。
|
||||
//
|
||||
// 用真实的 a.runStage 路径:把 handler 注册进 stageHost,然后由
|
||||
// a.setStageHandler(若不存在则直接塞进 stageHost 的实现)。
|
||||
func (r *stageRecorder) register(a *Agent) {
|
||||
if a.stageHost == nil {
|
||||
return
|
||||
}
|
||||
// ★ stageHost 是具体类型 *StageHost,不是接口:直接调方法。
|
||||
// RegisterStage 的 handler 签名是 func(*sdk.StageContext) error(见 stages.go:81)。
|
||||
a.stageHost.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||||
name := ""
|
||||
if len(ctx.ToolCalls) > 0 {
|
||||
name = ctx.ToolCalls[0].Name
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.before = append(r.before, name)
|
||||
deny := r.denyTool
|
||||
r.mu.Unlock()
|
||||
if deny != "" && name == deny {
|
||||
// 拦截的判定是 **ctx.Response != nil**(见 stage.go:38 的 return),
|
||||
// handler 返回 error 只表示"插件自身出错",不表示拦截。
|
||||
ctx.Response = strPtr("被插件拒绝:" + name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
a.stageHost.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||||
name := ""
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
name = ctx.ToolResults[0].Name
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.after = append(r.after, name)
|
||||
re := r.rewriteTo
|
||||
r.mu.Unlock()
|
||||
if re != "" && len(ctx.ToolResults) > 0 {
|
||||
ctx.ToolResults[0].Result = re
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// rtToolCallProvider 第一轮吐一个 tool_call,第二轮吐纯文本(结束)。
|
||||
//
|
||||
// 用两段式:rt 的循环要靠"模型不再要工具"退出,单轮会让判据永远等不到收尾。
|
||||
type rtToolCallProvider struct {
|
||||
toolName string
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *rtToolCallProvider) Name() string { return "rt-toolcall" }
|
||||
func (p *rtToolCallProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
// Chat 是**工作式子**用的路径(它走非流式 chatWithProviderChain)。
|
||||
//
|
||||
// ★ 第一版判据只实现了 ChatStream,于是工作式子永远拿不到 tool_call ⇒
|
||||
// 判据超时。这本身说明"两种子 agent 走的是不同 LLM 入口"这个事实
|
||||
// 必须被判据覆盖,否则测不到真实路径。
|
||||
func (p *rtToolCallProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
if n == 1 {
|
||||
return &agentAPI.CompletionResponse{
|
||||
ToolCalls: []agentAPI.ToolCall{{
|
||||
ID: "tc1", Name: p.toolName,
|
||||
Arguments: map[string]interface{}{"x": "1"},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "好的"}, nil
|
||||
}
|
||||
func (p *rtToolCallProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
|
||||
ch := make(chan agentAPI.StreamChunk, 3)
|
||||
if n == 1 {
|
||||
ch <- agentAPI.StreamChunk{
|
||||
ToolCall: &agentAPI.ToolCall{ID: "tc1", Name: p.toolName,
|
||||
Arguments: map[string]interface{}{"x": "1"}},
|
||||
}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
} else {
|
||||
ch <- agentAPI.StreamChunk{Content: "好的"}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRTChild_ToolsGoThroughPluginStages 是**核心判据**:rt 的工具必须触发两个钩子。
|
||||
//
|
||||
// 变红条件:rtRunOnce 改回直调 executeToolCallOutcome ⇒ before/after 均为空 ⇒ 红。
|
||||
func TestRTChild_ToolsGoThroughPluginStages(t *testing.T) {
|
||||
rec := &stageRecorder{}
|
||||
p := &rtToolCallProvider{toolName: "rtstage_probe"}
|
||||
|
||||
pio := agentIO.NewIOManager()
|
||||
host := newStageTestHost(t, "rtstage-host", pio, p)
|
||||
rec.register(host)
|
||||
registerStageTool(host, "rtstage_probe")
|
||||
registerPairChannels(t, pio, "sout", "sin")
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "st-rt", Input: "sin", Output: "sout"})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
defer host.StopRTChildren()
|
||||
|
||||
if _, err := host.PushRTInput(id, "调用工具"); err != nil {
|
||||
t.Fatalf("PushRTInput: %v", err)
|
||||
}
|
||||
waitForWithin(t, "rt 工具钩子被触发", 10*time.Second, func() bool {
|
||||
return len(rec.snapBefore()) > 0
|
||||
})
|
||||
|
||||
if got := rec.snapBefore(); !hasStageHit(got, "rtstage_probe") {
|
||||
t.Fatalf("before_toolcall 未收到 rt 的工具调用:%v", got)
|
||||
}
|
||||
waitFor(t, "after_toolcall 被触发", func() bool { return len(rec.snapAfter()) > 0 })
|
||||
if got := rec.snapAfter(); !hasStageHit(got, "rtstage_probe") {
|
||||
t.Fatalf("after_toolcall 未收到 rt 的工具调用:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_StageCanDenyTool 钉住"插件能拒绝 rt 的工具"(安全语义的实际效果)。
|
||||
//
|
||||
// 变红条件:绕开 before_toolcall ⇒ 拒绝不生效 ⇒ 工具真的被执行 ⇒ 红。
|
||||
func TestRTChild_StageCanDenyTool(t *testing.T) {
|
||||
rec := &stageRecorder{denyTool: "rtstage_deny"}
|
||||
p := &rtToolCallProvider{toolName: "rtstage_deny"}
|
||||
|
||||
pio := agentIO.NewIOManager()
|
||||
host := newStageTestHost(t, "rtdeny-host", pio, p)
|
||||
rec.register(host)
|
||||
called := registerStageTool(host, "rtstage_deny")
|
||||
registerPairChannels(t, pio, "dout", "din")
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "dn-rt", Input: "din", Output: "dout"})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
defer host.StopRTChildren()
|
||||
|
||||
if _, err := host.PushRTInput(id, "调用工具"); err != nil {
|
||||
t.Fatalf("PushRTInput: %v", err)
|
||||
}
|
||||
waitForWithin(t, "插件拒绝被记录", 10*time.Second, func() bool { return len(rec.snapBefore()) > 0 })
|
||||
// 给它时间真的去执行(若拒绝失效才会执行)。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if called() {
|
||||
t.Fatal("插件在 before_toolcall 里拒绝了该工具,但它**仍然被执行了** " +
|
||||
"—— 说明 rt 路径绕过了钩子")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerToolsGoThroughPluginStages 钉住普通子(工作式子)同样经过钩子。
|
||||
//
|
||||
// 变红条件:runChildTask 改回直调 executeToolCall ⇒ 钩子不触发 ⇒ 红。
|
||||
func TestWorkerToolsGoThroughPluginStages(t *testing.T) {
|
||||
rec := &stageRecorder{}
|
||||
p := &rtToolCallProvider{toolName: "wkstage_probe"}
|
||||
|
||||
pio := agentIO.NewIOManager()
|
||||
host := newStageTestHost(t, "wkstage-host", pio, p)
|
||||
rec.register(host)
|
||||
registerStageTool(host, "wkstage_probe")
|
||||
|
||||
host.childMu.Lock()
|
||||
host.childTasks = map[string]*childTaskState{}
|
||||
host.childMu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
host.runChildTask("wk_1", "执行任务", "cli", 2)
|
||||
}()
|
||||
waitForWithin(t, "工作式子的工具钩子被触发", 15*time.Second, func() bool { return len(rec.snapBefore()) > 0 })
|
||||
<-done
|
||||
|
||||
if got := rec.snapBefore(); !hasStageHit(got, "wkstage_probe") {
|
||||
t.Fatalf("before_toolcall 未收到普通子的工具调用:%v", got)
|
||||
}
|
||||
if got := rec.snapAfter(); !hasStageHit(got, "wkstage_probe") {
|
||||
t.Fatalf("after_toolcall 未收到普通子的工具调用:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerToolsStaySerial 钉住「工作式子仍是串行」。
|
||||
//
|
||||
// 改成并发会改变它已上线行为的时序假设(多步任务依赖前一步结果)。
|
||||
// 变红条件:去掉 batchOpts.NoBatch ⇒ 并发执行 ⇒ 红。
|
||||
func TestWorkerToolsStaySerial(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
active, maxActive := 0, 0
|
||||
probe := func() {
|
||||
mu.Lock()
|
||||
active++
|
||||
if active > maxActive {
|
||||
maxActive = active
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
mu.Lock()
|
||||
active--
|
||||
mu.Unlock()
|
||||
}
|
||||
_ = probe // 由 registerStageTool 的并发探针占位(见下)
|
||||
|
||||
// 用两个工具在同一条 assistant 消息里(模型一次吐两个 tool_call),
|
||||
// 若并发则 maxActive == 2,串行则 == 1。
|
||||
multi := &multiToolProvider{toolNames: []string{"wkser_a", "wkser_b"}}
|
||||
pio := agentIO.NewIOManager()
|
||||
host := newStageTestHost(t, "wkser-host", pio, multi)
|
||||
registerConcurrencyProbe(host, "wkser_a", &mu, &active, &maxActive)
|
||||
registerConcurrencyProbe(host, "wkser_b", &mu, &active, &maxActive)
|
||||
|
||||
host.childMu.Lock()
|
||||
host.childTasks = map[string]*childTaskState{}
|
||||
host.childMu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); host.runChildTask("wk_s", "并行试试", "cli", 2) }()
|
||||
waitForWithin(t, "两个工具都跑过", 15*time.Second, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return active == 0 && maxActive > 0
|
||||
})
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
got := maxActive
|
||||
mu.Unlock()
|
||||
if got > 1 {
|
||||
t.Fatalf("工作式子同时执行了 %d 个工具 —— 它必须保持串行"+
|
||||
"(多步任务依赖前一步结果,并发会打乱)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试脚手架 ──
|
||||
|
||||
// hasStageHit 是本文件用的"列表里有没有这个名字"。
|
||||
//
|
||||
// ★ 不叫 contains:context_test.go:228 已有 `contains(s, substr string) bool`,
|
||||
// 同名不同签名在同一个包里是编译错误。
|
||||
func hasStageHit(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newStageTestHost 造一个带 stageHost 的 host(stage 钩子必须有地方注册)。
|
||||
func newStageTestHost(t *testing.T, id string, pio *agentIO.IOManager, p agentAPI.Provider) *Agent {
|
||||
t.Helper()
|
||||
// ★ stageHost 不会由 New() 自动创建:它来自 AgentConfig.StageHost
|
||||
// (agent.go:582)。判据必须自己造一个,否则钩子根本没地方注册 ——
|
||||
// 这也是"插件钩子只挂在装配期"这个事实的体现。
|
||||
sh := NewStageHost()
|
||||
a := New(AgentConfig{ID: types.AgentID(id), IO: pio, Provider: p, StageHost: sh})
|
||||
if a.stageHost == nil {
|
||||
t.Fatalf("StageHost 未接上 —— 无法测插件钩子")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func registerPairChannels(t *testing.T, pio *agentIO.IOManager, out, in string) {
|
||||
t.Helper()
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: out}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{out, in} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "stagetest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerStageTool 注册一个探针工具,返回"是否真的被执行过"的查询函数。
|
||||
func registerStageTool(a *Agent, name string) func() bool {
|
||||
var called bool
|
||||
var mu sync.Mutex
|
||||
_ = a.stageHost.RegisterTool(name, sdk.ToolDef{
|
||||
Name: name,
|
||||
Description: "stage probe tool",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
mu.Lock()
|
||||
called = true
|
||||
mu.Unlock()
|
||||
return "probe-ok", nil
|
||||
})
|
||||
return func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return called
|
||||
}
|
||||
}
|
||||
|
||||
// registerConcurrencyProbe 注册一个记录并发峰值的工具。
|
||||
func registerConcurrencyProbe(a *Agent, name string, mu *sync.Mutex, active, maxActive *int) {
|
||||
_ = a.stageHost.RegisterTool(name, sdk.ToolDef{
|
||||
Name: name,
|
||||
Description: "concurrency probe",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
mu.Lock()
|
||||
*active++
|
||||
if *active > *maxActive {
|
||||
*maxActive = *active
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
mu.Lock()
|
||||
*active--
|
||||
mu.Unlock()
|
||||
return "ok", nil
|
||||
})
|
||||
}
|
||||
|
||||
// multiToolProvider 第一轮一次吐多个 tool_call,第二轮收尾。
|
||||
type multiToolProvider struct {
|
||||
toolNames []string
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *multiToolProvider) Name() string { return "multi-tool" }
|
||||
func (p *multiToolProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *multiToolProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
if n == 1 {
|
||||
calls := make([]agentAPI.ToolCall, 0, len(p.toolNames))
|
||||
for i, name := range p.toolNames {
|
||||
calls = append(calls, agentAPI.ToolCall{
|
||||
ID: "t" + string(rune('a'+i)), Name: name,
|
||||
Arguments: map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
return &agentAPI.CompletionResponse{ToolCalls: calls}, nil
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "done"}, nil
|
||||
}
|
||||
func (p *multiToolProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
ch := make(chan agentAPI.StreamChunk, len(p.toolNames)+2)
|
||||
if n == 1 {
|
||||
for i, name := range p.toolNames {
|
||||
ch <- agentAPI.StreamChunk{ToolCall: &agentAPI.ToolCall{
|
||||
ID: "t" + string(rune('a'+i)), Name: name,
|
||||
Arguments: map[string]interface{}{},
|
||||
}}
|
||||
}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
} else {
|
||||
ch <- agentAPI.StreamChunk{Content: "done"}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
@ -26,7 +26,7 @@ func TestChildContextFull_L4CarriesIdentityAndDispositions(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
spawnTestResident(t, parent, dir, "child-vec")
|
||||
|
||||
rc := parent.residents["child-vec"]
|
||||
rc := parent.residentOf("child-vec")
|
||||
child := rc.agent
|
||||
|
||||
// 把子的积累上下文推到窗口之上(同既有判据的手法:灌 a.context 而非拼好的消息)
|
||||
|
||||
Reference in New Issue
Block a user