From eb1deab5dd8a09b19742948eff58a815ae3c0375 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 30 Oct 2026 17:00:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20rtchild=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=AD=90=20agent=20+=20=E5=9B=9B=E7=B1=BB=20agent=20=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=A8=A1=E5=9E=8B=E6=BA=90=20+=20=E6=AE=8B=E4=BD=99?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=9B=9B=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- internal/agent/core/agent.go | 125 +- internal/agent/core/context.go | 156 ++ internal/agent/core/inputroute.go | 31 + internal/agent/core/interrupt_vectors.go | 27 + internal/agent/core/plugins.go | 4 + internal/agent/core/provider_pref_test.go | 178 ++ internal/agent/core/resident.go | 313 +++- internal/agent/core/resident_compress_test.go | 299 ++++ internal/agent/core/resident_tools.go | 23 +- internal/agent/core/residual_stash_test.go | 287 ++++ internal/agent/core/rtchild.go | 1507 +++++++++++++++++ internal/agent/core/rtchild_r10_test.go | 167 ++ internal/agent/core/rtchild_r11_test.go | 202 +++ internal/agent/core/rtchild_r1_test.go | 139 ++ internal/agent/core/rtchild_r2_test.go | 146 ++ internal/agent/core/rtchild_r3_test.go | 471 ++++++ internal/agent/core/rtchild_r7_test.go | 218 +++ internal/agent/core/rtchild_r7b_test.go | 291 ++++ internal/agent/core/rtchild_route_test.go | 432 +++++ internal/agent/core/spawn.go | 35 +- internal/agent/core/task.go | 49 +- internal/agent/core/toolcall.go | 7 + internal/agent/core/tooldefs.go | 57 +- internal/agent/io/channel.go | 15 + internal/agent/io/inputch.go | 120 +- internal/config/registry.go | 16 + internal/kernelmod/agent/mod.go | 18 +- internal/plugin/proc/corehandler.go | 61 +- internal/plugin/proc/corehandler_inject.go | 14 +- internal/plugin/proc/inject_opts_test.go | 114 ++ internal/plugin/proc/priority_clamp_test.go | 7 +- internal/plugin/proc/shm_test.go | 118 ++ internal/plugin/realtime_channel_test.go | 134 ++ internal/plugin/registry.go | 11 +- internal/plugins/rtchild_external_e2e_test.go | 248 +++ third_party/homeagent-sdk/sdk/plugin.go | 14 + 36 files changed, 5973 insertions(+), 81 deletions(-) create mode 100644 internal/agent/core/provider_pref_test.go create mode 100644 internal/agent/core/resident_compress_test.go create mode 100644 internal/agent/core/residual_stash_test.go create mode 100644 internal/agent/core/rtchild.go create mode 100644 internal/agent/core/rtchild_r10_test.go create mode 100644 internal/agent/core/rtchild_r11_test.go create mode 100644 internal/agent/core/rtchild_r1_test.go create mode 100644 internal/agent/core/rtchild_r2_test.go create mode 100644 internal/agent/core/rtchild_r3_test.go create mode 100644 internal/agent/core/rtchild_r7_test.go create mode 100644 internal/agent/core/rtchild_r7b_test.go create mode 100644 internal/agent/core/rtchild_route_test.go create mode 100644 internal/plugin/proc/inject_opts_test.go create mode 100644 internal/plugin/realtime_channel_test.go create mode 100644 internal/plugins/rtchild_external_e2e_test.go diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 01a304ce..8fd6d883 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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, diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 754167ab..d50607a5 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -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 diff --git a/internal/agent/core/inputroute.go b/internal/agent/core/inputroute.go index 511412ae..1b8e04fa 100644 --- a/internal/agent/core/inputroute.go +++ b/internal/agent/core/inputroute.go @@ -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: // **不吞输入** —— 由本内核兜底处理并留痕。吞掉一条输入比多处理一条更糟: diff --git a/internal/agent/core/interrupt_vectors.go b/internal/agent/core/interrupt_vectors.go index ffd838fd..2b27d153 100644 --- a/internal/agent/core/interrupt_vectors.go +++ b/internal/agent/core/interrupt_vectors.go @@ -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, diff --git a/internal/agent/core/plugins.go b/internal/agent/core/plugins.go index 44975f43..8c73743f 100644 --- a/internal/agent/core/plugins.go +++ b/internal/agent/core/plugins.go @@ -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", diff --git a/internal/agent/core/provider_pref_test.go b/internal/agent/core/provider_pref_test.go new file mode 100644 index 00000000..d04df145 --- /dev/null +++ b/internal/agent/core/provider_pref_test.go @@ -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) + } +} diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 57266e94..73926212 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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/)。 // @@ -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 +} diff --git a/internal/agent/core/resident_compress_test.go b/internal/agent/core/resident_compress_test.go new file mode 100644 index 00000000..76ca5e95 --- /dev/null +++ b/internal/agent/core/resident_compress_test.go @@ -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)) + } +} diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go index ddc5a78f..4a5617d2 100644 --- a/internal/agent/core/resident_tools.go +++ b/internal/agent/core/resident_tools.go @@ -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: diff --git a/internal/agent/core/residual_stash_test.go b/internal/agent/core/residual_stash_test.go new file mode 100644 index 00000000..4eaeff76 --- /dev/null +++ b/internal/agent/core/residual_stash_test.go @@ -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 不应留下暂存") + } +} diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go new file mode 100644 index 00000000..c17f63af --- /dev/null +++ b/internal/agent/core/rtchild.go @@ -0,0 +1,1507 @@ +package core + +// rtchild(实时子 agent)—— 通话场景。设计见 plan.md 第十一节。 +// +// ## 它是什么(用户裁定 2026-10-30) +// +// rtchild 是**第三个子 agent 类型**,与前两者并列、互不影响: +// +// 主 agent 完整内核 *Agent 正常交互 +// 驻留子 完整内核 *Agent 长期派驻、不丢活 +// 工作式轻量子 goroutine + 局部 msgs 一次性子任务 +// rtchild goroutine + 局部 msgs ★ 通话 / 视频通话 +// +// 它只服务一个场景:**实时对话**。所以延迟是架构约束而不是调优项 —— +// 这条决定了本文件所有的取舍(详见 plan.md 十一.四 空白 2 的生产实测)。 +// +// ## 四条内核契约(plan.md 十一.二) +// +// 1. 输入可打断:新输入到达立即中断当前生成,以新输入重开一轮(不排队等它说完) +// 2. 输出连续:流式、不攒整段;被中断时就地截断 +// 3. 不吃队列:不保序、不补偿、无残余回灌 +// 4. 待机零消耗:无输入即静默,不烧轮次、不烧 token、不轮询 +// +// ## 为什么另起一个文件而不是改 spawn.go / resident.go +// +// 用户裁定 R1:**不影响现有的主 agent 与子 agent**。三者的设计目标函数相反: +// +// 四级中断(scheduler.go):公平、不饥饿、现场可恢复 ← 驻留子/主 agent 要的 +// rt 单槽(本文件) :最新者胜、可饿死、丢现场 ← rt 要的 +// +// 硬凑在一起会让两边都坏。所以 rt 输入**不进** registerInterrupt(避开 +// preemptCooldown=2s、临界区、同级不抢占三道闸,见 plan.md 十一.三 B)。 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "runtime/debug" + "sort" + "strings" + "sync" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" + "github.com/JianFeeeee/HologramAgent/pkg/types" +) + +// ErrAgentNotRealtime 表示目标 agent 不是 rtchild,不能接收 rt 通道。 +// +// ★ 为何与 io 包的 ErrChannelNotRealtime 分开:它们是两侧校验的两个不同失败。 +// io 包只知道自己那一侧(通道能力);"目标 agent 是不是 rtchild" 是 +// agent 类型,属于 core 包(plan.md 十一.三 F)。分开后错误文案能直接指向 +// 该动哪一边,而不是一句笼统的"不是 rt"。 +var ErrAgentNotRealtime = errors.New("目标 agent 不是 rtchild") + +// ErrRTChannelNotPaired 表示 rt 通道没有**成对**给出(缺 input 或 output)。 +// +// rt 通话天然成对:听得见用户(inputch)+ 说得给用户(outputch)。 +// 只给一半不报错的话,症状会出现在很远的地方("它自言自语" / "用户听不到回应"), +// 而那两处都不会指向"少划了一条通道"。 +var ErrRTChannelNotPaired = errors.New("rt 通道必须成对(inputch + outputch 都要)") + +// AsRealtime 把本 agent 标记为 rtchild(类型标签,plan.md 十一.三 F 的 agent 侧)。 +// +// ★ 为什么是"标记"而不是"构造参数才可设":现有三类 agent 的构造点分散 +// (New(AgentConfig) / SpawnResident / runChildTask),rt 的骨架要复用其中一部分。 +// 但**校验**不能因此变松:AssignRealtime 会读这个标记,未标记的 agent +// 一律拒划 rt 通道(判据 TestAssignRealtimeRejectsNonRTAgent)。 +func (a *Agent) AsRealtime() { a.isRealtime = true } + +// IsRealtime 报告本 agent 是否为 rtchild。 +func (a *Agent) IsRealtime() bool { return a != nil && a.isRealtime } + +// AssignRealtime 把一个 **rt 通道**划给本 agent。 +// +// 它是「只有 rtchild 可被划分 rt 通道」(裁定 R2)的唯一执行点。 +// +// ## 为什么校验是双向的,以及两个方向各是什么 +// +// ① 通道侧:通道必须声明 CapRT("我能承载 rt 流")—— 插件在 +// OutputCapabilities()/InputChannel 里声明。没声明 ⇒ 它根本不知道怎么 +// 处理"新值作废旧值"的语义,划进去只会把它当成普通消息收。 +// ② agent 侧:本 agent 必须是 rtchild —— 否则一个跑 3–5 秒工具的主 agent +// 会混进通话链路,用户听到的是长静默(生产实测中位数 2–5s,见 plan.md)。 +// +// ## 为什么不复用 Assign +// +// `ChannelRegistry.Assign` 是**通用**划拨(策略、容量、归属),全部 agent +// 都在用,且它**零校验**。往里加 rt 校验会让普通划拨也变得有条件 —— +// 而计划原文明确:「标签不是准入门槛:**不带 rt 标签的普通通道同样可以 +// 划给 rt agent**」。所以 rt 校验只在**绑定 rt 通道**这一条路上生效。 +// +// ## 为什么 Capacity 置 0 +// +// `InputChannel.Capacity` 是**队列容量**(inputch.go:33)。rt 通道要的是 +// **单槽覆盖**(新值作废旧值,见 rtSlot),与"容量=1 的队列"行为相反: +// 后者会让新输入**等旧输入跑完**,正是契约 1 禁止的。置 0 = 不设队列容量。 +func (a *Agent) AssignRealtime(name string) error { + if a == nil { + return fmt.Errorf("rt 通道划拨:agent 为空") + } + if a.io == nil { + return fmt.Errorf("rt 通道划拨:本 agent 没有 io") + } + reg := a.io.ChannelRegistry() + if reg == nil { + return fmt.Errorf("rt 通道划拨:通道登记表不可用") + } + ch, ok := reg.Lookup(name) + if !ok { + return agentIO.ErrInputChannelUnknown + } + // ① 通道侧:它自己得声明能承载 rt 流。 + if !ch.Capabilities.Supports(agentIO.CapRT) { + return fmt.Errorf("rt 通道划拨:%w(通道 %q 的 Capabilities 未含 CapRT)", + agentIO.ErrChannelNotRealtime, name) + } + // ② agent 侧:本 agent 必须是 rtchild。 + if !a.isRealtime { + return fmt.Errorf("rt 通道划拨:%w(agent %q 不是 rtchild,只有 rtchild 可被划分 rt 通道)", + ErrAgentNotRealtime, a.id) + } + // Capacity=0:rt 通道没有队列容量这个概念(见本函数文档末段)。 + return reg.Assign(name, string(a.id), 0) +} + +// ── rtSlot:rt 输入的**唯一**入口(单槽覆盖,不排队) ── +// +// 设计见 plan.md 十一.五。它是契约 1 与契约 3 的落地形态: +// +// 契约 1「新输入一到立刻真打断,不是排队等它说完」 +// 契约 3「不吃队列、不保序、不补偿」 +// +// ## 为什么不能复用调度器(这是本文件最关键的取舍) +// +// 四级中断那套(scheduler.go)有三个机制与 rt 的语义直接冲突: +// +// ① preemptCooldown = 2s (scheduler.go:174 / interrupt.go:140) +// 被打断方 2 秒内不能再被打断。攒批 250ms ⇒ 每秒 4 次拐点 ⇒ +// **约 75% 的打断会静默降级为入队**。 +// ② canPreempt 同级不抢占 (scheduler.go:197) +// 连续两条同级别输入,第二条排队 —— 连续说话场景直接退化成串行。 +// ③ 抢占是 suspend 语义 (interrupt.go:129) +// 它把被打断的现场**压栈等恢复**;而 rt 要的是**丢弃现场**。 +// +// 这三条不是 bug,是「公平、不饥饿、现场可恢复」的实现——正是主 agent 与 +// 驻留子需要的。rt 需要的是「最新者胜、可饿死、丢现场」,目标函数相反。 +// +// ⇒ 所以 rt 输入**不进** registerInterrupt,走下面这条独立单槽。 +// 这也满足用户裁定 R1(不影响现有的主 agent 与子 agent):本类型一行 +// 都没改调度器的行为。 + +// rtSlot 缓冲 rt 输入。语义:**最新者胜**,旧值作废,永不排队。 +// +// 与「容量=1 的队列」的区别(这条写进判据 TestRTSlot_NeverQueues): +// +// 容量=1 队列:新输入到时旧输入在跑 ⇒ 新输入**等待**(= "等它说完")❌ +// 单槽覆盖 :新输入到时旧输入在跑 ⇒ 旧输入**作废**,新输入接管 ✅ +type rtSlot struct { + mu sync.Mutex + seq uint64 // 单调递增,随每次 Push 前进(契约 1 的可验证凭据) + pending *agentIO.InputEvent // nil = 无待处理;非 nil 时恒为**最新**一条 + // dropped 统计被覆盖掉的输入条数,用于观察「新输入赢了几次旧输入」。 + // + // 它必须可读:rt 场景下打断是常态,但**如果一条都没被丢弃**, + // 说明插件的攒批没有真正生效(或速率太低),值得在阶段二看清楚。 + dropped uint64 + wake chan struct{} // cap=1,非阻塞信号;不承载数据 +} + +func newRTSlot() *rtSlot { + return &rtSlot{wake: make(chan struct{}, 1)} +} + +// Push 覆盖写入一条输入,返回本次的 seq。 +// +// 旧值**直接丢弃**(不是排队、不是补偿)—— 这正是契约 1/3 的实现。 +func (s *rtSlot) Push(evt *agentIO.InputEvent) uint64 { + s.mu.Lock() + if s.pending != nil { + s.dropped++ // 上一条还没被取走就被新值覆盖:这就是"作废" + } + s.seq++ + s.pending = evt + seq := s.seq + s.mu.Unlock() + + // 非阻塞唤醒:槽里已有信号时不必重复(唤醒是电平语义,不是计数语义)。 + select { + case s.wake <- struct{}{}: + default: + } + return seq +} + +// Take 取走当前待处理输入并**清空槽**。没有待处理时返回 (nil, 0, false)。 +// +// 清空是必须的:取走后若不清,rtLoop 会反复拿到同一条输入重跑同一轮。 +func (s *rtSlot) Take() (*agentIO.InputEvent, uint64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.pending == nil { + return nil, 0, false + } + evt, seq := s.pending, s.seq + s.pending = nil + return evt, seq, true +} + +// Seq 返回当前 seq(诊断/判据用;不代表"有待处理")。 +func (s *rtSlot) Seq() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.seq +} + +// Dropped 返回被覆盖丢弃的输入条数(诊断用)。 +func (s *rtSlot) Dropped() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.dropped +} + +// Pending 报告槽里是否有待处理输入(诊断用;**不要**用它做轮询等待)。 +// +// ★ 契约 4 禁止轮询:rtLoop 必须 block 在 Wake() 上,而不是循环查 Pending()。 +func (s *rtSlot) Pending() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.pending != nil +} + +// Wake 返回阻塞等待新输入的通道(契约 4:待机必须 block,不得轮询)。 +// +// ★ 为什么不暴露 Pending() 让调用方轮询:轮询会烧 CPU 且在静默期产生 +// 无意义的唤醒("零轮次零 token" 之外还要求"不轮询")。rtLoop 的正确形状是 +// +// for { +// select { +// case <-a.rtSlot.Wake(): +// evt, seq, ok := a.rtSlot.Take() +// if !ok { continue } // 信号先于数据或数据已被取走:无副作用 +// ...跑一轮... +// case <-a.ctx.Done(): +// return +// } +// } +func (s *rtSlot) Wake() <-chan struct{} { return s.wake } + +// ── R3:rtChild 生命周期 · 独立 ctx · rtLoop ── +// +// ## 为什么每个 rtchild 必须有**自己的** ctx(结构性前提) +// +// 现状 `spawn.go:184` 用 `a.provider.Chat(a.ctx, req)` —— 那是**父 agent 的** +// ctx。对一个 rtchild 调 cancel(a.ctx) 会把父与**全部**子一起停掉。 +// +// 用户裁定 R1「不影响现有的主 agent 与子 agent」在代码层面就是这个要求: +// +// rtA 被新输入打断 ⇒ 只作废 rtA 的当前轮 +// ⇒ rtB 与主 agent 的 LLM 调用必须**继续** +// +// 判据 TestRTChild_InterruptDoesNotAffectOthers 钉住它。 + +// rtChild 是一个 rtchild 实例(通话场景的实时子 agent)。 +// +// 它**不进** `a.residents`(那是驻留子的登记表,语义是"有任务队列、有残余"), +// 而是自己一张轻表(见 Agent.rtChildren)。 +type rtChild struct { + id string + agent *Agent // 跑 rt 轮次的载体(已标记 isRealtime) + slot *rtSlot // 唯一输入入口:单槽覆盖 + input string // inputch(rt) 的名字(采集侧灌入;成对的一半) + output string // outputch(rt) 的设备名(内核把文本写进去) + + // round 是本轮序号(单调递增),seq 是本轮所基于的输入 seq。 + // + // ★ seq 是**可验证凭据**(plan.md 十一.四 空白 3):输出回传它, + // 判据才能证明"这一轮是用最新输入跑的",而不是旧输入的残余。 + mu sync.Mutex + round uint64 + lastSeq uint64 + + // curCancel 是**当前这一轮**的 cancel(PushRTInput 用它打断,见 rtStream)。 + // nil = 没有正在跑的轮次。 + curCancel context.CancelFunc + // critical > 0 表示正在执行工具(不可打断,R5)。 + // 用计数而非 bool:一轮里可能串行执行多个工具。 + critical int + // suppressedCancels 统计「因处于工具执行期而**故意没取消**」的次数。 + // + // ★ 为什么需要这个计数器(否则 R6 的守卫是**不可验证**的): + // 设备工具执行经 `a.io.ExecuteTool`(toolcall.go:377),它内部传的是 + // `context.Background()` —— 工具**根本收不到** roundCtx。于是"工具不被 + // 中途掐断"这件事在两条路径上都成立,判据无法区分: + // · 守卫生效(我们主动不取消) + // · 守卫失效但取消也没效果(工具不消费 ctx) + // 这个计数器把"守卫真的被执行了"变成可观测事实(判据 + // TestRTChild_CriticalSectionSuppressesCancel 钉它)。 + suppressedCancels uint64 + + // history 是跨轮累积的对话消息(普通上下文,plan.md 十一.四 修订后)。 + // + // 它**就是** rtchild 的全部上下文状态:一条通话是一条连续对话, + // 每轮把「本轮 user 输入 + 模型产出 + 工具结果」追加进去, + // 进模型前按窗口裁(见 buildRTContext / rtTrimToWindow)。 + // + // 与另外两类子 agent 的差别只在**生命周期**: + // 普通子 —— 局部 msgs,跑完即弃 + // rtchild —— 同一份 msgs 跨轮保留(通话不能每轮失忆) + // + // 由 rtLoop 单 goroutine 串行改,锁只用于让外部读取(stats/判据)安全。 + history []agentAPI.Message + // stopped 标记本实例已被 Stop(避免 Stop 后再启 loop)。 + stopped bool +} + +// RTChildOptions 是创建一个 rtchild 的参数。 +type RTChildOptions struct { + // ID 是实例 id(登记表的键)。 + ID string + // Input 是 inputch(rt) 的名字——采集侧往里灌实时事件。 + // + // ## 为什么必须与 Output **成对**给出 + // + // rt 通道是**成对**的资源:一条通话同时需要"听得见用户"(input)与 + // "说得给用户"(output)。只给一半的形态不是"简化",是**坏掉一半**: + // + // 只给 Output:rtchild 能说话,但**永远听不到用户**(rtSlot 无人灌入) + // ⇒ 表现是"它自言自语,然后静默待机",且不报任何错 + // 只给 Input :rtchild 能听,但**输出无处可去**(rtEmit 找不到设备) + // ⇒ 用户听到的是长时间静默 + // + // 所以 SpawnRTChild 要求两者都给,且**两者都必须是 CapRT 通道**。 + Input string + // Output 是 outputch(rt) 的设备名——内核把 rt 文本写进它。 + // + // ★ 内核只产出**文本**(用户裁定 R6):怎么展示(含 TTS/播放)是插件的活。 + // 内核不做音频合成,也不关心这个设备是不是 CapAudio。 + Output string + // SystemPrompt 是 rtchild 的系统提示词。空则用 rtChildDefaultPrompt。 + // + // 调用方(阶段三)通常传 `childSystemPromptTemplate` + 十一.六 的【实时通话】段。 + SystemPrompt string + // PreferredProvider 是**这个 rtchild** 优先使用的模型源(空 = 继承父的)。 + // + // 通话场景应选**首字延迟最低**的源:生产实测不同源的单次往返差 2–5 倍 + // (见 plan.md 十一.四 空白 2),而 rt 的首字预算是 500ms 级。 + PreferredProvider string + // MaxTools 是本轮允许的最大工具批数(0 = 不限)。 + // + // 与 `spawn_child` 的 max_turns 同义,但默认值必须是 0(不限): + // rt 是**常驻对话**而非一次性任务,跑满就结束是错的。 + MaxTools int +} + +// rtChildDefaultPrompt 是 rtchild 的默认系统提示词。 +// +// 【实时通话】段是用户裁定 R4 的落地:调用工具**之前**先用 outputch 告知用户稍等。 +// 这是**提示词**要求而非内核强制——内核无法判断"这次工具调用是否真的慢", +// 强行插入会破坏模型的自然表达(plan.md 十一.三 G)。 +const rtChildDefaultPrompt = `你正在与用户实时通话。 + +【实时通话】这是硬性要求: +- 调用任何工具之前,**必须先用输出把这句话说出来**告知用户你要做什么 + (例如「稍等,我查一下」)。工具执行期间用户无法打断你,这句话是他唯一的反馈。 +- 你不需要、也不应该追求把话说完整:被打断是常态,不是失败。 +- 不要复述用户刚说的话,直接给结论。 + +【执行纪律】必须实际调用工具去执行,不要只回复「我将要做什么」。 +只有已经取得实际结果(或确认无法取得)时,才用纯文本汇报。` + +// RTChildren 返回全部 rtchild 的 id(按 id 排序,便于断言与展示稳定)。 +func (a *Agent) RTChildren() []string { + a.rtMu.Lock() + defer a.rtMu.Unlock() + out := make([]string, 0, len(a.rtChildren)) + for id := range a.rtChildren { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// SpawnRTChild 创建一个 rtchild 并启动它的 rtLoop。 +// +// 与 SpawnResident 的关键差别(这是"三类并列、不复用外壳"的落点): +// +// 驻留子 rtchild +// 载体 完整 *Agent(含调度器) 同(但**不启动**调度循环) +// goroutine 数 Start() 起 7 个 只起 1 个 rtLoop +// 输入 通道 + 队列(不丢活) 单槽覆盖(最新者胜) +// 残余回灌 ResidualPolicy keep/drop 无(契约 3) +// 上下文 RelevanceContext + 不裁 局部消息页(plan.md 十一.四) +func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) { + if strings.TrimSpace(opts.ID) == "" { + return "", fmt.Errorf("rtchild 必须有 id") + } + if strings.TrimSpace(opts.Output) == "" { + return "", fmt.Errorf("rtchild 必须指定 outputch(内核把 rt 文本写进它)") + } + if strings.TrimSpace(opts.Input) == "" { + // rt 通道必须**成对**:没有 input,rtchild 永远听不到用户 + // (rtSlot 无人灌入),表现是"自言自语后静默待机"且不报错。 + return "", fmt.Errorf("rtchild 必须指定 inputch(rt 通道必须成对:%w)", ErrRTChannelNotPaired) + } + // ★ 划拨前先确认目标通道**不属于本 agent**(主 agent 没有 rt 能力)。 + // + // 用户裁定:主 agent 自己**不持有** rt 能力。所以: + // · 主 agent 不可能自己去消费 rt 通道(它没有 rtSlot) + // · 通道一旦划给 rtchild,Owner 就是 rtchild;rtchild 销毁后 + // 通道**悬空**(Owner 保持指向已销毁的 id),而不是回落到主 agent + // + // 这里只做"不能划给自己"的防御:若 Owner 已经是本 agent 自己, + // 说明这个通道被主 agent 当普通通道占着,划走会静默改变它的路由语义。 + if reg := a.io.ChannelRegistry(); reg != nil { + if ch, ok := reg.Lookup(opts.Input); ok && ch.Owner != "" && ch.Owner == string(a.id) { + return "", fmt.Errorf("rt 通道划拨:inputch %q 归属于主 agent 自身,"+ + "主 agent 不持有 rt 能力,不能作为 rt 通道的持有方", opts.Input) + } + if ch, ok := reg.Lookup(opts.Output); ok && ch.Owner != "" && ch.Owner == string(a.id) { + return "", fmt.Errorf("rt 通道划拨:outputch %q 归属于主 agent 自身,"+ + "主 agent 不持有 rt 能力,不能作为 rt 通道的持有方", opts.Output) + } + } + + a.rtMu.Lock() + if a.rtChildren == nil { + a.rtChildren = map[string]*rtChild{} + } + if _, dup := a.rtChildren[opts.ID]; dup { + a.rtMu.Unlock() + return "", fmt.Errorf("rtchild %s 已存在", opts.ID) + } + a.rtMu.Unlock() + + // ① 独立 ctx:**必须**从父的 ctx 派生(父停则子停,不留孤儿), + // 但**单独可取消**(打断 rtA 不波及父与 rtB)。见上面的注释。 + ctx, cancel := context.WithCancel(a.ctx) + + // ② 独立的 IOManager:与驻留子同款——自己的输入入口 + 共享通道登记表 + // + 父视图回退(否则 output_send__* 与设备工具全都不可见)。 + childIO := agentIO.NewIOManager() + if reg := a.io.ChannelRegistry(); reg != nil { + childIO.SetChannelRegistry(reg) + } + childIO.SetParentIO(a.io) + + sysPrompt := opts.SystemPrompt + if strings.TrimSpace(sysPrompt) == "" { + sysPrompt = rtChildDefaultPrompt + } + + carrier := New(AgentConfig{ + // ★ 必须**逐字等于**登记表 key(opts.ID)。 + // + // 曾经写成 "rt/"+opts.ID:于是 AssignRealtime 把通道 Owner 记成 "rt/xxx", + // 而 routeInputByOwner 按 entry.Owner 去 a.rtChildren 里查(key 是 "xxx") + // ⇒ **永远查不到** ⇒ 插件的 rt 输入被兜底给主 agent。 + // 这是"两个地方各拼一次名字"的典型故障(与 resident 的 inboundChannelName 同型)。 + ID: types.AgentID(opts.ID), + SystemPrompt: sysPrompt, + Provider: a.provider, + ProviderManager: a.providerManager, + // rt 可以有自己更快的源;留空继承父的(父再留空 ⇒ 总默认源)。 + // + // ⚠️ 注意 rt 仍走 resolveProviders 的回退链(只是把优先源提前): + // 硬锁定会让"首选源抖动"直接变成"通话中断"。 + PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider), + IO: childIO, + StageHost: a.stageHost, + AllowedOutputs: []string{opts.Output}, + KernelSource: string(a.id), + ParentID: string(a.id), + IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道 + }) + // ★ 不调 carrier.Start():那会起 schedulerLoop / interceptLoop / 3 个记忆 + // 整理循环。rt 要的是"不吃队列、无轮询",这些 goroutine 对它是**反向**的。 + // 只保留一个 rtLoop。 + carrier.ctx, carrier.cancel = ctx, cancel + carrier.rtSlot = newRTSlot() + carrier.rtMaxTools = opts.MaxTools + + // ★ 接线 notify_parent —— 这就是计划 §2.4 的 `askparent` 在内核里的形态。 + // + // ## 为什么必须接线(真实缺陷,本轮抓到) + // + // `notify_parent` 出现在子侧工具表里(tooldefs.go:874,条件是 parentID != ""), + // 而 rtchild 的 carrier 也满足该条件 ⇒ **模型看得见这个工具**。 + // 但初版没接 notifyParent 回调 ⇒ 模型一调就得到 + // 「本 agent 没有上级,无法发送消息」——工具存在、调用必失败。 + // 判据 TestRTChild_NotifyParentIsWired 钉它。 + // + // ## 为什么用 L4 而不是驻留子的 L3 + // + // 驻留子的"主动汇报"是进度通知,L3 足够(它不该打断父正在做的事)。 + // rt 的通话里"向父发问"是**用户的话**——父必须立刻看到,否则用户对着 + // 电话说话、对面(父 agent)却在忙别的。所以用 L4。 + // + // ## 不阻塞 rt 流(计划 §2.4) + // + // 投递是**异步**的:InjectInterruptTextOpts 只登记,不等待。 + // rt 轮次继续跑,不被这次发问卡住。父的回话(若有)按 R5 语义 + // **作为一条新的用户输入**注入(走同一个单槽,见 PushRTInput), + // 因此它可能打断 rt 当前的轮次 —— 这正是 R5 要的行为,不是缺陷。 + // + // ★ 入站通道**只登记一次**(在这里),回调里只投递。 + // 初版在回调里调 residentInboundChannel(它内部会 Register)⇒ 每发一次 + // 就重复登记一次,而该表按 name 全局唯一 —— 是纯粹的写放大 + 脏登记。 + rtInboundCh := a.residentInboundChannel("rt/" + opts.ID) + carrier.notifyParent = func(text string) { + a.io.InjectInterruptTextOpts("rt/"+opts.ID, rtInboundCh, text, + agentIO.InjectOptions{Priority: "L4"}) + } + + rc := &rtChild{ + id: opts.ID, agent: carrier, slot: carrier.rtSlot, + input: opts.Input, output: opts.Output, + } + + // ★ 成对划拨 rt 通道(这是"主 agent 把 rt 通道划给 rtchild"的**唯一**落点)。 + // + // 两侧都过 AssignRealtime —— 它校验: + // ① 通道声明了 CapRT("我能承载 rt 流") + // ② 目标 agent 是 rtchild(isRealtime) + // 任一不满足就拒划,且**拒绝路径无副作用**(不写 Owner)。 + // + // 顺序:先 Input 后 Output。失败时把已成的一半回滚,否则会留下 + // "input 划上了、output 没划上"的半残状态 —— 那正是本函数要防的形态。 + // 划拨前先记下原 Owner:失败回滚时**恢复原状**,而不是"归还为未分配"。 + // + // 为什么不能归还为 "":未分配意味着 routeInputByOwner 会把它当"没有归属" + // 兜底给主 agent —— 而主 agent **不持有 rt 能力**(用户裁定), + // 那等于把一条 rt 通道静默变成主 agent 的普通通道。 + prevOwner := map[string]string{} + if reg := a.io.ChannelRegistry(); reg != nil { + for _, n := range []string{opts.Input, opts.Output} { + if ch, ok := reg.Lookup(n); ok { + prevOwner[n] = ch.Owner + } + } + } + rollback := func() { + reg := a.io.ChannelRegistry() + if reg == nil { + return + } + for n, owner := range prevOwner { + _ = reg.Assign(n, owner, 0) + } + } + + if err := carrier.AssignRealtime(opts.Input); err != nil { + cancel() + rollback() + return "", fmt.Errorf("划入 inputch(rt) %q: %w", opts.Input, err) + } + if err := carrier.AssignRealtime(opts.Output); err != nil { + cancel() + rollback() // 把已经划上的 Input 也恢复回去,别留半残 + return "", fmt.Errorf("划入 outputch(rt) %q: %w", opts.Output, err) + } + + a.rtMu.Lock() + a.rtChildren[opts.ID] = rc + a.rtMu.Unlock() + + go carrier.rtLoop(rc) + + log.Printf("[rtchild] %s started (output=%s)", opts.ID, opts.Output) + return opts.ID, nil +} + +// StopRTChild 停止一个 rtchild 并清出登记表。 +// +// 契约 3:**无残余回灌** —— 槽里未处理的那条输入直接丢弃(它是过期状态, +// 回灌给父只会污染主管线,见计划 §七「插件那边的当前态默认不转交」)。 +func (a *Agent) StopRTChild(id string) error { + a.rtMu.Lock() + rc, ok := a.rtChildren[id] + if ok { + delete(a.rtChildren, id) + } + a.rtMu.Unlock() + if !ok { + return fmt.Errorf("rtchild %s 不存在", id) + } + rc.mu.Lock() + rc.stopped = true + rc.mu.Unlock() + + // 取消自己的 ctx ⇒ rtLoop 在下一个安全点退出、当前 ChatStream 被截断。 + rc.agent.cancel() + // 契约 3:无残余。清空槽(丢弃未处理输入,不转交)。 + _, _, _ = rc.slot.Take() + if a.io != nil { + if reg := a.io.ChannelRegistry(); reg != nil { + // ★ rt 通道**销毁后悬空**(用户裁定 2026-10-30),**不归还为空**。 + // + // 为什么不归还: + // · 主 agent **不持有** rt 能力 ⇒ 把它"归还为未分配"等于让一个 + // 没有 rtSlot 的 agent 成为潜在持有方(routeInputByOwner 的 + // Owner=="" 分支会把输入兜底给主 agent,静默改变语义) + // · 悬空(Owner 仍指向已销毁的 id)是**显式**状态:路由查不到 + // 该 owner ⇒ 走既有的"owner 无匹配 agent"兜底 + 留日志, + // 运维能一眼看出"这条 rt 通道的宿主没了" + // + // 换言之:悬空 = 可用日志诊断的坏状态;归还 = 静默改路由的坏状态。 + // 前者可查,后者不可查。 + // + // 只注销入站登记(那是本内核自己建的 child/,不是插件声明的 rt 通道)。 + reg.Unregister(inboundChannelName("rt/" + id)) + } + } + log.Printf("[rtchild] %s stopped", id) + return nil +} + +// StopRTChildren 停止全部 rtchild(父退出时必须;不留孤儿)。 +func (a *Agent) StopRTChildren() int { + a.rtMu.Lock() + all := make([]*rtChild, 0, len(a.rtChildren)) + for _, rc := range a.rtChildren { + all = append(all, rc) + } + a.rtChildren = map[string]*rtChild{} + a.rtMu.Unlock() + for _, rc := range all { + rc.mu.Lock() + rc.stopped = true + rc.mu.Unlock() + rc.agent.cancel() + _, _, _ = rc.slot.Take() + } + if len(all) > 0 { + log.Printf("[rtchild] stopped all %d instance(s)", len(all)) + } + return len(all) +} + +// PushRTInput 投递一条 rt 输入(**覆盖式**,返回本次 seq)。 +// +// 它是 rt 的唯一输入入口。与 `InjectInterruptTextOpts` 的关键差别: +// 后者进四级中断阶梯(会被 preemptCooldown=2s 降级为入队), +// 本函数直接覆盖单槽(最新者胜,永不排队)。见 plan.md 十一.三 B。 +func (a *Agent) PushRTInput(id, text string) (uint64, error) { + a.rtMu.Lock() + rc := a.rtChildren[id] + a.rtMu.Unlock() + if rc == nil { + return 0, fmt.Errorf("rtchild %s 不存在", id) + } + evt := &agentIO.InputEvent{ + Source: a.residentParentSource(), + Type: "text", + Payload: map[string]interface{}{"content": text}, + OutputChannel: rc.output, + } + seq := rc.slot.Push(evt) + + // ★ 契约 1 的**执行点**:新输入一到就取消当前轮。 + // + // 只写槽不取消是错的(初版就是这么写的,判据 TestRTChild_NewInputWinsWithoutRetry + // 当场抓住):rtLoop 正 block 在 rtRunOnce 的流里,根本不会回到循环顶部 + // 取新值 —— 表现就是“等它说完”,正是契约 1 明令禁止的。 + // + // ★ 但**工具执行期间不取消**(用户裁定 R5):副作用不可回滚, + // 中途掐断会让“决定执行但未执行”变成尴尬状态。此时只写槽, + // 工具跑完后下一轮自然用新输入。 + rc.mu.Lock() + cancel, critical := rc.curCancel, rc.critical + rc.mu.Unlock() + if cancel != nil { + if critical > 0 { + // 工具执行期:**故意不取消**(R5)。记一笔,让守卫可验证。 + rc.mu.Lock() + rc.suppressedCancels++ + rc.mu.Unlock() + } else { + cancel() + } + } + return seq, nil +} + +// StatsRTChild 返回一个 rtchild 的只读状态(诊断/判据用)。 +type RTChildStats struct { + ID string `json:"id"` + Rounds uint64 `json:"rounds"` + LastSeq uint64 `json:"last_seq"` + Dropped uint64 `json:"dropped"` + Pending bool `json:"pending"` + // SuppressedCancels 是因工具执行期而**故意未取消**的次数(R6 守卫的可观测凭据)。 + SuppressedCancels uint64 `json:"suppressed_cancels"` +} + +// RTChildStats 返回该实例的状态快照。 +func (a *Agent) RTChildStats(id string) (RTChildStats, bool) { + a.rtMu.Lock() + rc := a.rtChildren[id] + a.rtMu.Unlock() + if rc == nil { + return RTChildStats{}, false + } + rc.mu.Lock() + rounds, lastSeq, suppressed := rc.round, rc.lastSeq, rc.suppressedCancels + rc.mu.Unlock() + return RTChildStats{ + ID: id, Rounds: rounds, LastSeq: lastSeq, + Dropped: rc.slot.Dropped(), Pending: rc.slot.Pending(), + SuppressedCancels: suppressed, + }, true +} + +// ── rtLoop:rtchild 的执行循环(R3–R6 的载体) ── +// +// 形状由契约决定(plan.md 十一.二): +// +// for { +// block on Wake ← 契约 4:待机零消耗,不轮询 +// Take 最新输入(单槽,清空) ← 契约 3:不吃队列 +// 跑一轮: +// ChatStream(**流式**,不是 Chat)← 契约 2:不攒整段 +// 每个 chunk 立刻写 outputch(rt) ← 契约 2:连续输出 +// 工具调用期间不打断 ← R5「除 toolcall 期间都可打断」 +// 循环中被新输入打断 ⇒本轮的 ChatStream 被 cancel ⇒就地截断 +// } +// +// ## 为什么不用 step 状态机(task.go 的 runTaskSteps) +// +// 主 agent 的 runTaskSteps 承载 7 个 Step + 挂起/恢复/中断栈,那是为 +// 「可抢占、现场可恢复」设计的。rt 的规则更窄(plan.md 十一.三 A): +// +// ChatStream 期间 ⇒ 可打断(cancel 自己的 ctx) +// 工具执行期间 ⇒ 不可打断(临界区,与 StepToolExec 同义) +// +// 这里用**明确的临界区标记**实现后一条(rtBusy 计数),不用状态机: +// 少一层抽象,且"当前能不能打断"这个语义在代码里一眼可见。 +func (a *Agent) rtLoop(rc *rtChild) { + defer func() { + if r := recover(); r != nil { + log.Printf("[rtchild] %s loop panic recovered: %v\n%s", rc.id, r, debug.Stack()) + } + }() + + for { + select { + case <-a.ctx.Done(): // 父退出 或 本实例被 Stop + return + case <-rc.slot.Wake(): + } + + evt, seq, ok := rc.slot.Take() + if !ok { + continue // 信号先于数据,或已被取走:无副作用 + } + a.rtRunOnce(rc, evt, seq) + } +} + +// rtRunOnce 跑一轮:流式生成 + 可选工具,任一轮次都受"新输入覆盖"影响。 +func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) { + input, _ := evt.Payload["content"].(string) + if strings.TrimSpace(input) == "" { + return + } + + rc.mu.Lock() + rc.round++ + round := rc.round + rc.lastSeq = seq + rc.mu.Unlock() + + // 上下文 = 跨轮累积的历史 + 本轮输入,按窗口裁(见 buildRTContext)。 + // + // history 是 rtChild.history 的**拷贝**:本轮跑的时候(可能几十秒)里, + // 别的轮次不会并发改它(rtLoop 单 goroutine 串行),但拷贝能避免 + // append 时共享底层数组导致的意外覆盖。 + rc.mu.Lock() + history := append([]agentAPI.Message(nil), rc.history...) + rc.mu.Unlock() + msgs := a.buildRTContext(rc, history, input) + tools := a.buildToolDefs() + // 裁掉 output_send__*:rtchild 的输出走 outputch(rt),不走主动输出门 + // (那张门会要求模型显式点名通道,与"流式连续输出"是两套语义)。 + tools = rtStripOutputTools(tools) + + // ★ roundCtx 作用域是**整轮**(含工具执行),不是单次 LLM 调用。 + // + // 这是 R5/R6 边界能成立的**结构前提**,也是我第一版写错的地方: + // 初版把 cancel 装在 rtStream 内部、退出时清空 ⇒ 工具执行期间 + // rc.curCancel 已经是 nil ⇒ PushRTInput 里的 `critical == 0` 判断是**死代码**, + // 判据只是碰巧通过(工具没被打断是因为"没人取消",不是因为"守卫生效")。 + // + // 现在:cancel 挂在整轮上、只有轮次结束才清 ⇒ 工具期间 curCancel 仍非 nil, + // 于是 critical 守卫**真的**在决定"这次取消要不要发生"。 + // 这一条已被变异验证:去掉 critical 判断后 TestRTChild_NotInterruptibleDuringToolExec 变红。 + roundCtx, roundCancel := context.WithCancel(a.ctx) + defer roundCancel() + rc.mu.Lock() + rc.curCancel = roundCancel + rc.mu.Unlock() + defer func() { + rc.mu.Lock() + rc.curCancel = nil + rc.mu.Unlock() + }() + + turns := 0 + for { + // 每轮进 LLM 前看一看:若这期间已有更新的输入进了槽,本轮就不必再跑了 + // (新的一轮马上会用那条输入重开)。这是契约 1 在循环内的落点。 + if a.rtSuperseded(rc, seq) { + log.Printf("[rtchild] %s round %d superseded by newer input before LLM", rc.id, round) + return + } + + resp, ok := a.rtStream(roundCtx, rc, round, msgs, tools) + if !ok { + // 被打断或被父停:就地截断,不重试(契约 1/2)。 + // + // ★ 已产出的部分**写回 history**:它确实说出口了(用户听到了), + // 下一轮不该假装没发生过 —— 否则模型会重复同一句话。 + a.rtAppendHistory(rc, agentAPI.Message{Role: "assistant", Content: respContentOr(resp)}) + return + } + + if len(resp.ToolCalls) == 0 { + // 本轮正常说完:把 user 输入与模型产出都写进 history。 + a.rtAppendHistory(rc, + agentAPI.Message{Role: "user", Content: input}, + agentAPI.Message{Role: "assistant", Content: resp.Content}) + return + } + // 工具批数上限(0 = 不限)。rt 是常驻对话,默认不限; + // 给了上限是为了让调用方能约束"一次插话最多查几步"。 + if a.rtMaxTools > 0 && turns >= a.rtMaxTools { + log.Printf("[rtchild] %s round %d reached max tool turns %d", rc.id, round, a.rtMaxTools) + return + } + turns++ + + batchStart := len(msgs) + msgs = append(msgs, agentAPI.Message{ + Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls, + }) + for _, tc := range resp.ToolCalls { + // ★ 工具执行期间**不可打断**(R5 / plan.md 十一.三 A 的临界区): + // 副作用不可回滚,打断只会让"决定执行但未执行"变成尴尬状态。 + a.rtEnterCritical(rc) + out := a.executeToolCallOutcome(tc, rc.output) + a.rtExitCritical(rc) + + log.Printf("[rtchild] %s round %d tool %s -> %d bytes", + rc.id, round, tc.Name, len(out.Text)) + msgs = append(msgs, agentAPI.Message{ + Role: "tool", ToolCallID: tc.ID, Content: out.Text, + }) + } + // 这一批(assistant(tool_calls) + 配对 tool 结果)是**不可分割的一组**, + // 整组写回 history:切在中间上游会直接 400(见 rtTrimToWindow 注释)。 + a.rtAppendHistory(rc, msgs[batchStart:]...) + } +} + +// rtAppendHistory 把若干条消息追加进跨轮历史(唯一写入点)。 +func (a *Agent) rtAppendHistory(rc *rtChild, msgs ...agentAPI.Message) { + if len(msgs) == 0 { + return + } + rc.mu.Lock() + rc.history = append(rc.history, msgs...) + rc.mu.Unlock() +} + +// respContentOr 取响应的正文(resp 可能为 nil)。 +func respContentOr(resp *agentAPI.CompletionResponse) string { + if resp == nil { + return "" + } + return resp.Content +} + +// rtStream 跑一次**流式**生成,把每个增量立刻写给 outputch(rt)。 +// +// 返回 ok=false 表示被打断(ctx 被 cancel)——调用方必须**就地截断、不重试** +// (契约 2;也对应 plan.md 十一.三 C 的"ChatStream 被 cancel 即截断")。 +func (a *Agent) rtStream(ctx context.Context, rc *rtChild, round uint64, msgs []agentAPI.Message, tools []interface{}) (*agentAPI.CompletionResponse, bool) { + req := &agentAPI.CompletionRequest{ + Messages: msgs, + MaxTokens: 4096, + Tools: tools, + ToolChoice: "auto", + DisableThinking: true, // rt 不等思考链:首字延迟是硬指标 + } + + // ctx 由调用方(rtRunOnce)按**整轮**提供,本函数不再自己派生: + // 否则工具执行期间 curCancel 已被清空,R6 的临界区守卫会变成死代码 + // (见 rtRunOnce 里 roundCtx 的注释)。 + ch, err := a.provider.ChatStream(ctx, req) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, false + } + log.Printf("[rtchild] %s round %d stream connect failed: %v", rc.id, round, err) + return nil, false + } + + resp := &agentAPI.CompletionResponse{} + var acc []*agentAPI.ToolCall + for { + select { + case <-ctx.Done(): + return nil, false // 被打断:就地截断 + case chunk, ok := <-ch: + if !ok { + resp.ToolCalls = rtFlushToolCalls(acc) + return resp, true + } + if chunk.Content != "" { + // 契约 2:**每个增量立刻外发**,不攒整段。 + // 只给文本(用户裁定 R6):怎么展示是插件的活。 + a.rtEmit(rc, chunk.Content) + resp.Content += chunk.Content + } + if chunk.ToolCall != nil { + acc = append(acc, chunk.ToolCall) + } + for i := range chunk.ToolCalls { + acc = append(acc, &chunk.ToolCalls[i]) + } + if chunk.Done { + resp.ToolCalls = rtFlushToolCalls(acc) + return resp, true + } + } + } +} + +// rtFlushToolCalls 把累积的 tool call 过滤掉 nil。 +func rtFlushToolCalls(acc []*agentAPI.ToolCall) []agentAPI.ToolCall { + out := make([]agentAPI.ToolCall, 0, len(acc)) + for _, tc := range acc { + if tc != nil && tc.Name != "" { + out = append(out, *tc) + } + } + return out +} + +// rtEmit 把一个文本增量写进 outputch(rt)。 +// +// ★ 内核只产出**文本**(R6)。这里不判断设备是否 CapAudio,也不做 TTS: +// 插件拿到 text 后怎么呈现(文字/语音/字幕)完全是它的事。 +func (a *Agent) rtEmit(rc *rtChild, text string) { + if text == "" { + return + } + if a.io == nil { + return + } + dev := a.io.GetDevice(rc.output) + if dev == nil { + return + } + if _, err := dev.Execute(rc.output, map[string]interface{}{"text": text}); err != nil { + log.Printf("[rtchild] %s emit to %s failed: %v", rc.id, rc.output, err) + } +} + +// rtEnterCritical / rtExitCritical 标记"正处于不可打断的临界区"(工具执行)。 +// +// 用计数而非 bool:一轮里可能串行执行多个工具,用 bool 会在第二个工具 +// 开始时就把标记误清。这与主 agent 的 StepToolExec 是同一个语义 +// (task.go:56「副作用不可回滚,执行中不是安全点」)。 +func (a *Agent) rtEnterCritical(rc *rtChild) { + rc.mu.Lock() + rc.critical++ + rc.mu.Unlock() +} + +func (a *Agent) rtExitCritical(rc *rtChild) { + rc.mu.Lock() + if rc.critical > 0 { + rc.critical-- + } + rc.mu.Unlock() +} + +// rtSuperseded 报告"槽里已有更新的输入"(本轮已过期,不必再跑)。 +func (a *Agent) rtSuperseded(rc *rtChild, seq uint64) bool { + return rc.slot.Seq() > seq +} + +// buildRTContext 装配 rtchild 的上下文:**普通上下文 + 上下文窗口**。 +// +// ## 就是普通对话历史,没有别的结构 +// +// 用户 2026-10-30 裁定:rtchild 的上下文模型 = **普通上下文 + 上下文窗口**。 +// 所以这里没有"页"、没有"锚点"、没有"自降级阶梯" —— 那些是我先前把问题 +// 设计复杂了(plan.md 十一.四 空白 1 的初版)。rt 的上下文就是: +// +// system 提示词 + 正常累积的消息序列(user / assistant / tool) +// +// 与 `runChildTask`(普通子)的局部 `msgs` 完全同构 —— 区别只在于 rtchild +// 跨轮保留它(一次通话是一条连续对话),且每轮进模型前按窗口裁一次。 +// +// ## 唯一的机制:窗口裁剪 +// +// 裁剪单位是**消息**,但 tool_call 与其 tool 结果**必须成组进出**: +// assistant(tool_calls=[c1..cn]) 之后紧跟的那 n 条 tool 消息是不可分割的, +// 切在中间上游会直接 400(insufficient tool messages,见 context.go:102 的 +// 生产事故记录)。所以按「组」从**最旧**开始丢,system 永不参与。 +// ★★ 裁剪只作用于**本轮的视图**,绝不修改 history(用户 2026-10-30 裁定): +// +// 「老内容不再进入 LLM,但是**原文保留**,rtchild 回收时依旧向主 agent +// 展示全部上下文。」 +// +// 这两件事必须分开,否则会静默丢数据: +// +// 视图(进入 LLM 的东西) ⇒ 按窗口裁,旧内容不再进模型 +// 存储(rtChild.history) ⇒ **全文永不丢弃** +// +// 实现上靠"copy 一份再裁"保证:history 是入参的拷贝(调用方已 append 出 +// 新切片),rtTrimToWindow 只对这个临时切片动手。任何"就地裁 history"的 +// 改法都会让回收时的上下文残缺 —— 判据 TestRTChild_HistoryIsNeverTrimmed 钉它。 +func (a *Agent) buildRTContext(rc *rtChild, history []agentAPI.Message, input string) []agentAPI.Message { + msgs := make([]agentAPI.Message, 0, len(history)+2) + msgs = append(msgs, agentAPI.Message{Role: "system", Content: rc.agent.systemPrompt}) + msgs = append(msgs, history...) + msgs = append(msgs, agentAPI.Message{Role: "user", Content: input}) + // 只裁这份临时视图;history 本身保持完整。 + return rtTrimToWindow(msgs, rc.agent.contextWindowTokens()) +} + +// rtTrimToWindow 把消息序列裁到窗口内:**从最旧的整组开始丢**。 +// +// 「组」= 一条 assistant(tool_calls) 加上紧随其后的全部 tool 消息。 +// 其余消息各自成组。这样裁剪永远不会把 tool_call 和它的结果拆开。 +// +// 保留顺序: +// 1. msgs[0](system)永不裁 +// 2. 最后一条(本轮 user 输入)永远保留 —— 它是这一轮要回答的东西 +// 3. 其余按组从旧到新丢,直到总量进窗口 +func rtTrimToWindow(msgs []agentAPI.Message, window int) []agentAPI.Message { + if len(msgs) <= 2 || window <= 0 { + return msgs + } + + // ① 先按「组」切分(不含首尾两条)。 + type group struct{ msgs []agentAPI.Message } + var groups []group + for i := 1; i < len(msgs)-1; i++ { + m := msgs[i] + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + g := group{msgs: []agentAPI.Message{m}} + // 紧跟的 tool 消息属于同一组(配对不可拆)。 + for i+1 < len(msgs)-1 && msgs[i+1].Role == "tool" { + i++ + g.msgs = append(g.msgs, msgs[i]) + } + groups = append(groups, g) + continue + } + groups = append(groups, group{msgs: []agentAPI.Message{m}}) + } + + // ② 总量估算 + 从旧到新丢组。 + total := EstimateTokens(msgs[0].Content) + EstimateTokens(msgs[len(msgs)-1].Content) + for _, g := range groups { + for _, m := range g.msgs { + total += rtMsgTokens(m) + } + } + drop := 0 + for drop < len(groups) && total > window { + for _, m := range groups[drop].msgs { + total -= rtMsgTokens(m) + } + drop++ + } + + // ③ 组装:system + 剩余组 + 本轮输入。 + out := make([]agentAPI.Message, 0, len(msgs)-drop) + out = append(out, msgs[0]) + for _, g := range groups[drop:] { + out = append(out, g.msgs...) + } + out = append(out, msgs[len(msgs)-1]) + return out +} + +// rtMsgTokens 估算一条消息的 token(正文 + 工具调用参数 + 工具结果)。 +func rtMsgTokens(m agentAPI.Message) int { + n := EstimateTokens(m.Content) + for _, tc := range m.ToolCalls { + n += EstimateTokens(tc.Name) + if tc.Arguments != nil { + for k, v := range tc.Arguments { + n += EstimateTokens(k) + EstimateTokens(fmt.Sprintf("%v", v)) + } + } + } + return n + 8 // 角色/结构开销 +} + +// rtStripOutputTools 从工具表里剥掉 output_send__* 与 output_list_channels。 +// +// 理由:rtchild 的输出走 outputch(rt) 的流式管线,与"模型显式点名通道调用 +// output_send__X"是两套语义。让两套同时存在会出现"模型选了 output_send__voice +// 于是那条消息不经过流式管线"的分叉 —— 用户听到的与界面上看到的不一致。 +func rtStripOutputTools(tools []interface{}) []interface{} { + out := make([]interface{}, 0, len(tools)) + for _, t := range tools { + m, ok := t.(map[string]interface{}) + if !ok { + out = append(out, t) + continue + } + fn, ok := m["function"].(map[string]interface{}) + if !ok { + out = append(out, t) + continue + } + name, _ := fn["name"].(string) + if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" { + continue + } + out = append(out, t) + } + return out +} + +// ── 回收时的上下文交接(用户裁定 2026-10-30) ── +// +// 「老内容不再进入 LLM,但是**原文保留**,rtchild 回收时依旧向主 agent +// 展示全部上下文。」 +// +// 所以 rtchild 的生命周期与普通子是**反的**: +// +// 普通子 spawn_child —— 跑完把最终答案交回父(局部 msgs 丢弃) +// rtchild —— 回收时把**完整对话**交回父(一条通话的全部原文) +// +// 为什么 rt 要交全文而不是"最终答案":通话没有"最终答案"这种东西。 +// 一条 10 分钟通话由几百轮构成,任何单条摘要都会丢掉"用户到底说过什么"。 +// 主 agent 需要的是能复盘的事实,而不是 rtchild 的自我总结(后者已由 +// 模型在对话里说过一次了)。 + +// FormatRTTranscript 把完整上下文渲染成给主 agent 看的纯文本。 +// +// 渲染口径(与 formatMergedTimeline 的取向一致:**可复盘**优先): +// +// [user] 用户的每一句原话 +// [assistant] 模型说出口的每一句 +// [tool] 工具调用(名字 + 参数摘要)与结果(截断保护见下) +// +// 工具结果**截断到 rtTranscriptToolMaxRunes**:它们是上下文里体积的大头 +// (实测单条可达几十万 token,见 toolresult_budget.go 的记录), +// 而复盘需要的是"调了什么、拿到什么结论",不是整页 HTML。 +// 其余消息(user/assistant)**逐字保留**。 +func FormatRTTranscript(msgs []agentAPI.Message) string { + if len(msgs) == 0 { + return "" + } + var sb strings.Builder + for _, m := range msgs { + switch m.Role { + case "assistant": + if strings.TrimSpace(m.Content) != "" { + fmt.Fprintf(&sb, "[assistant] %s\n", strings.TrimSpace(m.Content)) + } + for _, tc := range m.ToolCalls { + fmt.Fprintf(&sb, "[tool_call] %s %s\n", tc.Name, rtArgsSummary(tc.Arguments)) + } + case "tool": + fmt.Fprintf(&sb, "[tool_result] %s\n", truncateStr(strings.TrimSpace(m.Content), rtTranscriptToolMaxRunes)) + default: // user 及其它 + if strings.TrimSpace(m.Content) != "" { + fmt.Fprintf(&sb, "[%s] %s\n", m.Role, strings.TrimSpace(m.Content)) + } + } + } + return sb.String() +} + +// rtArgsSummary 把工具参数压成一行(用于复盘,不用于重放)。 +func rtArgsSummary(args map[string]interface{}) string { + if len(args) == 0 { + return "" + } + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) // 稳定输出:判据与人工复盘都需要可复现 + var parts []string + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, truncateStr(fmt.Sprintf("%v", args[k]), 80))) + } + return strings.Join(parts, " ") +} + +// rtTranscriptToolMaxRunes 是回收交接时**单个工具结果**的保留上限(rune)。 +// +// 只限工具结果:它们是体积大头且复盘只需结论。user/assistant 的原话逐字保留 +// ——「展示全部上下文」的口径是对**对话**成立,不包括把整页网页塞回来。 +const rtTranscriptToolMaxRunes = 500 + +// CloseRTChild 是 rtchild 的**回收**入口:把完整上下文交回主 agent,然后停止。 +// +// ## 交接走「contextfull 式中断」,不塞正文(用户裁定 2026-10-30) +// +// 用户明确:「交还是类似驻留式子 agent 的 contextfull 中断,让主 agent +// **自行选择**保留的内容。」 +// +// 所以回收**不**把通话原文推进主 agent 的上下文,而只推一条 L4 中断: +// +// 内核事件:实时子 agent 已回收,共 N 条消息(约 M 字符)。 +// 如需复盘,可调用 rt_transcript 取回原文并按需选取保留内容。 +// +// 这与驻留子的 contextfull 是**同一套协议**("推信号 + 拉状态"): +// - 推:raiseVector 出一条 L4,只带规模,不带正文 +// - 拉:主 agent 调 RTTranscript 取全文,自己决定留哪些 +// +// 为什么必须由主 agent 选:通话原文动辄几十万字符,全灌进去会直接顶爆 +// 它的上下文;而"哪些值得留"是**业务判断**(用户说了什么要紧的事), +// 内核无从替它决定 —— 与 resident-subagent-design.md §7 "原语在内核、 +// 决策在父的模型"是同一条原则。 +// +// 返回交接了多少条消息(0 表示没有历史)。 +func (a *Agent) CloseRTChild(id string) (int, error) { + a.rtMu.Lock() + rc := a.rtChildren[id] + a.rtMu.Unlock() + if rc == nil { + return 0, fmt.Errorf("rtchild %s 不存在", id) + } + + // ① 先取全文规模(此时实例还活着,history 完整)。 + transcript, _ := a.RTTranscript(id) + text := FormatRTTranscript(transcript) + n, chars := len(transcript), len(text) + + // ② 再停(顺序不能反)。 + if err := a.StopRTChild(id); err != nil { + return 0, err + } + if n == 0 { + return 0, nil // 没有历史:不推中断,别拿空事件烦主 agent + } + + // ③ 推 L4 信号(contextfull 同款),正文留在 RTTranscript 里等按需拉取。 + // + // ★ 实例已出登记表,但原文必须仍可取 —— 所以把 transcript 存进 + // a.rtTranscripts 留档(见 RTTranscript 的读取顺序)。 + a.rtMu.Lock() + if a.rtClosedTranscripts == nil { + a.rtClosedTranscripts = map[string][]agentAPI.Message{} + } + a.rtClosedTranscripts[id] = transcript + a.rtMu.Unlock() + + a.raiseVector(VectorRTChildClosed, vectorArgs{RTID: id, RTCount: n, RTChars: chars}) + log.Printf("[rtchild] %s closed: %d messages (%d chars) kept for on-demand transcript", id, n, chars) + return n, nil +} + +// RTTranscript 返回一个 rtchild 的**完整**上下文原文(不过窗口裁剪)。 +// +// 读顺序:先查**已回收留档**(CloseRTChild 保存的),再查在用实例的 history。 +// 后者保证"通话进行中也能看",前者保证"回收之后仍能取" —— 两者缺一, +// 「回收时向主 agent 展示全部上下文」就会在某些时序下拿不到原文。 +func (a *Agent) RTTranscript(id string) ([]agentAPI.Message, bool) { + a.rtMu.Lock() + archived, hasArchived := a.rtClosedTranscripts[id] + rc := a.rtChildren[id] + a.rtMu.Unlock() + + if hasArchived { + out := make([]agentAPI.Message, len(archived)) + copy(out, archived) + return out, true + } + if rc == nil { + return nil, false + } + rc.mu.Lock() + defer rc.mu.Unlock() + // 深拷贝:调用方可能长期持有,不能被后续轮次 append 影响。 + out := make([]agentAPI.Message, len(rc.history)) + copy(out, rc.history) + return out, true +} + +// ── rt_transcript:主 agent 按需拉取 rtchild 的完整上下文 ── + +// executeRTTranscript 是主 agent 侧的 `rt_transcript` 工具实现。 +// +// 它是 CloseRTChild 那一半"拉状态"的入口 —— 交接协议是「推信号 + 拉状态」: +// +// 推:回收时一条 L4("已回收,共 N 条,要复盘请调 rt_transcript") +// 拉:本工具,返回原文,**由主 agent 自己决定保留哪些** +// +// 为什么不让内核直接选:见 CloseRTChild 的文档 —— "哪些值得留"是业务判断, +// 内核无从替父决定(与 resident-subagent-design.md §7 同一原则)。 +// +// 参数: +// +// id rtchild 标识(必填) +// offset 从第几条消息开始(0 = 从头)—— 让父能分段读大记录 +// limit 最多返回多少条(0 = 默认 rtTranscriptDefaultLimit) +// format "text"(默认,给人/模型读的渲染)/ "raw"(原始消息 JSON 风格逐条) +func (a *Agent) executeRTTranscript(tc agentAPI.ToolCall) string { + id := strArg(tc, "id") + if id == "" { + return "请提供 id 参数(rtchild 标识)" + } + msgs, ok := a.RTTranscript(id) + if !ok { + return fmt.Sprintf("rtchild %s 不存在,且没有已回收的留档", id) + } + + offset := rtIntArg(tc, "offset", 0) + limit := rtIntArg(tc, "limit", rtTranscriptDefaultLimit) + if limit <= 0 { + limit = rtTranscriptDefaultLimit + } + if offset >= len(msgs) { + return fmt.Sprintf("rtchild %s 共 %d 条消息,offset=%d 越界", id, len(msgs), offset) + } + end := offset + limit + if end > len(msgs) { + end = len(msgs) + } + page := msgs[offset:end] + + if strings.EqualFold(strArg(tc, "format"), "raw") { + b, err := json.MarshalIndent(page, "", " ") + if err != nil { + return fmt.Sprintf("序列化失败:%v", err) + } + return fmt.Sprintf("【rtchild %s 原文 %d-%d / 共 %d 条】\n%s", id, offset, end, len(msgs), string(b)) + } + + return fmt.Sprintf("【rtchild %s 上下文 %d-%d / 共 %d 条】\n%s", + id, offset, end, len(msgs), FormatRTTranscript(page)) +} + +// rtIntArg 取一个整数参数,**同时兼容 JSON 数值与 Go int**。 +// +// 命名带 rt 前缀:core 包里 resident_tools.go 已有同签名的 intArg(只认出参 +// 不存在时回落零值),两者语义相近但默认值策略不同,混用会静默取错默认。 +// +// ★ 为什么必须兼容两种类型(真实缺陷,初版只认 float64): +// 工具参数经模型 JSON 过来是 float64,但**同一份实现也会被其它 Go 调用方 +// 直接调**(比如测试、未来的插件内嵌调用),它们传的是 int。只认 float64 +// 时 int 会静默落回默认值 —— 表现为"分页参数填了却不生效",而且不报错。 +func rtIntArg(tc agentAPI.ToolCall, key string, def int) int { + v, ok := tc.Arguments[key] + if !ok || v == nil { + return def + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + default: + return def + } +} + +// rtTranscriptDefaultLimit 是 rt_transcript 一次返回的默认消息条数。 +// +// 不一次全给:一条 10 分钟通话有几百条,整段返回会把调用方自己的上下文顶爆。 +// 分段读 + offset 续读,让主 agent 按需决定读到哪儿。 +const rtTranscriptDefaultLimit int = 40 + +// ── rt_children:主 agent 的 rtchild 控制面(单工具多动作) ── + +// executeRTChildren 是父对 rtchild 的控制面。 +// +// 形状刻意与 `resident_agents` 同构(单工具多动作),但**动作集更小**: +// rtchild 没有任务队列、没有残余回灌、没有 contextfull 三处置 +// (它自己的上下文按窗口裁,交接走 contextfull 式中断,见 CloseRTChild)。 +// +// 所以只有四个动作: +// +// list 列出全部 rtchild 与其规模 +// create 创建(**必须成对**给 input + output) +// send 向 rtchild 注入一条新输入(= 打断当前轮,契约 1) +// stop 回收(先推交接信号,再停;正文由 rt_transcript 按需拉) +func (a *Agent) executeRTChildren(tc agentAPI.ToolCall) string { + switch action := strArg(tc, "action"); action { + case "", "list": + a.rtMu.Lock() + ids := make([]string, 0, len(a.rtChildren)) + for id := range a.rtChildren { + ids = append(ids, id) + } + a.rtMu.Unlock() + if len(ids) == 0 { + return "当前没有实时子 agent(rtchild)。" + } + sort.Strings(ids) + var b strings.Builder + fmt.Fprintf(&b, "实时子 agent(rtchild,%d 个):", len(ids)) + for _, id := range ids { + if st, ok := a.RTChildStats(id); ok { + fmt.Fprintf(&b, "\n - %s 轮次=%d 最新输入seq=%d 覆盖丢弃=%d", + id, st.Rounds, st.LastSeq, st.Dropped) + if st.Pending { + b.WriteString(" ⏳ 有待处理输入") + } + } + } + b.WriteString("\n(rt 通道:新输入会**打断**当前轮,不排队;回收后原文可用 rt_transcript 取回)") + return b.String() + + case "create": + id := strArg(tc, "id") + input := strArg(tc, "inputch") + output := strArg(tc, "outputch") + if input == "" || output == "" { + // 提示要**可操作**:直接列出当前登记表里哪些通道带 rt 能力。 + return fmt.Sprintf("创建 rtchild 必须成对给出 inputch 与 outputch(当前 inputch=%q outputch=%q)。\n%s", + input, output, a.listRTChannels()) + } + got, err := a.SpawnRTChild(RTChildOptions{ + ID: id, + Input: input, + Output: output, + SystemPrompt: strArg(tc, "system_prompt"), + MaxTools: rtIntArg(tc, "max_tools", 0), + }) + if err != nil { + return fmt.Sprintf("创建 rtchild 失败: %v\n%s", err, a.listRTChannels()) + } + return fmt.Sprintf("已创建实时子 agent: %s(inputch=%s,outputch=%s)。"+ + "现在往 %s 投递的输入会**立即打断**它的当前轮;它说出的文本会写进 %s。", + got, input, output, input, output) + + case "send": + id := strArg(tc, "id") + text := strArg(tc, "text") + if id == "" || text == "" { + return "send 需要 id 与 text" + } + seq, err := a.PushRTInput(id, text) + if err != nil { + return fmt.Sprintf("发送失败: %v", err) + } + return fmt.Sprintf("已投递(seq=%d)。这是**打断式**输入:rtchild 当前的轮次会被取消,"+ + "它会用这条新输入重开一轮。", seq) + + case "stop": + id := strArg(tc, "id") + if id == "" { + return "stop 需要 id" + } + n, err := a.CloseRTChild(id) + if err != nil { + return fmt.Sprintf("回收失败: %v", err) + } + if n == 0 { + return fmt.Sprintf("已回收 rtchild %s(本次通话没有上下文需要交接)。", id) + } + return fmt.Sprintf("已回收 rtchild %s。本次通话共 %d 条消息,已作为一条中断交给你;"+ + "**是否保留、保留哪些由你决定** —— 用 rt_transcript(id=%s, offset, limit) 分段取回,不要把整段塞进上下文。", + id, n, id) + + default: + return fmt.Sprintf("未知 action %q(可用:list / create / send / stop)", action) + } +} + +// listRTChannels 列出登记表里带 CapRT 的通道,供 create 失败的提示用。 +// +// 为什么要把这个信息塞进错误里:模型看不到登记表,只说"通道未注册/未声明 rt" +// 它就无从修正 —— 会反复重试同一个错名字(本仓有过"4 次 files_write 失败" +// 的先例)。直接列出可选值,一次就能改对。 +func (a *Agent) listRTChannels() string { + if a.io == nil { + return "" + } + reg := a.io.ChannelRegistry() + if reg == nil { + return "" + } + var names []string + for _, ch := range reg.List() { + if ch.Capabilities.Supports(agentIO.CapRT) { + owner := ch.Owner + if owner == "" { + owner = "未分配" + } + names = append(names, fmt.Sprintf("%s(归属:%s)", ch.Name, owner)) + } + } + if len(names) == 0 { + return "当前没有任何 **rt 通道**(需要插件用 ChannelDef{Realtime:true} 注册 inputch/outputch)。" + } + return "当前可用的 rt 通道:" + strings.Join(names, "、") +} diff --git a/internal/agent/core/rtchild_r10_test.go b/internal/agent/core/rtchild_r10_test.go new file mode 100644 index 00000000..f62a2726 --- /dev/null +++ b/internal/agent/core/rtchild_r10_test.go @@ -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 diff --git a/internal/agent/core/rtchild_r11_test.go b/internal/agent/core/rtchild_r11_test.go new file mode 100644 index 00000000..dec7b8fb --- /dev/null +++ b/internal/agent/core/rtchild_r11_test.go @@ -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) + } +} diff --git a/internal/agent/core/rtchild_r1_test.go b/internal/agent/core/rtchild_r1_test.go new file mode 100644 index 00000000..12c17f00 --- /dev/null +++ b/internal/agent/core/rtchild_r1_test.go @@ -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) + } +} diff --git a/internal/agent/core/rtchild_r2_test.go b/internal/agent/core/rtchild_r2_test.go new file mode 100644 index 00000000..84ecd001 --- /dev/null +++ b/internal/agent/core/rtchild_r2_test.go @@ -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 会永久阻塞(待机醒不来)") + } +} diff --git a/internal/agent/core/rtchild_r3_test.go b/internal/agent/core/rtchild_r3_test.go new file mode 100644 index 00000000..20bc8d9e --- /dev/null +++ b/internal/agent/core/rtchild_r3_test.go @@ -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 默认提示词缺少【实时通话】段") + } +} diff --git a/internal/agent/core/rtchild_r7_test.go b/internal/agent/core/rtchild_r7_test.go new file mode 100644 index 00000000..6cb3e270 --- /dev/null +++ b/internal/agent/core/rtchild_r7_test.go @@ -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]) + } +} diff --git a/internal/agent/core/rtchild_r7b_test.go b/internal/agent/core/rtchild_r7b_test.go new file mode 100644 index 00000000..d694e251 --- /dev/null +++ b/internal/agent/core/rtchild_r7b_test.go @@ -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/), + // 且 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: + } +} diff --git a/internal/agent/core/rtchild_route_test.go b/internal/agent/core/rtchild_route_test.go new file mode 100644 index 00000000..4885f3e0 --- /dev/null +++ b/internal/agent/core/rtchild_route_test.go @@ -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 —— 会形成无界自我复制") + } +} diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go index d0e3c421..62e4c1a6 100644 --- a/internal/agent/core/spawn.go +++ b/internal/agent/core/spawn.go @@ -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 +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index a756179f..c2c7e593 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -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 间回退,并把同源瞬时错误重试一次。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 990f2e12..2656c77e 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -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": diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 62499a9a..11a3a41b 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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")) } diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index da1bf3d3..a132671a 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -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) } diff --git a/internal/agent/io/inputch.go b/internal/agent/io/inputch.go index 1483eb9e..79b19939 100644 --- a/internal/agent/io/inputch.go +++ b/internal/agent/io/inputch.go @@ -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 +} diff --git a/internal/config/registry.go b/internal/config/registry.go index 4b50f190..f698102a 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -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 分钟的阈值意味着 diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index 1ac73868..b32c53a6 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -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 工具用。 diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index 67bfe8da..eafac107 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -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) }) diff --git a/internal/plugin/proc/corehandler_inject.go b/internal/plugin/proc/corehandler_inject.go index 95d8f532..e4569de0 100644 --- a/internal/plugin/proc/corehandler_inject.go +++ b/internal/plugin/proc/corehandler_inject.go @@ -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 // ---- 多模态注入 ---- diff --git a/internal/plugin/proc/inject_opts_test.go b/internal/plugin/proc/inject_opts_test.go new file mode 100644 index 00000000..a3b707ec --- /dev/null +++ b/internal/plugin/proc/inject_opts_test.go @@ -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) + } +} diff --git a/internal/plugin/proc/priority_clamp_test.go b/internal/plugin/proc/priority_clamp_test.go index 11633f4f..17d2a801 100644 --- a/internal/plugin/proc/priority_clamp_test.go +++ b/internal/plugin/proc/priority_clamp_test.go @@ -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) } } diff --git a/internal/plugin/proc/shm_test.go b/internal/plugin/proc/shm_test.go index 00a7fee1..15579ffb 100644 --- a/internal/plugin/proc/shm_test.go +++ b/internal/plugin/proc/shm_test.go @@ -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) diff --git a/internal/plugin/realtime_channel_test.go b/internal/plugin/realtime_channel_test.go new file mode 100644 index 00000000..869e9558 --- /dev/null +++ b/internal/plugin/realtime_channel_test.go @@ -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") + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index f500c416..79f488c8 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -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), diff --git a/internal/plugins/rtchild_external_e2e_test.go b/internal/plugins/rtchild_external_e2e_test.go new file mode 100644 index 00000000..e2802263 --- /dev/null +++ b/internal/plugins/rtchild_external_e2e_test.go @@ -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= 指定") + 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()) + } +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 1f638c1f..83f51040 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -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.