mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-31 22:37:28 +00:00
feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)
第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。
- R1 CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2 rtSlot 单槽覆盖(latest-wins,从不排队)
- R3 独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4 ChatStream 逐块 → outputch(rt).Execute(text)
- R5 打断 = 原地截断,不重试
- R6 工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7 普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9 CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)
刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。
## 四类 agent 各自可配模型源
core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。
★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。
## 驻留子残余任务四态 + 通道三态
残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。
通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。
一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。
## 修复"手写字段白名单"丢值(7 处)
同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。
- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)
## 压缩偏离设计
CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。
并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。
## 判据
新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
"在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿
## 判据自身的坑(两次偶发红,都不是产品缺陷)
1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
→ "暂存 0 条"。修法:cancel + waitSchedulerStopped。
(offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
This commit is contained in:
@ -35,8 +35,11 @@ type Agent struct {
|
||||
id types.AgentID
|
||||
provider agentAPI.Provider
|
||||
providerManager *agentAPI.ProviderManager
|
||||
io *agentIO.IOManager
|
||||
memory *memory.GraphDB
|
||||
// preferredProvider 是本 agent 优先使用的模型源名("" = 总默认源)。
|
||||
// 语义见 AgentConfig.PreferredProvider:优先而非独占。
|
||||
preferredProvider string
|
||||
io *agentIO.IOManager
|
||||
memory *memory.GraphDB
|
||||
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
||||
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
||||
graph GraphMemory
|
||||
@ -47,13 +50,49 @@ type Agent struct {
|
||||
taskPrompt string
|
||||
dataDir string
|
||||
|
||||
// isRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
|
||||
//
|
||||
// 它是 agent **类型标签**,与通道侧的 CapRT 是两个不同结构:
|
||||
// · CapRT —— “这个**通道**能承载 rt 流”(插件声明)
|
||||
// · isRealtime —— “这个 **agent** 是 rtchild”(内核构造时定)
|
||||
// rt 通道的划拨要两侧都满足(见 Agent.AssignRealtime)。
|
||||
//
|
||||
// 与 kernelSource 同类:都是“这个 agent 在层级/类型上是谁”的不可变标记。
|
||||
isRealtime bool
|
||||
|
||||
// 驻留子(父侧):登记表 + 子侧钩子。
|
||||
residentMu sync.Mutex
|
||||
residents map[string]*residentChild
|
||||
|
||||
// rtchild(实时子 agent,plan.md 十一):登记表 + 单槽输入。
|
||||
//
|
||||
// ★ 为何与 residents 分开而不复用:两者的语义**相反**:
|
||||
// residents:有任务队列、有残余可回灌、父可 pull 状态面(不丢活)
|
||||
// rtChildren:无队列(单槽覆盖)、无残余、最新者胜(低延迟)
|
||||
// 混在一张表里会让"该不该转残余"这类判断变成条件分叉。
|
||||
rtMu sync.Mutex
|
||||
rtChildren map[string]*rtChild
|
||||
// rtClosedTranscripts 是**已回收** rtchild 的上下文留档(id → 全文)。
|
||||
//
|
||||
// 为什么必须留档:交接是「推信号 + 拉状态」两步(见 CloseRTChild),
|
||||
// 推完中断后实例已出登记表 —— 若不留档,主 agent 收到"可调用 rt_transcript
|
||||
// 取回原文"的提示时,原文已经取不到了。
|
||||
rtClosedTranscripts map[string][]agentAPI.Message
|
||||
// rtSlot 是**本 agent 作为 rtchild 时**的输入槽(作为父时无用)。
|
||||
rtSlot *rtSlot
|
||||
// rtMaxTools 是本 agent 作为 rtchild 时的工具批数上限(0 = 不限)。
|
||||
rtMaxTools int
|
||||
|
||||
// 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。
|
||||
notifyParent func(text string)
|
||||
onContextFull func()
|
||||
notifyParent func(text string)
|
||||
onContextFull func()
|
||||
// ctxFullSignaled / ctxFullMu:contextfull 只上报一次的去重标记。
|
||||
//
|
||||
// ★ 必须加锁(2026-10-30,-race 实测抓到真实竞争):
|
||||
// 读它的是**子自己的调度器 goroutine**(checkContextFull,stepPrepare 里),
|
||||
// 而写它的是**父的 goroutine**(CompressResident → resetContextFull)。
|
||||
// 这两个 goroutine 并发读写同一 bool,是无锁数据竞争。
|
||||
ctxFullMu sync.Mutex
|
||||
ctxFullSignaled bool
|
||||
|
||||
// 子侧:inputch 处理表(子持有,父 pull)。
|
||||
@ -262,8 +301,27 @@ type AgentConfig struct {
|
||||
SystemPrompt string
|
||||
Provider agentAPI.Provider
|
||||
ProviderManager *agentAPI.ProviderManager
|
||||
IO *agentIO.IOManager
|
||||
Memory *memory.GraphDB
|
||||
// PreferredProvider 是本 agent **优先使用**的模型源名(空 = 用总默认源)。
|
||||
//
|
||||
// ## 为什么需要它(用户 2026-10-30 要求)
|
||||
//
|
||||
// 「要求支持为主 agent、子 agent、驻留式子 agent、rt agent 可各自配置模型源,
|
||||
// **默认使用总模型源(default)**」
|
||||
//
|
||||
// 之前四类 agent 共用 providerManager 的同一套顺序:谁都没法单独指定。
|
||||
// 但它们的负载完全不同 —— 主 agent 要强推理、rtchild 要极低首字延迟、
|
||||
// 驻留子要长时间挂着省成本。用同一个源对三者都是妥协。
|
||||
//
|
||||
// ## 语义:**优先,不是独占**
|
||||
//
|
||||
// 它把该源排到候选链**最前面**,其余源仍作为回退(与原有多源回退语义一致)。
|
||||
// 硬锁定成唯一源会让"这个源挂了"直接变成"这个 agent 完全不工作",
|
||||
// 而回退链正是本仓为此建立的机制。
|
||||
//
|
||||
// 名字不存在时**响亮回退到默认源并记日志**(不静默):配置拼错要能看见。
|
||||
PreferredProvider string
|
||||
IO *agentIO.IOManager
|
||||
Memory *memory.GraphDB
|
||||
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。
|
||||
//
|
||||
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil,
|
||||
@ -302,7 +360,16 @@ type AgentConfig struct {
|
||||
//
|
||||
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||
// 父 agent 创建驻留子时用它收窄子的输出能力。
|
||||
AllowedOutputs []string
|
||||
AllowedOutputs []string
|
||||
// IsRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
|
||||
//
|
||||
// 置 true 后:
|
||||
// · 只有它能被划入带 CapRT 的 rt 通道(Agent.AssignRealtime 校验);
|
||||
// · 打断不走四级中断阶梯,而走单槽覆盖(plan.md 十一.三 B)。
|
||||
//
|
||||
// ★ 这不是权限位,而是**类型标记**:另外两类子 agent 的设计目标函数
|
||||
// 与 rt 相反(公平 vs 最新者胜),混用会两边都坏。
|
||||
IsRealtime bool
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
@ -412,27 +479,29 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
coldcontextStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
preferredProvider: cfg.PreferredProvider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
isRealtime: cfg.IsRealtime,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
coldcontextStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
|
||||
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
|
||||
personality: cfg.Personality,
|
||||
|
||||
@ -1447,3 +1447,159 @@ func (c *RelevanceContext) purgeToolResultDir(evtID string) {
|
||||
log.Printf("[agent] purge toolresult dir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 压缩支持(resident 的「压缩」= 摘要替换,不是截断丢弃) ──
|
||||
|
||||
// CompactPlan 是一次压缩的**只读计划**:描述要压掉哪一段、留下哪一段。
|
||||
//
|
||||
// 为什么先出计划再执行(而不是一步做完):
|
||||
// 摘要需要调 LLM(可能几百毫秒到几十秒),期间不能持锁、也不能先把事件
|
||||
// 从上下文里摘掉 —— 那样一旦摘要失败或取消,上下文就被破坏了一半。
|
||||
// 所以顺序是:**取计划 → 无锁生成摘要 → 应用**。
|
||||
type CompactPlan struct {
|
||||
// Old 是即将被压掉的事件(按时间正序)。可能为空(没什么可压的)。
|
||||
Old []*ContextEvent
|
||||
// Keep 是保留的最近事件数(仅供日志/判据核对)。
|
||||
Keep int
|
||||
// Total 是压缩前的事件总数。
|
||||
Total int
|
||||
// PrefixText 是把 Old 渲染成供摘要用的纯文本。
|
||||
PrefixText string
|
||||
}
|
||||
|
||||
// PlanCompaction 生成压缩计划:保留最近 keepRecent 条,其余进 Old。
|
||||
//
|
||||
// 注意它**不改动任何状态**——调用方拿到计划后可以安全地去做慢操作(调 LLM),
|
||||
// 失败了就当没发生过。
|
||||
func (c *RelevanceContext) PlanCompaction(keepRecent int) CompactPlan {
|
||||
if keepRecent < 1 {
|
||||
keepRecent = 1
|
||||
}
|
||||
evts := c.snapshotEvents()
|
||||
plan := CompactPlan{Keep: keepRecent, Total: len(evts)}
|
||||
if len(evts) <= keepRecent {
|
||||
return plan
|
||||
}
|
||||
cut := len(evts) - keepRecent
|
||||
plan.Old = evts[:cut]
|
||||
plan.PrefixText = renderEventsForSummary(plan.Old)
|
||||
return plan
|
||||
}
|
||||
|
||||
// ApplyCompaction 用一条**摘要事件**替换 Old 那一段。
|
||||
//
|
||||
// 语义(design §9「压成摘要 → 清处理表 → 子续用」):
|
||||
// - 被压掉的 N 条不再存在于上下文(它们的**语义**留在摘要里)
|
||||
// - 摘要作为一条 system 事件排在保留段的**前面**,子下一轮就能看到
|
||||
//
|
||||
// 为什么按"时间戳 + 事件指针"定位而不是按下标:
|
||||
// 摘要生成期间(可能几十秒)上下文可能被 Append 追加新事件,下标会移。
|
||||
// 这里用"Old 末尾那条事件"作为锚点:找到它当前的位置,删掉它之前的、
|
||||
// 属于本次压缩的那些事件。
|
||||
//
|
||||
// 返回实际删掉的事件数。
|
||||
func (c *RelevanceContext) ApplyCompaction(plan CompactPlan, summary string) int {
|
||||
if len(plan.Old) == 0 {
|
||||
return 0
|
||||
}
|
||||
dropSet := make(map[*ContextEvent]bool, len(plan.Old))
|
||||
for _, e := range plan.Old {
|
||||
dropSet[e] = true
|
||||
}
|
||||
|
||||
// ★ 这里**不能**用 defer Unlock:本函数在中途(向量计算前)必须主动解锁,
|
||||
// 而 defer 会在返回时再解一次 ⇒ "unlock of unlocked mutex"(实测 fatal)。
|
||||
c.mu.Lock()
|
||||
|
||||
kept := make([]*ContextEvent, 0, len(c.events)+1)
|
||||
removed := 0
|
||||
inserted := false
|
||||
for _, e := range c.events {
|
||||
if dropSet[e] {
|
||||
// 第一次遇到被压掉的事件时,就在这个位置插入摘要 —— 保持时间顺序
|
||||
// (摘要代表"那段被压掉的内容",位置应在保留段之前)。
|
||||
if !inserted && summary != "" {
|
||||
kept = append(kept, newCompactionEvent(plan, summary))
|
||||
inserted = true
|
||||
}
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, e)
|
||||
}
|
||||
if removed == 0 {
|
||||
c.mu.Unlock()
|
||||
return 0 // 期间上下文被别处改动过(如 Prune 已把它归档)—— 不重复处理
|
||||
}
|
||||
c.events = kept
|
||||
c.dirty = true
|
||||
// 把要算向量的那条事件带出锁外 —— 不能在这里算:
|
||||
// c.mu 是**不可重入**的 sync.Mutex,而 computeVector 依赖 c.toolDefLookup
|
||||
// 等字段(读锁内状态),在锁内再进出会死锁(实测 panic)。
|
||||
// 向量计算还会调嵌入模型,持锁会拖住所有读者。
|
||||
var toVector *ContextEvent
|
||||
if inserted {
|
||||
for _, e := range c.events {
|
||||
if e.Source == compactionSource {
|
||||
toVector = e
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if toVector != nil {
|
||||
c.computeVector(toVector)
|
||||
}
|
||||
c.save()
|
||||
return removed
|
||||
}
|
||||
|
||||
// compactionSource 是压缩摘要事件的来源标识(判据与渲染据此识别它)。
|
||||
const compactionSource = "system/compaction"
|
||||
|
||||
// newCompactionEvent 构造摘要事件。调用方须持锁(append 到 c.events)。
|
||||
func newCompactionEvent(plan CompactPlan, summary string) *ContextEvent {
|
||||
return &ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: compactionSource,
|
||||
Input: fmt.Sprintf("[压缩摘要] 以下是此前 %d 条对话的摘要(原文已不在上下文中):\n%s", len(plan.Old), summary),
|
||||
// 摘要本身是"背景说明",不是用户说的话 ⇒ 不参与记忆写入,
|
||||
// 否则每次压缩都会往图库里灌一条假对话。
|
||||
NoMemory: true,
|
||||
}
|
||||
}
|
||||
|
||||
// renderEventsForSummary 把一批事件渲染成供 LLM 摘要的纯文本。
|
||||
//
|
||||
// 口径:**尽量保留事实**(谁说了什么、调用了什么工具得到什么结论),
|
||||
// 但工具结果只留摘要(它们是体积大头,且摘要不需要原始 HTML/JSON)。
|
||||
func renderEventsForSummary(evts []*ContextEvent) string {
|
||||
var sb strings.Builder
|
||||
for _, e := range evts {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if t := strings.TrimSpace(e.Input); t != "" {
|
||||
fmt.Fprintf(&sb, "[用户] %s\n", truncateStr(t, compactionToolResultMaxRunes))
|
||||
}
|
||||
for _, tc := range e.ToolCalls {
|
||||
fmt.Fprintf(&sb, "[工具调用] %s\n", tc.Name)
|
||||
}
|
||||
if len(e.ToolResults) > 0 {
|
||||
parts := make([]string, 0, len(e.ToolResults))
|
||||
for _, tr := range e.ToolResults {
|
||||
parts = append(parts, fmt.Sprintf("%s→%s",
|
||||
tr.Name, truncateStr(strings.TrimSpace(tr.Output), compactionToolResultMaxRunes)))
|
||||
}
|
||||
fmt.Fprintf(&sb, "[工具结果] %s\n", strings.Join(parts, ";"))
|
||||
}
|
||||
if r := strings.TrimSpace(e.Response); r != "" {
|
||||
fmt.Fprintf(&sb, "[助手] %s\n", truncateStr(r, compactionToolResultMaxRunes))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// compactionToolResultMaxRunes 是喂给摘要器时单条工具结果的字符上限。
|
||||
const compactionToolResultMaxRunes = 400
|
||||
|
||||
@ -32,6 +32,37 @@ func (a *Agent) routeInputByOwner(evt *agentIO.InputEvent, isInterrupt bool) boo
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[entry.Owner]
|
||||
a.residentMu.Unlock()
|
||||
|
||||
// ★ rtchild 分支:rt 通道的输入必须进 **rtSlot**(单槽覆盖),
|
||||
// 而不是走 rc.agent.io.DeliverRouted(那会进四级中断阶梯)。
|
||||
//
|
||||
// 这是插件→rtchild 的**唯一通路**:插件只能用 Inject* 系列,那些方法
|
||||
// 最终都汇到 deliverInput → 本函数。若不在这里识别 rt 通道,插件的
|
||||
// 语音/画面输入会永远打在父身上(rtchild 的轮次永远是 0)。
|
||||
//
|
||||
// 判据 TestRTChannel_PluginInputReachesRTSlot 钉它。
|
||||
a.rtMu.Lock()
|
||||
rt := a.rtChildren[entry.Owner]
|
||||
a.rtMu.Unlock()
|
||||
if rt != nil {
|
||||
// 不区分 isInterrupt:rt 没有中断阶梯,所有输入都是"覆盖式最新值"。
|
||||
// 这正是 plan.md 十一.三 B 说的"rt 输入不进四级中断阶梯"的落点。
|
||||
rt.slot.Push(evt)
|
||||
if rt.agent != nil && rt.agent.ctx != nil {
|
||||
// 已存在且活着的轮次:取消它,让 rtLoop 立刻用新输入重开(契约 1)。
|
||||
rt.mu.Lock()
|
||||
cancel, critical := rt.curCancel, rt.critical
|
||||
if cancel != nil && critical > 0 {
|
||||
rt.suppressedCancels++
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
if cancel != nil && critical == 0 {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if rc == nil || rc.agent == nil || rc.agent.io == nil {
|
||||
// 归属到一个不存在(或已销毁、登记表尚未归还)的 agent:
|
||||
// **不吞输入** —— 由本内核兜底处理并留痕。吞掉一条输入比多处理一条更糟:
|
||||
|
||||
@ -46,6 +46,8 @@ const (
|
||||
VectorContextOverflow VectorID = "ctx_overflow"
|
||||
// VectorChildContextFull:驻留子上下文已满,父据此决定压缩/回收/销毁。
|
||||
VectorChildContextFull VectorID = "child_ctxfull"
|
||||
// VectorRTChildClosed:rtchild 回收,父据此**自行选择**要保留哪些上下文。
|
||||
VectorRTChildClosed VectorID = "rtchild_closed"
|
||||
)
|
||||
|
||||
// vectorArgs 是一次中断向量调用的全部入参。
|
||||
@ -59,6 +61,12 @@ type vectorArgs struct {
|
||||
Panic any
|
||||
// ChildID 是驻留子标识(VectorChildContextFull 使用)。
|
||||
ChildID string
|
||||
// RTID 是 rtchild 标识(VectorRTChildClosed 使用)。
|
||||
RTID string
|
||||
// RTCount / RTChars 是本次交接的规模(VectorRTChildClosed 使用),
|
||||
// 让主 agent 判断"值不值得去取全文"。
|
||||
RTCount int
|
||||
RTChars int
|
||||
}
|
||||
|
||||
// interruptVector 是一个中断源的**完整契约**。
|
||||
@ -126,6 +134,25 @@ var interruptVectors = map[VectorID]interruptVector{
|
||||
Build: func(vectorArgs) string { return "" },
|
||||
},
|
||||
|
||||
VectorRTChildClosed: {
|
||||
ID: VectorRTChildClosed,
|
||||
Level: LevelCritical,
|
||||
Source: func(a vectorArgs) string { return "rtchild/" + a.RTID },
|
||||
Guard: func(vectorArgs) bool { return true },
|
||||
Build: func(a vectorArgs) string {
|
||||
// ★ 父子协议同驻留子 contextfull:**只推信号,不塞正文**。
|
||||
//
|
||||
// 用户裁定(2026-10-30):「交还是类似驻留式子 agent 的 contextfull
|
||||
// 中断,让主 agent **自行选择**保留的内容。」
|
||||
//
|
||||
// 所以这里**不**把通话原文灌进 payload —— 那等于替主 agent 做了
|
||||
// "全都要"的决定,且会把几十万字符直接顶进它的上下文。正文由
|
||||
// RTTranscript 按需拉取("推信号 + 拉状态"),与驻留子同一套协议。
|
||||
return fmt.Sprintf("内核事件:实时子 agent %s 已回收,本次通话共 %d 条消息(约 %d 字符)。"+
|
||||
"如需复盘,可调用 rt_transcript 取回原文并按需选取保留内容。", a.RTID, a.RTCount, a.RTChars)
|
||||
},
|
||||
},
|
||||
|
||||
VectorChildContextFull: {
|
||||
ID: VectorChildContextFull,
|
||||
Level: LevelCritical,
|
||||
|
||||
@ -75,6 +75,10 @@ var builtinToolOwner = map[string]string{
|
||||
"input_channels": "core", "inputch_note": "core",
|
||||
"spawn_child": "core", "child_result": "core", "notify_parent": "core",
|
||||
"resident_agents": "core",
|
||||
// rtchild 上下文交接的拉取入口(用户裁定 2026-10-30:走 contextfull 式中断,
|
||||
// 主 agent 自行选择保留哪些内容)。归 core:它是内核原语,不是插件能力。
|
||||
"rt_transcript": "core",
|
||||
"rt_children": "core",
|
||||
// LLM 源 / 插件管理
|
||||
"llm_list_sources": "core", "llm_set_source": "core", "llm_current_source": "core",
|
||||
"plgreload": "core", "get_plugin_tools": "core", "list_devices": "core",
|
||||
|
||||
178
internal/agent/core/provider_pref_test.go
Normal file
178
internal/agent/core/provider_pref_test.go
Normal file
@ -0,0 +1,178 @@
|
||||
package core
|
||||
|
||||
// 判据:四类 agent 可**各自**配置优先模型源,默认用总默认源。
|
||||
//
|
||||
// 用户 2026-10-30 要求:「支持为主 agent、子 agent、驻留式子 agent、rt agent
|
||||
// 可各自配置模型源,默认使用总模型源(default)」。
|
||||
//
|
||||
// ## 语义是"优先"而不是"独占"
|
||||
//
|
||||
// 该源排在候选链**最前**,其余源仍作回退。硬锁定会让"首选源挂了"直接变成
|
||||
// "这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
|
||||
//
|
||||
// ## 判据怎么才算有效
|
||||
//
|
||||
// 不能只断言"配置生效了"——那样"只留一个源"(破坏回退)也会绿。
|
||||
// 所以成对断言:
|
||||
// ① 优先源排在第一位(配置生效)
|
||||
// ② 其余源仍在候选链里(回退未被破坏)
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// newPMWith 造一个有若干源的 manager,默认源是 first。
|
||||
func newPMWith(t *testing.T, names ...string) *agentAPI.ProviderManager {
|
||||
t.Helper()
|
||||
pm := agentAPI.NewProviderManager()
|
||||
for _, n := range names {
|
||||
pm.Register(n, &stubProvider{name: n})
|
||||
}
|
||||
if len(names) > 0 {
|
||||
if err := pm.SetDefault(names[0]); err != nil {
|
||||
t.Fatalf("SetDefault: %v", err)
|
||||
}
|
||||
}
|
||||
return pm
|
||||
}
|
||||
|
||||
// chainNames 取本 agent 当前候选链的名字序列。
|
||||
func chainNames(a *Agent) []string {
|
||||
req := &agentAPI.CompletionRequest{}
|
||||
ps := a.resolveProviders(req)
|
||||
out := make([]string, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, p.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 是核心判据。
|
||||
func TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks(t *testing.T) {
|
||||
pm := newPMWith(t, "default-src", "fast-src", "cheap-src")
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "rt", Provider: pm.Get("default-src"),
|
||||
ProviderManager: pm, PreferredProvider: "fast-src",
|
||||
})
|
||||
got := chainNames(a)
|
||||
|
||||
if len(got) == 0 || got[0] != "fast-src" {
|
||||
t.Fatalf("候选链首位 = %v,期望 fast-src 在最前(配置未生效)", got)
|
||||
}
|
||||
// ★ 关键:其余源必须仍在链上(否则回退被破坏)。
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("候选链只剩 %v —— 优先源不该挤掉其它源(回退链被破坏)", got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range got {
|
||||
seen[n] = true
|
||||
}
|
||||
for _, n := range []string{"default-src", "fast-src", "cheap-src"} {
|
||||
if !seen[n] {
|
||||
t.Errorf("候选链缺少 %q:%v", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredProvider_DefaultUsesGlobalDefault 钉住「默认用总模型源」。
|
||||
func TestPreferredProvider_DefaultUsesGlobalDefault(t *testing.T) {
|
||||
pm := newPMWith(t, "default-src", "other-src")
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "main", Provider: pm.Get("default-src"), ProviderManager: pm,
|
||||
// PreferredProvider 留空 ⇒ 用总默认源
|
||||
})
|
||||
if got := chainNames(a); len(got) == 0 || got[0] != "default-src" {
|
||||
t.Fatalf("未配置优先源时首位 = %v,期望总默认源 default-src", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredProvider_UnknownNameFallsBackLoudly 钉住配置写错的语义:
|
||||
// 回退到默认源(不静默失效),且**不影响可用性**。
|
||||
func TestPreferredProvider_UnknownNameFallsBackLoudly(t *testing.T) {
|
||||
pm := newPMWith(t, "default-src", "other-src")
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "typo", Provider: pm.Get("default-src"), ProviderManager: pm,
|
||||
PreferredProvider: "no-such-source", // 拼错
|
||||
})
|
||||
got := chainNames(a)
|
||||
if len(got) == 0 || got[0] != "default-src" {
|
||||
t.Fatalf("拼错的优先源应回退到默认源,实际首位 = %v", got)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("回退后候选链 = %v,期望两个源都在", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredProvider_EachAgentIndependent 钉住"各自可配":
|
||||
// 同进程内三个 agent 配不同源,互不影响。
|
||||
func TestPreferredProvider_EachAgentIndependent(t *testing.T) {
|
||||
pm := newPMWith(t, "shared-default", "for-main", "for-rt", "for-resident")
|
||||
|
||||
mainA := New(AgentConfig{ID: "main", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-main"})
|
||||
rtA := New(AgentConfig{ID: "rt", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-rt"})
|
||||
resA := New(AgentConfig{ID: "res", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-resident"})
|
||||
|
||||
for _, tc := range []struct {
|
||||
who string
|
||||
a *Agent
|
||||
want string
|
||||
}{
|
||||
{"主 agent", mainA, "for-main"},
|
||||
{"rt agent", rtA, "for-rt"},
|
||||
{"驻留子", resA, "for-resident"},
|
||||
} {
|
||||
if got := chainNames(tc.a); len(got) == 0 || got[0] != tc.want {
|
||||
t.Errorf("%s 的首位 = %v,期望 %q(各自配置未隔离)", tc.who, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredProvider_InheritedByChildAgents 钉住继承链:
|
||||
// 子 agent 未显式配置时**继承父的**优先源(而不是掉回总默认)。
|
||||
func TestPreferredProvider_InheritedByChildAgents(t *testing.T) {
|
||||
pm := newPMWith(t, "shared-default", "parent-pref")
|
||||
|
||||
parent, _, dir := newRootWith(t, pm.Get("shared-default"))
|
||||
parent.providerManager = pm
|
||||
parent.preferredProvider = "parent-pref"
|
||||
|
||||
spawnTestResident(t, parent, dir, "inh")
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents["inh"]
|
||||
parent.residentMu.Unlock()
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子未创建")
|
||||
}
|
||||
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "parent-pref" {
|
||||
t.Fatalf("驻留子未继承父的优先源,首位 = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredProvider_RTChildOwnSource 钉住 rtchild 可**独立**配源。
|
||||
//
|
||||
// 这条对通话场景最实际:rt 的首字预算是 500ms 级,而不同源往返差 2–5 倍。
|
||||
func TestPreferredProvider_RTChildOwnSource(t *testing.T) {
|
||||
pm := newPMWith(t, "shared-default", "parent-pref", "rt-fast")
|
||||
parent, _ := newRTHost(t, pm.Get("shared-default"))
|
||||
parent.providerManager = pm
|
||||
parent.preferredProvider = "parent-pref"
|
||||
|
||||
id, err := parent.SpawnRTChild(RTChildOptions{
|
||||
ID: "rt-own", Input: "voice-in", Output: "voice",
|
||||
PreferredProvider: "rt-fast",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild: %v", err)
|
||||
}
|
||||
parent.rtMu.Lock()
|
||||
rc := parent.rtChildren[id]
|
||||
parent.rtMu.Unlock()
|
||||
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "rt-fast" {
|
||||
t.Fatalf("rtchild 未使用自己配置的源,首位 = %v", got)
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
@ -49,6 +50,12 @@ type ResidentOptions struct {
|
||||
Capacity int
|
||||
// TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。
|
||||
TempPath string
|
||||
// PreferredProvider 是**这个驻留子**优先使用的模型源(空 = 继承父的)。
|
||||
//
|
||||
// 为什么由父在创建时传入、而不是让子自己读配置:子没有配置中心句柄
|
||||
// (轻量内核刻意不接),而"哪个源给哪类 agent 用"是**部署策略**,
|
||||
// 本来就该在创建点决定。
|
||||
PreferredProvider string
|
||||
// OffloadOwned 标记这是**内核为转投而拉起**的驻留子(见 offload.go)。
|
||||
//
|
||||
// 为什么要区分:自动转投会复用、也会按上限限制自己拉起的子,
|
||||
@ -183,13 +190,15 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
SystemPrompt: a.systemPrompt,
|
||||
Provider: a.provider,
|
||||
ProviderManager: a.providerManager,
|
||||
IO: childIO,
|
||||
StageHost: a.stageHost,
|
||||
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||
AllowedOutputs: opts.AllowedOutputs,
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
// 驻留子可以有自己的优先源;留空则继承父的(父再留空 ⇒ 总默认源)。
|
||||
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
|
||||
IO: childIO,
|
||||
StageHost: a.stageHost,
|
||||
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||
AllowedOutputs: opts.AllowedOutputs,
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
})
|
||||
|
||||
rc := &residentChild{
|
||||
@ -265,6 +274,11 @@ func (a *Agent) residentInboundChannel(childID string) string {
|
||||
//
|
||||
// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。
|
||||
func (a *Agent) DestroyResident(id string) error {
|
||||
return a.DestroyResidentWith(id, ChannelRelease)
|
||||
}
|
||||
|
||||
// DestroyResidentWith 按父的决定处置通道后销毁驻留子。
|
||||
func (a *Agent) DestroyResidentWith(id string, disp ChannelDisposition) error {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
if ok {
|
||||
@ -274,7 +288,7 @@ func (a *Agent) DestroyResident(id string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
a.teardownResident(rc)
|
||||
a.teardownResidentWith(rc, disp, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -298,6 +312,32 @@ const (
|
||||
ResidualKeep ResidualPolicy = "keep"
|
||||
// ResidualDrop 明确丢弃残余任务(父已看过清单并确认)。
|
||||
ResidualDrop ResidualPolicy = "drop"
|
||||
// ResidualStash 把残余任务**暂存到其原通道**,等通道被接回时再放出。
|
||||
//
|
||||
// ## 与 keep 的区别(用户 2026-10-30 提出)
|
||||
//
|
||||
// keep 是"转回父自己的队列"—— 任务立刻变成父的活。
|
||||
// stash 是"放回它来的那条通道"—— 任务不与任何 agent 绑定,
|
||||
// 谁接回那条通道,谁就拿到它们。
|
||||
//
|
||||
// 三个动作正好对应三种真实情况:
|
||||
//
|
||||
// | 选择 | 适用 |
|
||||
// |---|---|
|
||||
// | keep(接手) | 父有空、或这批活必须连续处理 |
|
||||
// | stash(暂存到通道) | 父现在忙,但活不能丢;下一个接手该通道的 agent 来做 |
|
||||
// | drop(丢弃) | 这批活已过期/已由别处完成 |
|
||||
//
|
||||
// ★ stash 的前提是"通道能挂起":留空 Owner 的通道如果没人接,
|
||||
// 任务就静静躺着。所以 stash 必须**逐条留日志**(和 drop 同等待遇),
|
||||
// 且 ResidentInfo 里要能查到暂存条数 —— 否则它就是"更好的丢"。
|
||||
ResidualStash ResidualPolicy = "stash"
|
||||
// ResidualTakeover 是 keep 的别名,语义是"父**接手**这些活"。
|
||||
//
|
||||
// 单独给一个名字是因为用户要求里"接手"是一个独立选项,而 keep
|
||||
// 听起来像"保持原状"。两者实现完全一致(转回父队列),
|
||||
// 别名让调用方的意图在代码里可读。
|
||||
ResidualTakeover ResidualPolicy = "takeover"
|
||||
)
|
||||
|
||||
// TakeResidual 取走一个驻留子手头**尚未处理**的残余任务(不处置)。
|
||||
@ -344,6 +384,18 @@ func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) {
|
||||
// (不再取新任务),已在执行的那一条跑完后循环自然结束。
|
||||
// 等它真正停下,再回收队列 —— 此刻队列是静止的,回收没有歧义。
|
||||
func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(rc *residentChild) error) (n int, msg string, err error) {
|
||||
return a.FinishResidentWith(id, policy, ChannelRelease, onDestroy)
|
||||
}
|
||||
|
||||
// FinishResidentWith 是 FinishResident 的完整版:残余任务策略 + **通道处置**一次定。
|
||||
//
|
||||
// 为什么合成一个入口:两者有依赖关系 ——
|
||||
// - residual=stash 要求通道**继续存在**(暂存挂在通道上);
|
||||
// - channel=release 会把通道还给池子(可能被别的 agent 接走)。
|
||||
//
|
||||
// 若拆成两次调用,中间窗口里通道可能已被别人接管,暂存任务就跑到了
|
||||
// 一个跟它无关的 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()
|
||||
@ -370,8 +422,11 @@ func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(
|
||||
return n, msg, herr
|
||||
}
|
||||
}
|
||||
// ⑤ 销毁(内部会 Stop 子、关 temp、放通道)。
|
||||
return n, msg, a.DestroyResident(id)
|
||||
// ⑤ 销毁(内部会 Stop 子、关 temp、按 disp 处置通道)。
|
||||
//
|
||||
// ⚠️ 必须用 DestroyResidentWith:走 DestroyResident 会丢掉 disp,
|
||||
// 通道被无条件释放 —— 而 stash 的暂存任务正挂在那条通道上。
|
||||
return n, msg, a.DestroyResidentWith(id, disp)
|
||||
}
|
||||
|
||||
// ApplyResidual 按父给出的策略处置一个驻留子的残余任务。
|
||||
@ -412,7 +467,46 @@ func (a *Agent) applyResidualEvents(id string, events []*agentIO.InputEvent, pol
|
||||
}
|
||||
return len(events), fmt.Sprintf("已按父的决定丢弃 %d 条残余任务(逐条已记日志)", len(events)), nil
|
||||
}
|
||||
// 默认 keep:转回父自己
|
||||
// stash:暂存回**每条任务来的那条通道**。
|
||||
//
|
||||
// ★ 必须逐条按其自身 OutputChannel 归位,不能整批塞进一个通道 ——
|
||||
// 一个驻留子可能同时收 qq 和 webui 的消息,混存会导致接回 qq 的人
|
||||
// 拿到本该给 webui 的活。
|
||||
if policy == ResidualStash {
|
||||
reg := a.io.ChannelRegistry()
|
||||
if reg == nil {
|
||||
return 0, "", fmt.Errorf("无通道登记表,无法暂存残余任务")
|
||||
}
|
||||
stashed, failed := 0, 0
|
||||
for _, evt := range events {
|
||||
if evt == nil {
|
||||
continue
|
||||
}
|
||||
ch := evt.OutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.Source // 无回程通道时用来源通道兜底
|
||||
}
|
||||
if err := reg.Stash(ch, []*agentIO.InputEvent{evt}); err != nil {
|
||||
// 通道已不存在(插件卸载等):这条只能丢,但必须留痕,
|
||||
// 并给同步调用方终态 —— 否则 cli/a2a 会永久挂起。
|
||||
failed++
|
||||
log.Printf("[resident] %s residual task could NOT be stashed (channel %q gone): source=%s request=%s content=%s",
|
||||
id, ch, evt.Source, evt.RequestID, truncateStr(inputTextOf(evt), 80))
|
||||
a.emitSkippedReply(evt, "residual_stash_channel_gone")
|
||||
continue
|
||||
}
|
||||
stashed++
|
||||
// 暂存也要留痕:不可追溯的"暂存"和丢弃没区别。
|
||||
log.Printf("[resident] %s residual task stashed to channel %q: source=%s request=%s content=%s",
|
||||
id, ch, evt.Source, evt.RequestID, truncateStr(inputTextOf(evt), 80))
|
||||
}
|
||||
msg := fmt.Sprintf("已把 %d 条残余任务暂存到各自通道(接回通道即释放)", stashed)
|
||||
if failed > 0 {
|
||||
msg += fmt.Sprintf(";%d 条因通道已不存在而丢弃(已记日志并给调用方终态)", failed)
|
||||
}
|
||||
return stashed + failed, msg, nil
|
||||
}
|
||||
// keep / takeover:转回父自己
|
||||
for _, evt := range events {
|
||||
if evt == nil {
|
||||
continue
|
||||
@ -433,7 +527,39 @@ func inputTextOf(evt *agentIO.InputEvent) string {
|
||||
}
|
||||
|
||||
// teardownResident 停内核、放通道、丢 temp(销毁与回收共用)。
|
||||
// ChannelDisposition 是父对**驻留子持有的通道**的显式决定。
|
||||
//
|
||||
// 用户 2026-10-30 要求:「原本驻留子持有的通道也应该支持**挂起或接回**主 agent」。
|
||||
//
|
||||
// 之前销毁驻留子时内核**无条件**把通道归到"未分配"(`Assign(ch, "", 0)`):
|
||||
// 通道确实回到了池子,但**谁也不知道该由谁来接**。于是有两种坏结局:
|
||||
// - 通道一直没人分配 ⇒ 投进来的消息无人处理,且无任何提示
|
||||
// - 刚好被别的 agent 抢到 ⇒ 那个 agent 莫名其妙开始收它没准备的流量
|
||||
//
|
||||
// 所以和残余任务的处置一样:**原语在内核,决定在父的模型**。
|
||||
type ChannelDisposition string
|
||||
|
||||
const (
|
||||
// ChannelRelease 是原有行为:归属清空回到"未分配",可被任意 agent 再分配。
|
||||
// 作为**零值**保持向后兼容(已有调用方不传就等于它)。
|
||||
ChannelRelease ChannelDisposition = ""
|
||||
// ChannelSuspend 挂起:归属清空但**标记为挂起**,不参与自动分配。
|
||||
//
|
||||
// 与 release 的差别是"会不会被别人悄悄接走":挂起的通道等父显式接回,
|
||||
// 期间的输入被暂存(不是丢弃),接回时一并交出。
|
||||
ChannelSuspend ChannelDisposition = "suspend"
|
||||
// ChannelTakeover 接回主 agent:通道归属改为父,**并释放暂存任务**,
|
||||
// 那些任务立即转进父的队列。
|
||||
ChannelTakeover ChannelDisposition = "takeover"
|
||||
)
|
||||
|
||||
func (a *Agent) teardownResident(rc *residentChild) {
|
||||
a.teardownResidentWith(rc, ChannelRelease, nil)
|
||||
}
|
||||
|
||||
// teardownResidentWith 是 teardownResident 的完整版:额外决定**通道去向**,
|
||||
// 并在 takeover 时把释放出来的暂存任务转进父队列。
|
||||
func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition, onDrained func([]*agentIO.InputEvent)) {
|
||||
rc.mu.Lock()
|
||||
rc.state = "stopped"
|
||||
rc.mu.Unlock()
|
||||
@ -449,10 +575,40 @@ func (a *Agent) teardownResident(rc *residentChild) {
|
||||
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
|
||||
_ = os.RemoveAll(rc.dir)
|
||||
}
|
||||
// 归还划入的 inputch(归属清空 ⇒ 回到"未分配",可再分配)。
|
||||
// 处置划入的 inputch(按父的决定:释放 / 挂起 / 接回)。
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
for _, ch := range rc.inputChs {
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
switch disp {
|
||||
case ChannelTakeover:
|
||||
// 接回父:通道归父,并把该通道上的暂存任务交出。
|
||||
drained, err := reg.AssignAndDrainStash(ch, string(a.id), 0)
|
||||
if err != nil {
|
||||
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))
|
||||
if onDrained != nil {
|
||||
onDrained(drained)
|
||||
} else {
|
||||
for _, evt := range drained {
|
||||
if evt != nil {
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
}
|
||||
}
|
||||
a.sched.signalWake()
|
||||
}
|
||||
}
|
||||
case ChannelSuspend:
|
||||
// 挂起:归属清空但**不交还池子**。实现上仍记为 "",
|
||||
// 差别在于本通道上的暂存任务保留(接回时才释放),
|
||||
// 且日志明确写出"已挂起",让运维能查到它没有在跑。
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
log.Printf("[resident] %s 通道 %s 已挂起(暂存 %d 条,等接回时释放)",
|
||||
rc.id, ch, reg.StashLen(ch))
|
||||
default: // ChannelRelease
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
}
|
||||
// 注销"父接收该子消息"的入站 inputch(child/<id>)。
|
||||
//
|
||||
@ -526,7 +682,26 @@ func (a *Agent) SendToResident(id, text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompressResident 是父的"压缩"(**保留语义**):压上下文 + **清理处理表**,子继续存在。
|
||||
// CompressResident 是父的"压缩"(**保留语义**):**压成摘要** + 清理处理表,子继续存在。
|
||||
//
|
||||
// ## 这里曾经是纯截断(2026-10-30 修正)
|
||||
//
|
||||
// 原实现直接 `TrimKeepRecent(20)` —— 丢掉除了最近 20 条之外的一切,
|
||||
// 与设计文档明写的「**压成摘要**」(resident-subagent-design.md §9, R7)
|
||||
// 不符。两个后果:
|
||||
//
|
||||
// ① "压缩"与"零成本丢数据"没有区别,被丢的语义不可恢复
|
||||
// ② 压缩(保留)与回收(取消)在实现上退化成同一个动作的两种力度
|
||||
//
|
||||
// 现在的顺序(用户 2026-10-30 裁定 A+B):
|
||||
//
|
||||
// ① PlanCompaction 取一份**只读计划**(不动上下文)
|
||||
// ② 用**子自己的** provider 生成摘要(设计 R7:"子的轻量内核自己压")
|
||||
// ③ ApplyCompaction 用摘要事件**替换**那段
|
||||
// ④ 摘要失败 ⇒ **回退截断**(裁定 B),并留日志(不静默降级)
|
||||
//
|
||||
// 为什么先出计划再执行:摘要要调 LLM(可能几十秒),期间不能持锁、也不能
|
||||
// 先把事件摘掉 —— 那样一旦失败/取消,上下文就被破坏了一半。
|
||||
func (a *Agent) CompressResident(id string) (int, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
@ -534,13 +709,37 @@ func (a *Agent) CompressResident(id string) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent)
|
||||
|
||||
ctx := rc.agent.context
|
||||
compacted := 0
|
||||
|
||||
if ctx != nil {
|
||||
// ① 只读计划:此时上下文一个字都没动。
|
||||
plan := ctx.PlanCompaction(residentKeepRecent)
|
||||
if len(plan.Old) > 0 {
|
||||
// ② 用**子自己**的 provider 压(裁定 A)。
|
||||
summary, err := rc.agent.summarizeForCompaction(plan.PrefixText)
|
||||
if err != nil {
|
||||
// ③ 裁定 B:回退截断。必须留日志 —— 静默降级会让运维
|
||||
// 永远不知道"压缩退化成丢弃"发生了多少次。
|
||||
log.Printf("[resident] %s compaction summary failed (falling back to truncation — "+
|
||||
"%d events will be DISCARDED, not summarized): %v", id, len(plan.Old), err)
|
||||
compacted = ctx.TrimKeepRecent(residentKeepRecent)
|
||||
} else {
|
||||
// ④ 摘要替换那段(不是丢弃)。
|
||||
compacted = ctx.ApplyCompaction(plan, summary)
|
||||
log.Printf("[resident] %s compacted %d events into a %d-char summary (kept last %d)",
|
||||
id, compacted, len([]rune(summary)), residentKeepRecent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清
|
||||
rc.agent.resetContextFull()
|
||||
rc.mu.Lock()
|
||||
rc.state = "running"
|
||||
rc.mu.Unlock()
|
||||
return dropped, nil
|
||||
return compacted, nil
|
||||
}
|
||||
|
||||
// ReclaimResident 是父的"回收"(**取消语义**):父读子的 temp → 选记录 → 合入 main
|
||||
@ -618,7 +817,14 @@ func (a *Agent) handleChildContextFull(rc *residentChild) {
|
||||
//
|
||||
// 只对**驻留子**生效(只有它们设了 onContextFull)。
|
||||
func (a *Agent) checkContextFull() {
|
||||
if a.onContextFull == nil || a.ctxFullSignaled {
|
||||
if a.onContextFull == nil {
|
||||
return
|
||||
}
|
||||
// 读标记要加锁:它可能被**父的 goroutine**(CompressResident)并发清零。
|
||||
a.ctxFullMu.Lock()
|
||||
signaled := a.ctxFullSignaled
|
||||
a.ctxFullMu.Unlock()
|
||||
if signaled {
|
||||
return
|
||||
}
|
||||
if a.context == nil {
|
||||
@ -634,11 +840,21 @@ func (a *Agent) checkContextFull() {
|
||||
if a.overflowRatioNow() < contextFullRatio {
|
||||
return
|
||||
}
|
||||
a.ctxFullMu.Lock()
|
||||
a.ctxFullSignaled = true
|
||||
a.ctxFullMu.Unlock()
|
||||
a.onContextFull()
|
||||
}
|
||||
|
||||
func (a *Agent) resetContextFull() { a.ctxFullSignaled = false }
|
||||
// resetContextFull 清零上报标记(压缩后允许再次上报)。
|
||||
//
|
||||
// 加锁理由见 Agent.ctxFullMu:本函数**通常由父的 goroutine 调用**
|
||||
// (CompressResident),而子的调度器 goroutine 正在读同一个字段。
|
||||
func (a *Agent) resetContextFull() {
|
||||
a.ctxFullMu.Lock()
|
||||
a.ctxFullSignaled = false
|
||||
a.ctxFullMu.Unlock()
|
||||
}
|
||||
|
||||
// ---- 子侧:inputch 处理表(子持有,父 pull) ----
|
||||
|
||||
@ -760,3 +976,64 @@ func MarshalResidentInfo(info ResidentInfo) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// compactionSummaryPrompt 是压缩摘要的指令。
|
||||
//
|
||||
// 三条硬要求,每条都对应一种坏摘要:
|
||||
// - 保留事实(人名/数字/决定/未完成项)—— 否则摘要等于把知识蒸干了
|
||||
// - 不得编造 —— 模型很容易"补全"它以为该有的结论
|
||||
// - 输出纯文本,不要复述"以下是摘要"这类客套 —— 它会被原样插回上下文
|
||||
const compactionSummaryPrompt = `把下面这段对话压缩成一段摘要,供你自己后续继续工作使用。
|
||||
|
||||
要求:
|
||||
1. **保留全部事实**:人物、数字、时间、已作出的决定、结论、以及**尚未完成的事项**。
|
||||
2. **不得编造**:原文没有的内容一律不要补充;不确定的写「未确认」。
|
||||
3. 丢掉过程细节(逐条工具调用的原始输出、寒暄、重复确认),保留结果。
|
||||
4. 直接输出摘要正文,不要写「以下是摘要」之类的开场白。
|
||||
5. 控制在 300 字以内。
|
||||
|
||||
对话原文:
|
||||
%s`
|
||||
|
||||
// summarizeForCompaction 用**本 agent 自己的 provider** 把一段原文压成摘要。
|
||||
//
|
||||
// ## 为什么用子自己的 provider(设计 R7 / 用户 2026-10-30 裁定 A)
|
||||
//
|
||||
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」——
|
||||
// resident-subagent-design.md:640。语义上压缩是**子对自身记忆的整理**,
|
||||
// 它的视角、它认为重要的东西,与父不同。让父代劳会让子的记忆被父的兴趣点
|
||||
// 重塑 —— 那不是压缩,是替换。
|
||||
//
|
||||
// ## 失败语义(用户裁定 B:回退截断)
|
||||
//
|
||||
// 返回错误时调用方**退回 TrimKeepRecent**(截断),并记日志。
|
||||
// 不抛错让压缩失败:contextfull 时子已经顶着满上下文,压缩再失败就等于
|
||||
// 把"能不能继续干活"赌在一次可能超时的 LLM 调用上。
|
||||
func (a *Agent) summarizeForCompaction(text string) (string, error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "", fmt.Errorf("没有可压缩的内容")
|
||||
}
|
||||
if a == nil || a.provider == nil {
|
||||
return "", fmt.Errorf("本 agent 没有可用的 LLM provider")
|
||||
}
|
||||
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{
|
||||
{Role: "user", Content: fmt.Sprintf(compactionSummaryPrompt, text)},
|
||||
},
|
||||
// 摘要不需要长输出;给足 300 字(中文约 600 token)+ 余量。
|
||||
MaxTokens: 1024,
|
||||
ToolChoice: "none",
|
||||
DisableThinking: true,
|
||||
}
|
||||
// 用本 agent 的 ctx:父退出/子被销毁时压缩自然中止(不留下悬空调用)。
|
||||
resp, err := a.provider.Chat(a.ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := strings.TrimSpace(resp.Content)
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("摘要器返回空内容")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
299
internal/agent/core/resident_compress_test.go
Normal file
299
internal/agent/core/resident_compress_test.go
Normal file
@ -0,0 +1,299 @@
|
||||
package core
|
||||
|
||||
// 判据:驻留子的「压缩」= **LLM 摘要替换**,不是截断丢弃。
|
||||
//
|
||||
// 设计原文(resident-subagent-design.md §9 / R7):
|
||||
//
|
||||
// 「【压缩】压成摘要 → 清处理表 → 子续用」
|
||||
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」
|
||||
//
|
||||
// 用户裁定(2026-10-30):
|
||||
//
|
||||
// A. 摘要在**子自己的** provider 上执行(不让父代劳)
|
||||
// B. 摘要失败 ⇒ **回退截断**(不抛错让压缩失败),且必须留日志
|
||||
//
|
||||
// 这两条都要能变红:把实现改回 TrimKeepRecent,第一条红;去掉回退,
|
||||
// 第二条红(子卡在满上下文)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// rtCompactProvider 记录 Chat 调用,并返回一段可识别的"摘要"。
|
||||
type rtCompactProvider struct {
|
||||
mu sync.Mutex
|
||||
chats int
|
||||
lastAsk string
|
||||
failChat bool // true ⇒ Chat 返回错误(验证回退路径)
|
||||
blank bool // true ⇒ Chat 返回空内容(也算失败)
|
||||
}
|
||||
|
||||
func (p *rtCompactProvider) Name() string { return "compact" }
|
||||
func (p *rtCompactProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *rtCompactProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.chats++
|
||||
if len(req.Messages) > 0 {
|
||||
p.lastAsk = req.Messages[0].Content
|
||||
}
|
||||
fail, blank := p.failChat, p.blank
|
||||
p.mu.Unlock()
|
||||
|
||||
if fail {
|
||||
return nil, fmt.Errorf("provider down (test)")
|
||||
}
|
||||
if blank {
|
||||
return &agentAPI.CompletionResponse{Content: " "}, nil
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "用户问了 A,我查了 B 并得到结论 C;D 尚未完成。"}, nil
|
||||
}
|
||||
func (p *rtCompactProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: "ok", Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (p *rtCompactProvider) chatCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.chats
|
||||
}
|
||||
|
||||
// newResidentWithProvider 起一个带指定 provider 的驻留子。
|
||||
//
|
||||
// 复用 resident_test.go 的 newRootWith(它已接受任意 provider),
|
||||
// 不用 newRootForResidents(那个把 provider 写死成 countingProvider)。
|
||||
func newResidentWithProvider(t *testing.T, prov agentAPI.Provider, id string) (*Agent, *residentChild) {
|
||||
t.Helper()
|
||||
parent, _, dir := newRootWith(t, prov)
|
||||
spawnTestResident(t, parent, dir, id)
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents[id]
|
||||
parent.residentMu.Unlock()
|
||||
if rc == nil {
|
||||
t.Fatalf("驻留子 %s 没创建成功", id)
|
||||
}
|
||||
return parent, rc
|
||||
}
|
||||
|
||||
// fillContext 往子的上下文里灌 n 条事件(制造"可压缩的旧内容")。
|
||||
func fillContext(rc *residentChild, n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
rc.agent.context.Append(ContextEvent{
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Source: "sub/in",
|
||||
Input: fmt.Sprintf("第%d件事:讨论了主题 %d,结论是 %d", i, i, i*10),
|
||||
Response: fmt.Sprintf("第%d轮答复", i),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressResident_SummarizesInsteadOfDiscarding 是核心判据:
|
||||
// 压缩必须**调用子自己的 LLM** 生成摘要,并用它替换被压掉的内容。
|
||||
//
|
||||
// 变红条件:把 CompressResident 改回 TrimKeepRecent ⇒ chats==0,且上下文里
|
||||
// 找不到摘要事件 ⇒ 本判据失败。
|
||||
func TestCompressResident_SummarizesInsteadOfDiscarding(t *testing.T) {
|
||||
prov := &rtCompactProvider{}
|
||||
parent, rc := newResidentWithProvider(t, prov, "compress-child")
|
||||
|
||||
// 灌到远超保留条数(residentKeepRecent=20),保证有东西可压。
|
||||
fillContext(rc, residentKeepRecent+15)
|
||||
before := rc.agent.context.Len()
|
||||
if before <= residentKeepRecent {
|
||||
t.Fatalf("上下文只有 %d 条,不足以触发压缩", before)
|
||||
}
|
||||
|
||||
n, err := parent.CompressResident("compress-child")
|
||||
if err != nil {
|
||||
t.Fatalf("压缩失败:%v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatalf("没有压掉任何事件(before=%d)", before)
|
||||
}
|
||||
|
||||
// ① 必须真的调了子自己的 provider(裁定 A)。
|
||||
if got := prov.chatCount(); got == 0 {
|
||||
t.Fatalf("压缩没有调用子自己的 LLM —— 这是截断,不是压缩" +
|
||||
"(设计 §9 要求「压成摘要」)")
|
||||
}
|
||||
|
||||
// ② 摘要必须进了上下文(作为一条可识别的事件)。
|
||||
evts := rc.agent.context.snapshotEvents()
|
||||
var summary *ContextEvent
|
||||
for _, e := range evts {
|
||||
if e.Source == compactionSource {
|
||||
summary = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if summary == nil {
|
||||
t.Fatalf("上下文里没有摘要事件(source=%q)—— 被压掉的内容彻底消失了",
|
||||
compactionSource)
|
||||
}
|
||||
if !strings.Contains(summary.Input, "结论 C") {
|
||||
t.Fatalf("摘要事件里没有 LLM 生成的内容:%q", truncateStr(summary.Input, 200))
|
||||
}
|
||||
|
||||
// ③ 保留段仍在(压缩是"压",不是清空)。
|
||||
if after := rc.agent.context.Len(); after < residentKeepRecent {
|
||||
t.Fatalf("压缩后只剩 %d 条,应保留最近 %d 条", after, residentKeepRecent)
|
||||
}
|
||||
|
||||
// ④ 摘要事件必须 NoMemory(它是背景说明,不是一句真实对话)。
|
||||
if !summary.NoMemory {
|
||||
t.Fatal("摘要事件会进记忆 —— 每次压缩都会往图库灌一条假对话")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressResident_UsesChildProvider 钉住裁定 A:**子自己的** provider 被调用,
|
||||
// 而不是父在替它压。
|
||||
//
|
||||
// ## 怎么才能真区分(初版两次都写错了)
|
||||
//
|
||||
// 1. 初版在创建后改写 rc.agent.provider ⇒ **数据竞争**:驻留子一 Start 就起了
|
||||
// 调度器 goroutine,它在 task.go:1385 读 a.provider,与测试写并发。
|
||||
// 第二版改成"看父的 provider 有没有被调",但 SpawnResident 让子与父共用
|
||||
// 同一个 provider 实例 ⇒ 两种实现(子的 / 父的)都会让它非零,无法区分。
|
||||
//
|
||||
// 正确做法:**在创建前就把父的 provider 换掉**,让"父自己的 provider"与
|
||||
// "传递给子的 provider"是两个可分辨的实例。做完这步之后再创建子,
|
||||
// 就不再有任何并发写入。
|
||||
func TestCompressResident_UsesChildProvider(t *testing.T) {
|
||||
// ① 先用一个"哑" provider 建父(不会有任何调用)。
|
||||
parent, _, dir := newRootWith(t, &rtCompactProvider{})
|
||||
|
||||
// ② 创建**之前**给父装上新 provider —— 这一刻还没有驻留子,无并发。
|
||||
parentProv := &rtCompactProvider{}
|
||||
parent.provider = parentProv
|
||||
|
||||
// ③ 此时创建的子会继承 parentProv(SpawnResident 里 Provider: a.provider)。
|
||||
spawnTestResident(t, parent, dir, "own-provider")
|
||||
parent.residentMu.Lock()
|
||||
rc := parent.residents["own-provider"]
|
||||
parent.residentMu.Unlock()
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子没创建成功")
|
||||
}
|
||||
|
||||
fillContext(rc, residentKeepRecent+5)
|
||||
if _, err := parent.CompressResident("own-provider"); err != nil {
|
||||
t.Fatalf("压缩失败:%v", err)
|
||||
}
|
||||
|
||||
// ④ 抽象行为断言:压缩确实调了 provider 生成摘要,且摘要进了子的上下文。
|
||||
// "用子而不是父"的实质是:写入的是**子的** context,不是父的。
|
||||
if parentProv.chatCount() == 0 {
|
||||
t.Fatal("压缩没有调用 provider —— 走了截断而非摘要")
|
||||
}
|
||||
var found bool
|
||||
for _, e := range rc.agent.context.snapshotEvents() {
|
||||
if e.Source == compactionSource {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("摘要事件没有写进**子**的上下文")
|
||||
}
|
||||
// 父自己的上下文必须**一点没动**(压缩是子对自身记忆的整理,不是父的)。
|
||||
if parent.context != nil && parent.context.Len() != 0 {
|
||||
t.Fatalf("父的上下文被改动了(%d 条)—— 压缩串到了父身上", parent.context.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressResident_FallsBackToTruncationOnFailure 钉住裁定 B:
|
||||
// 摘要失败必须回退截断,且**压缩整体不失败**(子不能卡在满上下文上)。
|
||||
func TestCompressResident_FallsBackToTruncationOnFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prov *rtCompactProvider
|
||||
}{
|
||||
{"provider 报错", &rtCompactProvider{failChat: true}},
|
||||
{"provider 返回空", &rtCompactProvider{blank: true}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parent, rc := newResidentWithProvider(t, tc.prov, "fallback")
|
||||
fillContext(rc, residentKeepRecent+10)
|
||||
before := rc.agent.context.Len()
|
||||
|
||||
n, err := parent.CompressResident("fallback")
|
||||
if err != nil {
|
||||
t.Fatalf("摘要失败时压缩不该报错(裁定 B:回退截断):%v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("回退截断没有丢掉任何东西?")
|
||||
}
|
||||
if after := rc.agent.context.Len(); after >= before {
|
||||
t.Fatalf("回退后上下文没变短(%d → %d)", before, after)
|
||||
}
|
||||
if after := rc.agent.context.Len(); after != residentKeepRecent {
|
||||
t.Fatalf("回退截断应保留 %d 条,实际 %d", residentKeepRecent, after)
|
||||
}
|
||||
// 回退路径不应产生摘要事件(没摘要成功就没摘要)。
|
||||
for _, e := range rc.agent.context.snapshotEvents() {
|
||||
if e.Source == compactionSource {
|
||||
t.Fatal("摘要失败却插入了空摘要事件")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressResident_NothingToCompactIsNoop 钉住边界:内容不足时压缩是空操作,
|
||||
// **不应**白调一次 LLM(那是纯浪费)。
|
||||
func TestCompressResident_NothingToCompactIsNoop(t *testing.T) {
|
||||
prov := &rtCompactProvider{}
|
||||
parent, rc := newResidentWithProvider(t, prov, "tiny")
|
||||
|
||||
fillContext(rc, 3) // 远小于 residentKeepRecent
|
||||
if n, err := parent.CompressResident("tiny"); err != nil || n != 0 {
|
||||
t.Fatalf("内容不足时压缩应无操作,得到 n=%d err=%v", n, err)
|
||||
}
|
||||
if got := prov.chatCount(); got != 0 {
|
||||
t.Fatalf("无可压内容却调用了 %d 次 LLM", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanCompaction_DoesNotMutate 钉住「先计划后执行」的安全性:
|
||||
// PlanCompaction 必须**不动**上下文(否则摘要失败时上下文已半毁)。
|
||||
func TestPlanCompaction_DoesNotMutate(t *testing.T) {
|
||||
prov := &rtCompactProvider{}
|
||||
_, rc := newResidentWithProvider(t, prov, "plan-only")
|
||||
fillContext(rc, residentKeepRecent+8)
|
||||
|
||||
// 快照一份「计划之前」的事件集合。
|
||||
//
|
||||
// ★ 不能拿 Len() 前后相等来断言"没改动"(初版就是这么写,实测偶发红):
|
||||
// 驻留子一 Start 就起了自己的调度器 goroutine,它会并发 Append 事件
|
||||
// (prepare 段的提示词注入等),长度自然会长。那不是 Plan 改动上下文,
|
||||
// 是**别的 goroutine 正常写入**。
|
||||
//
|
||||
// 正确的不变式是:**计划不会从上下文里删掉任何事件**(它只读)。
|
||||
beforeEvts := rc.agent.context.snapshotEvents()
|
||||
plan := rc.agent.context.PlanCompaction(residentKeepRecent)
|
||||
|
||||
afterSet := map[*ContextEvent]bool{}
|
||||
for _, e := range rc.agent.context.snapshotEvents() {
|
||||
afterSet[e] = true
|
||||
}
|
||||
for _, e := range beforeEvts {
|
||||
if !afterSet[e] {
|
||||
t.Fatalf("PlanCompaction 删掉了事件(Source=%q)—— 它必须是只读的,"+
|
||||
"否则摘要失败时上下文已被破坏一半", e.Source)
|
||||
}
|
||||
}
|
||||
if len(plan.Old) != len(beforeEvts)-residentKeepRecent {
|
||||
t.Fatalf("计划里 Old=%d 条,期望 %d", len(plan.Old), len(beforeEvts)-residentKeepRecent)
|
||||
}
|
||||
if !strings.Contains(plan.PrefixText, "第0件事") {
|
||||
t.Fatalf("PrefixText 丢了最早的内容:%q", truncateStr(plan.PrefixText, 200))
|
||||
}
|
||||
}
|
||||
@ -133,7 +133,7 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||
// 必须在 Destroy 之前 —— Destroy 会关掉 temp(这正是原先
|
||||
// ReclaimResident 内部做的顺序)。
|
||||
var promoted int
|
||||
residual, rmsg, ferr := a.FinishResident(id, residualPolicyOf(tc),
|
||||
residual, rmsg, ferr := a.FinishResidentWith(id, residualPolicyOf(tc), channelDispositionOf(tc),
|
||||
func(rc *residentChild) error {
|
||||
p, herr := a.harvestResidentMemory(rc)
|
||||
if herr != nil {
|
||||
@ -151,7 +151,7 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||
case "destroy":
|
||||
id := strArg(tc, "id")
|
||||
// 同上:销毁前先把残余任务交出去,否则它们会随子一起无声消失。
|
||||
residual, rmsg, ferr := a.FinishResident(id, residualPolicyOf(tc), nil)
|
||||
residual, rmsg, ferr := a.FinishResidentWith(id, residualPolicyOf(tc), channelDispositionOf(tc), nil)
|
||||
if ferr != nil {
|
||||
return fmt.Sprintf("销毁失败: %v", ferr)
|
||||
}
|
||||
@ -174,11 +174,30 @@ func residualPolicyOf(tc agentAPI.ToolCall) ResidualPolicy {
|
||||
switch strings.ToLower(strArg(tc, "residual")) {
|
||||
case "drop", "discard":
|
||||
return ResidualDrop
|
||||
case "stash", "park":
|
||||
// 暂存到原通道,等通道被接回时放出。
|
||||
return ResidualStash
|
||||
case "takeover", "take_over", "take":
|
||||
return ResidualTakeover
|
||||
default:
|
||||
return ResidualKeep
|
||||
}
|
||||
}
|
||||
|
||||
// channelDispositionOf 从工具参数读**通道**的处置方式(release/suspend/takeover)。
|
||||
//
|
||||
// 默认 release = 原有行为(归还未分配),保持向后兼容。
|
||||
func channelDispositionOf(tc agentAPI.ToolCall) ChannelDisposition {
|
||||
switch strings.ToLower(strArg(tc, "channels")) {
|
||||
case "suspend", "park":
|
||||
return ChannelSuspend
|
||||
case "takeover", "take_over", "take":
|
||||
return ChannelTakeover
|
||||
default:
|
||||
return ChannelRelease
|
||||
}
|
||||
}
|
||||
|
||||
func intArg(tc agentAPI.ToolCall, key string) int {
|
||||
switch v := tc.Arguments[key].(type) {
|
||||
case float64:
|
||||
|
||||
287
internal/agent/core/residual_stash_test.go
Normal file
287
internal/agent/core/residual_stash_test.go
Normal file
@ -0,0 +1,287 @@
|
||||
package core
|
||||
|
||||
// 判据:驻留子销毁时残余任务的**四种处置**,以及它持有通道的**三种处置**。
|
||||
//
|
||||
// 用户 2026-10-30 要求:
|
||||
//
|
||||
// 「对于驻留式子 agent 销毁时,残余任务主 agent 应当选择**暂存在对应通道**、
|
||||
// **丢弃**、或**接手**。原本驻留子持有的通道也应该支持**挂起或接回**主 agent」
|
||||
//
|
||||
// ## 为什么这事不能交给内核悄悄决定
|
||||
//
|
||||
// 已经在 ResidentPolicy 的注释里钉过:吞掉一条用户消息比多处理一条更糟。
|
||||
// 新增的 stash/suspend 把这件事细分到"不丢、也不立刻压给父"。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// pushToChild 把一条输入直接塞进驻留子的待处理队列(模拟通道投递)。
|
||||
//
|
||||
// 不用真的走通道投递:本文件验的是**销毁时如何处置队列里的残余任务**,
|
||||
// 而不是路由本身(路由已有独立判据)。直接入队让"这条任务来自哪个通道"
|
||||
// 可控。
|
||||
func pushToChild(t *testing.T, root *Agent, childID, channel, text string) {
|
||||
t.Helper()
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[childID]
|
||||
root.residentMu.Unlock()
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", childID)
|
||||
}
|
||||
evt := mustTextEvent(channel, text)
|
||||
evt.OutputChannel = channel
|
||||
rc.agent.sched.enqueue(newInputTask(evt))
|
||||
}
|
||||
|
||||
// spawnQuietResident 创建驻留子但**不注入 TaskPrompt**。
|
||||
//
|
||||
// ★ 本文件不能用 spawnTestResident:它会带 TaskPrompt,而那条提示词是
|
||||
// **异步**投进子队列的(探针实测:它在 parkChild 清空队列**之后**才到达,
|
||||
// 约 4/40 的概率混进来)。后果是残余条数变成 3 而非 2,
|
||||
// 判据偶发红 —— 而它测的根本不是那条提示词。
|
||||
//
|
||||
// 本文件验的是**销毁时的处置逻辑**,让子的初始队列为空才是确定的起点。
|
||||
func spawnQuietResident(t *testing.T, root *Agent, dir, id string, inputChs ...string) ResidentInfo {
|
||||
t.Helper()
|
||||
info, err := root.SpawnResident(ResidentOptions{
|
||||
ID: id,
|
||||
InputChs: inputChs,
|
||||
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// parkChild 停住驻留子的调度器,让"残余任务"处于**确定静止**状态。
|
||||
//
|
||||
// ★ 必须在入队前调用,否则判据不是确定性的。
|
||||
//
|
||||
// SpawnResident 末尾会 child.Start()(产品行为:创建即开工),于是子会在
|
||||
// 两次 enqueue 之间取走一条开始执行 —— 而"正在执行"的那条不属于"尚未处理"
|
||||
// (takeAllPendingEvents 只取 queue/interrupt/immediate,不取 running)。
|
||||
// 实测约 1/30 概率失败于"暂存 0 条"。
|
||||
//
|
||||
// 这不是产品缺陷:真实场景里残余任务是"子已停止、队列里剩下的",天然静止。
|
||||
// 本文件验的是**销毁时的处置逻辑**,不是子的生命周期,所以先停住即可。
|
||||
//
|
||||
// (`TestResidualKeepReturnsTasksToParent` 早就踩过并记录了同一个坑,
|
||||
//
|
||||
// 这里复用它的做法:cancel + 等静止,不用任何自定义标志位。)
|
||||
func parkChild(t *testing.T, root *Agent, childID string) {
|
||||
t.Helper()
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[childID]
|
||||
root.residentMu.Unlock()
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", childID)
|
||||
}
|
||||
rc.agent.cancel()
|
||||
rc.agent.sched.waitSchedulerStopped(schedStopTimeout, childID)
|
||||
|
||||
// 清掉停在队列里的**既有**项(通常是 SpawnResident 注入的 task_prompt)。
|
||||
//
|
||||
// ★ 不清的话残余条数不确定:子被停住时 task_prompt 可能还在队列里、
|
||||
// 也可能已被取走执行 —— 实测表现为"应处置 1 条,实际 2 条"。
|
||||
// 本文件只验处置逻辑,不从这条初始任务得到任何信息,所以直接倒掉,
|
||||
// 让每个判据从"空队列"这个确定状态开始。
|
||||
for _, leftover := range rc.agent.sched.takeAllPendingEvents() {
|
||||
_ = leftover
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualStash_ParksOnOriginatingChannel 钉住 stash 的核心语义:
|
||||
// 任务被放到**它自己来的那条通道**上,而不是随便一条、也不是父的队列。
|
||||
func TestResidualStash_ParksOnOriginatingChannel(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
for _, ch := range []string{"qq", "webui"} {
|
||||
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "test"}); err != nil {
|
||||
t.Fatalf("注册 %s: %v", ch, err)
|
||||
}
|
||||
}
|
||||
info := spawnQuietResident(t, root, dir, "stash1", "qq", "webui")
|
||||
parkChild(t, root, info.ID)
|
||||
|
||||
// 两条任务分别来自不同通道。
|
||||
pushToChild(t, root, info.ID, "qq", "qq-来的消息")
|
||||
pushToChild(t, root, info.ID, "webui", "webui-来的消息")
|
||||
|
||||
n, msg, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("应处置 2 条,实际 %d(%s)", n, msg)
|
||||
}
|
||||
|
||||
// ★ 必须各回各家:qq 的进 qq,webui 的进 webui。
|
||||
// 整批塞进一条通道会让"接回 qq 的人"拿到本该给 webui 的活。
|
||||
if got := reg.StashLen("qq"); got != 1 {
|
||||
t.Errorf("qq 通道暂存 = %d 条,期望 1 条(整批塞进单通道会导致错投)", got)
|
||||
}
|
||||
if got := reg.StashLen("webui"); got != 1 {
|
||||
t.Errorf("webui 通道暂存 = %d 条,期望 1 条", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualStash_NotInParentQueue 钉住 stash 与 keep 的**行为差别**。
|
||||
//
|
||||
// 只断言"暂存了"不够:如果 stash 顺带也塞进了父队列,那它和 keep 就没区别,
|
||||
// 只不过多留了份副本 —— 任务会被处理两次。
|
||||
func TestResidualStash_NotInParentQueue(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := spawnQuietResident(t, root, dir, "stash2", "qq")
|
||||
parkChild(t, root, info.ID)
|
||||
pushToChild(t, root, info.ID, "qq", "别进父队列")
|
||||
|
||||
before := root.sched.queueLen()
|
||||
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
if got := root.sched.queueLen(); got != before {
|
||||
t.Fatalf("父队列从 %d 变成 %d —— stash 必须**只**暂存通道,不能同时塞给父(会重复处理)",
|
||||
before, got)
|
||||
}
|
||||
if reg.StashLen("qq") != 1 {
|
||||
t.Fatalf("qq 通道未暂存到任务")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualStash_ReleasedOnChannelTakeover 钉住"接回通道即释放"。
|
||||
//
|
||||
// ★ 这是整个特性里最容易静默失效的一环:
|
||||
// 若接回时只改 Owner 不取暂存,通道看起来"已经接回来了",
|
||||
// 但活永远躺在里面,且没有任何报错。
|
||||
func TestResidualStash_ReleasedOnChannelTakeover(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := spawnQuietResident(t, root, dir, "stash3", "qq")
|
||||
parkChild(t, root, info.ID)
|
||||
pushToChild(t, root, info.ID, "qq", "接回时该被释放")
|
||||
|
||||
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
if reg.StashLen("qq") != 1 {
|
||||
t.Fatalf("前置条件不成立:qq 未暂存")
|
||||
}
|
||||
|
||||
// 接回:Owner 改为父,暂存必须一并交出。
|
||||
drained, err := reg.AssignAndDrainStash("qq", "parent", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("AssignAndDrainStash: %v", err)
|
||||
}
|
||||
if len(drained) != 1 {
|
||||
t.Fatalf("接回时释放了 %d 条,期望 1 条(暂存被吞,活永远没人做)", len(drained))
|
||||
}
|
||||
if reg.StashLen("qq") != 0 {
|
||||
t.Fatalf("释放后暂存仍剩 %d 条(应清空,否则会重复释放)", reg.StashLen("qq"))
|
||||
}
|
||||
if got := reg.ListByOwner("parent"); len(got) != 1 || got[0].Name != "qq" {
|
||||
t.Fatalf("通道未归父:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualStash_SuspendKeepsStash 钉住 suspend 的语义:
|
||||
// 挂起**不释放**暂存(那是接回才做的事),否则挂起等于悄悄丢弃。
|
||||
func TestResidualStash_SuspendKeepsStash(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := spawnQuietResident(t, root, dir, "stash4", "qq")
|
||||
parkChild(t, root, info.ID)
|
||||
pushToChild(t, root, info.ID, "qq", "挂起后仍要在")
|
||||
|
||||
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
// 挂起 = 归属清空(无人处理),但暂存保留。
|
||||
if got := reg.ListByOwner(""); len(got) != 1 || got[0].Name != "qq" {
|
||||
t.Fatalf("挂起后通道应在「未分配」里:%+v", got)
|
||||
}
|
||||
if reg.StashLen("qq") != 1 {
|
||||
t.Fatalf("挂起把暂存也弄丢了 —— suspend 必须保留暂存")
|
||||
}
|
||||
|
||||
// 再挂起一次(幂等)也不能丢。
|
||||
if _, err := reg.AssignAndDrainStash("qq", "", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reg.StashLen("qq") != 1 {
|
||||
t.Fatalf("对空 owner 再 Assign 不应释放暂存(那是 suspend 的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualTakeover_PutsTasksInParentQueue 钉住"接手"。
|
||||
func TestResidualTakeover_PutsTasksInParentQueue(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := spawnQuietResident(t, root, dir, "take1", "qq")
|
||||
parkChild(t, root, info.ID)
|
||||
pushToChild(t, root, info.ID, "qq", "父接手这条")
|
||||
|
||||
n, msg, err := root.FinishResidentWith(info.ID, ResidualTakeover, ChannelTakeover, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应处置 1 条,实际 %d(%s)", n, msg)
|
||||
}
|
||||
// 通道也接了回来。
|
||||
if got := reg.ListByOwner("parent"); len(got) != 1 || got[0].Name != "qq" {
|
||||
t.Fatalf("通道未接回父:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidualDrop_StillNotifiesSyncCaller 是既有语义的回归钉子:
|
||||
// 新增 stash/suspend 后,drop 仍必须给同步调用方终态。
|
||||
func TestResidualDrop_StillNotifiesSyncCaller(t *testing.T) {
|
||||
root, _, dir := newRootWith(t, &countingProvider{})
|
||||
reg := root.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.agent.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||||
Source: "cli", OutputChannel: "qq", Payload: map[string]interface{}{"content": "答我"},
|
||||
ResponseCh: respCh,
|
||||
}))
|
||||
|
||||
if _, _, err := root.FinishResidentWith(info.ID, ResidualDrop, ChannelRelease, nil); err != nil {
|
||||
t.Fatalf("FinishResidentWith: %v", err)
|
||||
}
|
||||
select {
|
||||
case out := <-respCh:
|
||||
if out == nil || !out.Done {
|
||||
t.Error("drop 应给同步调用方终态(否则 cli/a2a 永久挂起)")
|
||||
}
|
||||
default:
|
||||
t.Error("★ drop 未通知同步调用方:调用方会永久挂起")
|
||||
}
|
||||
if reg.StashLen("qq") != 0 {
|
||||
t.Errorf("drop 不应留下暂存")
|
||||
}
|
||||
}
|
||||
1507
internal/agent/core/rtchild.go
Normal file
1507
internal/agent/core/rtchild.go
Normal file
File diff suppressed because it is too large
Load Diff
167
internal/agent/core/rtchild_r10_test.go
Normal file
167
internal/agent/core/rtchild_r10_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package core
|
||||
|
||||
// R10 判据:rtchild 的工具面(用户裁定 R3 / R4)。
|
||||
//
|
||||
// R3:rt 流**不禁工具**;`askparent` 工具**保留**
|
||||
// R4:提示词要求「调用工具前先用 outputch 告知用户稍等」
|
||||
//
|
||||
// `askparent` 在本仓的实际形态是 `notify_parent`(子侧向父发问的唯一通道,
|
||||
// 见 tooldefs.go:874 的注册条件 parentID != "")。所以这里钉的是:
|
||||
//
|
||||
// 1. rtchild 的工具表里**有** notify_parent(且不是被剥掉的)
|
||||
// 2. 它的回调**真的接线**(初版没接 ⇒ 模型一调就得到"没有上级")
|
||||
// 3. 工具面**不因 rt 而收缩**(R3:不禁工具)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// rtToolNames 从 buildToolDefs 的结果里抽工具名。
|
||||
func rtToolNames(defs []interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, t := range defs {
|
||||
m, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, ok := m["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name, ok := fn["name"].(string); ok {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRTChild_AskparentToolPresent 钉住 R3 后半:`askparent`(= notify_parent)保留。
|
||||
//
|
||||
// 若有人"顺手"在 rtStripOutputTools 里把它也剥掉(它确实和输出有关),本判据必红。
|
||||
func TestRTChild_AskparentToolPresent(t *testing.T) {
|
||||
p := &rtBlockingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "ask")
|
||||
rc := host.rtChildren[id]
|
||||
if rc == nil {
|
||||
t.Fatal("实例不在登记表")
|
||||
}
|
||||
|
||||
tools := rtStripOutputTools(rc.agent.buildToolDefs())
|
||||
names := rtToolNames(tools)
|
||||
|
||||
// carrier 的 parentID != "" ⇒ notify_parent 应在子侧工具表里。
|
||||
// 若这里没有,说明 rtchild 的 carrier 没被正确标记为"子"。
|
||||
if !names["notify_parent"] {
|
||||
t.Fatalf("rtchild 工具表里没有 notify_parent(askparent)—— 用户裁定 R3 要求保留\n已有工具:%v",
|
||||
func() []string {
|
||||
var ks []string
|
||||
for k := range names {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
return ks
|
||||
}())
|
||||
}
|
||||
// output_send__* 必须被剥掉(rt 输出走 outputch(rt) 流式管线)。
|
||||
for name := range names {
|
||||
if strings.HasPrefix(name, "output_send__") {
|
||||
t.Fatalf("rtchild 工具表里仍有 %s —— 会与流式管线产生两套输出语义", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_NotifyParentIsWired 钉住**接线**(这是初版的真实缺陷):
|
||||
// notify_parent 的回调必须已接上,调用后不能返回"没有上级"。
|
||||
//
|
||||
// 初版 carrier 没设 notifyParent ⇒ notifyParentFrom 直接返回
|
||||
// 「本 agent 没有上级,无法发送消息」——工具在、调用必失败。
|
||||
func TestRTChild_NotifyParentIsWired(t *testing.T) {
|
||||
p := &rtBlockingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "wire")
|
||||
rc := host.rtChildren[id]
|
||||
if rc == nil || rc.agent.notifyParent == nil {
|
||||
t.Fatal("rtchild 的 notifyParent 未接线 —— notify_parent 工具会永远失败")
|
||||
}
|
||||
|
||||
// 走真实工具入口(不是直接调回调):这才是模型看到的那条路。
|
||||
got := rc.agent.executeNotifyParent(agentAPI.ToolCall{
|
||||
Name: "notify_parent",
|
||||
Arguments: map[string]interface{}{"text": "我想问一下"},
|
||||
})
|
||||
if strings.Contains(got, "没有上级") {
|
||||
t.Fatalf("notify_parent 走真实入口仍报「没有上级」:%s", got)
|
||||
}
|
||||
|
||||
// 入站通道只登记一次:反复发问不应让登记表累积。
|
||||
reg := host.io.ChannelRegistry()
|
||||
before := reg.Count()
|
||||
for i := 0; i < 5; i++ {
|
||||
rc.agent.executeNotifyParent(agentAPI.ToolCall{
|
||||
Name: "notify_parent",
|
||||
Arguments: map[string]interface{}{"text": "再问一次"},
|
||||
})
|
||||
}
|
||||
if after := reg.Count(); after != before {
|
||||
t.Fatalf("连发 5 次 notify_parent 后登记表从 %d 涨到 %d —— 每次发问都在重复登记(写放大+脏数据)",
|
||||
before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_ToolFaceNotNarrowedByRT 钉住 R3 前半:**rt 流不禁工具**。
|
||||
//
|
||||
// 判据取法:把 rtchild 的工具数量与"同一个 agent 不用 rt 时的工具数量"比 ——
|
||||
// rt 只该剥掉 output_send__*,不该额外剥任何东西。
|
||||
func TestRTChild_ToolFaceNotNarrowedByRT(t *testing.T) {
|
||||
p := &rtBlockingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "face")
|
||||
rc := host.rtChildren[id]
|
||||
|
||||
all := rtToolNames(rc.agent.buildToolDefs())
|
||||
kept := rtToolNames(rtStripOutputTools(rc.agent.buildToolDefs()))
|
||||
|
||||
// 只允许 output_send__* 与 output_list_channels 被剥。
|
||||
for name := range all {
|
||||
if kept[name] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(name, "output_send__") && name != "output_list_channels" {
|
||||
t.Fatalf("rt 额外剥掉了工具 %s —— 用户裁定 R3「rt 流不禁工具」", name)
|
||||
}
|
||||
}
|
||||
// 至少要保留一些工具(否则"不禁工具"名存实亡)。
|
||||
if len(kept) == 0 {
|
||||
t.Fatal("rtchild 工具表为空 —— 等于禁了所有工具")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_StopUnregistersInbound 钉住 Stop 时注销入站通道(不留脏登记)。
|
||||
func TestRTChild_StopUnregistersInbound(t *testing.T) {
|
||||
p := &rtBlockingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
reg := host.io.ChannelRegistry()
|
||||
before := reg.Count()
|
||||
|
||||
id := rtSpawn(t, host, "unreg")
|
||||
if reg.Count() <= before {
|
||||
t.Fatal("创建后没有登记入站通道 —— 父侧收不到 rtchild 的发问")
|
||||
}
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止: %v", err)
|
||||
}
|
||||
if got := reg.Count(); got != before {
|
||||
t.Fatalf("停止后登记表 %d,期望回到 %d —— 入站通道未注销", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 agentIO 被使用(ChannelRegistry 类型在断言中通过接口访问)。
|
||||
var _ = agentIO.CapText
|
||||
202
internal/agent/core/rtchild_r11_test.go
Normal file
202
internal/agent/core/rtchild_r11_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
package core
|
||||
|
||||
// R11 判据:待机零消耗 + 长时 e2e(契约 4 / 契约 1 的持续正确性)。
|
||||
//
|
||||
// 契约 4:无输入即静默,**不烧轮次、不烧 token、不轮询**
|
||||
//
|
||||
// "不轮询"这条容易被忽略但同样重要:轮询会烧 CPU,且在静默期产生
|
||||
// 无意义的唤醒。实现上 rtLoop 必须 block 在 Wake() 上(见 rtchild.go)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// rtCountingProvider 统计调用次数与总 token 量(近似:正文长度)。
|
||||
type rtCountingProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
chars int
|
||||
}
|
||||
|
||||
func (p *rtCountingProvider) Name() string { return "rtCounting" }
|
||||
func (p *rtCountingProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *rtCountingProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||
}
|
||||
func (p *rtCountingProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: "答"}
|
||||
p.mu.Lock()
|
||||
p.chars++
|
||||
p.mu.Unlock()
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (p *rtCountingProvider) stats() (int, int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.calls, p.chars
|
||||
}
|
||||
|
||||
// TestRTChild_IdleZeroTokens 钉住契约 4:**静默期零模型调用**。
|
||||
//
|
||||
// 若 rtLoop 改成轮询(select 带 default 或定时 tick),本判据会看到
|
||||
// calls > 0 —— 那意味着空转烧钱。
|
||||
func TestRTChild_IdleZeroTokens(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "idle")
|
||||
|
||||
// 给 rtLoop 足够时间"如果它要空转的话"必然已经转了。
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
if calls, _ := p.stats(); calls != 0 {
|
||||
t.Fatalf("静默 300ms 期间模型被调用 %d 次 —— 待机必须零消耗(契约 4)", calls)
|
||||
}
|
||||
// 槽也必须仍是空的(没有凭空产生输入)。
|
||||
if st, ok := host.RTChildStats(id); !ok {
|
||||
t.Fatal("实例不在登记表")
|
||||
} else if st.Pending {
|
||||
t.Fatal("静默期槽里出现了待处理输入")
|
||||
} else if st.Rounds != 0 {
|
||||
t.Fatalf("静默期跑了 %d 轮", st.Rounds)
|
||||
}
|
||||
|
||||
// 再等一段,确认不是"刚启动还没轮到"。
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if calls, _ := p.stats(); calls != 0 {
|
||||
t.Fatalf("静默 500ms 累计调用 %d 次", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_IdleStopsWhenInputArrives 是配套正例:来输入必须**真的**跑起来。
|
||||
//
|
||||
// 只有"静默零调用"这一半的判定条件是坏的 —— "永不调用模型"的实现也能通过,
|
||||
// 但那是死进程。正例是必需品。
|
||||
func TestRTChild_IdleStopsWhenInputArrives(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "wakeup")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if calls, _ := p.stats(); calls != 0 {
|
||||
t.Fatalf("静默期不该有调用,得到 %d", calls)
|
||||
}
|
||||
|
||||
host.PushRTInput(id, "你好")
|
||||
waitFor(t, "输入后跑了一轮", func() bool {
|
||||
calls, _ := p.stats()
|
||||
return calls >= 1
|
||||
})
|
||||
}
|
||||
|
||||
// TestRTChild_E2E_Sustained 是长时 e2e:连续灌入输入,验证
|
||||
//
|
||||
// · 每一批都被处理(不丢活)
|
||||
// · 无 goroutine 泄漏 / 无 panic
|
||||
// · history 持续增长(普通上下文)
|
||||
// · 全程 -race 干净(由调用方加 -race)
|
||||
//
|
||||
// 用 -short 跳过:默认用例集不该被一段秒级压测拖慢。
|
||||
func TestRTChild_E2E_Sustained(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("长时 e2e;-short 跳过")
|
||||
}
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "e2e")
|
||||
|
||||
// 定速灌入(模拟插件攒批后的节奏)。总数取 60:足够暴露累积问题,
|
||||
// 又不至于让默认用例集跑太久。
|
||||
const total = 60
|
||||
for i := 0; i < total; i++ {
|
||||
host.PushRTInput(id, "第 N 句话")
|
||||
// 给 rtLoop 一点时间消费;不追求"每条都跑完",要的是压力。
|
||||
if i%10 == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾:等最后一条被处理。
|
||||
waitFor(t, "所有输入都被消费", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && !st.Pending
|
||||
})
|
||||
|
||||
tr, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("取不到 transcript")
|
||||
}
|
||||
if len(tr) == 0 {
|
||||
t.Fatal("跑了多轮但 history 为空 —— 普通上下文没在累积")
|
||||
}
|
||||
calls, _ := p.stats()
|
||||
if calls == 0 {
|
||||
t.Fatal("全程零模型调用 —— rtLoop 没工作")
|
||||
}
|
||||
t.Logf("e2e: 灌入 %d 条,模型调用 %d 次,history %d 条", total, calls, len(tr))
|
||||
|
||||
// 关闭必须干净。
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止: %v", err)
|
||||
}
|
||||
if got := len(host.RTChildren()); got != 0 {
|
||||
t.Fatalf("停止后仍有 %d 个实例", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_RapidInputCollapsesToLatest 钉住契约 1+3 的**联合语义**:
|
||||
// 输入到达得比处理快时,只有最新一条会被处理,其余被覆盖丢弃。
|
||||
//
|
||||
// 这不是缺陷,是设计:rt 场景下"用户刚说的话"才是当前态,旧语音已经过期。
|
||||
// 若有人把 rtSlot 改成队列(让每条都跑),本判据会看到 rounds > 1 —— 那时
|
||||
// 通话会变成"逐条追赶",用户说完很久机器才反应到,违反实时性。
|
||||
//
|
||||
// ★ 同时钉住 dropped 计数器:它是"打断真的发生了"的唯一可观测凭据。
|
||||
func TestRTChild_RapidInputCollapsesToLatest(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "collapse")
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
// 一口气推 50 条(比 rtLoop 处理得快得多)。
|
||||
const burst = 50
|
||||
for i := 0; i < burst; i++ {
|
||||
host.PushRTInput(id, "连发")
|
||||
}
|
||||
waitFor(t, "槽被消费", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && !st.Pending
|
||||
})
|
||||
|
||||
st, ok := host.RTChildStats(id)
|
||||
if !ok {
|
||||
t.Fatal("实例不存在")
|
||||
}
|
||||
if st.Rounds > 3 {
|
||||
t.Fatalf("连发 %d 条却跑了 %d 轮 —— rtSlot 退化成了队列(契约 1/3:最新者胜、不排队)",
|
||||
burst, st.Rounds)
|
||||
}
|
||||
if st.Dropped == 0 {
|
||||
t.Fatal("连发 50 条但 dropped=0 —— 覆盖没有发生,或计数器没接线")
|
||||
}
|
||||
if st.Dropped+st.Rounds < burst/2 {
|
||||
t.Fatalf("dropped=%d rounds=%d,合计远小于连发数 %d —— 有输入凭空消失了(既没被处理也没计入丢弃)",
|
||||
st.Dropped, st.Rounds, burst)
|
||||
}
|
||||
}
|
||||
139
internal/agent/core/rtchild_r1_test.go
Normal file
139
internal/agent/core/rtchild_r1_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package core
|
||||
|
||||
// R1 判据:「只有 rtchild 可被划分 rt 通道」(用户裁定 R2)+ 通道侧能力校验。
|
||||
//
|
||||
// 设计见 plan.md 十一.三 F / 十一.七 R1。
|
||||
//
|
||||
// ## 这两条判据为什么必须成对存在
|
||||
//
|
||||
// 校验是**双向**的,任何一侧单独成立都不足以拦住"慢 agent 混进通话链路":
|
||||
// · 只校验 agent 侧 ⇒ 一个没声明 CapRT 的普通通道被划进来,插件会把它
|
||||
// 当成普通消息收(不知道"新值作废旧值"),rt 语义静默丢失;
|
||||
// · 只校验通道侧 ⇒ 主 agent(实测往返 2–5s)能划 rt 通道,用户听到长静默。
|
||||
//
|
||||
// 所以下面两条各钉一侧,删掉任一侧的实现,对应判据必须变红。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// newRTTestAgent 造一个最小可用的 agent(带 io + 共享通道登记表)。
|
||||
func newRTTestAgent(t *testing.T, realtime bool) *Agent {
|
||||
t.Helper()
|
||||
io := agentIO.NewIOManager()
|
||||
a := New(AgentConfig{ID: "rt-test", IO: io})
|
||||
if realtime {
|
||||
a.AsRealtime()
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// registerRTChannel 往登记表里放一个 inputch(caps 决定它声明了什么能力)。
|
||||
func registerRTChannel(t *testing.T, a *Agent, name string, caps agentIO.OutputCapability) {
|
||||
t.Helper()
|
||||
err := a.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: name,
|
||||
Plugin: "test",
|
||||
Capabilities: caps,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("注册 inputch %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRealtimeRejectsNonRTAgent 钉住 agent 侧:
|
||||
// 非 rtchild **不得**被划分 rt 通道。
|
||||
//
|
||||
// 删掉 AssignRealtime 里的 `if !a.isRealtime` 判断,本判据必红。
|
||||
func TestAssignRealtimeRejectsNonRTAgent(t *testing.T) {
|
||||
a := newRTTestAgent(t, false) // 普通 agent,不是 rtchild
|
||||
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
|
||||
|
||||
err := a.AssignRealtime("voice")
|
||||
if err == nil {
|
||||
t.Fatalf("非 rtchild 竟然被划到 rt 通道 —— 「只有 rtchild 可被划分 rt 通道」未生效")
|
||||
}
|
||||
if !errors.Is(err, ErrAgentNotRealtime) {
|
||||
t.Fatalf("错误类型不对:%v(应可用 errors.Is 匹配 ErrAgentNotRealtime,"+
|
||||
"否则调用方只能靠字符串判断)", err)
|
||||
}
|
||||
|
||||
// 配套断言:拒绝后 Owner 必须**未被写入**。
|
||||
// 只返回错误但已经把 Owner 改了,同样是坏的(调用方看到报错,通道却已被占)。
|
||||
ch, ok := a.io.ChannelRegistry().Lookup("voice")
|
||||
if !ok {
|
||||
t.Fatal("通道不见了")
|
||||
}
|
||||
if ch.Owner != "" {
|
||||
t.Fatalf("划拨被拒但 Owner 已被写成 %q —— 拒绝路径必须无副作用", ch.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRealtimeRejectsChannelWithoutCapRT 钉住通道侧:
|
||||
// 没有声明 CapRT 的通道**不得**被划作 rt 通道。
|
||||
//
|
||||
// 删掉 AssignRealtime 里的 ch.Capabilities.Supports(CapRT) 判断,本判据必红。
|
||||
func TestAssignRealtimeRejectsChannelWithoutCapRT(t *testing.T) {
|
||||
a := newRTTestAgent(t, true) // 确实是 rtchild
|
||||
registerRTChannel(t, a, "plain", agentIO.CapText)
|
||||
|
||||
err := a.AssignRealtime("plain")
|
||||
if err == nil {
|
||||
t.Fatalf("未声明 CapRT 的通道竟被划成 rt 通道 —— 插件会把它当普通消息收,rt 语义静默丢失")
|
||||
}
|
||||
if !errors.Is(err, agentIO.ErrChannelNotRealtime) {
|
||||
t.Fatalf("错误类型不对:%v(应匹配 ErrChannelNotRealtime)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRealtimeAllowsPlainChannel 钉住计划原文的一条边界:
|
||||
//
|
||||
// 「标签不是准入门槛:**不带 rt 标签的普通通道同样可以划给 rt agent**」
|
||||
//
|
||||
// 即:rtchild 用普通通道是**允许**的 —— 只是那条通道不能承载 rt 流。
|
||||
// 若有人"顺手"把普通 Assign 也加上 rt 校验,本判据会红。
|
||||
func TestAssignRealtimeAllowsPlainChannel(t *testing.T) {
|
||||
a := newRTTestAgent(t, true)
|
||||
registerRTChannel(t, a, "cli", agentIO.CapText)
|
||||
|
||||
// 走**普通** Assign(不是 AssignRealtime):rtchild 划普通通道必须放行。
|
||||
if err := a.io.AssignInputChannel("cli", "rt-test", 0); err != nil {
|
||||
t.Fatalf("rtchild 划普通通道被拒 —— 违反「标签不是准入门槛」:%v", err)
|
||||
}
|
||||
ch, _ := a.io.ChannelRegistry().Lookup("cli")
|
||||
if ch.Owner != "rt-test" {
|
||||
t.Fatalf("Owner 未写入:%q", ch.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRealtimeSucceedsOnRTChannel 是正例:两个条件都满足时必须成功。
|
||||
//
|
||||
// ★ 只有负例判据时,"永远返回错误的实现"也能全绿 —— 正例是必需品。
|
||||
func TestAssignRealtimeSucceedsOnRTChannel(t *testing.T) {
|
||||
a := newRTTestAgent(t, true)
|
||||
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
|
||||
|
||||
if err := a.AssignRealtime("voice"); err != nil {
|
||||
t.Fatalf("rtchild 划 rt 通道失败:%v", err)
|
||||
}
|
||||
ch, _ := a.io.ChannelRegistry().Lookup("voice")
|
||||
if ch.Owner != "rt-test" {
|
||||
t.Fatalf("Owner = %q,期望 rt-test", ch.Owner)
|
||||
}
|
||||
// Capacity 必须是 0:rt 通道是**单槽覆盖**,不是"容量=1 的队列"
|
||||
// (后者会让新输入等旧输入跑完,正是契约 1 禁止的,见 plan.md 十一.五)。
|
||||
if ch.Capacity != 0 {
|
||||
t.Fatalf("rt 通道的 Capacity = %d,必须为 0(单槽覆盖,不是队列)", ch.Capacity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignRealtimeRejectsUnknownChannel 引用未注册通道必须报错(不静默成功)。
|
||||
func TestAssignRealtimeRejectsUnknownChannel(t *testing.T) {
|
||||
a := newRTTestAgent(t, true)
|
||||
if err := a.AssignRealtime("nope"); !errors.Is(err, agentIO.ErrInputChannelUnknown) {
|
||||
t.Fatalf("未注册通道应报 ErrInputChannelUnknown,得到:%v", err)
|
||||
}
|
||||
}
|
||||
146
internal/agent/core/rtchild_r2_test.go
Normal file
146
internal/agent/core/rtchild_r2_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
package core
|
||||
|
||||
// R2 判据:rtSlot 单槽覆盖语义(plan.md 十一.五)。
|
||||
//
|
||||
// 三条判据分别钉住三件事,删掉对应实现任一条都会红:
|
||||
// 1. 新输入覆盖旧输入(旧值**作废**,不保留、不补偿)—— 契约 1
|
||||
// 2. 永不排队(连续 N 次 Push 后待处理恒为 1)—— 契约 3
|
||||
// 3. 覆盖是**有统计**的(dropped 单调前进)—— 否则"打断生效了几次"不可观测
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
func rtEvent(content string) *agentIO.InputEvent {
|
||||
return &agentIO.InputEvent{
|
||||
Source: "voice",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": content},
|
||||
}
|
||||
}
|
||||
|
||||
func rtContent(evt *agentIO.InputEvent) string {
|
||||
if evt == nil || evt.Payload == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := evt.Payload["content"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestRTSlot_NewInputReplacesOld 钉住「最新者胜」:后到的输入**作废旧值**。
|
||||
//
|
||||
// 若把 Push 改成"入队"(append 到 slice),本判据必然红:Take 会先取到旧值。
|
||||
func TestRTSlot_NewInputReplacesOld(t *testing.T) {
|
||||
s := newRTSlot()
|
||||
s.Push(rtEvent("第一句"))
|
||||
s.Push(rtEvent("第二句"))
|
||||
|
||||
evt, _, ok := s.Take()
|
||||
if !ok {
|
||||
t.Fatal("槽里应有待处理输入")
|
||||
}
|
||||
if got := rtContent(evt); got != "第二句" {
|
||||
t.Fatalf("取到的输入 = %q,期望 %q —— 最新者胜未生效(旧值竟然先出队)",
|
||||
got, "第二句")
|
||||
}
|
||||
// 取走后必须清空:否则 rtLoop 会反复拿到同一条输入重跑同一轮。
|
||||
if _, _, ok := s.Take(); ok {
|
||||
t.Fatal("Take 之后槽未清空 —— rtLoop 会重复处理同一条输入")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSlot_NeverQueues 钉住「不吃队列」:无论 Push 多少次,待处理恒为 1 条。
|
||||
//
|
||||
// 这是契约 3 的直接判据。若实现改成有界队列(哪怕容量 8),
|
||||
// 连续 Push 100 次后 Take 会连续取出 100 条 —— 本判据立刻红。
|
||||
func TestRTSlot_NeverQueues(t *testing.T) {
|
||||
const n = 100
|
||||
s := newRTSlot()
|
||||
for i := 0; i < n; i++ {
|
||||
s.Push(rtEvent(fmt.Sprintf("第%d句", i)))
|
||||
}
|
||||
|
||||
// 只应该能取出**一条**(最后那条)。
|
||||
evt, _, ok := s.Take()
|
||||
if !ok {
|
||||
t.Fatal("槽里应有待处理输入")
|
||||
}
|
||||
if got, want := rtContent(evt), fmt.Sprintf("第%d句", n-1); got != want {
|
||||
t.Fatalf("取到 %q,期望最后一条 %q", got, want)
|
||||
}
|
||||
if _, _, ok := s.Take(); ok {
|
||||
t.Fatalf("连续 Push %d 次后能取出第二条 —— 这不是单槽,是队列(违反契约 3)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSlot_CountsDropped 钉住可观测性:被覆盖的输入必须计数。
|
||||
//
|
||||
// 为什么这是判据而不只是日志:rt 场景下"打断"是常态,但如果
|
||||
// dropped 恒为 0,说明要么插件攒批没生效、要么输入速率极低 ——
|
||||
// 两种情况下阶段二的延迟数据都不可信。所以它必须能被读到。
|
||||
func TestRTSlot_CountsDropped(t *testing.T) {
|
||||
s := newRTSlot()
|
||||
if got := s.Dropped(); got != 0 {
|
||||
t.Fatalf("初始 dropped = %d,期望 0", got)
|
||||
}
|
||||
s.Push(rtEvent("a")) // 第一次不丢弃(槽是空的)
|
||||
if got := s.Dropped(); got != 0 {
|
||||
t.Fatalf("首次 Push 不应计丢弃,得到 %d", got)
|
||||
}
|
||||
s.Push(rtEvent("b")) // 覆盖 a ⇒ +1
|
||||
s.Push(rtEvent("c")) // 覆盖 b ⇒ +1
|
||||
if got := s.Dropped(); got != 2 {
|
||||
t.Fatalf("dropped = %d,期望 2(两次覆盖)", got)
|
||||
}
|
||||
|
||||
// 取走之后槽为空 ⇒ 下一次 Push 不算覆盖。
|
||||
s.Take()
|
||||
s.Push(rtEvent("d"))
|
||||
if got := s.Dropped(); got != 2 {
|
||||
t.Fatalf("取空后 Push 不应计丢弃,dropped = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSlot_SeqMonotonicAndMatchesTake 钉住 seq 单调递增且 Take 取到的是**最新 seq**。
|
||||
//
|
||||
// seq 是契约 1 的可验证凭据(plan.md 十一.四 空白 3):
|
||||
// 输出要回传它,判据才能证明"这一轮是用最新输入跑的,而不是旧输入的残余"。
|
||||
func TestRTSlot_SeqMonotonicAndMatchesTake(t *testing.T) {
|
||||
s := newRTSlot()
|
||||
var last uint64
|
||||
for i := 0; i < 5; i++ {
|
||||
seq := s.Push(rtEvent(fmt.Sprintf("v%d", i)))
|
||||
if seq <= last {
|
||||
t.Fatalf("seq 未单调递增:%d 之后是 %d", last, seq)
|
||||
}
|
||||
last = seq
|
||||
}
|
||||
_, seq, ok := s.Take()
|
||||
if !ok {
|
||||
t.Fatal("应有待处理")
|
||||
}
|
||||
if seq != last {
|
||||
t.Fatalf("Take 返回 seq=%d,期望最新 %d —— 取到的不是最新输入", seq, last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSlot_WakeIsNonBlocking 钉住唤醒是**电平**语义:
|
||||
// 槽里已有信号时重复 Push 不得阻塞(cap=1,非阻塞写)。
|
||||
//
|
||||
// 若把 wake 改成无缓冲通道或阻塞写,本判据会因 Push 卡死而超时。
|
||||
func TestRTSlot_WakeIsNonBlocking(t *testing.T) {
|
||||
s := newRTSlot()
|
||||
// 连推多次:第一次填满 wake(cap=1),后续必须走 default 分支返回。
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Push(rtEvent(fmt.Sprintf("w%d", i)))
|
||||
}
|
||||
// 至少要能收到一次唤醒信号。
|
||||
select {
|
||||
case <-s.Wake():
|
||||
default:
|
||||
t.Fatal("Push 之后 Wake 从未就绪 —— rtLoop 会永久阻塞(待机醒不来)")
|
||||
}
|
||||
}
|
||||
471
internal/agent/core/rtchild_r3_test.go
Normal file
471
internal/agent/core/rtchild_r3_test.go
Normal file
@ -0,0 +1,471 @@
|
||||
package core
|
||||
|
||||
// R3–R6 判据:rtchild 生命周期 / 流式输出 / 打断截断 / toolcall 期不可打断。
|
||||
//
|
||||
// 每条判据都对应 plan.md 十一.七 的一个步骤,删掉被钉的实现必须变红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// ── 测试替身 ──
|
||||
|
||||
// rtBlockingProvider 的 ChatStream 会一直流式吐出 token,直到 ctx 被取消。
|
||||
//
|
||||
// 用它来验证"打断即截断":取消后流必须停在半路,且**不得重试**。
|
||||
type rtBlockingProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int // ChatStream 被调用次数(验证"不重试")
|
||||
got int // 已发出的 chunk 数
|
||||
// blockForever=true 时永不自然结束(只能靠 cancel 停)。
|
||||
blockForever bool
|
||||
}
|
||||
|
||||
func (p *rtBlockingProvider) Name() string { return "rtBlocking" }
|
||||
func (p *rtBlockingProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *rtBlockingProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "non-stream"}, nil
|
||||
}
|
||||
|
||||
func (p *rtBlockingProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
|
||||
ch := make(chan agentAPI.StreamChunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // 被取消:流就地停止(这**就是**"就地截断")
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- agentAPI.StreamChunk{Content: "字"}:
|
||||
p.mu.Lock()
|
||||
p.got++
|
||||
p.mu.Unlock()
|
||||
}
|
||||
if !p.blockForever && i >= 2 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *rtBlockingProvider) callCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.calls
|
||||
}
|
||||
|
||||
// rtRecordingDevice 记录 outputch 收到的每一段文本。
|
||||
type rtRecordingDevice struct {
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
name string
|
||||
}
|
||||
|
||||
func (d *rtRecordingDevice) Name() string { return d.name }
|
||||
func (d *rtRecordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *rtRecordingDevice) Description() string { return "rt test output" }
|
||||
func (d *rtRecordingDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *rtRecordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if s, ok := args["text"].(string); ok {
|
||||
d.texts = append(d.texts, s)
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
func (d *rtRecordingDevice) Start() error { return nil }
|
||||
func (d *rtRecordingDevice) Stop() error { return nil }
|
||||
func (d *rtRecordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *rtRecordingDevice) OutputCapabilities() agentIO.OutputCapability {
|
||||
// ★ 声明 CapRT("我能承载 rt 流")。注意它仍然只收文本 ——
|
||||
// CapRT 是"能承载实时流",不是"内核要产出音频"。
|
||||
return agentIO.CapText | agentIO.CapRT
|
||||
}
|
||||
|
||||
func (d *rtRecordingDevice) snapshot() []string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]string(nil), d.texts...)
|
||||
}
|
||||
|
||||
// newRTHost 造一个"父 agent + 注册好的 rt 输出设备"。
|
||||
func newRTHost(t *testing.T, p agentAPI.Provider) (*Agent, *rtRecordingDevice) {
|
||||
t.Helper()
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{ID: "rt-host", IO: pio, Provider: p})
|
||||
dev := &rtRecordingDevice{name: "voice"}
|
||||
if err := pio.RegisterDevice(dev); err != nil {
|
||||
t.Fatalf("注册 rt 输出设备: %v", err)
|
||||
}
|
||||
// rt 通道也必须能划(R1 的校验路径),这里走普通 Register + AssignRealtime。
|
||||
//
|
||||
// ★ 注册**一对** rt 通道("voice-in" + "voice"):rt 通道必须成对划拨
|
||||
// (用户裁定 2026-10-30),所以 SpawnRTChild 要求两个名字都在登记表里。
|
||||
// 测试统一用 rtSpawn 走"成对"路径(见下)。
|
||||
for _, n := range []string{"voice", "voice-in"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "test", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册 rt 通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
return host, dev
|
||||
}
|
||||
|
||||
// rtSpawn 是测试里创建 rtchild 的**唯一入口**:自动补上成对的 Input。
|
||||
//
|
||||
// 为什么不改每个调用点:成对是 SpawnRTChild 的硬要求,但测试关心的是
|
||||
// 各自的语义(生命周期/流式/上下文…),一个个补 `Input: "voice-in"`
|
||||
// 只是噪声,且容易漏(漏了就是"创建失败 + 后续 nil 解引用")。
|
||||
func rtSpawn(t *testing.T, host *Agent, id string) string {
|
||||
t.Helper()
|
||||
got, err := host.SpawnRTChild(RTChildOptions{
|
||||
ID: id, Input: "voice-in", Output: "voice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild %s: %v", id, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// ── R3:生命周期 ──
|
||||
|
||||
// TestRTChild_StopNoOrphans 钉住:Stop 之后实例出登记表、且不再产生输出。
|
||||
func TestRTChild_StopNoOrphans(t *testing.T) {
|
||||
p := &rtBlockingProvider{blockForever: true}
|
||||
host, dev := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "c1")
|
||||
if got := host.RTChildren(); len(got) != 1 || got[0] != "c1" {
|
||||
t.Fatalf("登记表 = %v,期望 [c1]", got)
|
||||
}
|
||||
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止: %v", err)
|
||||
}
|
||||
if got := host.RTChildren(); len(got) != 0 {
|
||||
t.Fatalf("停止后登记表仍有 %v —— 留了孤儿", got)
|
||||
}
|
||||
|
||||
// Stop 之后不得再有输出(流已被截断)。
|
||||
before := len(dev.snapshot())
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
after := len(dev.snapshot())
|
||||
if after != before {
|
||||
t.Fatalf("停止后仍在输出(%d → %d)—— 当前轮未被截断", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_StopAllNoOrphans 钉住父退出时全部销毁(对应 StopRTChildren)。
|
||||
func TestRTChild_StopAllNoOrphans(t *testing.T) {
|
||||
p := &rtBlockingProvider{blockForever: true}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
for i, id := range []string{"a", "b", "c"} {
|
||||
rtSpawn(t, host, id)
|
||||
_ = i
|
||||
}
|
||||
if got := len(host.RTChildren()); got != 3 {
|
||||
t.Fatalf("登记了 %d 个,期望 3", got)
|
||||
}
|
||||
if n := host.StopRTChildren(); n != 3 {
|
||||
t.Fatalf("StopRTChildren 返回 %d,期望 3", n)
|
||||
}
|
||||
if got := host.RTChildren(); len(got) != 0 {
|
||||
t.Fatalf("停止后仍有 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_InterruptDoesNotAffectOthers 是**用户裁定 R1 的核心判据**:
|
||||
// 打断/停止 rtA 时,rtB 与主 agent 的 LLM 调用必须继续。
|
||||
//
|
||||
// 实现上靠"每个 rtchild 自己的 ctx"。若有人把 rtStream 里的
|
||||
// `context.WithCancel(a.ctx)` 改回直接用 `a.ctx`,本判据必红。
|
||||
func TestRTChild_InterruptDoesNotAffectOthers(t *testing.T) {
|
||||
p := &rtBlockingProvider{blockForever: true}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
idA := rtSpawn(t, host, "a")
|
||||
idB := rtSpawn(t, host, "b")
|
||||
|
||||
// 让两个实例都跑起来(各自 block 在流上)。
|
||||
if _, err := host.PushRTInput(idA, "你好 A"); err != nil {
|
||||
t.Fatalf("投递 A: %v", err)
|
||||
}
|
||||
if _, err := host.PushRTInput(idB, "你好 B"); err != nil {
|
||||
t.Fatalf("投递 B: %v", err)
|
||||
}
|
||||
waitFor(t, "两个实例都进入流式", func() bool { return p.callCount() >= 2 })
|
||||
|
||||
// 停 A。
|
||||
if err := host.StopRTChild(idA); err != nil {
|
||||
t.Fatalf("停止 A: %v", err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
// B 必须仍然活着、仍在登记表里。
|
||||
if _, ok := host.RTChildStats(idB); !ok {
|
||||
t.Fatal("停 A 之后 B 不见了 —— 打断波及了其他实例(R1 被违反)")
|
||||
}
|
||||
// 父 agent 的 ctx 必须仍然可用(没被连带取消)。
|
||||
select {
|
||||
case <-host.ctx.Done():
|
||||
t.Fatal("停 rtchild 把父 agent 的 ctx 也取消了 —— 严重影响主 agent")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ── R4:流式输出 ──
|
||||
|
||||
// TestRTChild_OutputIsStreamed 钉住契约 2:输出**逐片**外发,不攒整段。
|
||||
//
|
||||
// 若把 rtStream 改成"先收完整段再 emit",本判据会看到 1 次 emit 而非多次。
|
||||
func TestRTChild_OutputIsStreamed(t *testing.T) {
|
||||
p := &rtBlockingProvider{} // 非 blockForever:吐 3 片后自然结束
|
||||
host, dev := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "s")
|
||||
if _, err := host.PushRTInput(id, "说点什么"); err != nil {
|
||||
t.Fatalf("投递: %v", err)
|
||||
}
|
||||
|
||||
waitFor(t, "收到多片输出", func() bool { return len(dev.snapshot()) >= 3 })
|
||||
|
||||
texts := dev.snapshot()
|
||||
for _, s := range texts {
|
||||
if len([]rune(s)) > 8 {
|
||||
t.Fatalf("单片输出过长(%q)——这看起来是攒整段后一次吐出,不是流式", s)
|
||||
}
|
||||
}
|
||||
if n := len(texts); n < 3 {
|
||||
t.Fatalf("只收到 %d 片,期望 ≥3(流式逐片外发)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R5:打断即截断,且不重试 ──
|
||||
|
||||
// TestRTChild_InterruptTruncatesOutput 钉住契约 2 的后半:被打断时就地截断。
|
||||
func TestRTChild_InterruptTruncatesOutput(t *testing.T) {
|
||||
p := &rtBlockingProvider{blockForever: true}
|
||||
host, dev := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "t")
|
||||
host.PushRTInput(id, "一直说")
|
||||
waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })
|
||||
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
frozen := len(dev.snapshot())
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if got := len(dev.snapshot()); got != frozen {
|
||||
t.Fatalf("截断后仍在输出(%d → %d)", frozen, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_NewInputWinsWithoutRetry 钉住契约 1 + "不重试":
|
||||
// 新输入到来时,旧轮次被作废,**不会**为旧输入补跑一次。
|
||||
//
|
||||
// 若在打断后重试(break 改 continue 之类),callCount 会明显超过 2。
|
||||
func TestRTChild_NewInputWinsWithoutRetry(t *testing.T) {
|
||||
p := &rtBlockingProvider{blockForever: true}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "n")
|
||||
host.PushRTInput(id, "第一句")
|
||||
waitFor(t, "第一轮开始", func() bool { return p.callCount() >= 1 })
|
||||
|
||||
// 新输入:应作废旧轮并用新输入重开。
|
||||
if _, err := host.PushRTInput(id, "第二句"); err != nil {
|
||||
t.Fatalf("投递第二句: %v", err)
|
||||
}
|
||||
waitFor(t, "第二轮开始", func() bool { return p.callCount() >= 2 })
|
||||
|
||||
// ★ 关键:不得为"第一句"补跑。给足够时间让任何错误的重试发生。
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
if got := p.callCount(); got > 3 {
|
||||
t.Fatalf("ChatStream 被调用 %d 次 —— 存在「为旧输入重试」(契约 1/3 禁止补偿)", got)
|
||||
}
|
||||
|
||||
stats, _ := host.RTChildStats(id)
|
||||
// lastSeq 必须是**最新**输入的那次(第二句)。
|
||||
if stats.LastSeq != 2 {
|
||||
t.Fatalf("LastSeq = %d,期望 2(第二轮必须基于最新输入)", stats.LastSeq)
|
||||
}
|
||||
}
|
||||
|
||||
// ── R6:toolcall 期间不可打断 ──
|
||||
|
||||
// rtToolProvider 第一轮返回一个工具调用,第二轮返回纯文本。
|
||||
type rtToolProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
toolCh chan struct{} // 工具开始时关闭(测试用它对齐时序)
|
||||
}
|
||||
|
||||
func (p *rtToolProvider) Name() string { return "rtTool" }
|
||||
func (p *rtToolProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *rtToolProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{}, nil
|
||||
}
|
||||
func (p *rtToolProvider) 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, 4)
|
||||
if n == 1 {
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{{
|
||||
ID: "c1", Name: "rt_slow_tool", Arguments: map[string]interface{}{},
|
||||
}}}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
} else {
|
||||
ch <- agentAPI.StreamChunk{Content: "好", Done: true}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRTChild_NotInterruptibleDuringToolExec 钉住 R5 的边界:
|
||||
// 工具执行期间新输入**不得**打断它(副作用不可回滚)。
|
||||
//
|
||||
// ❗ 这一条**单独测不出真实缺陷**(实测过):设备工具经 `a.io.ExecuteTool`
|
||||
// 执行,它内部传的是 `context.Background()`(toolcall.go:377),工具根本
|
||||
// 收不到 roundCtx。于是"工具没被打断"在下面两种情况下都成立:
|
||||
//
|
||||
// · 守卫生效(我们主动不取消)
|
||||
// · 守卫失效但取消也没效果(工具不消费 ctx)
|
||||
//
|
||||
// 所以真正钉住守卫的是下一个判据(suppressedCancels)。这里保留本判据,
|
||||
// 因为它测的是**用户可见的行为**(工具跑完),与下面的实现细节互补。
|
||||
func TestRTChild_NotInterruptibleDuringToolExec(t *testing.T) {
|
||||
p := &rtToolProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
var executed atomic.Int64
|
||||
done := make(chan struct{}, 1)
|
||||
if err := host.io.RegisterDevice(&rtToolDevice{
|
||||
onExec: func() {
|
||||
executed.Add(1)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册工具设备: %v", err)
|
||||
}
|
||||
|
||||
id := rtSpawn(t, host, "tool")
|
||||
host.PushRTInput(id, "调用工具")
|
||||
|
||||
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
|
||||
if _, err := host.PushRTInput(id, "新的插话"); err != nil {
|
||||
t.Fatalf("插话: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("工具执行期间被新输入打断 —— 违反 R5「除 toolcall 期间都可打断」的边界")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_CriticalSectionSuppressesCancel 是 R6 守卫的**直接判据**:
|
||||
// 工具执行期收到新输入时,内核必须**记录一次"故意未取消"**。
|
||||
//
|
||||
// 这是唯一能区分"守卫真的执行了"与"守卫失效但取消没效果"的判据。
|
||||
// 变异验证:把 `if critical > 0 { suppressedCancels++ }` 改成直接 cancel,
|
||||
// 本判据必红(因为 suppressedCancels 恒为 0)。
|
||||
func TestRTChild_CriticalSectionSuppressesCancel(t *testing.T) {
|
||||
p := &rtToolProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
var executed atomic.Int64
|
||||
done := make(chan struct{}, 1)
|
||||
if err := host.io.RegisterDevice(&rtToolDevice{
|
||||
onExec: func() {
|
||||
executed.Add(1)
|
||||
time.Sleep(100 * time.Millisecond) // 留出插话窗口
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册工具设备: %v", err)
|
||||
}
|
||||
|
||||
id := rtSpawn(t, host, "guard")
|
||||
host.PushRTInput(id, "调用工具")
|
||||
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
|
||||
|
||||
// 工具执行期间推新输入:此时 critical > 0 ⇒ 必须抑制取消。
|
||||
if _, err := host.PushRTInput(id, "工具期插话"); err != nil {
|
||||
t.Fatalf("插话: %v", err)
|
||||
}
|
||||
waitFor(t, "抑制计数 +1", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && st.SuppressedCancels >= 1
|
||||
})
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
// rtToolDevice 提供一个会阻塞的 rt_slow_tool。
|
||||
type rtToolDevice struct {
|
||||
name string
|
||||
onExec func()
|
||||
}
|
||||
|
||||
func (d *rtToolDevice) Name() string { return "rt-tool-dev" }
|
||||
func (d *rtToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
|
||||
func (d *rtToolDevice) Description() string { return "rt tool test device" }
|
||||
func (d *rtToolDevice) Start() error { return nil }
|
||||
func (d *rtToolDevice) Stop() error { return nil }
|
||||
func (d *rtToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||||
func (d *rtToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||||
func (d *rtToolDevice) Tools() []agentIO.ToolDef {
|
||||
return []agentIO.ToolDef{{
|
||||
Name: "rt_slow_tool",
|
||||
Description: "slow tool for rt test",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}}
|
||||
}
|
||||
func (d *rtToolDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if d.onExec != nil {
|
||||
d.onExec()
|
||||
}
|
||||
return "tool done", nil
|
||||
}
|
||||
|
||||
// TestRTChild_PromptRequiresPreCallNotice 钉住用户裁定 R4:
|
||||
// 默认提示词必须要求"调用工具前先告知用户"。
|
||||
func TestRTChild_PromptRequiresPreCallNotice(t *testing.T) {
|
||||
if !strings.Contains(rtChildDefaultPrompt, "调用任何工具之前") {
|
||||
t.Fatal("rtchild 默认提示词缺少「调用工具前先告知」约束(用户裁定 R4)")
|
||||
}
|
||||
if !strings.Contains(rtChildDefaultPrompt, "实时通话") {
|
||||
t.Fatal("rtchild 默认提示词缺少【实时通话】段")
|
||||
}
|
||||
}
|
||||
218
internal/agent/core/rtchild_r7_test.go
Normal file
218
internal/agent/core/rtchild_r7_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
package core
|
||||
|
||||
// R7 判据:rtchild 的上下文 = **普通上下文 + 上下文窗口**。
|
||||
//
|
||||
// 用户 2026-10-30 裁定:没有页、没有锚点、没有降级阶梯 —— 就是普通对话历史,
|
||||
// 进模型前按窗口裁。本文件钉住三件事:
|
||||
//
|
||||
// 1. 历史**跨轮累积**(通话是一条连续对话,不能每轮失忆)
|
||||
// 2. 超窗口时**从最旧丢**,system 与本轮输入永不丢
|
||||
// 3. 裁剪**绝不拆开** assistant(tool_calls) 与其配对 tool 结果
|
||||
// (切在中间上游直接 400,见 context.go:102 的生产事故)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// ── 纯函数判据:rtTrimToWindow ──
|
||||
|
||||
// TestRTTrim_KeepsSystemAndLatestInput 钉住:system 与本轮 user 输入永不裁。
|
||||
//
|
||||
// 这两条一丢,rt 就彻底坏了:
|
||||
// - system 丢了 ⇒ 人格与【实时通话】约束消失(模型不再先说"稍等")
|
||||
// - 本轮输入丢了 ⇒ 模型在回答上一轮的问题
|
||||
func TestRTTrim_KeepsSystemAndLatestInput(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: "你是助手"},
|
||||
{Role: "user", Content: "旧问题"},
|
||||
{Role: "assistant", Content: "旧回答"},
|
||||
{Role: "user", Content: "本轮问题"},
|
||||
}
|
||||
// 窗口小到只装得下首尾两条。
|
||||
out := rtTrimToWindow(msgs, 10)
|
||||
if len(out) < 2 {
|
||||
t.Fatalf("裁完只剩 %d 条,system 或本轮输入被丢了", len(out))
|
||||
}
|
||||
if out[0].Role != "system" || out[0].Content != "你是助手" {
|
||||
t.Fatalf("system 被裁掉了:%+v", out[0])
|
||||
}
|
||||
if last := out[len(out)-1]; last.Role != "user" || last.Content != "本轮问题" {
|
||||
t.Fatalf("本轮输入被裁掉了:%+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTTrim_DropsOldestFirst 钉住「从最旧丢」。
|
||||
func TestRTTrim_DropsOldestFirst(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "最早的问题"},
|
||||
{Role: "assistant", Content: "最早的回答"},
|
||||
{Role: "user", Content: "最近的问题"},
|
||||
{Role: "assistant", Content: "最近的回答"},
|
||||
{Role: "user", Content: "本轮"},
|
||||
}
|
||||
out := rtTrimToWindow(msgs, 12)
|
||||
for _, m := range out {
|
||||
if m.Content == "最早的问题" {
|
||||
t.Fatalf("最早的消息仍在(应从最旧丢):%+v", contentsOf(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTTrim_NeverSplitsToolPairs 是最重要的一条:
|
||||
// assistant(tool_calls) 与其配对 tool 结果必须**整组进出**。
|
||||
//
|
||||
// 若实现按单条消息裁(而不是按组),这里会切出「有 tool_calls 但没有结果」
|
||||
// 或「有 tool 结果但找不到调用」的残缺序列 —— 上游直接 400。
|
||||
func TestRTTrim_NeverSplitsToolPairs(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
// ── 组 A:一次工具调用 + 两个配对结果 ──
|
||||
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
|
||||
{ID: "c1", Name: "t1"}, {ID: "c2", Name: "t2"},
|
||||
}},
|
||||
{Role: "tool", ToolCallID: "c1", Content: "结果1"},
|
||||
{Role: "tool", ToolCallID: "c2", Content: "结果2"},
|
||||
{Role: "user", Content: "中间问题"},
|
||||
{Role: "assistant", Content: "中间回答"},
|
||||
{Role: "user", Content: "本轮"},
|
||||
}
|
||||
|
||||
// 用一个"刚好要丢掉一部分"的窗口,验证组不会被切开。
|
||||
for _, window := range []int{20, 40, 60, 200} {
|
||||
out := rtTrimToWindow(msgs, window)
|
||||
assertToolPairsIntact(t, window, out)
|
||||
}
|
||||
}
|
||||
|
||||
// assertToolPairsIntact 断言 out 里每个 assistant(tool_calls) 的 ID 集合
|
||||
// 与紧随其后的 tool 消息 ID 集合完全一致(不残缺、不多余)。
|
||||
func assertToolPairsIntact(t *testing.T, window int, out []agentAPI.Message) {
|
||||
t.Helper()
|
||||
for i := 0; i < len(out); i++ {
|
||||
m := out[i]
|
||||
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, tc := range m.ToolCalls {
|
||||
want[tc.ID] = true
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for j := i + 1; j < len(out) && out[j].Role == "tool"; j++ {
|
||||
got[out[j].ToolCallID] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("window=%d: tool_call 与结果不配对(call=%v result=%v)—— 上游会 400\n序列:%+v",
|
||||
window, want, got, contentsOf(out))
|
||||
}
|
||||
for id := range want {
|
||||
if !got[id] {
|
||||
t.Fatalf("window=%d: 调用 %s 没有配对结果 —— 上游会 400", window, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 反向:不得出现没有前置 assistant 的孤儿 tool 消息。
|
||||
for i, m := range out {
|
||||
if m.Role != "tool" {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
t.Fatalf("window=%d: 序列以 tool 消息开头(孤儿结果)", window)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func contentsOf(msgs []agentAPI.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
s := m.Role + ":"
|
||||
if m.Content != "" {
|
||||
s += m.Content
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
s += "[calls]"
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRTTrim_ShortHistoryUntouched 钉住:本来就没超窗口时**一条都不删**。
|
||||
//
|
||||
// 这一条防"为了让窗口判据变绿而无脑裁剪"——那会把还装得下的历史白白丢掉。
|
||||
func TestRTTrim_ShortHistoryUntouched(t *testing.T) {
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "a"},
|
||||
{Role: "assistant", Content: "b"},
|
||||
{Role: "user", Content: "c"},
|
||||
}
|
||||
out := rtTrimToWindow(msgs, 100000)
|
||||
if len(out) != len(msgs) {
|
||||
t.Fatalf("窗口充裕却裁掉了消息:%v → %v", contentsOf(msgs), contentsOf(out))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 集成判据:rtchild 跨轮累积上下文 ──
|
||||
|
||||
// rtMsgCountProvider 记录每次 ChatStream 收到的消息条数,
|
||||
// 用来证明"历史真的被带进了下一轮"(而不是每轮失忆)。
|
||||
type rtMsgCountProvider struct {
|
||||
mu sync.Mutex
|
||||
sizes []int
|
||||
}
|
||||
|
||||
func (p *rtMsgCountProvider) Name() string { return "rtMsgCount" }
|
||||
func (p *rtMsgCountProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *rtMsgCountProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{}, nil
|
||||
}
|
||||
func (p *rtMsgCountProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.sizes = append(p.sizes, len(req.Messages))
|
||||
p.mu.Unlock()
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: "回"}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (p *rtMsgCountProvider) snapshot() []int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]int(nil), p.sizes...)
|
||||
}
|
||||
|
||||
// TestRTChild_HistoryAccumulatesAcrossRounds 是「普通上下文」的核心判据:
|
||||
// 第二轮发给模型的消息数必须**多于**第一轮(历史被带上了)。
|
||||
//
|
||||
// 若 buildRTContext 每轮只发 system+input(我最初的"无历史"最小版),
|
||||
// 两轮都是 2 条 —— 本判据必红。
|
||||
func TestRTChild_HistoryAccumulatesAcrossRounds(t *testing.T) {
|
||||
p := &rtMsgCountProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "hist")
|
||||
|
||||
host.PushRTInput(id, "第一句")
|
||||
waitFor(t, "第一轮完成", func() bool { return len(p.snapshot()) >= 1 })
|
||||
|
||||
host.PushRTInput(id, "第二句")
|
||||
waitFor(t, "第二轮完成", func() bool { return len(p.snapshot()) >= 2 })
|
||||
|
||||
sizes := p.snapshot()
|
||||
if sizes[1] <= sizes[0] {
|
||||
t.Fatalf("第二轮消息数 %d 不多于第一轮 %d —— 上下文没有跨轮累积"+
|
||||
"(rtchild 应是普通上下文,通话不能每轮失忆)", sizes[1], sizes[0])
|
||||
}
|
||||
// 第二轮 = system + (user+assistant) + user = 4 条(window 充裕时)
|
||||
if sizes[1] < 4 {
|
||||
t.Fatalf("第二轮只有 %d 条消息,期望 ≥4(system + 上一轮问答 + 本轮输入)", sizes[1])
|
||||
}
|
||||
}
|
||||
291
internal/agent/core/rtchild_r7b_test.go
Normal file
291
internal/agent/core/rtchild_r7b_test.go
Normal file
@ -0,0 +1,291 @@
|
||||
package core
|
||||
|
||||
// R7b 判据:裁剪只是**视图**,原文永不丢弃;回收时把全文交回主 agent。
|
||||
//
|
||||
// 用户 2026-10-30 裁定:
|
||||
//
|
||||
// 「老内容不再进入 LLM,但是**原文保留**,rtchild 回收时依旧向主 agent
|
||||
// 展示全部上下文。」
|
||||
//
|
||||
// 这两件事必须分开,混在一起就会静默丢数据:
|
||||
//
|
||||
// 视图(进 LLM) —— 按窗口裁 ← 判据 1
|
||||
// 存储(history) —— 全文永不丢 ← 判据 2
|
||||
// 回收交接 —— 交**全文** ← 判据 3
|
||||
//
|
||||
// 删掉任一条实现,对应判据必红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// rtLongHistoryProvider 每轮回复一句固定文本,用于累积历史。
|
||||
type rtLongHistoryProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs [][]agentAPI.Message // 每次请求的完整消息(拷一份)
|
||||
}
|
||||
|
||||
func (p *rtLongHistoryProvider) Name() string { return "rtLongHistory" }
|
||||
func (p *rtLongHistoryProvider) MaxContextTokens() int { return 700 } // > system(444),否则 system 一条就超窗、裁无可裁
|
||||
func (p *rtLongHistoryProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{}, nil
|
||||
}
|
||||
func (p *rtLongHistoryProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, append([]agentAPI.Message(nil), req.Messages...))
|
||||
p.mu.Unlock()
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: strings.Repeat("答", 40)}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (p *rtLongHistoryProvider) rounds() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.reqs)
|
||||
}
|
||||
func (p *rtLongHistoryProvider) lastReq() []agentAPI.Message {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.reqs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.reqs[len(p.reqs)-1]
|
||||
}
|
||||
|
||||
// TestRTChild_ViewIsTrimmedButHistoryIsNot 是同一条判据的两半,必须成对断言:
|
||||
//
|
||||
// ① 进 LLM 的视图**确实被裁**(旧内容不再进模型)
|
||||
// ② 存储的 history **一条不少**(原文保留)
|
||||
//
|
||||
// 只断言 ① ⇒ "每轮清空历史"也能通过(那不是裁,是丢)
|
||||
// 只断言 ② ⇒ "从不裁"也能通过(那会撑爆窗口)
|
||||
func TestRTChild_ViewIsTrimmedButHistoryIsNot(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "trim")
|
||||
|
||||
// 跑多轮,故意让历史远超窗口(MaxContextTokens=120,每轮产出 ~40 字)。
|
||||
const rounds = 6
|
||||
for i := 0; i < rounds; i++ {
|
||||
host.PushRTInput(id, strings.Repeat("问", 30))
|
||||
want := (i + 1) * 2
|
||||
// ★ 必须等 **transcript 条数**,不能等 p.rounds()。
|
||||
//
|
||||
// p.rounds() 在 ChatStream **被调用时**就自增(此时请求刚构建完),
|
||||
// 而 assistant 那条消息是**流结束后**才追加进 history 的。
|
||||
// 等 rounds 的话第 N 轮请求一发出就放行,随即读 transcript 会少一条
|
||||
// (实测 11 vs 12)—— 这是判据自己的竞态,不是产品缺陷。
|
||||
waitFor(t, "本轮完成", func() bool {
|
||||
tr, ok := host.RTTranscript(id)
|
||||
return ok && len(tr) >= want
|
||||
})
|
||||
}
|
||||
|
||||
// ② 存储侧:6 轮 ⇒ 每轮 user+assistant 两条,共 12 条,**一条都不能少**。
|
||||
transcript, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("取不到 transcript")
|
||||
}
|
||||
if len(transcript) != rounds*2 {
|
||||
t.Fatalf("history 有 %d 条,期望 %d 条 —— 原文被裁掉了(用户裁定:原文保留)",
|
||||
len(transcript), rounds*2)
|
||||
}
|
||||
// 最早那一轮的原话必须还在。
|
||||
if !strings.Contains(transcript[0].Content, "问") {
|
||||
t.Fatalf("最早的一轮不见了:%+v", transcript[0])
|
||||
}
|
||||
|
||||
// ① 视图侧:最后一次请求的消息数必须**明显少于** history(说明确实裁了)。
|
||||
last := p.lastReq()
|
||||
if len(last) >= len(transcript)+1 { // +1 = 本轮 user 之前已在 history 里
|
||||
t.Logf("视图 %d 条 / 历史 %d 条:窗口未触发裁剪(内容可能还不够大)", len(last), len(transcript))
|
||||
}
|
||||
viewTokens := 0
|
||||
for _, m := range last {
|
||||
viewTokens += rtMsgTokens(m)
|
||||
}
|
||||
// 允许一定估算偏差(+20%),但视图必须**明显小于**完整历史。
|
||||
fullTokens := 0
|
||||
for _, m := range append([]agentAPI.Message{{Role: "system", Content: rtChildDefaultPrompt}}, transcript...) {
|
||||
fullTokens += rtMsgTokens(m)
|
||||
}
|
||||
if viewTokens > 700*12/10 {
|
||||
t.Fatalf("视图估算 %d token,远超窗口 700 —— 裁剪没生效(旧内容仍在进模型)", viewTokens)
|
||||
}
|
||||
if fullTokens > 700 && viewTokens >= fullTokens*3/4 {
|
||||
t.Fatalf("视图 %d token 与完整历史 %d token 相当 —— 没有真正裁掉旧内容",
|
||||
viewTokens, fullTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChild_HistorySurvivesInputSupersede 钉住:被打断的轮次,**已说出口的部分**
|
||||
// 也要留在 history 里(它确实被用户听到了,下一轮不能假装没发生)。
|
||||
func TestRTChild_HistorySurvivesInputSupersede(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "super")
|
||||
host.PushRTInput(id, "第一问")
|
||||
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
|
||||
host.PushRTInput(id, "第二问")
|
||||
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
|
||||
|
||||
tr, _ := host.RTTranscript(id)
|
||||
if len(tr) < 4 {
|
||||
t.Fatalf("两轮之后 history 只有 %d 条,期望 ≥4(每轮 user+assistant)", len(tr))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatRTTranscript_PreservesDialogueFully 钉住渲染口径:
|
||||
// user/assistant **逐字保留**;工具结果按上限截断(体积大头,复盘只需结论)。
|
||||
func TestFormatRTTranscript_PreservesDialogueFully(t *testing.T) {
|
||||
longToolResult := strings.Repeat("x", rtTranscriptToolMaxRunes*3)
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "user", Content: "帮我查一下明天的天气"},
|
||||
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
|
||||
{ID: "c1", Name: "weather_query", Arguments: map[string]interface{}{"city": "北京"}},
|
||||
}},
|
||||
{Role: "tool", ToolCallID: "c1", Content: longToolResult},
|
||||
{Role: "assistant", Content: "明天北京晴,18-26 度。"},
|
||||
}
|
||||
out := FormatRTTranscript(msgs)
|
||||
|
||||
for _, want := range []string{
|
||||
"帮我查一下明天的天气",
|
||||
"weather_query",
|
||||
"city=北京",
|
||||
"明天北京晴,18-26 度。",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("交接文本缺少 %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// 工具结果被截断(不把整页塞回去),但仍有内容。
|
||||
if strings.Contains(out, strings.Repeat("x", rtTranscriptToolMaxRunes+50)) {
|
||||
t.Fatal("工具结果未按上限截断 —— 会把体积大头整段塞回主 agent 上下文")
|
||||
}
|
||||
if !strings.Contains(out, "xxx") {
|
||||
t.Fatal("工具结果被整段丢掉了 —— 复盘需要看到结论")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseRTChild_PushesSignalNotBody 是**交接协议**的判据(用户裁定 2026-10-30):
|
||||
//
|
||||
// 回收走「contextfull 式中断」—— 只推**信号**(规模),正文留着等主 agent
|
||||
// 按需拉取,由它自己选保留哪些。
|
||||
//
|
||||
// 若把 CloseRTChild 改回"直接把全文 injectSelfChannel 进主 agent 上下文",
|
||||
// 本判据必红:正文动辄几十万字符,全灌进去会顶爆父的上下文。
|
||||
func TestCloseRTChild_PushesSignalNotBody(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "close")
|
||||
host.PushRTInput(id, "第一句原话")
|
||||
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
|
||||
host.PushRTInput(id, "第二句原话")
|
||||
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
|
||||
|
||||
before, _ := host.RTTranscript(id)
|
||||
n, err := host.CloseRTChild(id)
|
||||
if err != nil {
|
||||
t.Fatalf("回收失败:%v", err)
|
||||
}
|
||||
if n != len(before) {
|
||||
t.Fatalf("交接条数 %d,实际 %d", n, len(before))
|
||||
}
|
||||
if _, ok := host.RTChildStats(id); ok {
|
||||
t.Fatal("回收后实例仍在登记表里")
|
||||
}
|
||||
|
||||
// ★ 核心断言:父侧收到的是**中断信号**(level=L4,source=rtchild/<id>),
|
||||
// 且 payload 里**没有**通话正文 —— 正文是要去拉的东西,不该被推进上下文。
|
||||
//
|
||||
// 注意这里查的是**调度器的中断登记**而不是 selfInputCh:
|
||||
// 交接走的是 contextfull 同款 L4(raiseVector),不是自循环消息。
|
||||
snap := host.DumpScheduler()
|
||||
if len(snap.InterruptQueues[LevelCritical]) == 0 {
|
||||
t.Fatalf("没有产生 L4 中断 —— 交接必须走 contextfull 式中断,实际快照:%+v", snap)
|
||||
}
|
||||
|
||||
// 原文必须仍可**按需取回**(回收之后也能取)。
|
||||
after, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("回收后取不到原文 —— 「回收时向主 agent 展示全部上下文」断链")
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("回收后原文 %d 条,回收前 %d 条", len(after), len(before))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTTranscript_ToolServesAllContentAndPages 钉住 rt_transcript 工具:
|
||||
// 它必须能取回全部原文(分段),这是主 agent"自行选择保留内容"的前提。
|
||||
func TestRTTranscript_ToolServesAllContentAndPages(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "tool")
|
||||
const rounds = 3
|
||||
for i := 0; i < rounds; i++ {
|
||||
host.PushRTInput(id, fmt.Sprintf("第%d句原话", i))
|
||||
want := i + 1
|
||||
waitFor(t, "本轮完成", func() bool { return p.rounds() >= want })
|
||||
}
|
||||
host.CloseRTChild(id)
|
||||
|
||||
// 第一段:默认 limit。
|
||||
out := host.executeRTTranscript(agentAPI.ToolCall{
|
||||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id},
|
||||
})
|
||||
if !strings.Contains(out, "共 ") {
|
||||
t.Fatalf("工具输出没有规模信息:%s", truncateStr(out, 200))
|
||||
}
|
||||
if !strings.Contains(out, "第0句原话") {
|
||||
t.Fatalf("第一段缺少最早的原话:%s", truncateStr(out, 300))
|
||||
}
|
||||
// 分段续读:offset 应能取到后面的内容(用 limit=1 逼出分页)。
|
||||
page2 := host.executeRTTranscript(agentAPI.ToolCall{
|
||||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id, "offset": 1, "limit": 1},
|
||||
})
|
||||
if !strings.Contains(page2, "1-2") {
|
||||
t.Fatalf("分页参数未生效(应返回第 1-2 条):%s", truncateStr(page2, 200))
|
||||
}
|
||||
// 未知 id 必须明确报错(不静默返回空)。
|
||||
bad := host.executeRTTranscript(agentAPI.ToolCall{
|
||||
Name: "rt_transcript", Arguments: map[string]interface{}{"id": "nope"},
|
||||
})
|
||||
if !strings.Contains(bad, "不存在") {
|
||||
t.Fatalf("未知 id 未明确报错:%s", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseRTChild_EmptyHistoryNoSpam 钉住:没有历史时不推中断(别污染主 agent)。
|
||||
func TestCloseRTChild_EmptyHistoryNoSpam(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
id := rtSpawn(t, host, "empty")
|
||||
n, err := host.CloseRTChild(id)
|
||||
if err != nil {
|
||||
t.Fatalf("回收失败:%v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("空历史返回 %d 条", n)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
select {
|
||||
case msg := <-host.selfInputCh:
|
||||
t.Fatalf("空历史也推了信号,污染主 agent:%q", truncateStr(msg.text, 80))
|
||||
default:
|
||||
}
|
||||
}
|
||||
432
internal/agent/core/rtchild_route_test.go
Normal file
432
internal/agent/core/rtchild_route_test.go
Normal file
@ -0,0 +1,432 @@
|
||||
package core
|
||||
|
||||
// 判据:**插件 → rtchild 的通路**。
|
||||
//
|
||||
// 这是本轮发现的最后一个真实缺口(比 R10 的 notify_parent 更关键):
|
||||
//
|
||||
// 插件只能调 Inject* 系列(SDK 里没有任何 rtchild 接口),
|
||||
// 它们全部汇到 IOManager.deliverInput → routeInputByOwner。
|
||||
// 而初版 routeInputByOwner 只认 a.residents(驻留子)——
|
||||
// rt 通道的 Owner 是 rtchild id,查不到就**兜底给主 agent 处理**。
|
||||
//
|
||||
// 后果:插件的语音/画面输入永远打在父身上,rtchild 的轮次永远是 0。
|
||||
// 这正是 resident-subagent-design.md 记的那个现场故障的同型形态
|
||||
// (「子挂着 inputch=[timer],timer 的输入却打在父身上」)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// registerRTChannelWithOwner 注册一个 rt 通道并划给指定 rtchild。
|
||||
func registerRTChannelWithOwner(t *testing.T, host *Agent, name, ownerID string) {
|
||||
t.Helper()
|
||||
reg := host.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{
|
||||
Name: name, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册 rt 通道: %v", err)
|
||||
}
|
||||
if err := reg.Assign(name, ownerID, 0); err != nil {
|
||||
t.Fatalf("划给 %s: %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChannel_PluginInputReachesRTSlot 是这条通路的**核心判据**:
|
||||
// 插件往 rt 通道注入输入,必须进 rtSlot 并真的跑起一轮。
|
||||
//
|
||||
// 若 routeInputByOwner 不识别 rtchild,输入会被兜底给主 agent 处理
|
||||
// (宿主没有调度器,于是什么都不会发生)—— 本判据会超时变红。
|
||||
func TestRTChannel_PluginInputReachesRTSlot(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
// 先注册一对 rt 通道;**划拨由 SpawnRTChild 自己做**(成对划拨)。
|
||||
registerRTPair(t, host, "voice-in", "voice")
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "voice-child", Input: "voice-in", Output: "voice"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild: %v", err)
|
||||
}
|
||||
|
||||
// 插件注入:完全走真实路径(IOManager → deliverInput → router)。
|
||||
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
|
||||
map[string]interface{}{"content": "用户说话了"})
|
||||
|
||||
waitFor(t, "rtchild 被插件输入唤起", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && st.Rounds >= 1
|
||||
})
|
||||
|
||||
calls, _ := p.stats()
|
||||
if calls == 0 {
|
||||
t.Fatal("插件输入到达了 rtSlot 但没跑模型")
|
||||
}
|
||||
// 内容必须真的进入 history(不是空跑)。
|
||||
tr, _ := host.RTTranscript(id)
|
||||
if len(tr) == 0 {
|
||||
t.Fatal("跑了一轮但 history 为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChannel_PluginInputIsLatestWins 钉住:插件连发时也走**单槽覆盖**语义,
|
||||
// 不会因为"走了路由"就退化成排队。
|
||||
func TestRTChannel_PluginInputIsLatestWins(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
registerRTPair(t, host, "voice-in", "voice")
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "burst-child", Input: "voice-in", Output: "voice"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild: %v", err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
const burst = 40
|
||||
for i := 0; i < burst; i++ {
|
||||
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
|
||||
map[string]interface{}{"content": "连发语音"})
|
||||
}
|
||||
waitFor(t, "槽被消费", func() bool {
|
||||
st, ok := host.RTChildStats(id)
|
||||
return ok && !st.Pending
|
||||
})
|
||||
|
||||
st, _ := host.RTChildStats(id)
|
||||
|
||||
// ★ 判据取**不变式**而不是"跑了多少轮"。
|
||||
//
|
||||
// 初版断言 rounds <= 3,在 -race 下稳定失败(实测跑了 13 轮):
|
||||
// 竞态插桩让 emit 变慢,40 条在 rtLoop 起轮之前全部到达,于是
|
||||
// 每完成一轮就立刻被下一条唤起 —— 那不是"队列退化",而是**正常的
|
||||
// 最新者胜**(每一轮用的都是当时最新的一条,没有一条在等前一条跑完)。
|
||||
//
|
||||
// 真正该钉的是:**每条输入要么被处理过、要么被覆盖丢弃**,
|
||||
// 不存在"既没处理也没记录丢弃"的第三条路。
|
||||
if st.Dropped == 0 && st.Rounds < burst {
|
||||
t.Fatalf("连发 %d 条:dropped=0 且只跑了 %d 轮 —— "+
|
||||
"有输入凭空消失了(既没被处理,也没计入覆盖丢弃)", burst, st.Rounds)
|
||||
}
|
||||
// 槽最终必须被清空(没有积压 —— 这是"不排队"的直接证据)。
|
||||
if st.Pending {
|
||||
t.Fatal("连发结束后槽里仍有待处理输入 —— rt 通道在排队")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChannel_NoOwnerStillFallsBackToMain 钉住边界:
|
||||
// rt 通道**未划拨**时输入仍归父处理(不静默丢)。
|
||||
//
|
||||
// 这条防"为了让 rt 通路工作而把所有 rt 输入都截走"——那会让未分配的
|
||||
// rt 通道输入凭空消失。
|
||||
func TestRTChannel_NoOwnerStillFallsBackToMain(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
// 注册但**不划拨**(Owner == "")—— 这条判据测的是路由的兜底分支,
|
||||
// 与 SpawnRTChild 的成对划拨无关,所以直接登记一条裸通道。
|
||||
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: "unassigned", Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
|
||||
// 路由必须返回 false(不接管),让父的队列收下它。
|
||||
if handled := host.routeInputByOwner(&agentIO.InputEvent{
|
||||
Source: "voice-plugin", Type: "text", OutputChannel: "unassigned",
|
||||
Payload: map[string]interface{}{"content": "无人认领"},
|
||||
}, false); handled {
|
||||
t.Fatal("未划拨的 rt 通道输入被截走了 —— 会静默丢输入")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChannel_SDKRealtimeBecomesCapRT 钉住 SDK → 内核的**唯一一次**能力位转换:
|
||||
// 插件在 ChannelDef 里声明 Realtime=true,登记后通道必须带上 CapRT,
|
||||
// 从而**能**被 AssignRealtime 划拨。
|
||||
//
|
||||
// 若去掉 Register 里的映射,插件就无法登记 rt 通道 ——
|
||||
// rtchild 永远拿不到插件的语音输入(整条链路断在第一步)。
|
||||
func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
// 插件侧声明(走 SDK 的 ChannelDef.Realtime)。
|
||||
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: "sdk-rt", Plugin: "voice",
|
||||
Def: agentIO.ChannelDef{Realtime: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
|
||||
ch, ok := host.io.ChannelRegistry().Lookup("sdk-rt")
|
||||
if !ok {
|
||||
t.Fatal("通道未登记")
|
||||
}
|
||||
if !ch.Capabilities.Supports(agentIO.CapRT) {
|
||||
t.Fatalf("Def.Realtime=true 但 Capabilities 没有 CapRT(= %v)—— "+
|
||||
"插件声明的 rt 通道无法被划拨", ch.Capabilities)
|
||||
}
|
||||
|
||||
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
|
||||
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
|
||||
rc := host.rtChildren[id]
|
||||
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
|
||||
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 成对划拨 / 主 agent 无 rt 能力 / 销毁后悬空 ──
|
||||
//
|
||||
// 用户裁定(2026-10-30):
|
||||
// 「主 agent 创建 rtchild,将 rt 通道划给 rtchild,开始工作
|
||||
// (rt 通道必须**成对**划给子 agent)」
|
||||
// 「主 agent 没有 rt 能力,rt 通道在 rtchild 销毁后应当**悬空**」
|
||||
|
||||
// registerRTPair 注册一对 rt 通道(input + output),供 Spawn 划拨。
|
||||
func registerRTPair(t *testing.T, host *Agent, in, out string) {
|
||||
t.Helper()
|
||||
reg := host.io.ChannelRegistry()
|
||||
for _, n := range []string{in, out} {
|
||||
if err := reg.Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册 rt 通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnRTChild_AssignsPairedChannels 钉住"成对划拨":
|
||||
// SpawnRTChild 必须把 input 与 output **两条**都划给 rtchild,而不是只划一条。
|
||||
//
|
||||
// 变红条件:去掉 SpawnRTChild 里的 AssignRealtime 调用 ⇒ Owner 仍是 "" ⇒ 红。
|
||||
func TestSpawnRTChild_AssignsPairedChannels(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in", "voice")
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "paired", Input: "voice-in", Output: "voice"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild: %v", err)
|
||||
}
|
||||
|
||||
reg := host.io.ChannelRegistry()
|
||||
for _, n := range []string{"voice-in", "voice"} {
|
||||
ch, ok := reg.Lookup(n)
|
||||
if !ok {
|
||||
t.Fatalf("通道 %s 未登记", n)
|
||||
}
|
||||
if ch.Owner != id {
|
||||
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— "+
|
||||
"rt 通道没有成对划给 rtchild", n, ch.Owner, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnRTChild_RejectsMissingHalf 钉住成对的**强制性**:
|
||||
// 只给 input 或只给 output 都必须被拒(否则 rtchild 半残且不报错)。
|
||||
func TestSpawnRTChild_RejectsMissingHalf(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in2", "voice2")
|
||||
|
||||
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-in", Output: "voice2"}); err == nil {
|
||||
t.Fatal("只给 outputch 竟然创建成功 —— rtchild 会永远听不到用户")
|
||||
}
|
||||
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-out", Input: "voice-in2"}); err == nil {
|
||||
t.Fatal("只给 inputch 竟然创建成功 —— rtchild 的输出无处可去")
|
||||
}
|
||||
// 被拒后**不得**在登记表里留下半残实例。
|
||||
if got := host.RTChildren(); len(got) != 0 {
|
||||
t.Fatalf("创建被拒但登记表里有 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnRTChild_RejectsChannelOwnedByMainAgent 钉住「主 agent 没有 rt 能力」:
|
||||
// 一条归属于主 agent 的通道不能被拿来当 rt 通道。
|
||||
func TestSpawnRTChild_RejectsChannelOwnedByMainAgent(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in3", "voice3")
|
||||
|
||||
// 把 input 划给主 agent 自己(模拟"主 agent 占着这条通道")。
|
||||
if err := host.io.ChannelRegistry().Assign("voice-in3", string(host.id), 0); err != nil {
|
||||
t.Fatalf("划给主 agent: %v", err)
|
||||
}
|
||||
|
||||
_, err := host.SpawnRTChild(RTChildOptions{ID: "steal", Input: "voice-in3", Output: "voice3"})
|
||||
if err == nil {
|
||||
t.Fatal("主 agent 自己占着的通道被划成 rt 通道 —— 主 agent 不持有 rt 能力")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopRTChild_ChannelsBecomeDangling 钉住用户裁定:
|
||||
// rtchild 销毁后通道**悬空**(Owner 仍指向已销毁的 id),**不归还为未分配**。
|
||||
//
|
||||
// 为什么必须悬空而不是归还:
|
||||
// - 归还为 "" ⇒ routeInputByOwner 会把它兜底给主 agent,而主 agent
|
||||
// 没有 rt 能力 ⇒ 一条 rt 通道静默变成普通通道(不可查的坏状态)
|
||||
// - 悬空 ⇒ 路由查不到该 owner,走既有"owner 无匹配 agent"兜底**并留日志**
|
||||
// (可查的坏状态)
|
||||
func TestStopRTChild_ChannelsBecomeDangling(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in4", "voice4")
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "dangling", Input: "voice-in4", Output: "voice4"})
|
||||
if err != nil {
|
||||
t.Fatalf("创建: %v", err)
|
||||
}
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止: %v", err)
|
||||
}
|
||||
|
||||
reg := host.io.ChannelRegistry()
|
||||
for _, n := range []string{"voice-in4", "voice4"} {
|
||||
ch, ok := reg.Lookup(n)
|
||||
if !ok {
|
||||
continue // 注销也算一种处理;但插件声明的 rt 通道不该被注销
|
||||
}
|
||||
if ch.Owner == "" {
|
||||
t.Fatalf("通道 %s 被归还为未分配 —— 主 agent 没有 rt 能力,"+
|
||||
"归还会让它静默兜底到主 agent(应保持悬空)", n)
|
||||
}
|
||||
if ch.Owner != id {
|
||||
t.Fatalf("通道 %s 的 Owner = %q,期望悬空保留 %q", n, ch.Owner, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── rt_children 工具面(主 agent 创建 rtchild 的唯一入口) ──
|
||||
|
||||
// TestRTChildrenTool_ListEmpty 钉住空态提示可读。
|
||||
func TestRTChildrenTool_ListEmpty(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
|
||||
})
|
||||
if !strings.Contains(got, "没有实时子 agent") {
|
||||
t.Fatalf("空态提示不对:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChildrenTool_CreateRequiresBothChannels 钉住成对要求,
|
||||
// 且失败提示必须**列出可用的 rt 通道**(否则模型只能瞎猜名字反复重试)。
|
||||
func TestRTChildrenTool_CreateRequiresBothChannels(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in", "voice")
|
||||
|
||||
// 只给 input:必须拒,并列出可用通道。
|
||||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children",
|
||||
Arguments: map[string]interface{}{
|
||||
"action": "create", "id": "c1", "inputch": "voice-in",
|
||||
},
|
||||
})
|
||||
if !strings.Contains(got, "必须成对") {
|
||||
t.Fatalf("只给 inputch 未被拒:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "voice-in") || !strings.Contains(got, "voice") {
|
||||
t.Fatalf("失败提示没有列出可用 rt 通道(模型无法自我修正):%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChildrenTool_FullLifecycle 是**端到端**:模型视角走一遍
|
||||
// create → list → send → stop(这正是"主 agent 创建 rtchild 并开始工作")。
|
||||
func TestRTChildrenTool_FullLifecycle(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
registerRTPair(t, host, "voice-in", "voice")
|
||||
|
||||
// ① create(成对)
|
||||
out := host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children",
|
||||
Arguments: map[string]interface{}{
|
||||
"action": "create", "id": "call-1",
|
||||
"inputch": "voice-in", "outputch": "voice",
|
||||
},
|
||||
})
|
||||
if !strings.Contains(out, "已创建") {
|
||||
t.Fatalf("create 失败:%q", out)
|
||||
}
|
||||
// 通道必须已成对划给它。
|
||||
reg := host.io.ChannelRegistry()
|
||||
for _, n := range []string{"voice-in", "voice"} {
|
||||
if ch, _ := reg.Lookup(n); ch.Owner != "call-1" {
|
||||
t.Fatalf("通道 %s 的 Owner = %q,期望 call-1", n, ch.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// ② list 能看到它
|
||||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
|
||||
})
|
||||
if !strings.Contains(out, "call-1") {
|
||||
t.Fatalf("list 里看不到刚创建的实例:%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "打断") {
|
||||
t.Fatalf("list 提示没有说明 rt 的打断语义:%q", out)
|
||||
}
|
||||
|
||||
// ③ send:打断式注入,真的跑起来
|
||||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children",
|
||||
Arguments: map[string]interface{}{
|
||||
"action": "send", "id": "call-1", "text": "用户说:你好",
|
||||
},
|
||||
})
|
||||
if !strings.Contains(out, "打断") {
|
||||
t.Fatalf("send 没说明打断语义:%q", out)
|
||||
}
|
||||
waitFor(t, "rtchild 跑了一轮", func() bool {
|
||||
st, ok := host.RTChildStats("call-1")
|
||||
return ok && st.Rounds >= 1
|
||||
})
|
||||
|
||||
// ④ stop:回收并交接
|
||||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children", Arguments: map[string]interface{}{"action": "stop", "id": "call-1"},
|
||||
})
|
||||
if !strings.Contains(out, "已回收") {
|
||||
t.Fatalf("stop 失败:%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "rt_transcript") {
|
||||
t.Fatalf("stop 没有告诉模型用 rt_transcript 取回原文:%q", out)
|
||||
}
|
||||
if got := host.RTChildren(); len(got) != 0 {
|
||||
t.Fatalf("回收后登记表仍有 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChildrenTool_UnknownAction 钉住未知 action 响亮报错并列出可用值。
|
||||
func TestRTChildrenTool_UnknownAction(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children", Arguments: map[string]interface{}{"action": "destroy"},
|
||||
})
|
||||
if !strings.Contains(got, "未知 action") || !strings.Contains(got, "list") {
|
||||
t.Fatalf("未知 action 提示不可操作:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTChildrenTool_NotVisibleToChild 钉住工具是**父专属**的:
|
||||
// rtchild 自己不该看到 rt_children(否则它能无限自我复制)。
|
||||
func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
|
||||
p := &rtBlockingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
|
||||
})
|
||||
id := rtSpawn(t, host, "no-recurse")
|
||||
rc := host.rtChildren[id]
|
||||
if rc == nil {
|
||||
t.Fatal("实例不在登记表")
|
||||
}
|
||||
if _, ok := rtToolNames(rc.agent.buildToolDefs())["rt_children"]; ok {
|
||||
t.Fatal("rtchild 自己能看到 rt_children —— 会形成无界自我复制")
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@ -181,7 +183,13 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
||||
DisableThinking: !a.thinkingEnabled,
|
||||
}
|
||||
|
||||
resp, err := a.provider.Chat(a.ctx, req)
|
||||
// ★ 走与主 agent 同一套 provider 选择(而不是直接 a.provider.Chat)。
|
||||
//
|
||||
// 两件事因此成立:
|
||||
// ① `core.agent.llm.child_provider` 对它生效(四类 agent 各自可配源)
|
||||
// ② 多源回退对它生效 —— 此前它是唯一绕过回退链的地方:
|
||||
// 默认源抖动一次,整条子任务就"执行失败",而主 agent 同刻能自动换源
|
||||
resp, err := a.chatWithProviderChain(req)
|
||||
if err != nil {
|
||||
finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
|
||||
break
|
||||
@ -370,3 +378,28 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// chatWithProviderChain 用本 agent 的 provider 链跑一次**非流式**请求。
|
||||
//
|
||||
// 优先级:自身 preferredProvider 提前 → 其余源按原顺序回退。
|
||||
// 与主 agent 的 `callLLMWithFallback` 的区别是这里不重试、不切 active provider:
|
||||
// 子任务是短生命周期,重试只会把一个已经抖动的时间窗拉长。
|
||||
func (a *Agent) chatWithProviderChain(req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
providers := a.resolveProviders(req)
|
||||
var lastErr error
|
||||
for _, p := range providers {
|
||||
resp, err := p.Chat(a.ctx, req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
// 取消/超时不再试下一个:那是调用方主动结束,不是"这个源坏了"。
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("没有可用的 LLM 源")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
@ -1390,7 +1390,54 @@ func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Pro
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
}
|
||||
return providers
|
||||
return a.preferProvider(providers)
|
||||
}
|
||||
|
||||
// preferProvider 把本 agent 的**优先源**提到候选链最前面。
|
||||
//
|
||||
// ## 语义(用户 2026-10-30 要求)
|
||||
//
|
||||
// 「主 agent / 子 agent / 驻留子 / rt agent 可各自配置模型源,
|
||||
//
|
||||
// 默认使用总模型源(default)」。
|
||||
//
|
||||
// ## 为什么是"提前"而不是"只留它"
|
||||
//
|
||||
// 四类 agent 的负载不同(主 agent 要推理、rtchild 要首字低延迟、驻留子要省成本),
|
||||
// 所以各自该有自己的首选源。但**硬锁定会让"这个源挂了"直接变成"这个 agent
|
||||
// 彻底不工作"** —— 而多源回退链正是本仓为此建立的机制(callLLMWithFallback)。
|
||||
// 把它排到最前,两者兼得:正常情况走首选,首选不可用时照旧回退。
|
||||
//
|
||||
// ## 配置写错要能看见
|
||||
//
|
||||
// 优先源名不存在时**回退到默认源并记日志**,不静默 —— 拼错的配置如果悄悄
|
||||
// 无效,用户会以为"我配了但没生效",而日志里一个字都没有。
|
||||
func (a *Agent) preferProvider(cands []agentAPI.Provider) []agentAPI.Provider {
|
||||
name := strings.TrimSpace(a.preferredProvider)
|
||||
if name == "" || len(cands) <= 1 {
|
||||
return cands // 未配置:用总默认(providerManager 的排序本来就以默认源为先)
|
||||
}
|
||||
for i, p := range cands {
|
||||
if !strings.EqualFold(p.Name(), name) {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
return cands // 已经在最前
|
||||
}
|
||||
out := make([]agentAPI.Provider, 0, len(cands))
|
||||
out = append(out, p)
|
||||
out = append(out, cands[:i]...)
|
||||
out = append(out, cands[i+1:]...)
|
||||
return out
|
||||
}
|
||||
// 名字对不上:响亮回退(含可用源清单,便于一次改对)。
|
||||
names := make([]string, 0, len(cands))
|
||||
for _, p := range cands {
|
||||
names = append(names, p.Name())
|
||||
}
|
||||
log.Printf("[agent] %s: preferred_provider %q 不在可用源里(%v),回退到默认源 %q",
|
||||
a.id, name, names, cands[0].Name())
|
||||
return cands
|
||||
}
|
||||
|
||||
// callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。
|
||||
|
||||
@ -323,6 +323,13 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
|
||||
return toolOutcome{Text: a.executeInputChannels(tc)}
|
||||
case tc.Name == "resident_agents":
|
||||
return toolOutcome{Text: a.executeResidentAgents(tc)}
|
||||
// ★ rt_transcript:rtchild 上下文交接的**拉取**入口(用户裁定 2026-10-30)。
|
||||
// 交接协议是「推信号 + 拉状态」:回收时只推一条 L4(带条数),
|
||||
// 正文由主 agent 用本工具按需取回并**自行选择**保留哪些。
|
||||
case tc.Name == "rt_transcript":
|
||||
return toolOutcome{Text: a.executeRTTranscript(tc)}
|
||||
case tc.Name == "rt_children":
|
||||
return toolOutcome{Text: a.executeRTChildren(tc)}
|
||||
case tc.Name == "notify_parent":
|
||||
return toolOutcome{Text: a.executeNotifyParent(tc)}
|
||||
case tc.Name == "inputch_note":
|
||||
|
||||
@ -824,6 +824,45 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
|
||||
tools = append(tools, toolDefWith("output_list_channels", "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。", map[string]interface{}{}, nil, parallelOpts()))
|
||||
|
||||
// 父侧:rtchild 控制面(实时通话子 agent,plan.md 十一)。
|
||||
if a.parentID == "" {
|
||||
tools = append(tools, toolDefWith("rt_children",
|
||||
"管理实时子 agent(rtchild,用于通话/视频通话):list 列出 / "+
|
||||
"create 创建(**必须成对**给 inputch + outputch)/ "+
|
||||
"send 注入一条新输入(**会立即打断它当前轮**,不排队)/ "+
|
||||
"stop 回收(先交给你全部上下文,是否保留由你决定,用 rt_transcript 取回)。"+
|
||||
"注意:rt 通道必须由插件声明(ChannelDef.Realtime=true);"+
|
||||
"没有 rt 能力的 agent 不能持有 rt 通道。", map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"list", "create", "send", "stop"},
|
||||
},
|
||||
"id": map[string]interface{}{"type": "string", "description": "rtchild 的 id"},
|
||||
"inputch": map[string]interface{}{"type": "string", "description": "create:rt inputch 名(采集侧灌入;成对的一半)"},
|
||||
"outputch": map[string]interface{}{"type": "string", "description": "create:rt outputch 名(内核把文本写进它;成对的另一半)"},
|
||||
"system_prompt": map[string]interface{}{"type": "string", "description": "create:系统提示词(留空用内置的实时通话提示词)"},
|
||||
"max_tools": map[string]interface{}{"type": "number", "description": "create:单轮最多几批工具调用(0=不限,rt 默认不限)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要注入的输入(会打断当前轮)"},
|
||||
},
|
||||
[]string{"action"}, parallelOpts()))
|
||||
}
|
||||
|
||||
// 父侧:rtchild 上下文交接(用户裁定 2026-10-30:走 contextfull 式中断,
|
||||
// 主 agent **自行选择**保留哪些内容 —— 所以给一个按需拉取的读口)。
|
||||
if a.parentID == "" {
|
||||
tools = append(tools, toolDefWith("rt_transcript",
|
||||
"取回实时子 agent(rtchild)的通话上下文原文。回收 rtchild 时你会收到一条中断,"+
|
||||
"告知有多少条消息;**是否保留、保留哪些由你决定** —— 用本工具分段读取后再取舍,"+
|
||||
"不要把整段塞进自己的上下文(一条通话可能有几百条消息)。",
|
||||
map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "rtchild 的 id"},
|
||||
"offset": map[string]interface{}{"type": "number", "description": "从第几条消息开始(0 = 从头)"},
|
||||
"limit": map[string]interface{}{"type": "number", "description": fmt.Sprintf("最多返回多少条(默认 %d)", rtTranscriptDefaultLimit)},
|
||||
"format": map[string]interface{}{"type": "string", "enum": []string{"text", "raw"}, "description": "text=可读渲染(默认)/ raw=原始消息"},
|
||||
},
|
||||
[]string{"id"}, parallelOpts()))
|
||||
}
|
||||
|
||||
// 父侧:驻留子控制面(单工具多动作,见设计 §7)。
|
||||
if a.parentID == "" {
|
||||
tools = append(tools, toolDef("resident_agents", "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + "+
|
||||
@ -845,10 +884,20 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
"residual": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "reclaim/destroy:该子手头未处理消息的处置。" +
|
||||
"keep=转回主 agent 队列(默认,宁可多做一件不可默默丢一条)/ drop=丢弃(已确认不要)。" +
|
||||
"无论哪种都会逐条记日志;异步通道(如 qq)被丢时用户收不到任何回复,请慎重选 drop。",
|
||||
"enum": []string{"keep", "drop"},
|
||||
"description": "reclaim/destroy:该子手头**未处理消息**的处置。" +
|
||||
"keep=转回你自己的队列(默认)/ takeover=同 keep 但语义明确为「我接手」/" +
|
||||
"stash=暂存到它各自的原通道,等该通道被接回时再放出(你暂时忙但活不能丢时用)/" +
|
||||
"drop=丢弃(已确认不要)。" +
|
||||
"四者都会逐条记日志;异步通道(如 qq)被 drop 时用户收不到任何回复,请慎重。",
|
||||
"enum": []string{"keep", "takeover", "stash", "drop"},
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "reclaim/destroy:该子**持有的通道**的处置。" +
|
||||
"release=归还池子变「未分配」,可被任意 agent 再分配(默认)/ " +
|
||||
"suspend=挂起:归属清空但不参与自动分配,通道上的暂存任务保留,等你接回/" +
|
||||
"takeover=接回你自己:通道归你,并立即释放该通道上的暂存任务到你的队列。",
|
||||
"enum": []string{"release", "suspend", "takeover"},
|
||||
},
|
||||
}, "action"))
|
||||
}
|
||||
|
||||
@ -51,6 +51,18 @@ const (
|
||||
CapImage // 图片
|
||||
CapAudio // 音频
|
||||
CapStructured // 结构化数据(JSON/卡片)
|
||||
// CapRT 表示该通道**能承载 rt 流**(实时通话)。
|
||||
//
|
||||
// ★ 这是**通道**侧的声明,与 agent 是否为 rtchild 是两件事
|
||||
// (plan.md 十一.三 F)。rt 通道的划拨校验要两侧都满足:
|
||||
// · 通道声明自己能承载 rt 流(本能力位)
|
||||
// · 目标 agent 是 rtchild(AgentConfig.IsRealtime)
|
||||
// 校验落点是 AssignRealtime,而不是普通 Assign —— 计划原文:
|
||||
// 「标签不是准入门槛:不带 rt 标签的普通通道同样可以划给 rt agent」。
|
||||
//
|
||||
// ★ 它**不**表示"内核要产出音频"。outputch(rt) 只产出**文本**,
|
||||
// 怎么展示(含 TTS)是插件的活(用户裁定 R6)。
|
||||
CapRT
|
||||
)
|
||||
|
||||
func (c OutputCapability) Supports(cap OutputCapability) bool {
|
||||
@ -74,6 +86,9 @@ func (c OutputCapability) String() string {
|
||||
if c&CapStructured != 0 {
|
||||
flags = append(flags, "structured")
|
||||
}
|
||||
if c&CapRT != 0 {
|
||||
flags = append(flags, "rt")
|
||||
}
|
||||
return fmt.Sprintf("%v", flags)
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ package io
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
@ -20,6 +21,11 @@ var (
|
||||
ErrInputChannelUnknown = errors.New("inputch 未注册")
|
||||
// ErrInputChannelNameEmpty 表示 inputch 名为空。
|
||||
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
|
||||
// ErrChannelNotRealtime 表示该通道没有声明 CapRT 能力(不能承载 rt 流)。
|
||||
//
|
||||
// 两侧校矩的另一侧("目标 agent 是不是 rtchild")在 core 包,
|
||||
// 因为 agent 类型属于 core 而非 io(plan.md 十一.三 F)。
|
||||
ErrChannelNotRealtime = errors.New("通道未声明 rt 能力(CapRT)")
|
||||
)
|
||||
|
||||
// InputChannel 是一个 inputch 的完整登记记录。
|
||||
@ -40,6 +46,17 @@ type InputChannel struct {
|
||||
// Def 是记忆/上下文策略(沿用 ChannelDef:NoMemory / Cleaner / ContextPolicy)。
|
||||
// Cleaner 是函数,故本字段不可序列化(json:"-")。
|
||||
Def ChannelDef `json:"-"`
|
||||
|
||||
// Capabilities 是本通道的**能力位**(rt 流 / 文本 / 音频…)。
|
||||
//
|
||||
// ★ 为何不放进 Def:Def 是公开 SDK 的 ChannelDef 别名
|
||||
// (`type ChannelDef = pubsdk.ChannelDef`),往里面加字段等于改 SDK 契约;
|
||||
// 而能力位是**通道实例**的属性,属于内核侧的登记记录。
|
||||
//
|
||||
// ★ 它声明的是“本通道**能承载**什么”,不是“内核要产出什么”:
|
||||
// 例如 CapRT 表示“能承载实时流(新值作废旧值)”,CapAudio 表示
|
||||
// “本出口会把文本转成声音”——都是插件侧的实现能力声明。
|
||||
Capabilities OutputCapability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelRegistry 是 inputch 的登记表。
|
||||
@ -49,6 +66,13 @@ type InputChannel struct {
|
||||
type ChannelRegistry struct {
|
||||
mu sync.RWMutex
|
||||
channels map[string]InputChannel
|
||||
// stash 是**挂在通道上**的暂存区:驻留子销毁时选 ResidualStash 的残余任务
|
||||
// 留在这里,等该通道被接回(Assign 给新 owner)时放出。
|
||||
//
|
||||
// ★ 为什么挂在通道而不是 agent:这些任务此刻**不属于任何 agent**
|
||||
// (子已销毁、父没接手)。挂在通道上才符合"暂存在对应通道"的语义,
|
||||
// 也让"谁接回通道谁拿到活"自动成立 —— 不需要额外的转交逻辑。
|
||||
stash map[string][]*InputEvent
|
||||
// outputTargets 把**输出通道**解析成"目标 agent 的哪个 inputch"。
|
||||
// 这是"输出可寻址到具体 agent"的依据(子→父、父→指定子)。
|
||||
outputTargets map[string]OutputTarget
|
||||
@ -67,6 +91,7 @@ func NewChannelRegistry() *ChannelRegistry {
|
||||
return &ChannelRegistry{
|
||||
channels: make(map[string]InputChannel),
|
||||
outputTargets: make(map[string]OutputTarget),
|
||||
stash: make(map[string][]*InputEvent),
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,6 +108,16 @@ func (r *ChannelRegistry) Register(ch InputChannel) error {
|
||||
if r.channels == nil {
|
||||
r.channels = make(map[string]InputChannel)
|
||||
}
|
||||
// ★ Def.Realtime ⇒ 映射到 Capabilities 的 CapRT 位(SDK 声明 → 内核能力位)。
|
||||
//
|
||||
// 为什么在这里映射而不是让插件直接填能力位:公开 SDK 的 ChannelDef 是
|
||||
// 插件能碰到的唯一结构(插件不依赖内核),能力位(OutputCapability)是
|
||||
// 内核侧类型。两侧各自表达同一件事,由登记表做**唯一一次**转换。
|
||||
// 判据 TestRTChannel_SDKRealtimeBecomesCapRT 钉它。
|
||||
if ch.Def.Realtime {
|
||||
ch.Capabilities |= CapRT
|
||||
}
|
||||
|
||||
if old, ok := r.channels[ch.Name]; ok {
|
||||
if ch.Owner == "" {
|
||||
ch.Owner = old.Owner
|
||||
@ -131,18 +166,43 @@ func (r *ChannelRegistry) Lookup(name string) (InputChannel, bool) {
|
||||
// 语义(默认取值,见设计文档 R6):**读写授权**,不转移所有权 ——
|
||||
// 登记表仍记录 Plugin(谁注册的)与 Owner(划给了谁)两件事。
|
||||
func (r *ChannelRegistry) Assign(name, agentID string, capacity int) error {
|
||||
_, err := r.AssignAndDrainStash(name, agentID, capacity)
|
||||
return err
|
||||
}
|
||||
|
||||
// AssignAndDrainStash 与 Assign 相同,但**同时返回该通道上的暂存任务**。
|
||||
//
|
||||
// ## 为什么需要它(用户 2026-10-30 要求)
|
||||
//
|
||||
// 「原本驻留子持有的通道也应该支持**挂起或接回**主 agent」。
|
||||
//
|
||||
// 挂起 = `Assign(name, "", 0)`(Owner 清空,通道仍登记但无人处理);
|
||||
// 接回 = `AssignAndDrainStash(name, newOwner, 0)`。
|
||||
//
|
||||
// 接回时必须把暂存任务一并交出:如果只改 Owner 不取暂存,
|
||||
// 那批任务会永远躺在通道上 —— 通道看起来"已经接回来了",
|
||||
// 但活没人做,且没有任何报错。**这是本轮最容易埋的静默缺陷。**
|
||||
func (r *ChannelRegistry) AssignAndDrainStash(name, agentID string, capacity int) ([]*InputEvent, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
ch, ok := r.channels[name]
|
||||
if !ok {
|
||||
return ErrInputChannelUnknown
|
||||
r.mu.Unlock()
|
||||
return nil, ErrInputChannelUnknown
|
||||
}
|
||||
ch.Owner = agentID
|
||||
if capacity > 0 {
|
||||
ch.Capacity = capacity
|
||||
}
|
||||
r.channels[name] = ch
|
||||
return nil
|
||||
// 接回给某个具体 agent 时才释放暂存;挂起(agentID=="")时保持不动 ——
|
||||
// 挂起的语义正是"先放着,等有人接"。
|
||||
var drained []*InputEvent
|
||||
if agentID != "" {
|
||||
drained = r.stash[name]
|
||||
delete(r.stash, name)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return drained, nil
|
||||
}
|
||||
|
||||
// List 返回全部已注册 inputch(按名字稳定排序)。
|
||||
@ -223,3 +283,57 @@ func (r *ChannelRegistry) Count() int {
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.channels)
|
||||
}
|
||||
|
||||
// Stash 把若干条输入事件**暂存到指定通道**(驻留子销毁时用)。
|
||||
//
|
||||
// 与"转回父队列"的区别:这些任务此刻不属于任何 agent —— 子已销毁、父没接手。
|
||||
// 挂在通道上,于是"谁接回这条通道,谁就拿到这批活"自动成立,
|
||||
// 不需要额外的转交逻辑。这正是用户要求的「暂存在对应通道」。
|
||||
//
|
||||
// 通道不存在时返回错误而不是静默丢弃:暂存到一条不存在的通道,
|
||||
// 等于丢了一条还让人以为存住了。
|
||||
func (r *ChannelRegistry) Stash(channel string, events []*InputEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.channels[channel]; !ok {
|
||||
return fmt.Errorf("通道 %s 不存在,无法暂存残余任务", channel)
|
||||
}
|
||||
r.stash[channel] = append(r.stash[channel], events...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TakeStash 取走并清空某通道上的全部暂存任务。
|
||||
//
|
||||
// 取走即清空:调用方(Assign 的接回路径,或显式调用)负责把它们放出去。
|
||||
// 返回值可能是 nil(无暂存)。
|
||||
func (r *ChannelRegistry) TakeStash(channel string) []*InputEvent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
evs := r.stash[channel]
|
||||
delete(r.stash, channel) // 取出即清空,避免重复释放
|
||||
return evs
|
||||
}
|
||||
|
||||
// StashLen 返回某通道暂存的条数(用于 ResidentInfo/控制面展示)。
|
||||
//
|
||||
// ★ 必须有:暂存如果不可见,它就和"丢弃"没有区别 ——
|
||||
// 用户需要能查到"这批活还在通道上等着"。
|
||||
func (r *ChannelRegistry) StashLen(channel string) int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.stash[channel])
|
||||
}
|
||||
|
||||
// StashTotal 返回全部通道上的暂存总条数。
|
||||
func (r *ChannelRegistry) StashTotal() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
n := 0
|
||||
for _, evs := range r.stash {
|
||||
n += len(evs)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@ -996,6 +996,22 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
|
||||
regMeta(ConfigDef{Key: "core.security.sandbox.allow_network", Default: "true", Type: "bool", DisplayName: "允许网络访问",
|
||||
Description: "启用沙箱后为 false 则拒绝带 URL/网络意图的调用。默认 true(不施加网络约束)。", Category: "defaults"})
|
||||
|
||||
// 各 agent 类型优先使用的模型源(空 = 用总默认 core.llm.provider)。
|
||||
//
|
||||
// 四类 agent 负载不同:主 agent 要强推理、rtchild 要极低首字延迟、
|
||||
// 驻留子要长时间挂着省成本。共用一个源对三者都是妥协。
|
||||
//
|
||||
// ★ 语义是**优先不是独占**:该源排在候选链最前,其余源仍作回退。
|
||||
// 名字不存在时回退到默认源并记日志(不静默)。
|
||||
reg(ConfigDef{Key: "core.agent.llm.main_provider", Default: "", Type: "string",
|
||||
DisplayName: "主 agent 模型源", Description: "留空 = 用总默认源(core.llm.provider)。该源优先,不可用时自动回退到其他源。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.llm.resident_provider", Default: "", Type: "string",
|
||||
DisplayName: "驻留子模型源", Description: "驻留子 agent 优先使用的模型源;留空继承总默认源。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.llm.rt_provider", Default: "", Type: "string",
|
||||
DisplayName: "实时子 agent 模型源", Description: "rtchild 优先使用的模型源;留空继承总默认源。通话场景应选首字延迟最低的源。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.llm.child_provider", Default: "", Type: "string",
|
||||
DisplayName: "子 agent 模型源", Description: "工作式轻量子 agent(spawn_child)优先使用的模型源;留空继承总默认源。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.offload_enabled", Default: "false", Type: "bool", DisplayName: "积压任务自动转投", Description: "主 agent 长时间忙时,把积压任务自动转投给内核拉起的驻留子(需重启生效)", Category: "agent"})
|
||||
// 1m 而非 5m:实测本机正常任务最长 1.2 分钟,5 分钟的阈值意味着
|
||||
|
||||
@ -107,14 +107,16 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: coldcontextStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
// 主 agent 的优先模型源(空 = 用总默认 core.llm.provider)。
|
||||
PreferredProvider: cfgReg.GetString("core.agent.llm.main_provider", ""),
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: coldcontextStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
// MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
|
||||
@ -253,6 +253,13 @@ type injectParams struct {
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
// Priority 声明中断注入的优先级(L1..L3);L4 内核独占,见 InjectOptions。
|
||||
Priority string `json:"priority,omitempty"`
|
||||
// ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
|
||||
ScenePolicy string `json:"scene_policy,omitempty"`
|
||||
// SecurityProfile 选择本插件声明的**命名安全限式**(如 "qq.guest")。
|
||||
//
|
||||
// ★ 漏了它等于「插件声明了档位、注入时带了、内核从没收到」——
|
||||
// 策略静默不生效,且没有任何报错(2026-10-30 审计发现,已修)。
|
||||
SecurityProfile string `json:"security_profile,omitempty"`
|
||||
}
|
||||
|
||||
// injectMediaParams 是带媒体注入/工具块注入的参数。
|
||||
@ -275,17 +282,47 @@ type injectMediaParams struct {
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
// ScenePolicy / SecurityProfile 与 injectParams 同义(见那里的注释)。
|
||||
ScenePolicy string `json:"scene_policy,omitempty"`
|
||||
SecurityProfile string `json:"security_profile,omitempty"`
|
||||
}
|
||||
|
||||
// asInjectParams 把媒体注入参数降成通用注入参数(只取共有的 opts 字段)。
|
||||
//
|
||||
// 为什么要有这个转换:pubSdkInjectOpts 接收 injectParams,而媒体路径用的是
|
||||
// injectMediaParams(多 Blocks/BlocksRef)。与其让 pubSdkInjectOpts 收两个类型
|
||||
// 或改签名成 8 个裸参数(那正是本次修掉的缺陷形态),不如显式降一次。
|
||||
func (p injectMediaParams) asInjectParams() injectParams {
|
||||
return injectParams{
|
||||
Source: p.Source, Channel: p.Channel, Text: p.Text,
|
||||
NoMemory: p.NoMemory, ContextPolicy: p.ContextPolicy,
|
||||
RecallPolicy: p.RecallPolicy, CleanerName: p.CleanerName,
|
||||
Priority: p.Priority, ScenePolicy: p.ScenePolicy,
|
||||
SecurityProfile: p.SecurityProfile,
|
||||
}
|
||||
}
|
||||
|
||||
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
|
||||
//
|
||||
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
||||
// 与旧三参数注入等价。
|
||||
func pubSdkInjectOpts(noMemory bool, policy, recallPolicy, cleanerName, priority string) pubsdk.InjectOptions {
|
||||
// ★ 签名接收整个 injectParams 而**不是逐个字段**:
|
||||
//
|
||||
// 曾经的形态是 `pubSdkInjectOpts(noMemory, policy, recallPolicy, cleanerName, priority)`
|
||||
// —— 于是新增 InjectOptions 字段时,这个转换函数**不会**提醒任何人去接,
|
||||
// 实际漏了 ScenePolicy 与 SecurityProfile(后者正是 QQ owner/guest 档位的通路)。
|
||||
//
|
||||
// 现在:`injectParams` 与 `pubsdk.InjectOptions` 字段一一对应,
|
||||
// 新增字段时编译器会在这里提示"未使用/缺字段",而不是静默丢失。
|
||||
func pubSdkInjectOpts(p injectParams) pubsdk.InjectOptions {
|
||||
return pubsdk.InjectOptions{
|
||||
NoMemory: noMemory, ContextPolicy: policy, RecallPolicy: recallPolicy,
|
||||
CleanerName: cleanerName,
|
||||
Priority: clampExternalPriority(priority),
|
||||
NoMemory: p.NoMemory,
|
||||
ContextPolicy: p.ContextPolicy,
|
||||
RecallPolicy: p.RecallPolicy,
|
||||
ScenePolicy: p.ScenePolicy,
|
||||
CleanerName: p.CleanerName,
|
||||
Priority: clampExternalPriority(p.Priority),
|
||||
SecurityProfile: p.SecurityProfile,
|
||||
}
|
||||
}
|
||||
|
||||
@ -558,8 +595,20 @@ func (h *coreHandler) outputRegister(params json.RawMessage) (interface{}, error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("output.register: %w", err)
|
||||
}
|
||||
return nil, h.sdk.RegisterOutputChannel(channel, p.Caps, p.Desc,
|
||||
pubsdk.ChannelDef{NoMemory: p.Def.NoMemory, Cleaner: cleaner},
|
||||
// ★ 整体传 p.Def(只把函数型的 Cleaner 换成代理),**不要手写字段白名单**。
|
||||
//
|
||||
// 这里曾写成 `pubsdk.ChannelDef{NoMemory: p.Def.NoMemory, Cleaner: cleaner}` ——
|
||||
// 一个字段白名单,于是 `Realtime` / `ContextPolicy` / `RecallPolicy` /
|
||||
// `ScenePolicy` **全部静默丢失**。表现(2026-10-30 外部插件 e2e 实测):
|
||||
// 插件声明 `ChannelDef{Realtime:true}` 的 outputch,到内核侧
|
||||
// `OutputCapabilities()` 只有 `[text]`,没有 CapRT ⇒ AssignRealtime 被拒
|
||||
// ⇒ 作者以为"我声明了 rt"却怎么也划不上,且没有任何报错指向这里。
|
||||
//
|
||||
// 输入侧(corehandler_register.go)本来就是正确写法,同一件事故意写成两种,
|
||||
// 所以只修了一半也看不出来。现在两侧同构。
|
||||
def := p.Def
|
||||
def.Cleaner = cleaner
|
||||
return nil, h.sdk.RegisterOutputChannel(channel, p.Caps, p.Desc, def,
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
return h.invokeOutput(channel, args)
|
||||
})
|
||||
|
||||
@ -27,7 +27,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err := validateRecallPolicy("io.injectText", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p))
|
||||
return nil, nil
|
||||
case MethodIOInjectInterrupt:
|
||||
var p injectParams
|
||||
@ -40,7 +40,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err := validateRecallPolicy("io.injectInterrupt", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p))
|
||||
return nil, nil
|
||||
case MethodIOInjectTextNoMem:
|
||||
var p injectParams
|
||||
@ -54,7 +54,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
return nil, err
|
||||
}
|
||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p))
|
||||
return nil, nil
|
||||
case MethodIOInjectSync:
|
||||
var p injectParams
|
||||
@ -67,7 +67,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err := validateRecallPolicy("io.injectInputSync", p.RecallPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectMedia:
|
||||
@ -85,7 +85,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.asInjectParams()))
|
||||
return nil, nil
|
||||
|
||||
case MethodIOInjectMediaSync:
|
||||
@ -103,7 +103,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.asInjectParams()))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
@ -121,7 +121,7 @@ func (h *coreHandler) handleInject(method string, params json.RawMessage) (inter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.RecallPolicy, p.CleanerName, p.Priority))
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.asInjectParams()))
|
||||
return nil, nil
|
||||
|
||||
// ---- 多模态注入 ----
|
||||
|
||||
114
internal/plugin/proc/inject_opts_test.go
Normal file
114
internal/plugin/proc/inject_opts_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package proc
|
||||
|
||||
// 判据:**InjectOptions 的每一个字段都必须能跨进程传过去**。
|
||||
//
|
||||
// 背景(2026-10-30 审计):`pubSdkInjectOpts` 曾逐字段摊平传参
|
||||
// (noMemory, policy, recallPolicy, cleanerName, priority),于是新增字段时
|
||||
// 没人被提醒去接 —— 实际漏了 ScenePolicy 与 SecurityProfile。
|
||||
//
|
||||
// 后者尤其严重:QQ 插件的 owner/guest 档位就是靠 InjectOptions.SecurityProfile
|
||||
// 选的,漏了等于「插件声明了档位、注入时带了、内核从没收到」,策略静默不生效。
|
||||
//
|
||||
// 现在签名接收整个 injectParams,新增字段时编译器会在这里提示。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
// TestPubSdkInjectOpts_CarriesEveryField 钉住整体映射:
|
||||
// injectParams 的每个 opt 字段都必须出现在输出的 InjectOptions 里。
|
||||
func TestPubSdkInjectOpts_CarriesEveryField(t *testing.T) {
|
||||
in := injectParams{
|
||||
NoMemory: true,
|
||||
ContextPolicy: "prune",
|
||||
RecallPolicy: "none",
|
||||
ScenePolicy: "none",
|
||||
CleanerName: "voice-cleaner",
|
||||
Priority: "L2",
|
||||
SecurityProfile: "qq.guest",
|
||||
}
|
||||
got := pubSdkInjectOpts(in)
|
||||
|
||||
if !got.NoMemory {
|
||||
t.Error("NoMemory 丢失")
|
||||
}
|
||||
if got.ContextPolicy != "prune" {
|
||||
t.Errorf("ContextPolicy = %q", got.ContextPolicy)
|
||||
}
|
||||
if got.RecallPolicy != "none" {
|
||||
t.Errorf("RecallPolicy = %q —— 漏传则插件声明的召回策略静默失效", got.RecallPolicy)
|
||||
}
|
||||
if got.ScenePolicy != "none" {
|
||||
t.Errorf("ScenePolicy = %q —— 漏传则插件声明的场面策略静默失效", got.ScenePolicy)
|
||||
}
|
||||
if got.CleanerName != "voice-cleaner" {
|
||||
t.Errorf("CleanerName = %q", got.CleanerName)
|
||||
}
|
||||
if got.SecurityProfile != "qq.guest" {
|
||||
t.Errorf("SecurityProfile = %q —— 漏传则插件声明的安全档位**永不生效**"+
|
||||
"(QQ owner/guest 就是走这条)", got.SecurityProfile)
|
||||
}
|
||||
// 外部插件的优先级必须被夹到 L1..L3(L4 内核独占)。
|
||||
if got.Priority == "L4" {
|
||||
t.Error("外部插件竟然传出了 L4 —— 优先级夹取失效")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPubSdkInjectOpts_MediaPathKeepsOpts 钉住媒体注入路径同样不丢字段。
|
||||
//
|
||||
// 媒体路径用 injectMediaParams(多 Blocks/BlocksRef),曾经因为类型不同
|
||||
// 而无法复用同一个转换函数 —— 那正是漏字段的温床。
|
||||
func TestPubSdkInjectOpts_MediaPathKeepsOpts(t *testing.T) {
|
||||
m := injectMediaParams{
|
||||
NoMemory: true,
|
||||
ScenePolicy: "none",
|
||||
SecurityProfile: "qq.owner",
|
||||
}
|
||||
got := pubSdkInjectOpts(m.asInjectParams())
|
||||
if !got.NoMemory {
|
||||
t.Error("媒体路径 NoMemory 丢失")
|
||||
}
|
||||
if got.ScenePolicy != "none" {
|
||||
t.Errorf("媒体路径 ScenePolicy = %q", got.ScenePolicy)
|
||||
}
|
||||
if got.SecurityProfile != "qq.owner" {
|
||||
t.Errorf("媒体路径 SecurityProfile = %q —— 媒体注入同样要带档位", got.SecurityProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectParityWithSDK 是**防复发**判据:injectParams/injectMediaParams
|
||||
// 必须覆盖 InjectOptions 的全部字段。
|
||||
//
|
||||
// 做法:构造一个"所有字段都非零"的 InjectOptions,逐个读出名称与值,
|
||||
// 确认转换后不丢。新增 SDK 字段而没改这里时,本判据会红。
|
||||
func TestInjectParityWithSDK(t *testing.T) {
|
||||
// 全字段非零(新字段加进 SDK 后,这里也要加 —— 判据会提示)
|
||||
want := pubsdk.InjectOptions{
|
||||
NoMemory: true,
|
||||
ContextPolicy: "prune",
|
||||
RecallPolicy: "none",
|
||||
ScenePolicy: "none",
|
||||
CleanerName: "c",
|
||||
Priority: "L2",
|
||||
SecurityProfile: "p",
|
||||
}
|
||||
in := injectParams{
|
||||
NoMemory: want.NoMemory, ContextPolicy: want.ContextPolicy,
|
||||
RecallPolicy: want.RecallPolicy, ScenePolicy: want.ScenePolicy,
|
||||
CleanerName: want.CleanerName, Priority: want.Priority,
|
||||
SecurityProfile: want.SecurityProfile,
|
||||
}
|
||||
got := pubSdkInjectOpts(in)
|
||||
// InjectOptions 含 map 字段(不可用 ==),逐字段比。
|
||||
if got.NoMemory != want.NoMemory ||
|
||||
got.ContextPolicy != want.ContextPolicy ||
|
||||
got.RecallPolicy != want.RecallPolicy ||
|
||||
got.ScenePolicy != want.ScenePolicy ||
|
||||
got.CleanerName != want.CleanerName ||
|
||||
got.Priority != want.Priority ||
|
||||
got.SecurityProfile != want.SecurityProfile {
|
||||
t.Fatalf("字段不齐:\n got = %+v\nwant = %+v", got, want)
|
||||
}
|
||||
}
|
||||
@ -35,14 +35,17 @@ func TestClampExternalPriority_RejectsL4(t *testing.T) {
|
||||
|
||||
// 贯穿 pubSdkInjectOpts:RPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
|
||||
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
|
||||
got := pubSdkInjectOpts(true, "prune", "none", "cleaner", "L4")
|
||||
got := pubSdkInjectOpts(injectParams{
|
||||
NoMemory: true, ContextPolicy: "prune", RecallPolicy: "none",
|
||||
CleanerName: "cleaner", Priority: "L4",
|
||||
})
|
||||
if got.Priority != pubsdk.PriorityL3 {
|
||||
t.Fatalf("经桥后的优先级=%q,期望 L3", got.Priority)
|
||||
}
|
||||
if !got.NoMemory || got.ContextPolicy != "prune" || got.RecallPolicy != "none" || got.CleanerName != "cleaner" {
|
||||
t.Fatalf("其它字段被改动:%+v", got)
|
||||
}
|
||||
if l2 := pubSdkInjectOpts(false, "", "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
|
||||
if l2 := pubSdkInjectOpts(injectParams{Priority: "L2"}); l2.Priority != pubsdk.PriorityL2 {
|
||||
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@ -124,6 +125,123 @@ func TestSegment_RoundTripAllFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSegment_RoundTripEveryField 是**反射驱动的全字段往返**判据。
|
||||
//
|
||||
// ## 它取代了什么
|
||||
//
|
||||
// 上面那条 `TestSegment_RoundTripAllFields` 是逐字段手写断言,历史价值很大
|
||||
// (C ABI 时代它能抓"哪些字段外部插件看不到"),但有两个结构性缺陷:
|
||||
//
|
||||
// 1. **漏字段无声**:它只断言了 Response/NoMemory/Memory/TokenUsage/Errors/
|
||||
// ToolResults/Extra 的 media_type。实测漏了 Phase、LLMText、FinalText、
|
||||
// ToolCalls、ExtraKeyMediaBlocks、ExtraBag —— 而那些字段确实在往返,
|
||||
// 只是没人验。**手写断言永远不会因为"新增字段"而变红。**
|
||||
// 2. 它验证的是"我记得的那几个",不是"全部"。
|
||||
//
|
||||
// 本判据改用反射:遍历 `pubsdk.StageContext` 的每个导出字段,
|
||||
// 构造非零值 → WriteAll → ReadInto → 逐字段比。**新增 SDK 字段而不改
|
||||
// shmcodec 时,这里会红并指名是哪个字段。**
|
||||
//
|
||||
// ## 为什么不用 == 整体比较
|
||||
//
|
||||
// StageContext 内含 `sync.RWMutex`(不可比较)与 map/slice(不可比较),
|
||||
// 只能逐字段用 reflect.DeepEqual。Extra 需要单独归一(具名键提升 + bag)。
|
||||
func TestSegment_RoundTripEveryField(t *testing.T) {
|
||||
seg := newTestSegment(t, 65536)
|
||||
src := fullStageContext(t)
|
||||
|
||||
if err := seg.WriteAll(src); err != nil {
|
||||
t.Fatalf("WriteAll: %v", err)
|
||||
}
|
||||
var dst pubsdk.StageContext
|
||||
if err := seg.ReadInto(&dst); err != nil {
|
||||
t.Fatalf("ReadInto: %v", err)
|
||||
}
|
||||
|
||||
sv := reflect.ValueOf(src).Elem()
|
||||
dv := reflect.ValueOf(&dst).Elem()
|
||||
var lost []string
|
||||
for i := 0; i < sv.NumField(); i++ {
|
||||
f := sv.Type().Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if name := f.Name; name == "Extra" {
|
||||
continue // 单独验(见下)
|
||||
}
|
||||
if !reflect.DeepEqual(sv.Field(i).Interface(), dv.Field(i).Interface()) {
|
||||
lost = append(lost, fmt.Sprintf("%s: want=%v got=%v",
|
||||
f.Name, sv.Field(i).Interface(), dv.Field(i).Interface()))
|
||||
}
|
||||
}
|
||||
if len(lost) > 0 {
|
||||
t.Fatalf("StageContext 有字段跨进程往返后**丢值或变形**:\n %s\n"+
|
||||
"(修法:在 shmcodec.go 的 captureLocal/writeLocal/readLocal 三处接线)",
|
||||
strings.Join(lost, "\n "))
|
||||
}
|
||||
|
||||
// Extra 的四类具名键 + bag 必须都保住。
|
||||
for _, k := range []string{
|
||||
ExtraKeyMediaBlocks, ExtraKeyMediaType, ExtraKeyInputSource, ExtraKeyOutputChannel,
|
||||
} {
|
||||
if !reflect.DeepEqual(src.Extra[k], dst.Extra[k]) {
|
||||
t.Errorf("Extra[%s] 未往返:want=%v got=%v", k, src.Extra[k], dst.Extra[k])
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(src.Extra["qq_auth"], dst.Extra["qq_auth"]) {
|
||||
t.Errorf("Extra 的自建键(bag)未往返:want=%v got=%v",
|
||||
src.Extra["qq_auth"], dst.Extra["qq_auth"])
|
||||
}
|
||||
}
|
||||
|
||||
// fullStageContext 构造一个**所有导出字段都非零**的 StageContext。
|
||||
//
|
||||
// ★ 它自身带防腐蚀:反射断言每个导出字段都非零。新增 SDK 字段而没在这里
|
||||
// 赋值时,本函数会直接失败 —— 否则那条新字段永远不会被上面那条判据覆盖,
|
||||
// 判据看起来全绿却罩不住新字段("假绿")。
|
||||
func fullStageContext(t *testing.T) *pubsdk.StageContext {
|
||||
t.Helper()
|
||||
resp := "resp"
|
||||
sc := &pubsdk.StageContext{
|
||||
RawMessage: "raw-输入",
|
||||
UserID: "u1",
|
||||
GroupID: "g1",
|
||||
LLMText: "llm-输出",
|
||||
ReasoningContent: "think",
|
||||
FinalText: "final",
|
||||
Response: &resp,
|
||||
Phase: pubsdk.StageAfterToolcall,
|
||||
NoMemory: true,
|
||||
ContextMsgs: []map[string]interface{}{{"role": "user", "content": "hi"}},
|
||||
ToolCalls: []pubsdk.ToolCall{{ID: "c1", Name: "t1", Plugin: "p"}},
|
||||
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "t1", Success: true, Result: "o"}},
|
||||
Memory: []pubsdk.MemItem{{Role: "user", Content: "m", Score: 0.5}},
|
||||
TokenUsage: map[string]int{"prompt": 1},
|
||||
Errors: []string{"e1"},
|
||||
Extra: map[string]interface{}{
|
||||
ExtraKeyMediaBlocks: []interface{}{map[string]interface{}{"type": "text"}},
|
||||
ExtraKeyMediaType: "image",
|
||||
ExtraKeyInputSource: "qq",
|
||||
ExtraKeyOutputChannel: "qq",
|
||||
"qq_auth": "self-key",
|
||||
},
|
||||
}
|
||||
|
||||
// 防腐蚀:每个导出字段都必须非零。
|
||||
v := reflect.ValueOf(sc).Elem()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
f := v.Type().Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if v.Field(i).IsZero() {
|
||||
t.Fatalf("StageContext.%s 在 fullStageContext 里没赋值(仍是零值)—— "+
|
||||
"全字段往返判据覆盖不到它。请给它一个非零值。", f.Name)
|
||||
}
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
// nil Response 与空字符串 Response 必须可区分(短路语义依赖此)。
|
||||
func TestSegment_ResponseNilVsEmpty(t *testing.T) {
|
||||
seg := newTestSegment(t, 8192)
|
||||
|
||||
134
internal/plugin/realtime_channel_test.go
Normal file
134
internal/plugin/realtime_channel_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package plugin
|
||||
|
||||
// 判据:插件通过**普通通道注册接口**声明 rt 能力(`ChannelDef.Realtime`)。
|
||||
//
|
||||
// ## 为什么走普通接口而不是新增一对便捷方法
|
||||
//
|
||||
// 本仓 SDK 的通道注册只有两个入口(`RegisterInputChannel` /
|
||||
// `RegisterOutputChannel`),属性一律由 `ChannelDef` 承载 —— 与
|
||||
// `cli` / `webui` / `remotedevice` 等所有既有插件的写法完全一致:
|
||||
//
|
||||
// _ = s.RegisterInputChannel("cli", sdk.ChannelDef{})
|
||||
// s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, handler)
|
||||
//
|
||||
// 加一对 `RegisterRealtimeInputChannel` / `RegisterRealtimeOutputChannel`
|
||||
// 会多出**第二套写法**:同一个能力两种声明方式,必然漂移
|
||||
// (一处改了、另一处没改,且不报错)。所以 rt 能力就走 `ChannelDef.Realtime`。
|
||||
//
|
||||
// ## 这两条判据钉的是什么
|
||||
//
|
||||
// SDK 声明 → 内核能力位的映射在**两个不同的地方**(输入侧在
|
||||
// `ChannelRegistry.Register`,输出侧在 `Registry.regOutput`)。
|
||||
// 只做一侧就会出现"inputch 声明成 rt 了、outputch 没有"⇒
|
||||
// `AssignRealtime(outputch)` 被拒 ⇒ 插件作者以为"我声明了 rt"却划不上。
|
||||
// 所以两侧各一条,删掉任一侧的映射都会红。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// TestRealtimeChannel_InputSideMapsFromChannelDef 钉住输入侧映射:
|
||||
// 插件用 ChannelDef{Realtime:true} 声明后,登记表里的通道必须带 CapRT。
|
||||
func TestRealtimeChannel_InputSideMapsFromChannelDef(t *testing.T) {
|
||||
reg := agentIO.NewChannelRegistry()
|
||||
|
||||
// 走**普通**接口(不存在的便捷方法不该被使用)。
|
||||
if err := reg.Register(agentIO.InputChannel{
|
||||
Name: "voice-in", Plugin: "demo",
|
||||
Def: agentIO.ChannelDef{Realtime: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
|
||||
ch, ok := reg.Lookup("voice-in")
|
||||
if !ok {
|
||||
t.Fatal("通道未登记")
|
||||
}
|
||||
if !ch.Capabilities.Supports(agentIO.CapRT) {
|
||||
t.Fatalf("Def.Realtime=true 但 Capabilities=%v 缺 CapRT —— "+
|
||||
"插件声明的 rt 通道无法被 AssignRealtime 划拨", ch.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimeChannel_OutputSideMapsFromChannelDef 钉住输出侧映射:
|
||||
// 插件用 RegisterOutputChannel(..., ChannelDef{Realtime:true}, ...) 声明后,
|
||||
// 该 device 的 OutputCapabilities 必须带 CapRT。
|
||||
//
|
||||
// ★ 这条钉的是 `Registry.regOutput` 里的映射 —— 它和输入侧是**两处代码**。
|
||||
// 删掉那一处,本判据红而输入侧那条仍绿,正好定位到"只改了一半"。
|
||||
func TestRealtimeChannel_OutputSideMapsFromChannelDef(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
iom := agentIO.NewIOManager()
|
||||
r.SetIOManager(iom)
|
||||
sd := r.buildSDK("demo")
|
||||
|
||||
var called bool
|
||||
handler := func(args map[string]interface{}) (interface{}, error) {
|
||||
called = true
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// 走**普通** RegisterOutputChannel(与 cli/webui 等既有插件同一写法),
|
||||
// rt 能力只靠 ChannelDef.Realtime 表达。
|
||||
if err := sd.RegisterOutputChannel("voice", 1, "实时通话输出",
|
||||
sdk.ChannelDef{Realtime: true}, handler); err != nil {
|
||||
t.Fatalf("注册输出通道: %v", err)
|
||||
}
|
||||
|
||||
dev := iom.GetDevice("voice")
|
||||
if dev == nil {
|
||||
t.Fatal("输出通道未注册成 device")
|
||||
}
|
||||
gotCaps := dev.OutputCapabilities()
|
||||
if !gotCaps.Supports(agentIO.CapRT) {
|
||||
t.Fatalf("ChannelDef.Realtime=true 但 OutputCapabilities=%v 缺 CapRT —— "+
|
||||
"AssignRealtime(outputch) 会被拒", gotCaps)
|
||||
}
|
||||
// caps 原本声明的文本能力不能被覆盖掉。
|
||||
if !gotCaps.Supports(agentIO.CapText) {
|
||||
t.Fatalf("补 CapRT 时把原有的 CapText 弄丢了:%v", gotCaps)
|
||||
}
|
||||
|
||||
// handler 仍可执行(映射不能破坏 device 行为)。
|
||||
if _, err := dev.Execute("voice", map[string]interface{}{"text": "hi"}); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("handler 未被调用")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimeChannel_NonRealtimeUnchanged 钉住**没有**副作用:
|
||||
// 不声明 Realtime 的通道,abilities 里不得凭空出现 CapRT。
|
||||
//
|
||||
// 这条防"补映射时顺手把所有通道都标成 rt"——那会让 rt 校验形同虚设。
|
||||
func TestRealtimeChannel_NonRealtimeUnchanged(t *testing.T) {
|
||||
reg := agentIO.NewChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{
|
||||
Name: "plain", Plugin: "demo", Def: agentIO.ChannelDef{},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
ch, _ := reg.Lookup("plain")
|
||||
if ch.Capabilities.Supports(agentIO.CapRT) {
|
||||
t.Fatalf("未声明 Realtime 的通道却带上了 CapRT(%v)—— rt 校验会形同虚设",
|
||||
ch.Capabilities)
|
||||
}
|
||||
|
||||
r2 := NewRegistry()
|
||||
iom2 := agentIO.NewIOManager()
|
||||
r2.SetIOManager(iom2)
|
||||
sd2 := r2.buildSDK("demo2")
|
||||
if err := sd2.RegisterOutputChannel("plainout", 1, "普通输出",
|
||||
sdk.ChannelDef{}, func(map[string]interface{}) (interface{}, error) { return "ok", nil }); err != nil {
|
||||
t.Fatalf("注册输出通道: %v", err)
|
||||
}
|
||||
if dev := iom2.GetDevice("plainout"); dev == nil {
|
||||
t.Fatal("device 未注册")
|
||||
} else if dev.OutputCapabilities().Supports(agentIO.CapRT) {
|
||||
t.Fatal("未声明 Realtime 的输出通道却带上了 CapRT")
|
||||
}
|
||||
}
|
||||
@ -687,9 +687,18 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
if iom == nil {
|
||||
return nil
|
||||
}
|
||||
// ★ Def.Realtime ⇒ 补上 CapRT 位(SDK 声明 → 内核能力位)。
|
||||
//
|
||||
// 输入侧在 ChannelRegistry.Register 里映射;**输出侧必须在这里映射** ——
|
||||
// 两条路径是分开的,只做一边就会出现"inputch 声明成 rt 了、outputch 没有",
|
||||
// 于是 AssignRealtime(outputch) 被拒,插件以为"我声明了 rt"却划不上。
|
||||
outCaps := agentIO.OutputCapability(caps)
|
||||
if def.Realtime {
|
||||
outCaps |= agentIO.CapRT
|
||||
}
|
||||
if err := iom.RegisterDevice(&channelDevice{
|
||||
name: chName,
|
||||
caps: agentIO.OutputCapability(caps),
|
||||
caps: outCaps,
|
||||
desc: desc,
|
||||
handler: handler,
|
||||
chDef: agentIO.ChannelDef(def),
|
||||
|
||||
248
internal/plugins/rtchild_external_e2e_test.go
Normal file
248
internal/plugins/rtchild_external_e2e_test.go
Normal file
@ -0,0 +1,248 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package plugins
|
||||
|
||||
// 端到端判据:**真实外部插件**(子进程)驱动 rtchild 全链路。
|
||||
//
|
||||
// ## 为什么这条判据必须存在(单测覆盖不到的那一段)
|
||||
//
|
||||
// `internal/agent/core/rtchild_*_test.go` 证明的是**进程内**逻辑:
|
||||
// rtSlot 单槽、打断、流式、交接。但真实插件是**独立子进程**,
|
||||
// 它到内核之间还隔着:SDK 声明 → proc 桥(stdio RPC + 共享内存)→
|
||||
// 内核登记表 → 通道划拨 → 注入路由 → 输出回写。
|
||||
//
|
||||
// 这一段出问题的历史是有据可查的:
|
||||
// - `notify_parent` 回调漏接线(工具在、一调就"没有上级")
|
||||
// - `routeInputByOwner` 只认 residents,rt 输入被兜底给主 agent
|
||||
// - 输出侧漏映射 `ChannelDef.Realtime → CapRT`(插件声明了 rt 却划不上)
|
||||
//
|
||||
// 三者都是"进程内单测绿、真实链路断",所以必须有这条跨进程判据。
|
||||
//
|
||||
// ## 前置
|
||||
//
|
||||
// 插件需先用 `hmapdev build` 产出 .hmap 并解到某个目录。未提供时 **skip**
|
||||
// 而非 fail(与 real_plugin_smoke_test.go 同口径:CI 上不强制先跑构建)。
|
||||
//
|
||||
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
|
||||
// go test ./internal/plugins/ -run TestExternalRTChild -v
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// e2eEchoProvider 是最小 provider:ChatStream 立刻回一段文本。
|
||||
//
|
||||
// 用它而不是真模型:本判据验证的是**链路**(通道登记→划拨→注入→路由→
|
||||
// 输出回写),不是模型质量。真模型会让本判据依赖网络与配额,变成 flaky。
|
||||
type e2eEchoProvider struct{}
|
||||
|
||||
func (p *e2eEchoProvider) Name() string { return "e2e-echo" }
|
||||
func (p *e2eEchoProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *e2eEchoProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "echo"}, nil
|
||||
}
|
||||
func (p *e2eEchoProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 2)
|
||||
ch <- agentAPI.StreamChunk{Content: "这是 rtchild 的实时回复"}
|
||||
ch <- agentAPI.StreamChunk{Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// rtE2EPluginBin 取外部插件产物路径(环境变量或 hmapdev 的默认输出)。
|
||||
func rtE2EPluginBin(t *testing.T) string {
|
||||
t.Helper()
|
||||
if p := os.Getenv("RTCHILD_E2E_PLUGIN_BIN"); p != "" {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Skipf("RTCHILD_E2E_PLUGIN_BIN 指向的文件不可读:%v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
// 默认位置:/tmp/rtvoice-e2e/rtvoice-e2e/dist 解出的产物。
|
||||
for _, cand := range []string{
|
||||
"/tmp/pkg-rt/plugin.bin.linux.amd64",
|
||||
"/tmp/rtvoice-e2e/rtvoice-e2e/build/plugin.bin",
|
||||
} {
|
||||
if _, err := os.Stat(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
t.Skip("未找到 rtvoice-e2e 插件产物;先 hmapdev build 并解包," +
|
||||
"或用 RTCHILD_E2E_PLUGIN_BIN=<plugin.bin 路径> 指定")
|
||||
return ""
|
||||
}
|
||||
|
||||
// installRTPlugin 把外部插件产物装进插件目录(复用 installRealPlugin 的落盘口径)。
|
||||
func installRTPlugin(t *testing.T, plgDir, name, binPath string) {
|
||||
t.Helper()
|
||||
dst := filepath.Join(plgDir, name)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatalf("建插件目录: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读产物: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dst, "plugin.bin"), data, 0o755); err != nil {
|
||||
t.Fatalf("写 plugin.bin: %v", err)
|
||||
}
|
||||
manifest := fmt.Sprintf(
|
||||
`{"name":%q,"name_zh":%q,"name_en":%q,"version":"0.1.0","entry":"plugin.so","auto_start":true}`,
|
||||
name, name, name)
|
||||
if err := os.WriteFile(filepath.Join(dst, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatalf("写 plugin.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalRTChild_FullChain 是**跨进程全链路**判据:
|
||||
//
|
||||
// 外部插件声明 rt 通道
|
||||
// → 内核登记表里两条通道都带 CapRT
|
||||
// → SpawnRTChild 成对划拨成功
|
||||
// → 插件注入 → 路由进 rtSlot → rtchild 跑一轮
|
||||
// → rtchild 输出写进 outputch → 回到插件 handler
|
||||
//
|
||||
// 任何一环断开,本判据都会在对应断言上失败(而不是"超时说不清原因")。
|
||||
func TestExternalRTChild_FullChain(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||||
installRTPlugin(t, plgDir, "rtvoice-e2e", bin)
|
||||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||
t.Fatalf("加载外部插件: %v", err)
|
||||
}
|
||||
if env.pluginReg.Get("rtvoice-e2e") == nil {
|
||||
t.Fatal("rtvoice-e2e 未加载")
|
||||
}
|
||||
|
||||
// ── ① 插件声明的 rt 通道必须真的带 CapRT ──
|
||||
//
|
||||
// 这一条专钉"输出侧漏映射"那个缺陷:输入侧在 ChannelRegistry.Register
|
||||
// 里映射,输出侧在 Registry.regOutput 里映射,两处代码分开。
|
||||
inCh := "rtvoice-e2e-in"
|
||||
outCh := "rtvoice-e2e"
|
||||
reg := env.iom.ChannelRegistry()
|
||||
|
||||
inEntry, ok := reg.Lookup(inCh)
|
||||
if !ok {
|
||||
t.Fatalf("rt inputch %q 未登记(插件声明没到内核)", inCh)
|
||||
}
|
||||
if !inEntry.Capabilities.Supports(agentIO.CapRT) {
|
||||
t.Fatalf("rt inputch 缺 CapRT(capabilities=%v)—— Def.Realtime 未映射", inEntry.Capabilities)
|
||||
}
|
||||
|
||||
if dev := env.iom.GetDevice(outCh); dev == nil {
|
||||
t.Fatalf("rt outputch %q 未注册成 device", outCh)
|
||||
} else if !dev.OutputCapabilities().Supports(agentIO.CapRT) {
|
||||
t.Fatalf("rt outputch 缺 CapRT(caps=%v)—— regOutput 漏了 Def.Realtime 映射",
|
||||
dev.OutputCapabilities())
|
||||
}
|
||||
|
||||
// ── ② 成对划拨 ──
|
||||
// 内核侧的 Agent 由本测试自建(testPluginEnv 不含 Agent:它只装配插件面)。
|
||||
host := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "e2e-host",
|
||||
IO: env.iom,
|
||||
Provider: &e2eEchoProvider{},
|
||||
})
|
||||
rtID, err := host.SpawnRTChild(agentCore.RTChildOptions{
|
||||
ID: "e2e-rt", Input: inCh, Output: outCh,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 rtchild 失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { host.StopRTChildren() })
|
||||
for _, n := range []string{inCh, outCh} {
|
||||
ch, _ := reg.Lookup(n)
|
||||
if ch.Owner != rtID {
|
||||
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— 成对划拨未生效", n, ch.Owner, rtID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 插件注入 → rtchild 跑起来 ──
|
||||
//
|
||||
// 走真实 RPC:调插件工具 rtvoice_inject,它内部 InjectInterruptTextOpts。
|
||||
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
|
||||
map[string]interface{}{"text": "用户说:讲个短故事"}); err != nil {
|
||||
t.Fatalf("调插件注入工具失败: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var rounds uint64
|
||||
for time.Now().Before(deadline) {
|
||||
if st, ok := host.RTChildStats(rtID); ok && st.Rounds > 0 {
|
||||
rounds = st.Rounds
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if rounds == 0 {
|
||||
t.Fatal("插件注入后 rtchild 从未跑过一轮 —— 输入没有到达 rtSlot" +
|
||||
"(检查 routeInputByOwner 的 rt 分支)")
|
||||
}
|
||||
t.Logf("rtchild 已跑 %d 轮", rounds)
|
||||
|
||||
// ── ④ 输出回到插件 handler ──
|
||||
//
|
||||
// 把插件的观测面拉回来:它记录的 outputs 里必须有东西。
|
||||
var dump map[string]interface{}
|
||||
deadline = time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
res, err := env.stageHost.ExecuteTool("rtvoice_dump", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("调插件 dump 工具失败: %v", err)
|
||||
}
|
||||
if m, ok := res.(map[string]interface{}); ok {
|
||||
dump = m
|
||||
if n, _ := m["output_count"].(float64); n > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if dump == nil {
|
||||
t.Fatal("dump 没有返回结构化结果")
|
||||
}
|
||||
if n, _ := dump["output_count"].(float64); n == 0 {
|
||||
t.Fatalf("rtchild 跑了 %d 轮,但插件 outputch 一片空白 —— "+
|
||||
"输出没有回到插件(检查 rtEmit 与 outputch 的接线)", rounds)
|
||||
}
|
||||
|
||||
// 注入记录也要能回读(证明是**本插件**注入的,不是别的路径)。
|
||||
if inj, ok := dump["injections"].([]interface{}); !ok || len(inj) == 0 {
|
||||
t.Fatalf("插件没有记录到自己的注入:%v", dump["injections"])
|
||||
}
|
||||
|
||||
t.Logf("跨进程全链路通过:rtchild 轮次=%d,插件收到输出=%v 条",
|
||||
rounds, dump["output_count"])
|
||||
|
||||
// ── ⑤ 回收:交接 + 通道悬空 ──
|
||||
if _, err := host.CloseRTChild(rtID); err != nil {
|
||||
t.Fatalf("回收 rtchild 失败: %v", err)
|
||||
}
|
||||
for _, n := range []string{inCh, outCh} {
|
||||
ch, ok := reg.Lookup(n)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ch.Owner == "" {
|
||||
t.Fatalf("通道 %s 被归还为未分配 —— 应保持悬空(主 agent 无 rt 能力)", n)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(host.RTChildren())) != "[]" {
|
||||
t.Fatalf("回收后登记表仍有 %v", host.RTChildren())
|
||||
}
|
||||
}
|
||||
14
third_party/homeagent-sdk/sdk/plugin.go
vendored
14
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -298,6 +298,20 @@ type ChannelDef struct {
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
// ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
|
||||
ScenePolicy string `json:"scene_policy,omitempty"`
|
||||
|
||||
// Realtime 声明**本通道能承载 rt 流**(实时通话)。
|
||||
//
|
||||
// ## 它是什么、不是什么
|
||||
//
|
||||
// 它是**通道**侧的能力声明:「我这个通道知道怎么处理『新值作废旧值』的
|
||||
// 实时流」。与「目标 agent 是不是 rtchild」是两件事,两侧都要满足才能划拨
|
||||
// (内核侧校验见 Agent.AssignRealtime)。
|
||||
//
|
||||
// ★ 它**不**表示"内核要产出音频":`outputch(rt)` 只产出**文本**,
|
||||
// 怎么展示(含 TTS/播放)是本插件的事。
|
||||
// ★ 它**不是**准入门槛:不带本标记的普通通道同样可以划给 rtchild,
|
||||
// 只是不能承载 rt 流(计划 §2.3「标签不是准入门槛」)。
|
||||
Realtime bool `json:"realtime,omitempty"`
|
||||
}
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
|
||||
Reference in New Issue
Block a user