feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态

## rtchild(R1–R13)

第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。

- R1  CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2  rtSlot 单槽覆盖(latest-wins,从不排队)
- R3  独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4  ChatStream 逐块 → outputch(rt).Execute(text)
- R5  打断 = 原地截断,不重试
- R6  工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7  普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9  CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)

刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。

## 四类 agent 各自可配模型源

core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。

★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。

## 驻留子残余任务四态 + 通道三态

残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。

通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。

一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。

## 修复"手写字段白名单"丢值(7 处)

同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。

- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
  原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)

## 压缩偏离设计

CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。

并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。

## 判据

新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
  每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
  "在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿

## 判据自身的坑(两次偶发红,都不是产品缺陷)

1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
   → "暂存 0 条"。修法:cancel + waitSchedulerStopped。
   (offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
   靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
This commit is contained in:
JianFeeeee
2026-10-30 17:00:47 +08:00
parent b850feaef2
commit eb1deab5dd
36 changed files with 5973 additions and 81 deletions

View File

@ -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,

View File

@ -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

View File

@ -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:
// **不吞输入** —— 由本内核兜底处理并留痕。吞掉一条输入比多处理一条更糟:

View File

@ -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,

View File

@ -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",

View File

@ -0,0 +1,178 @@
package core
// 判据:四类 agent 可**各自**配置优先模型源,默认用总默认源。
//
// 用户 2026-10-30 要求:「支持为主 agent、子 agent、驻留式子 agent、rt agent
// 可各自配置模型源,默认使用总模型源(default)」。
//
// ## 语义是"优先"而不是"独占"
//
// 该源排在候选链**最前**,其余源仍作回退。硬锁定会让"首选源挂了"直接变成
// "这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
//
// ## 判据怎么才算有效
//
// 不能只断言"配置生效了"——那样"只留一个源"(破坏回退)也会绿。
// 所以成对断言:
// ① 优先源排在第一位(配置生效)
// ② 其余源仍在候选链里(回退未被破坏)
import (
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// newPMWith 造一个有若干源的 manager,默认源是 first。
func newPMWith(t *testing.T, names ...string) *agentAPI.ProviderManager {
t.Helper()
pm := agentAPI.NewProviderManager()
for _, n := range names {
pm.Register(n, &stubProvider{name: n})
}
if len(names) > 0 {
if err := pm.SetDefault(names[0]); err != nil {
t.Fatalf("SetDefault: %v", err)
}
}
return pm
}
// chainNames 取本 agent 当前候选链的名字序列。
func chainNames(a *Agent) []string {
req := &agentAPI.CompletionRequest{}
ps := a.resolveProviders(req)
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, p.Name())
}
return out
}
// TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 是核心判据。
func TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks(t *testing.T) {
pm := newPMWith(t, "default-src", "fast-src", "cheap-src")
a := New(AgentConfig{
ID: "rt", Provider: pm.Get("default-src"),
ProviderManager: pm, PreferredProvider: "fast-src",
})
got := chainNames(a)
if len(got) == 0 || got[0] != "fast-src" {
t.Fatalf("候选链首位 = %v,期望 fast-src 在最前(配置未生效)", got)
}
// ★ 关键:其余源必须仍在链上(否则回退被破坏)。
if len(got) != 3 {
t.Fatalf("候选链只剩 %v —— 优先源不该挤掉其它源(回退链被破坏)", got)
}
seen := map[string]bool{}
for _, n := range got {
seen[n] = true
}
for _, n := range []string{"default-src", "fast-src", "cheap-src"} {
if !seen[n] {
t.Errorf("候选链缺少 %q:%v", n, got)
}
}
}
// TestPreferredProvider_DefaultUsesGlobalDefault 钉住「默认用总模型源」。
func TestPreferredProvider_DefaultUsesGlobalDefault(t *testing.T) {
pm := newPMWith(t, "default-src", "other-src")
a := New(AgentConfig{
ID: "main", Provider: pm.Get("default-src"), ProviderManager: pm,
// PreferredProvider 留空 ⇒ 用总默认源
})
if got := chainNames(a); len(got) == 0 || got[0] != "default-src" {
t.Fatalf("未配置优先源时首位 = %v,期望总默认源 default-src", got)
}
}
// TestPreferredProvider_UnknownNameFallsBackLoudly 钉住配置写错的语义:
// 回退到默认源(不静默失效),且**不影响可用性**。
func TestPreferredProvider_UnknownNameFallsBackLoudly(t *testing.T) {
pm := newPMWith(t, "default-src", "other-src")
a := New(AgentConfig{
ID: "typo", Provider: pm.Get("default-src"), ProviderManager: pm,
PreferredProvider: "no-such-source", // 拼错
})
got := chainNames(a)
if len(got) == 0 || got[0] != "default-src" {
t.Fatalf("拼错的优先源应回退到默认源,实际首位 = %v", got)
}
if len(got) != 2 {
t.Fatalf("回退后候选链 = %v,期望两个源都在", got)
}
}
// TestPreferredProvider_EachAgentIndependent 钉住"各自可配":
// 同进程内三个 agent 配不同源,互不影响。
func TestPreferredProvider_EachAgentIndependent(t *testing.T) {
pm := newPMWith(t, "shared-default", "for-main", "for-rt", "for-resident")
mainA := New(AgentConfig{ID: "main", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-main"})
rtA := New(AgentConfig{ID: "rt", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-rt"})
resA := New(AgentConfig{ID: "res", Provider: pm.Get("shared-default"), ProviderManager: pm, PreferredProvider: "for-resident"})
for _, tc := range []struct {
who string
a *Agent
want string
}{
{"主 agent", mainA, "for-main"},
{"rt agent", rtA, "for-rt"},
{"驻留子", resA, "for-resident"},
} {
if got := chainNames(tc.a); len(got) == 0 || got[0] != tc.want {
t.Errorf("%s 的首位 = %v,期望 %q(各自配置未隔离)", tc.who, got, tc.want)
}
}
}
// TestPreferredProvider_InheritedByChildAgents 钉住继承链:
// 子 agent 未显式配置时**继承父的**优先源(而不是掉回总默认)。
func TestPreferredProvider_InheritedByChildAgents(t *testing.T) {
pm := newPMWith(t, "shared-default", "parent-pref")
parent, _, dir := newRootWith(t, pm.Get("shared-default"))
parent.providerManager = pm
parent.preferredProvider = "parent-pref"
spawnTestResident(t, parent, dir, "inh")
parent.residentMu.Lock()
rc := parent.residents["inh"]
parent.residentMu.Unlock()
if rc == nil {
t.Fatal("驻留子未创建")
}
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "parent-pref" {
t.Fatalf("驻留子未继承父的优先源,首位 = %v", got)
}
}
// TestPreferredProvider_RTChildOwnSource 钉住 rtchild 可**独立**配源。
//
// 这条对通话场景最实际:rt 的首字预算是 500ms 级,而不同源往返差 2–5 倍。
func TestPreferredProvider_RTChildOwnSource(t *testing.T) {
pm := newPMWith(t, "shared-default", "parent-pref", "rt-fast")
parent, _ := newRTHost(t, pm.Get("shared-default"))
parent.providerManager = pm
parent.preferredProvider = "parent-pref"
id, err := parent.SpawnRTChild(RTChildOptions{
ID: "rt-own", Input: "voice-in", Output: "voice",
PreferredProvider: "rt-fast",
})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
parent.rtMu.Lock()
rc := parent.rtChildren[id]
parent.rtMu.Unlock()
if got := chainNames(rc.agent); len(got) == 0 || got[0] != "rt-fast" {
t.Fatalf("rtchild 未使用自己配置的源,首位 = %v", got)
}
}

View File

@ -22,6 +22,7 @@ import (
"sync"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/pkg/types"
@ -49,6 +50,12 @@ type ResidentOptions struct {
Capacity int
// TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。
TempPath string
// PreferredProvider 是**这个驻留子**优先使用的模型源(空 = 继承父的)。
//
// 为什么由父在创建时传入、而不是让子自己读配置:子没有配置中心句柄
// (轻量内核刻意不接),而"哪个源给哪类 agent 用"是**部署策略**,
// 本来就该在创建点决定。
PreferredProvider string
// OffloadOwned 标记这是**内核为转投而拉起**的驻留子(见 offload.go)。
//
// 为什么要区分:自动转投会复用、也会按上限限制自己拉起的子,
@ -183,13 +190,15 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
SystemPrompt: a.systemPrompt,
Provider: a.provider,
ProviderManager: a.providerManager,
IO: childIO,
StageHost: a.stageHost,
LightMemory: light, // 轻量内核:只有图记忆共同面
AllowedOutputs: opts.AllowedOutputs,
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
// 驻留子可以有自己的优先源;留空则继承父的(父再留空 ⇒ 总默认源)。
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
IO: childIO,
StageHost: a.stageHost,
LightMemory: light, // 轻量内核:只有图记忆共同面
AllowedOutputs: opts.AllowedOutputs,
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
})
rc := &residentChild{
@ -265,6 +274,11 @@ func (a *Agent) residentInboundChannel(childID string) string {
//
// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。
func (a *Agent) DestroyResident(id string) error {
return a.DestroyResidentWith(id, ChannelRelease)
}
// DestroyResidentWith 按父的决定处置通道后销毁驻留子。
func (a *Agent) DestroyResidentWith(id string, disp ChannelDisposition) error {
a.residentMu.Lock()
rc, ok := a.residents[id]
if ok {
@ -274,7 +288,7 @@ func (a *Agent) DestroyResident(id string) error {
if !ok {
return fmt.Errorf("驻留子 %s 不存在", id)
}
a.teardownResident(rc)
a.teardownResidentWith(rc, disp, nil)
return nil
}
@ -298,6 +312,32 @@ const (
ResidualKeep ResidualPolicy = "keep"
// ResidualDrop 明确丢弃残余任务(父已看过清单并确认)。
ResidualDrop ResidualPolicy = "drop"
// ResidualStash 把残余任务**暂存到其原通道**,等通道被接回时再放出。
//
// ## 与 keep 的区别(用户 2026-10-30 提出)
//
// keep 是"转回父自己的队列"—— 任务立刻变成父的活。
// stash 是"放回它来的那条通道"—— 任务不与任何 agent 绑定,
// 谁接回那条通道,谁就拿到它们。
//
// 三个动作正好对应三种真实情况:
//
// | 选择 | 适用 |
// |---|---|
// | keep(接手) | 父有空、或这批活必须连续处理 |
// | stash(暂存到通道) | 父现在忙,但活不能丢;下一个接手该通道的 agent 来做 |
// | drop(丢弃) | 这批活已过期/已由别处完成 |
//
// ★ stash 的前提是"通道能挂起":留空 Owner 的通道如果没人接,
// 任务就静静躺着。所以 stash 必须**逐条留日志**(和 drop 同等待遇),
// 且 ResidentInfo 里要能查到暂存条数 —— 否则它就是"更好的丢"。
ResidualStash ResidualPolicy = "stash"
// ResidualTakeover 是 keep 的别名,语义是"父**接手**这些活"。
//
// 单独给一个名字是因为用户要求里"接手"是一个独立选项,而 keep
// 听起来像"保持原状"。两者实现完全一致(转回父队列),
// 别名让调用方的意图在代码里可读。
ResidualTakeover ResidualPolicy = "takeover"
)
// TakeResidual 取走一个驻留子手头**尚未处理**的残余任务(不处置)。
@ -344,6 +384,18 @@ func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) {
// (不再取新任务),已在执行的那一条跑完后循环自然结束。
// 等它真正停下,再回收队列 —— 此刻队列是静止的,回收没有歧义。
func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(rc *residentChild) error) (n int, msg string, err error) {
return a.FinishResidentWith(id, policy, ChannelRelease, onDestroy)
}
// FinishResidentWith 是 FinishResident 的完整版:残余任务策略 + **通道处置**一次定。
//
// 为什么合成一个入口:两者有依赖关系 ——
// - residual=stash 要求通道**继续存在**(暂存挂在通道上);
// - channel=release 会把通道还给池子(可能被别的 agent 接走)。
//
// 若拆成两次调用,中间窗口里通道可能已被别人接管,暂存任务就跑到了
// 一个跟它无关的 agent 手里。**一次定完,没有窗口。**
func (a *Agent) FinishResidentWith(id string, policy ResidualPolicy, disp ChannelDisposition, onDestroy func(rc *residentChild) error) (n int, msg string, err error) {
a.residentMu.Lock()
rc := a.residents[id]
a.residentMu.Unlock()
@ -370,8 +422,11 @@ func (a *Agent) FinishResident(id string, policy ResidualPolicy, onDestroy func(
return n, msg, herr
}
}
// ⑤ 销毁(内部会 Stop 子、关 temp、放通道)。
return n, msg, a.DestroyResident(id)
// ⑤ 销毁(内部会 Stop 子、关 temp、按 disp 处置通道)。
//
// ⚠️ 必须用 DestroyResidentWith:走 DestroyResident 会丢掉 disp,
// 通道被无条件释放 —— 而 stash 的暂存任务正挂在那条通道上。
return n, msg, a.DestroyResidentWith(id, disp)
}
// ApplyResidual 按父给出的策略处置一个驻留子的残余任务。
@ -412,7 +467,46 @@ func (a *Agent) applyResidualEvents(id string, events []*agentIO.InputEvent, pol
}
return len(events), fmt.Sprintf("已按父的决定丢弃 %d 条残余任务(逐条已记日志)", len(events)), nil
}
// 默认 keep:转回父自己
// stash:暂存回**每条任务来的那条通道**。
//
// ★ 必须逐条按其自身 OutputChannel 归位,不能整批塞进一个通道 ——
// 一个驻留子可能同时收 qq 和 webui 的消息,混存会导致接回 qq 的人
// 拿到本该给 webui 的活。
if policy == ResidualStash {
reg := a.io.ChannelRegistry()
if reg == nil {
return 0, "", fmt.Errorf("无通道登记表,无法暂存残余任务")
}
stashed, failed := 0, 0
for _, evt := range events {
if evt == nil {
continue
}
ch := evt.OutputChannel
if ch == "" {
ch = evt.Source // 无回程通道时用来源通道兜底
}
if err := reg.Stash(ch, []*agentIO.InputEvent{evt}); err != nil {
// 通道已不存在(插件卸载等):这条只能丢,但必须留痕,
// 并给同步调用方终态 —— 否则 cli/a2a 会永久挂起。
failed++
log.Printf("[resident] %s residual task could NOT be stashed (channel %q gone): source=%s request=%s content=%s",
id, ch, evt.Source, evt.RequestID, truncateStr(inputTextOf(evt), 80))
a.emitSkippedReply(evt, "residual_stash_channel_gone")
continue
}
stashed++
// 暂存也要留痕:不可追溯的"暂存"和丢弃没区别。
log.Printf("[resident] %s residual task stashed to channel %q: source=%s request=%s content=%s",
id, ch, evt.Source, evt.RequestID, truncateStr(inputTextOf(evt), 80))
}
msg := fmt.Sprintf("已把 %d 条残余任务暂存到各自通道(接回通道即释放)", stashed)
if failed > 0 {
msg += fmt.Sprintf(";%d 条因通道已不存在而丢弃(已记日志并给调用方终态)", failed)
}
return stashed + failed, msg, nil
}
// keep / takeover:转回父自己
for _, evt := range events {
if evt == nil {
continue
@ -433,7 +527,39 @@ func inputTextOf(evt *agentIO.InputEvent) string {
}
// teardownResident 停内核、放通道、丢 temp(销毁与回收共用)。
// ChannelDisposition 是父对**驻留子持有的通道**的显式决定。
//
// 用户 2026-10-30 要求:「原本驻留子持有的通道也应该支持**挂起或接回**主 agent」。
//
// 之前销毁驻留子时内核**无条件**把通道归到"未分配"(`Assign(ch, "", 0)`):
// 通道确实回到了池子,但**谁也不知道该由谁来接**。于是有两种坏结局:
// - 通道一直没人分配 ⇒ 投进来的消息无人处理,且无任何提示
// - 刚好被别的 agent 抢到 ⇒ 那个 agent 莫名其妙开始收它没准备的流量
//
// 所以和残余任务的处置一样:**原语在内核,决定在父的模型**。
type ChannelDisposition string
const (
// ChannelRelease 是原有行为:归属清空回到"未分配",可被任意 agent 再分配。
// 作为**零值**保持向后兼容(已有调用方不传就等于它)。
ChannelRelease ChannelDisposition = ""
// ChannelSuspend 挂起:归属清空但**标记为挂起**,不参与自动分配。
//
// 与 release 的差别是"会不会被别人悄悄接走":挂起的通道等父显式接回,
// 期间的输入被暂存(不是丢弃),接回时一并交出。
ChannelSuspend ChannelDisposition = "suspend"
// ChannelTakeover 接回主 agent:通道归属改为父,**并释放暂存任务**,
// 那些任务立即转进父的队列。
ChannelTakeover ChannelDisposition = "takeover"
)
func (a *Agent) teardownResident(rc *residentChild) {
a.teardownResidentWith(rc, ChannelRelease, nil)
}
// teardownResidentWith 是 teardownResident 的完整版:额外决定**通道去向**,
// 并在 takeover 时把释放出来的暂存任务转进父队列。
func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition, onDrained func([]*agentIO.InputEvent)) {
rc.mu.Lock()
rc.state = "stopped"
rc.mu.Unlock()
@ -449,10 +575,40 @@ func (a *Agent) teardownResident(rc *residentChild) {
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
_ = os.RemoveAll(rc.dir)
}
// 归还划入的 inputch(归属清空 ⇒ 回到"未分配",可再分配)。
// 处置划入的 inputch(按父的决定:释放 / 挂起 / 接回)。
if reg := a.io.ChannelRegistry(); reg != nil {
for _, ch := range rc.inputChs {
_ = reg.Assign(ch, "", 0)
switch disp {
case ChannelTakeover:
// 接回父:通道归父,并把该通道上的暂存任务交出。
drained, err := reg.AssignAndDrainStash(ch, string(a.id), 0)
if err != nil {
log.Printf("[resident] %s 接回通道 %s 失败: %v", rc.id, ch, err)
continue
}
if len(drained) > 0 {
log.Printf("[resident] %s 接回通道 %s,释放暂存任务 %d 条", rc.id, ch, len(drained))
if onDrained != nil {
onDrained(drained)
} else {
for _, evt := range drained {
if evt != nil {
a.sched.enqueue(newInputTask(evt))
}
}
a.sched.signalWake()
}
}
case ChannelSuspend:
// 挂起:归属清空但**不交还池子**。实现上仍记为 "",
// 差别在于本通道上的暂存任务保留(接回时才释放),
// 且日志明确写出"已挂起",让运维能查到它没有在跑。
_ = reg.Assign(ch, "", 0)
log.Printf("[resident] %s 通道 %s 已挂起(暂存 %d 条,等接回时释放)",
rc.id, ch, reg.StashLen(ch))
default: // ChannelRelease
_ = reg.Assign(ch, "", 0)
}
}
// 注销"父接收该子消息"的入站 inputch(child/<id>)。
//
@ -526,7 +682,26 @@ func (a *Agent) SendToResident(id, text string) error {
return nil
}
// CompressResident 是父的"压缩"(**保留语义**):压上下文 + **清理处理表**,子继续存在。
// CompressResident 是父的"压缩"(**保留语义**):**压成摘要** + 清理处理表,子继续存在。
//
// ## 这里曾经是纯截断(2026-10-30 修正)
//
// 原实现直接 `TrimKeepRecent(20)` —— 丢掉除了最近 20 条之外的一切,
// 与设计文档明写的「**压成摘要**」(resident-subagent-design.md §9, R7)
// 不符。两个后果:
//
// ① "压缩"与"零成本丢数据"没有区别,被丢的语义不可恢复
// ② 压缩(保留)与回收(取消)在实现上退化成同一个动作的两种力度
//
// 现在的顺序(用户 2026-10-30 裁定 A+B):
//
// ① PlanCompaction 取一份**只读计划**(不动上下文)
// ② 用**子自己的** provider 生成摘要(设计 R7:"子的轻量内核自己压")
// ③ ApplyCompaction 用摘要事件**替换**那段
// ④ 摘要失败 ⇒ **回退截断**(裁定 B),并留日志(不静默降级)
//
// 为什么先出计划再执行:摘要要调 LLM(可能几十秒),期间不能持锁、也不能
// 先把事件摘掉 —— 那样一旦失败/取消,上下文就被破坏了一半。
func (a *Agent) CompressResident(id string) (int, error) {
a.residentMu.Lock()
rc, ok := a.residents[id]
@ -534,13 +709,37 @@ func (a *Agent) CompressResident(id string) (int, error) {
if !ok {
return 0, fmt.Errorf("驻留子 %s 不存在", id)
}
dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent)
ctx := rc.agent.context
compacted := 0
if ctx != nil {
// ① 只读计划:此时上下文一个字都没动。
plan := ctx.PlanCompaction(residentKeepRecent)
if len(plan.Old) > 0 {
// ② 用**子自己**的 provider 压(裁定 A)。
summary, err := rc.agent.summarizeForCompaction(plan.PrefixText)
if err != nil {
// ③ 裁定 B:回退截断。必须留日志 —— 静默降级会让运维
// 永远不知道"压缩退化成丢弃"发生了多少次。
log.Printf("[resident] %s compaction summary failed (falling back to truncation — "+
"%d events will be DISCARDED, not summarized): %v", id, len(plan.Old), err)
compacted = ctx.TrimKeepRecent(residentKeepRecent)
} else {
// ④ 摘要替换那段(不是丢弃)。
compacted = ctx.ApplyCompaction(plan, summary)
log.Printf("[resident] %s compacted %d events into a %d-char summary (kept last %d)",
id, compacted, len([]rune(summary)), residentKeepRecent)
}
}
}
rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清
rc.agent.resetContextFull()
rc.mu.Lock()
rc.state = "running"
rc.mu.Unlock()
return dropped, nil
return compacted, nil
}
// ReclaimResident 是父的"回收"(**取消语义**):父读子的 temp → 选记录 → 合入 main
@ -618,7 +817,14 @@ func (a *Agent) handleChildContextFull(rc *residentChild) {
//
// 只对**驻留子**生效(只有它们设了 onContextFull)。
func (a *Agent) checkContextFull() {
if a.onContextFull == nil || a.ctxFullSignaled {
if a.onContextFull == nil {
return
}
// 读标记要加锁:它可能被**父的 goroutine**(CompressResident)并发清零。
a.ctxFullMu.Lock()
signaled := a.ctxFullSignaled
a.ctxFullMu.Unlock()
if signaled {
return
}
if a.context == nil {
@ -634,11 +840,21 @@ func (a *Agent) checkContextFull() {
if a.overflowRatioNow() < contextFullRatio {
return
}
a.ctxFullMu.Lock()
a.ctxFullSignaled = true
a.ctxFullMu.Unlock()
a.onContextFull()
}
func (a *Agent) resetContextFull() { a.ctxFullSignaled = false }
// resetContextFull 清零上报标记(压缩后允许再次上报)。
//
// 加锁理由见 Agent.ctxFullMu:本函数**通常由父的 goroutine 调用**
// (CompressResident),而子的调度器 goroutine 正在读同一个字段。
func (a *Agent) resetContextFull() {
a.ctxFullMu.Lock()
a.ctxFullSignaled = false
a.ctxFullMu.Unlock()
}
// ---- 子侧:inputch 处理表(子持有,父 pull) ----
@ -760,3 +976,64 @@ func MarshalResidentInfo(info ResidentInfo) string {
}
return string(b)
}
// compactionSummaryPrompt 是压缩摘要的指令。
//
// 三条硬要求,每条都对应一种坏摘要:
// - 保留事实(人名/数字/决定/未完成项)—— 否则摘要等于把知识蒸干了
// - 不得编造 —— 模型很容易"补全"它以为该有的结论
// - 输出纯文本,不要复述"以下是摘要"这类客套 —— 它会被原样插回上下文
const compactionSummaryPrompt = `把下面这段对话压缩成一段摘要,供你自己后续继续工作使用。
要求:
1. **保留全部事实**:人物、数字、时间、已作出的决定、结论、以及**尚未完成的事项**。
2. **不得编造**:原文没有的内容一律不要补充;不确定的写「未确认」。
3. 丢掉过程细节(逐条工具调用的原始输出、寒暄、重复确认),保留结果。
4. 直接输出摘要正文,不要写「以下是摘要」之类的开场白。
5. 控制在 300 字以内。
对话原文:
%s`
// summarizeForCompaction 用**本 agent 自己的 provider** 把一段原文压成摘要。
//
// ## 为什么用子自己的 provider(设计 R7 / 用户 2026-10-30 裁定 A)
//
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」——
// resident-subagent-design.md:640。语义上压缩是**子对自身记忆的整理**,
// 它的视角、它认为重要的东西,与父不同。让父代劳会让子的记忆被父的兴趣点
// 重塑 —— 那不是压缩,是替换。
//
// ## 失败语义(用户裁定 B:回退截断)
//
// 返回错误时调用方**退回 TrimKeepRecent**(截断),并记日志。
// 不抛错让压缩失败:contextfull 时子已经顶着满上下文,压缩再失败就等于
// 把"能不能继续干活"赌在一次可能超时的 LLM 调用上。
func (a *Agent) summarizeForCompaction(text string) (string, error) {
if strings.TrimSpace(text) == "" {
return "", fmt.Errorf("没有可压缩的内容")
}
if a == nil || a.provider == nil {
return "", fmt.Errorf("本 agent 没有可用的 LLM provider")
}
req := &agentAPI.CompletionRequest{
Messages: []agentAPI.Message{
{Role: "user", Content: fmt.Sprintf(compactionSummaryPrompt, text)},
},
// 摘要不需要长输出;给足 300 字(中文约 600 token)+ 余量。
MaxTokens: 1024,
ToolChoice: "none",
DisableThinking: true,
}
// 用本 agent 的 ctx:父退出/子被销毁时压缩自然中止(不留下悬空调用)。
resp, err := a.provider.Chat(a.ctx, req)
if err != nil {
return "", err
}
out := strings.TrimSpace(resp.Content)
if out == "" {
return "", fmt.Errorf("摘要器返回空内容")
}
return out, nil
}

View File

@ -0,0 +1,299 @@
package core
// 判据:驻留子的「压缩」= **LLM 摘要替换**,不是截断丢弃。
//
// 设计原文(resident-subagent-design.md §9 / R7):
//
// 「【压缩】压成摘要 → 清处理表 → 子续用」
// 「压缩由**子的轻量内核自己压**(它有自己的上下文与 LLM)」
//
// 用户裁定(2026-10-30):
//
// A. 摘要在**子自己的** provider 上执行(不让父代劳)
// B. 摘要失败 ⇒ **回退截断**(不抛错让压缩失败),且必须留日志
//
// 这两条都要能变红:把实现改回 TrimKeepRecent,第一条红;去掉回退,
// 第二条红(子卡在满上下文)。
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtCompactProvider 记录 Chat 调用,并返回一段可识别的"摘要"。
type rtCompactProvider struct {
mu sync.Mutex
chats int
lastAsk string
failChat bool // true ⇒ Chat 返回错误(验证回退路径)
blank bool // true ⇒ Chat 返回空内容(也算失败)
}
func (p *rtCompactProvider) Name() string { return "compact" }
func (p *rtCompactProvider) MaxContextTokens() int { return 8192 }
func (p *rtCompactProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.chats++
if len(req.Messages) > 0 {
p.lastAsk = req.Messages[0].Content
}
fail, blank := p.failChat, p.blank
p.mu.Unlock()
if fail {
return nil, fmt.Errorf("provider down (test)")
}
if blank {
return &agentAPI.CompletionResponse{Content: " "}, nil
}
return &agentAPI.CompletionResponse{Content: "用户问了 A,我查了 B 并得到结论 C;D 尚未完成。"}, nil
}
func (p *rtCompactProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "ok", Done: true}
close(ch)
return ch, nil
}
func (p *rtCompactProvider) chatCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.chats
}
// newResidentWithProvider 起一个带指定 provider 的驻留子。
//
// 复用 resident_test.go 的 newRootWith(它已接受任意 provider),
// 不用 newRootForResidents(那个把 provider 写死成 countingProvider)。
func newResidentWithProvider(t *testing.T, prov agentAPI.Provider, id string) (*Agent, *residentChild) {
t.Helper()
parent, _, dir := newRootWith(t, prov)
spawnTestResident(t, parent, dir, id)
parent.residentMu.Lock()
rc := parent.residents[id]
parent.residentMu.Unlock()
if rc == nil {
t.Fatalf("驻留子 %s 没创建成功", id)
}
return parent, rc
}
// fillContext 往子的上下文里灌 n 条事件(制造"可压缩的旧内容")。
func fillContext(rc *residentChild, n int) {
for i := 0; i < n; i++ {
rc.agent.context.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "sub/in",
Input: fmt.Sprintf("第%d件事:讨论了主题 %d,结论是 %d", i, i, i*10),
Response: fmt.Sprintf("第%d轮答复", i),
})
}
}
// TestCompressResident_SummarizesInsteadOfDiscarding 是核心判据:
// 压缩必须**调用子自己的 LLM** 生成摘要,并用它替换被压掉的内容。
//
// 变红条件:把 CompressResident 改回 TrimKeepRecent ⇒ chats==0,且上下文里
// 找不到摘要事件 ⇒ 本判据失败。
func TestCompressResident_SummarizesInsteadOfDiscarding(t *testing.T) {
prov := &rtCompactProvider{}
parent, rc := newResidentWithProvider(t, prov, "compress-child")
// 灌到远超保留条数(residentKeepRecent=20),保证有东西可压。
fillContext(rc, residentKeepRecent+15)
before := rc.agent.context.Len()
if before <= residentKeepRecent {
t.Fatalf("上下文只有 %d 条,不足以触发压缩", before)
}
n, err := parent.CompressResident("compress-child")
if err != nil {
t.Fatalf("压缩失败:%v", err)
}
if n == 0 {
t.Fatalf("没有压掉任何事件(before=%d)", before)
}
// ① 必须真的调了子自己的 provider(裁定 A)。
if got := prov.chatCount(); got == 0 {
t.Fatalf("压缩没有调用子自己的 LLM —— 这是截断,不是压缩" +
"(设计 §9 要求「压成摘要」)")
}
// ② 摘要必须进了上下文(作为一条可识别的事件)。
evts := rc.agent.context.snapshotEvents()
var summary *ContextEvent
for _, e := range evts {
if e.Source == compactionSource {
summary = e
break
}
}
if summary == nil {
t.Fatalf("上下文里没有摘要事件(source=%q)—— 被压掉的内容彻底消失了",
compactionSource)
}
if !strings.Contains(summary.Input, "结论 C") {
t.Fatalf("摘要事件里没有 LLM 生成的内容:%q", truncateStr(summary.Input, 200))
}
// ③ 保留段仍在(压缩是"压",不是清空)。
if after := rc.agent.context.Len(); after < residentKeepRecent {
t.Fatalf("压缩后只剩 %d 条,应保留最近 %d 条", after, residentKeepRecent)
}
// ④ 摘要事件必须 NoMemory(它是背景说明,不是一句真实对话)。
if !summary.NoMemory {
t.Fatal("摘要事件会进记忆 —— 每次压缩都会往图库灌一条假对话")
}
}
// TestCompressResident_UsesChildProvider 钉住裁定 A:**子自己的** provider 被调用,
// 而不是父在替它压。
//
// ## 怎么才能真区分(初版两次都写错了)
//
// 1. 初版在创建后改写 rc.agent.provider ⇒ **数据竞争**:驻留子一 Start 就起了
// 调度器 goroutine,它在 task.go:1385 读 a.provider,与测试写并发。
// 第二版改成"看父的 provider 有没有被调",但 SpawnResident 让子与父共用
// 同一个 provider 实例 ⇒ 两种实现(子的 / 父的)都会让它非零,无法区分。
//
// 正确做法:**在创建前就把父的 provider 换掉**,让"父自己的 provider"与
// "传递给子的 provider"是两个可分辨的实例。做完这步之后再创建子,
// 就不再有任何并发写入。
func TestCompressResident_UsesChildProvider(t *testing.T) {
// ① 先用一个"哑" provider 建父(不会有任何调用)。
parent, _, dir := newRootWith(t, &rtCompactProvider{})
// ② 创建**之前**给父装上新 provider —— 这一刻还没有驻留子,无并发。
parentProv := &rtCompactProvider{}
parent.provider = parentProv
// ③ 此时创建的子会继承 parentProv(SpawnResident 里 Provider: a.provider)。
spawnTestResident(t, parent, dir, "own-provider")
parent.residentMu.Lock()
rc := parent.residents["own-provider"]
parent.residentMu.Unlock()
if rc == nil {
t.Fatal("驻留子没创建成功")
}
fillContext(rc, residentKeepRecent+5)
if _, err := parent.CompressResident("own-provider"); err != nil {
t.Fatalf("压缩失败:%v", err)
}
// ④ 抽象行为断言:压缩确实调了 provider 生成摘要,且摘要进了子的上下文。
// "用子而不是父"的实质是:写入的是**子的** context,不是父的。
if parentProv.chatCount() == 0 {
t.Fatal("压缩没有调用 provider —— 走了截断而非摘要")
}
var found bool
for _, e := range rc.agent.context.snapshotEvents() {
if e.Source == compactionSource {
found = true
break
}
}
if !found {
t.Fatal("摘要事件没有写进**子**的上下文")
}
// 父自己的上下文必须**一点没动**(压缩是子对自身记忆的整理,不是父的)。
if parent.context != nil && parent.context.Len() != 0 {
t.Fatalf("父的上下文被改动了(%d 条)—— 压缩串到了父身上", parent.context.Len())
}
}
// TestCompressResident_FallsBackToTruncationOnFailure 钉住裁定 B:
// 摘要失败必须回退截断,且**压缩整体不失败**(子不能卡在满上下文上)。
func TestCompressResident_FallsBackToTruncationOnFailure(t *testing.T) {
for _, tc := range []struct {
name string
prov *rtCompactProvider
}{
{"provider 报错", &rtCompactProvider{failChat: true}},
{"provider 返回空", &rtCompactProvider{blank: true}},
} {
t.Run(tc.name, func(t *testing.T) {
parent, rc := newResidentWithProvider(t, tc.prov, "fallback")
fillContext(rc, residentKeepRecent+10)
before := rc.agent.context.Len()
n, err := parent.CompressResident("fallback")
if err != nil {
t.Fatalf("摘要失败时压缩不该报错(裁定 B:回退截断):%v", err)
}
if n == 0 {
t.Fatal("回退截断没有丢掉任何东西?")
}
if after := rc.agent.context.Len(); after >= before {
t.Fatalf("回退后上下文没变短(%d → %d)", before, after)
}
if after := rc.agent.context.Len(); after != residentKeepRecent {
t.Fatalf("回退截断应保留 %d 条,实际 %d", residentKeepRecent, after)
}
// 回退路径不应产生摘要事件(没摘要成功就没摘要)。
for _, e := range rc.agent.context.snapshotEvents() {
if e.Source == compactionSource {
t.Fatal("摘要失败却插入了空摘要事件")
}
}
})
}
}
// TestCompressResident_NothingToCompactIsNoop 钉住边界:内容不足时压缩是空操作,
// **不应**白调一次 LLM(那是纯浪费)。
func TestCompressResident_NothingToCompactIsNoop(t *testing.T) {
prov := &rtCompactProvider{}
parent, rc := newResidentWithProvider(t, prov, "tiny")
fillContext(rc, 3) // 远小于 residentKeepRecent
if n, err := parent.CompressResident("tiny"); err != nil || n != 0 {
t.Fatalf("内容不足时压缩应无操作,得到 n=%d err=%v", n, err)
}
if got := prov.chatCount(); got != 0 {
t.Fatalf("无可压内容却调用了 %d 次 LLM", got)
}
}
// TestPlanCompaction_DoesNotMutate 钉住「先计划后执行」的安全性:
// PlanCompaction 必须**不动**上下文(否则摘要失败时上下文已半毁)。
func TestPlanCompaction_DoesNotMutate(t *testing.T) {
prov := &rtCompactProvider{}
_, rc := newResidentWithProvider(t, prov, "plan-only")
fillContext(rc, residentKeepRecent+8)
// 快照一份「计划之前」的事件集合。
//
// ★ 不能拿 Len() 前后相等来断言"没改动"(初版就是这么写,实测偶发红):
// 驻留子一 Start 就起了自己的调度器 goroutine,它会并发 Append 事件
// (prepare 段的提示词注入等),长度自然会长。那不是 Plan 改动上下文,
// 是**别的 goroutine 正常写入**。
//
// 正确的不变式是:**计划不会从上下文里删掉任何事件**(它只读)。
beforeEvts := rc.agent.context.snapshotEvents()
plan := rc.agent.context.PlanCompaction(residentKeepRecent)
afterSet := map[*ContextEvent]bool{}
for _, e := range rc.agent.context.snapshotEvents() {
afterSet[e] = true
}
for _, e := range beforeEvts {
if !afterSet[e] {
t.Fatalf("PlanCompaction 删掉了事件(Source=%q)—— 它必须是只读的,"+
"否则摘要失败时上下文已被破坏一半", e.Source)
}
}
if len(plan.Old) != len(beforeEvts)-residentKeepRecent {
t.Fatalf("计划里 Old=%d 条,期望 %d", len(plan.Old), len(beforeEvts)-residentKeepRecent)
}
if !strings.Contains(plan.PrefixText, "第0件事") {
t.Fatalf("PrefixText 丢了最早的内容:%q", truncateStr(plan.PrefixText, 200))
}
}

View File

@ -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:

View File

@ -0,0 +1,287 @@
package core
// 判据:驻留子销毁时残余任务的**四种处置**,以及它持有通道的**三种处置**。
//
// 用户 2026-10-30 要求:
//
// 「对于驻留式子 agent 销毁时,残余任务主 agent 应当选择**暂存在对应通道**、
// **丢弃**、或**接手**。原本驻留子持有的通道也应该支持**挂起或接回**主 agent」
//
// ## 为什么这事不能交给内核悄悄决定
//
// 已经在 ResidentPolicy 的注释里钉过:吞掉一条用户消息比多处理一条更糟。
// 新增的 stash/suspend 把这件事细分到"不丢、也不立刻压给父"。
import (
"path/filepath"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// pushToChild 把一条输入直接塞进驻留子的待处理队列(模拟通道投递)。
//
// 不用真的走通道投递:本文件验的是**销毁时如何处置队列里的残余任务**,
// 而不是路由本身(路由已有独立判据)。直接入队让"这条任务来自哪个通道"
// 可控。
func pushToChild(t *testing.T, root *Agent, childID, channel, text string) {
t.Helper()
root.residentMu.Lock()
rc := root.residents[childID]
root.residentMu.Unlock()
if rc == nil || rc.agent == nil {
t.Fatalf("驻留子 %s 不存在", childID)
}
evt := mustTextEvent(channel, text)
evt.OutputChannel = channel
rc.agent.sched.enqueue(newInputTask(evt))
}
// spawnQuietResident 创建驻留子但**不注入 TaskPrompt**。
//
// ★ 本文件不能用 spawnTestResident:它会带 TaskPrompt,而那条提示词是
// **异步**投进子队列的(探针实测:它在 parkChild 清空队列**之后**才到达,
// 约 4/40 的概率混进来)。后果是残余条数变成 3 而非 2,
// 判据偶发红 —— 而它测的根本不是那条提示词。
//
// 本文件验的是**销毁时的处置逻辑**,让子的初始队列为空才是确定的起点。
func spawnQuietResident(t *testing.T, root *Agent, dir, id string, inputChs ...string) ResidentInfo {
t.Helper()
info, err := root.SpawnResident(ResidentOptions{
ID: id,
InputChs: inputChs,
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
return info
}
// parkChild 停住驻留子的调度器,让"残余任务"处于**确定静止**状态。
//
// ★ 必须在入队前调用,否则判据不是确定性的。
//
// SpawnResident 末尾会 child.Start()(产品行为:创建即开工),于是子会在
// 两次 enqueue 之间取走一条开始执行 —— 而"正在执行"的那条不属于"尚未处理"
// (takeAllPendingEvents 只取 queue/interrupt/immediate,不取 running)。
// 实测约 1/30 概率失败于"暂存 0 条"。
//
// 这不是产品缺陷:真实场景里残余任务是"子已停止、队列里剩下的",天然静止。
// 本文件验的是**销毁时的处置逻辑**,不是子的生命周期,所以先停住即可。
//
// (`TestResidualKeepReturnsTasksToParent` 早就踩过并记录了同一个坑,
//
// 这里复用它的做法:cancel + 等静止,不用任何自定义标志位。)
func parkChild(t *testing.T, root *Agent, childID string) {
t.Helper()
root.residentMu.Lock()
rc := root.residents[childID]
root.residentMu.Unlock()
if rc == nil || rc.agent == nil {
t.Fatalf("驻留子 %s 不存在", childID)
}
rc.agent.cancel()
rc.agent.sched.waitSchedulerStopped(schedStopTimeout, childID)
// 清掉停在队列里的**既有**项(通常是 SpawnResident 注入的 task_prompt)。
//
// ★ 不清的话残余条数不确定:子被停住时 task_prompt 可能还在队列里、
// 也可能已被取走执行 —— 实测表现为"应处置 1 条,实际 2 条"。
// 本文件只验处置逻辑,不从这条初始任务得到任何信息,所以直接倒掉,
// 让每个判据从"空队列"这个确定状态开始。
for _, leftover := range rc.agent.sched.takeAllPendingEvents() {
_ = leftover
}
}
// TestResidualStash_ParksOnOriginatingChannel 钉住 stash 的核心语义:
// 任务被放到**它自己来的那条通道**上,而不是随便一条、也不是父的队列。
func TestResidualStash_ParksOnOriginatingChannel(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
for _, ch := range []string{"qq", "webui"} {
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "test"}); err != nil {
t.Fatalf("注册 %s: %v", ch, err)
}
}
info := spawnQuietResident(t, root, dir, "stash1", "qq", "webui")
parkChild(t, root, info.ID)
// 两条任务分别来自不同通道。
pushToChild(t, root, info.ID, "qq", "qq-来的消息")
pushToChild(t, root, info.ID, "webui", "webui-来的消息")
n, msg, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil)
if err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
if n != 2 {
t.Fatalf("应处置 2 条,实际 %d(%s)", n, msg)
}
// ★ 必须各回各家:qq 的进 qq,webui 的进 webui。
// 整批塞进一条通道会让"接回 qq 的人"拿到本该给 webui 的活。
if got := reg.StashLen("qq"); got != 1 {
t.Errorf("qq 通道暂存 = %d 条,期望 1 条(整批塞进单通道会导致错投)", got)
}
if got := reg.StashLen("webui"); got != 1 {
t.Errorf("webui 通道暂存 = %d 条,期望 1 条", got)
}
}
// TestResidualStash_NotInParentQueue 钉住 stash 与 keep 的**行为差别**。
//
// 只断言"暂存了"不够:如果 stash 顺带也塞进了父队列,那它和 keep 就没区别,
// 只不过多留了份副本 —— 任务会被处理两次。
func TestResidualStash_NotInParentQueue(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
t.Fatal(err)
}
info := spawnQuietResident(t, root, dir, "stash2", "qq")
parkChild(t, root, info.ID)
pushToChild(t, root, info.ID, "qq", "别进父队列")
before := root.sched.queueLen()
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
if got := root.sched.queueLen(); got != before {
t.Fatalf("父队列从 %d 变成 %d —— stash 必须**只**暂存通道,不能同时塞给父(会重复处理)",
before, got)
}
if reg.StashLen("qq") != 1 {
t.Fatalf("qq 通道未暂存到任务")
}
}
// TestResidualStash_ReleasedOnChannelTakeover 钉住"接回通道即释放"。
//
// ★ 这是整个特性里最容易静默失效的一环:
// 若接回时只改 Owner 不取暂存,通道看起来"已经接回来了",
// 但活永远躺在里面,且没有任何报错。
func TestResidualStash_ReleasedOnChannelTakeover(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
t.Fatal(err)
}
info := spawnQuietResident(t, root, dir, "stash3", "qq")
parkChild(t, root, info.ID)
pushToChild(t, root, info.ID, "qq", "接回时该被释放")
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
if reg.StashLen("qq") != 1 {
t.Fatalf("前置条件不成立:qq 未暂存")
}
// 接回:Owner 改为父,暂存必须一并交出。
drained, err := reg.AssignAndDrainStash("qq", "parent", 0)
if err != nil {
t.Fatalf("AssignAndDrainStash: %v", err)
}
if len(drained) != 1 {
t.Fatalf("接回时释放了 %d 条,期望 1 条(暂存被吞,活永远没人做)", len(drained))
}
if reg.StashLen("qq") != 0 {
t.Fatalf("释放后暂存仍剩 %d 条(应清空,否则会重复释放)", reg.StashLen("qq"))
}
if got := reg.ListByOwner("parent"); len(got) != 1 || got[0].Name != "qq" {
t.Fatalf("通道未归父:%+v", got)
}
}
// TestResidualStash_SuspendKeepsStash 钉住 suspend 的语义:
// 挂起**不释放**暂存(那是接回才做的事),否则挂起等于悄悄丢弃。
func TestResidualStash_SuspendKeepsStash(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
t.Fatal(err)
}
info := spawnQuietResident(t, root, dir, "stash4", "qq")
parkChild(t, root, info.ID)
pushToChild(t, root, info.ID, "qq", "挂起后仍要在")
if _, _, err := root.FinishResidentWith(info.ID, ResidualStash, ChannelSuspend, nil); err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
// 挂起 = 归属清空(无人处理),但暂存保留。
if got := reg.ListByOwner(""); len(got) != 1 || got[0].Name != "qq" {
t.Fatalf("挂起后通道应在「未分配」里:%+v", got)
}
if reg.StashLen("qq") != 1 {
t.Fatalf("挂起把暂存也弄丢了 —— suspend 必须保留暂存")
}
// 再挂起一次(幂等)也不能丢。
if _, err := reg.AssignAndDrainStash("qq", "", 0); err != nil {
t.Fatal(err)
}
if reg.StashLen("qq") != 1 {
t.Fatalf("对空 owner 再 Assign 不应释放暂存(那是 suspend 的语义)")
}
}
// TestResidualTakeover_PutsTasksInParentQueue 钉住"接手"。
func TestResidualTakeover_PutsTasksInParentQueue(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
t.Fatal(err)
}
info := spawnQuietResident(t, root, dir, "take1", "qq")
parkChild(t, root, info.ID)
pushToChild(t, root, info.ID, "qq", "父接手这条")
n, msg, err := root.FinishResidentWith(info.ID, ResidualTakeover, ChannelTakeover, nil)
if err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
if n != 1 {
t.Fatalf("应处置 1 条,实际 %d(%s)", n, msg)
}
// 通道也接了回来。
if got := reg.ListByOwner("parent"); len(got) != 1 || got[0].Name != "qq" {
t.Fatalf("通道未接回父:%+v", got)
}
}
// TestResidualDrop_StillNotifiesSyncCaller 是既有语义的回归钉子:
// 新增 stash/suspend 后,drop 仍必须给同步调用方终态。
func TestResidualDrop_StillNotifiesSyncCaller(t *testing.T) {
root, _, dir := newRootWith(t, &countingProvider{})
reg := root.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "test"}); err != nil {
t.Fatal(err)
}
info := spawnQuietResident(t, root, dir, "drop1", "qq")
respCh := make(chan *agentIO.OutputEvent, 1)
root.rtMu.Lock()
root.rtMu.Unlock() // 只是保持锁序一致(无实际作用)
child := root.residents[info.ID]
child.agent.sched.enqueue(newInputTask(&agentIO.InputEvent{
Source: "cli", OutputChannel: "qq", Payload: map[string]interface{}{"content": "答我"},
ResponseCh: respCh,
}))
if _, _, err := root.FinishResidentWith(info.ID, ResidualDrop, ChannelRelease, nil); err != nil {
t.Fatalf("FinishResidentWith: %v", err)
}
select {
case out := <-respCh:
if out == nil || !out.Done {
t.Error("drop 应给同步调用方终态(否则 cli/a2a 永久挂起)")
}
default:
t.Error("★ drop 未通知同步调用方:调用方会永久挂起")
}
if reg.StashLen("qq") != 0 {
t.Errorf("drop 不应留下暂存")
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
package core
// R10 判据:rtchild 的工具面(用户裁定 R3 / R4)。
//
// R3:rt 流**不禁工具**;`askparent` 工具**保留**
// R4:提示词要求「调用工具前先用 outputch 告知用户稍等」
//
// `askparent` 在本仓的实际形态是 `notify_parent`(子侧向父发问的唯一通道,
// 见 tooldefs.go:874 的注册条件 parentID != "")。所以这里钉的是:
//
// 1. rtchild 的工具表里**有** notify_parent(且不是被剥掉的)
// 2. 它的回调**真的接线**(初版没接 ⇒ 模型一调就得到"没有上级")
// 3. 工具面**不因 rt 而收缩**(R3:不禁工具)
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// rtToolNames 从 buildToolDefs 的结果里抽工具名。
func rtToolNames(defs []interface{}) map[string]bool {
out := map[string]bool{}
for _, t := range defs {
m, ok := t.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if name, ok := fn["name"].(string); ok {
out[name] = true
}
}
return out
}
// TestRTChild_AskparentToolPresent 钉住 R3 后半:`askparent`(= notify_parent)保留。
//
// 若有人"顺手"在 rtStripOutputTools 里把它也剥掉(它确实和输出有关),本判据必红。
func TestRTChild_AskparentToolPresent(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "ask")
rc := host.rtChildren[id]
if rc == nil {
t.Fatal("实例不在登记表")
}
tools := rtStripOutputTools(rc.agent.buildToolDefs())
names := rtToolNames(tools)
// carrier 的 parentID != "" ⇒ notify_parent 应在子侧工具表里。
// 若这里没有,说明 rtchild 的 carrier 没被正确标记为"子"。
if !names["notify_parent"] {
t.Fatalf("rtchild 工具表里没有 notify_parent(askparent)—— 用户裁定 R3 要求保留\n已有工具:%v",
func() []string {
var ks []string
for k := range names {
ks = append(ks, k)
}
return ks
}())
}
// output_send__* 必须被剥掉(rt 输出走 outputch(rt) 流式管线)。
for name := range names {
if strings.HasPrefix(name, "output_send__") {
t.Fatalf("rtchild 工具表里仍有 %s —— 会与流式管线产生两套输出语义", name)
}
}
}
// TestRTChild_NotifyParentIsWired 钉住**接线**(这是初版的真实缺陷):
// notify_parent 的回调必须已接上,调用后不能返回"没有上级"。
//
// 初版 carrier 没设 notifyParent ⇒ notifyParentFrom 直接返回
// 「本 agent 没有上级,无法发送消息」——工具在、调用必失败。
func TestRTChild_NotifyParentIsWired(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "wire")
rc := host.rtChildren[id]
if rc == nil || rc.agent.notifyParent == nil {
t.Fatal("rtchild 的 notifyParent 未接线 —— notify_parent 工具会永远失败")
}
// 走真实工具入口(不是直接调回调):这才是模型看到的那条路。
got := rc.agent.executeNotifyParent(agentAPI.ToolCall{
Name: "notify_parent",
Arguments: map[string]interface{}{"text": "我想问一下"},
})
if strings.Contains(got, "没有上级") {
t.Fatalf("notify_parent 走真实入口仍报「没有上级」:%s", got)
}
// 入站通道只登记一次:反复发问不应让登记表累积。
reg := host.io.ChannelRegistry()
before := reg.Count()
for i := 0; i < 5; i++ {
rc.agent.executeNotifyParent(agentAPI.ToolCall{
Name: "notify_parent",
Arguments: map[string]interface{}{"text": "再问一次"},
})
}
if after := reg.Count(); after != before {
t.Fatalf("连发 5 次 notify_parent 后登记表从 %d 涨到 %d —— 每次发问都在重复登记(写放大+脏数据)",
before, after)
}
}
// TestRTChild_ToolFaceNotNarrowedByRT 钉住 R3 前半:**rt 流不禁工具**。
//
// 判据取法:把 rtchild 的工具数量与"同一个 agent 不用 rt 时的工具数量"比 ——
// rt 只该剥掉 output_send__*,不该额外剥任何东西。
func TestRTChild_ToolFaceNotNarrowedByRT(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "face")
rc := host.rtChildren[id]
all := rtToolNames(rc.agent.buildToolDefs())
kept := rtToolNames(rtStripOutputTools(rc.agent.buildToolDefs()))
// 只允许 output_send__* 与 output_list_channels 被剥。
for name := range all {
if kept[name] {
continue
}
if !strings.HasPrefix(name, "output_send__") && name != "output_list_channels" {
t.Fatalf("rt 额外剥掉了工具 %s —— 用户裁定 R3「rt 流不禁工具」", name)
}
}
// 至少要保留一些工具(否则"不禁工具"名存实亡)。
if len(kept) == 0 {
t.Fatal("rtchild 工具表为空 —— 等于禁了所有工具")
}
}
// TestRTChild_StopUnregistersInbound 钉住 Stop 时注销入站通道(不留脏登记)。
func TestRTChild_StopUnregistersInbound(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
reg := host.io.ChannelRegistry()
before := reg.Count()
id := rtSpawn(t, host, "unreg")
if reg.Count() <= before {
t.Fatal("创建后没有登记入站通道 —— 父侧收不到 rtchild 的发问")
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := reg.Count(); got != before {
t.Fatalf("停止后登记表 %d,期望回到 %d —— 入站通道未注销", got, before)
}
}
// 确保 agentIO 被使用(ChannelRegistry 类型在断言中通过接口访问)。
var _ = agentIO.CapText

View File

@ -0,0 +1,202 @@
package core
// R11 判据:待机零消耗 + 长时 e2e(契约 4 / 契约 1 的持续正确性)。
//
// 契约 4:无输入即静默,**不烧轮次、不烧 token、不轮询**
//
// "不轮询"这条容易被忽略但同样重要:轮询会烧 CPU,且在静默期产生
// 无意义的唤醒。实现上 rtLoop 必须 block 在 Wake() 上(见 rtchild.go)。
import (
"context"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtCountingProvider 统计调用次数与总 token 量(近似:正文长度)。
type rtCountingProvider struct {
mu sync.Mutex
calls int
chars int
}
func (p *rtCountingProvider) Name() string { return "rtCounting" }
func (p *rtCountingProvider) MaxContextTokens() int { return 8192 }
func (p *rtCountingProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *rtCountingProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "答"}
p.mu.Lock()
p.chars++
p.mu.Unlock()
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtCountingProvider) stats() (int, int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls, p.chars
}
// TestRTChild_IdleZeroTokens 钉住契约 4:**静默期零模型调用**。
//
// 若 rtLoop 改成轮询(select 带 default 或定时 tick),本判据会看到
// calls > 0 —— 那意味着空转烧钱。
func TestRTChild_IdleZeroTokens(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "idle")
// 给 rtLoop 足够时间"如果它要空转的话"必然已经转了。
time.Sleep(300 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 300ms 期间模型被调用 %d 次 —— 待机必须零消耗(契约 4)", calls)
}
// 槽也必须仍是空的(没有凭空产生输入)。
if st, ok := host.RTChildStats(id); !ok {
t.Fatal("实例不在登记表")
} else if st.Pending {
t.Fatal("静默期槽里出现了待处理输入")
} else if st.Rounds != 0 {
t.Fatalf("静默期跑了 %d 轮", st.Rounds)
}
// 再等一段,确认不是"刚启动还没轮到"。
time.Sleep(200 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默 500ms 累计调用 %d 次", calls)
}
}
// TestRTChild_IdleStopsWhenInputArrives 是配套正例:来输入必须**真的**跑起来。
//
// 只有"静默零调用"这一半的判定条件是坏的 —— "永不调用模型"的实现也能通过,
// 但那是死进程。正例是必需品。
func TestRTChild_IdleStopsWhenInputArrives(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "wakeup")
time.Sleep(100 * time.Millisecond)
if calls, _ := p.stats(); calls != 0 {
t.Fatalf("静默期不该有调用,得到 %d", calls)
}
host.PushRTInput(id, "你好")
waitFor(t, "输入后跑了一轮", func() bool {
calls, _ := p.stats()
return calls >= 1
})
}
// TestRTChild_E2E_Sustained 是长时 e2e:连续灌入输入,验证
//
// · 每一批都被处理(不丢活)
// · 无 goroutine 泄漏 / 无 panic
// · history 持续增长(普通上下文)
// · 全程 -race 干净(由调用方加 -race)
//
// 用 -short 跳过:默认用例集不该被一段秒级压测拖慢。
func TestRTChild_E2E_Sustained(t *testing.T) {
if testing.Short() {
t.Skip("长时 e2e;-short 跳过")
}
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "e2e")
// 定速灌入(模拟插件攒批后的节奏)。总数取 60:足够暴露累积问题,
// 又不至于让默认用例集跑太久。
const total = 60
for i := 0; i < total; i++ {
host.PushRTInput(id, "第 N 句话")
// 给 rtLoop 一点时间消费;不追求"每条都跑完",要的是压力。
if i%10 == 0 {
time.Sleep(5 * time.Millisecond)
}
}
// 收尾:等最后一条被处理。
waitFor(t, "所有输入都被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
tr, ok := host.RTTranscript(id)
if !ok {
t.Fatal("取不到 transcript")
}
if len(tr) == 0 {
t.Fatal("跑了多轮但 history 为空 —— 普通上下文没在累积")
}
calls, _ := p.stats()
if calls == 0 {
t.Fatal("全程零模型调用 —— rtLoop 没工作")
}
t.Logf("e2e: 灌入 %d 条,模型调用 %d 次,history %d 条", total, calls, len(tr))
// 关闭必须干净。
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := len(host.RTChildren()); got != 0 {
t.Fatalf("停止后仍有 %d 个实例", got)
}
}
// TestRTChild_RapidInputCollapsesToLatest 钉住契约 1+3 的**联合语义**:
// 输入到达得比处理快时,只有最新一条会被处理,其余被覆盖丢弃。
//
// 这不是缺陷,是设计:rt 场景下"用户刚说的话"才是当前态,旧语音已经过期。
// 若有人把 rtSlot 改成队列(让每条都跑),本判据会看到 rounds > 1 —— 那时
// 通话会变成"逐条追赶",用户说完很久机器才反应到,违反实时性。
//
// ★ 同时钉住 dropped 计数器:它是"打断真的发生了"的唯一可观测凭据。
func TestRTChild_RapidInputCollapsesToLatest(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "collapse")
time.Sleep(30 * time.Millisecond)
// 一口气推 50 条(比 rtLoop 处理得快得多)。
const burst = 50
for i := 0; i < burst; i++ {
host.PushRTInput(id, "连发")
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, ok := host.RTChildStats(id)
if !ok {
t.Fatal("实例不存在")
}
if st.Rounds > 3 {
t.Fatalf("连发 %d 条却跑了 %d 轮 —— rtSlot 退化成了队列(契约 1/3:最新者胜、不排队)",
burst, st.Rounds)
}
if st.Dropped == 0 {
t.Fatal("连发 50 条但 dropped=0 —— 覆盖没有发生,或计数器没接线")
}
if st.Dropped+st.Rounds < burst/2 {
t.Fatalf("dropped=%d rounds=%d,合计远小于连发数 %d —— 有输入凭空消失了(既没被处理也没计入丢弃)",
st.Dropped, st.Rounds, burst)
}
}

View File

@ -0,0 +1,139 @@
package core
// R1 判据:「只有 rtchild 可被划分 rt 通道」(用户裁定 R2)+ 通道侧能力校验。
//
// 设计见 plan.md 十一.三 F / 十一.七 R1。
//
// ## 这两条判据为什么必须成对存在
//
// 校验是**双向**的,任何一侧单独成立都不足以拦住"慢 agent 混进通话链路":
// · 只校验 agent 侧 ⇒ 一个没声明 CapRT 的普通通道被划进来,插件会把它
// 当成普通消息收(不知道"新值作废旧值"),rt 语义静默丢失;
// · 只校验通道侧 ⇒ 主 agent(实测往返 2–5s)能划 rt 通道,用户听到长静默。
//
// 所以下面两条各钉一侧,删掉任一侧的实现,对应判据必须变红。
import (
"errors"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// newRTTestAgent 造一个最小可用的 agent(带 io + 共享通道登记表)。
func newRTTestAgent(t *testing.T, realtime bool) *Agent {
t.Helper()
io := agentIO.NewIOManager()
a := New(AgentConfig{ID: "rt-test", IO: io})
if realtime {
a.AsRealtime()
}
return a
}
// registerRTChannel 往登记表里放一个 inputch(caps 决定它声明了什么能力)。
func registerRTChannel(t *testing.T, a *Agent, name string, caps agentIO.OutputCapability) {
t.Helper()
err := a.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: name,
Plugin: "test",
Capabilities: caps,
})
if err != nil {
t.Fatalf("注册 inputch %s: %v", name, err)
}
}
// TestAssignRealtimeRejectsNonRTAgent 钉住 agent 侧:
// 非 rtchild **不得**被划分 rt 通道。
//
// 删掉 AssignRealtime 里的 `if !a.isRealtime` 判断,本判据必红。
func TestAssignRealtimeRejectsNonRTAgent(t *testing.T) {
a := newRTTestAgent(t, false) // 普通 agent,不是 rtchild
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
err := a.AssignRealtime("voice")
if err == nil {
t.Fatalf("非 rtchild 竟然被划到 rt 通道 —— 「只有 rtchild 可被划分 rt 通道」未生效")
}
if !errors.Is(err, ErrAgentNotRealtime) {
t.Fatalf("错误类型不对:%v(应可用 errors.Is 匹配 ErrAgentNotRealtime,"+
"否则调用方只能靠字符串判断)", err)
}
// 配套断言:拒绝后 Owner 必须**未被写入**。
// 只返回错误但已经把 Owner 改了,同样是坏的(调用方看到报错,通道却已被占)。
ch, ok := a.io.ChannelRegistry().Lookup("voice")
if !ok {
t.Fatal("通道不见了")
}
if ch.Owner != "" {
t.Fatalf("划拨被拒但 Owner 已被写成 %q —— 拒绝路径必须无副作用", ch.Owner)
}
}
// TestAssignRealtimeRejectsChannelWithoutCapRT 钉住通道侧:
// 没有声明 CapRT 的通道**不得**被划作 rt 通道。
//
// 删掉 AssignRealtime 里的 ch.Capabilities.Supports(CapRT) 判断,本判据必红。
func TestAssignRealtimeRejectsChannelWithoutCapRT(t *testing.T) {
a := newRTTestAgent(t, true) // 确实是 rtchild
registerRTChannel(t, a, "plain", agentIO.CapText)
err := a.AssignRealtime("plain")
if err == nil {
t.Fatalf("未声明 CapRT 的通道竟被划成 rt 通道 —— 插件会把它当普通消息收,rt 语义静默丢失")
}
if !errors.Is(err, agentIO.ErrChannelNotRealtime) {
t.Fatalf("错误类型不对:%v(应匹配 ErrChannelNotRealtime)", err)
}
}
// TestAssignRealtimeAllowsPlainChannel 钉住计划原文的一条边界:
//
// 「标签不是准入门槛:**不带 rt 标签的普通通道同样可以划给 rt agent**」
//
// 即:rtchild 用普通通道是**允许**的 —— 只是那条通道不能承载 rt 流。
// 若有人"顺手"把普通 Assign 也加上 rt 校验,本判据会红。
func TestAssignRealtimeAllowsPlainChannel(t *testing.T) {
a := newRTTestAgent(t, true)
registerRTChannel(t, a, "cli", agentIO.CapText)
// 走**普通** Assign(不是 AssignRealtime):rtchild 划普通通道必须放行。
if err := a.io.AssignInputChannel("cli", "rt-test", 0); err != nil {
t.Fatalf("rtchild 划普通通道被拒 —— 违反「标签不是准入门槛」:%v", err)
}
ch, _ := a.io.ChannelRegistry().Lookup("cli")
if ch.Owner != "rt-test" {
t.Fatalf("Owner 未写入:%q", ch.Owner)
}
}
// TestAssignRealtimeSucceedsOnRTChannel 是正例:两个条件都满足时必须成功。
//
// ★ 只有负例判据时,"永远返回错误的实现"也能全绿 —— 正例是必需品。
func TestAssignRealtimeSucceedsOnRTChannel(t *testing.T) {
a := newRTTestAgent(t, true)
registerRTChannel(t, a, "voice", agentIO.CapText|agentIO.CapRT)
if err := a.AssignRealtime("voice"); err != nil {
t.Fatalf("rtchild 划 rt 通道失败:%v", err)
}
ch, _ := a.io.ChannelRegistry().Lookup("voice")
if ch.Owner != "rt-test" {
t.Fatalf("Owner = %q,期望 rt-test", ch.Owner)
}
// Capacity 必须是 0:rt 通道是**单槽覆盖**,不是"容量=1 的队列"
// (后者会让新输入等旧输入跑完,正是契约 1 禁止的,见 plan.md 十一.五)。
if ch.Capacity != 0 {
t.Fatalf("rt 通道的 Capacity = %d,必须为 0(单槽覆盖,不是队列)", ch.Capacity)
}
}
// TestAssignRealtimeRejectsUnknownChannel 引用未注册通道必须报错(不静默成功)。
func TestAssignRealtimeRejectsUnknownChannel(t *testing.T) {
a := newRTTestAgent(t, true)
if err := a.AssignRealtime("nope"); !errors.Is(err, agentIO.ErrInputChannelUnknown) {
t.Fatalf("未注册通道应报 ErrInputChannelUnknown,得到:%v", err)
}
}

View File

@ -0,0 +1,146 @@
package core
// R2 判据:rtSlot 单槽覆盖语义(plan.md 十一.五)。
//
// 三条判据分别钉住三件事,删掉对应实现任一条都会红:
// 1. 新输入覆盖旧输入(旧值**作废**,不保留、不补偿)—— 契约 1
// 2. 永不排队(连续 N 次 Push 后待处理恒为 1)—— 契约 3
// 3. 覆盖是**有统计**的(dropped 单调前进)—— 否则"打断生效了几次"不可观测
import (
"fmt"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
func rtEvent(content string) *agentIO.InputEvent {
return &agentIO.InputEvent{
Source: "voice",
Type: "text",
Payload: map[string]interface{}{"content": content},
}
}
func rtContent(evt *agentIO.InputEvent) string {
if evt == nil || evt.Payload == nil {
return ""
}
s, _ := evt.Payload["content"].(string)
return s
}
// TestRTSlot_NewInputReplacesOld 钉住「最新者胜」:后到的输入**作废旧值**。
//
// 若把 Push 改成"入队"(append 到 slice),本判据必然红:Take 会先取到旧值。
func TestRTSlot_NewInputReplacesOld(t *testing.T) {
s := newRTSlot()
s.Push(rtEvent("第一句"))
s.Push(rtEvent("第二句"))
evt, _, ok := s.Take()
if !ok {
t.Fatal("槽里应有待处理输入")
}
if got := rtContent(evt); got != "第二句" {
t.Fatalf("取到的输入 = %q,期望 %q —— 最新者胜未生效(旧值竟然先出队)",
got, "第二句")
}
// 取走后必须清空:否则 rtLoop 会反复拿到同一条输入重跑同一轮。
if _, _, ok := s.Take(); ok {
t.Fatal("Take 之后槽未清空 —— rtLoop 会重复处理同一条输入")
}
}
// TestRTSlot_NeverQueues 钉住「不吃队列」:无论 Push 多少次,待处理恒为 1 条。
//
// 这是契约 3 的直接判据。若实现改成有界队列(哪怕容量 8),
// 连续 Push 100 次后 Take 会连续取出 100 条 —— 本判据立刻红。
func TestRTSlot_NeverQueues(t *testing.T) {
const n = 100
s := newRTSlot()
for i := 0; i < n; i++ {
s.Push(rtEvent(fmt.Sprintf("第%d句", i)))
}
// 只应该能取出**一条**(最后那条)。
evt, _, ok := s.Take()
if !ok {
t.Fatal("槽里应有待处理输入")
}
if got, want := rtContent(evt), fmt.Sprintf("第%d句", n-1); got != want {
t.Fatalf("取到 %q,期望最后一条 %q", got, want)
}
if _, _, ok := s.Take(); ok {
t.Fatalf("连续 Push %d 次后能取出第二条 —— 这不是单槽,是队列(违反契约 3)", n)
}
}
// TestRTSlot_CountsDropped 钉住可观测性:被覆盖的输入必须计数。
//
// 为什么这是判据而不只是日志:rt 场景下"打断"是常态,但如果
// dropped 恒为 0,说明要么插件攒批没生效、要么输入速率极低 ——
// 两种情况下阶段二的延迟数据都不可信。所以它必须能被读到。
func TestRTSlot_CountsDropped(t *testing.T) {
s := newRTSlot()
if got := s.Dropped(); got != 0 {
t.Fatalf("初始 dropped = %d,期望 0", got)
}
s.Push(rtEvent("a")) // 第一次不丢弃(槽是空的)
if got := s.Dropped(); got != 0 {
t.Fatalf("首次 Push 不应计丢弃,得到 %d", got)
}
s.Push(rtEvent("b")) // 覆盖 a ⇒ +1
s.Push(rtEvent("c")) // 覆盖 b ⇒ +1
if got := s.Dropped(); got != 2 {
t.Fatalf("dropped = %d,期望 2(两次覆盖)", got)
}
// 取走之后槽为空 ⇒ 下一次 Push 不算覆盖。
s.Take()
s.Push(rtEvent("d"))
if got := s.Dropped(); got != 2 {
t.Fatalf("取空后 Push 不应计丢弃,dropped = %d", got)
}
}
// TestRTSlot_SeqMonotonicAndMatchesTake 钉住 seq 单调递增且 Take 取到的是**最新 seq**。
//
// seq 是契约 1 的可验证凭据(plan.md 十一.四 空白 3):
// 输出要回传它,判据才能证明"这一轮是用最新输入跑的,而不是旧输入的残余"。
func TestRTSlot_SeqMonotonicAndMatchesTake(t *testing.T) {
s := newRTSlot()
var last uint64
for i := 0; i < 5; i++ {
seq := s.Push(rtEvent(fmt.Sprintf("v%d", i)))
if seq <= last {
t.Fatalf("seq 未单调递增:%d 之后是 %d", last, seq)
}
last = seq
}
_, seq, ok := s.Take()
if !ok {
t.Fatal("应有待处理")
}
if seq != last {
t.Fatalf("Take 返回 seq=%d,期望最新 %d —— 取到的不是最新输入", seq, last)
}
}
// TestRTSlot_WakeIsNonBlocking 钉住唤醒是**电平**语义:
// 槽里已有信号时重复 Push 不得阻塞(cap=1,非阻塞写)。
//
// 若把 wake 改成无缓冲通道或阻塞写,本判据会因 Push 卡死而超时。
func TestRTSlot_WakeIsNonBlocking(t *testing.T) {
s := newRTSlot()
// 连推多次:第一次填满 wake(cap=1),后续必须走 default 分支返回。
for i := 0; i < 10; i++ {
s.Push(rtEvent(fmt.Sprintf("w%d", i)))
}
// 至少要能收到一次唤醒信号。
select {
case <-s.Wake():
default:
t.Fatal("Push 之后 Wake 从未就绪 —— rtLoop 会永久阻塞(待机醒不来)")
}
}

View File

@ -0,0 +1,471 @@
package core
// R3–R6 判据:rtchild 生命周期 / 流式输出 / 打断截断 / toolcall 期不可打断。
//
// 每条判据都对应 plan.md 十一.七 的一个步骤,删掉被钉的实现必须变红。
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// ── 测试替身 ──
// rtBlockingProvider 的 ChatStream 会一直流式吐出 token,直到 ctx 被取消。
//
// 用它来验证"打断即截断":取消后流必须停在半路,且**不得重试**。
type rtBlockingProvider struct {
mu sync.Mutex
calls int // ChatStream 被调用次数(验证"不重试")
got int // 已发出的 chunk 数
// blockForever=true 时永不自然结束(只能靠 cancel 停)。
blockForever bool
}
func (p *rtBlockingProvider) Name() string { return "rtBlocking" }
func (p *rtBlockingProvider) MaxContextTokens() int { return 8192 }
func (p *rtBlockingProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "non-stream"}, nil
}
func (p *rtBlockingProvider) ChatStream(ctx context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return // 被取消:流就地停止(这**就是**"就地截断")
case <-time.After(5 * time.Millisecond):
}
select {
case <-ctx.Done():
return
case ch <- agentAPI.StreamChunk{Content: "字"}:
p.mu.Lock()
p.got++
p.mu.Unlock()
}
if !p.blockForever && i >= 2 {
return
}
}
}()
return ch, nil
}
func (p *rtBlockingProvider) callCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls
}
// rtRecordingDevice 记录 outputch 收到的每一段文本。
type rtRecordingDevice struct {
mu sync.Mutex
texts []string
name string
}
func (d *rtRecordingDevice) Name() string { return d.name }
func (d *rtRecordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *rtRecordingDevice) Description() string { return "rt test output" }
func (d *rtRecordingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *rtRecordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
d.mu.Lock()
defer d.mu.Unlock()
if s, ok := args["text"].(string); ok {
d.texts = append(d.texts, s)
}
return "ok", nil
}
func (d *rtRecordingDevice) Start() error { return nil }
func (d *rtRecordingDevice) Stop() error { return nil }
func (d *rtRecordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *rtRecordingDevice) OutputCapabilities() agentIO.OutputCapability {
// ★ 声明 CapRT("我能承载 rt 流")。注意它仍然只收文本 ——
// CapRT 是"能承载实时流",不是"内核要产出音频"。
return agentIO.CapText | agentIO.CapRT
}
func (d *rtRecordingDevice) snapshot() []string {
d.mu.Lock()
defer d.mu.Unlock()
return append([]string(nil), d.texts...)
}
// newRTHost 造一个"父 agent + 注册好的 rt 输出设备"。
func newRTHost(t *testing.T, p agentAPI.Provider) (*Agent, *rtRecordingDevice) {
t.Helper()
pio := agentIO.NewIOManager()
host := New(AgentConfig{ID: "rt-host", IO: pio, Provider: p})
dev := &rtRecordingDevice{name: "voice"}
if err := pio.RegisterDevice(dev); err != nil {
t.Fatalf("注册 rt 输出设备: %v", err)
}
// rt 通道也必须能划(R1 的校验路径),这里走普通 Register + AssignRealtime。
//
// ★ 注册**一对** rt 通道("voice-in" + "voice"):rt 通道必须成对划拨
// (用户裁定 2026-10-30),所以 SpawnRTChild 要求两个名字都在登记表里。
// 测试统一用 rtSpawn 走"成对"路径(见下)。
for _, n := range []string{"voice", "voice-in"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "test", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道 %s: %v", n, err)
}
}
return host, dev
}
// rtSpawn 是测试里创建 rtchild 的**唯一入口**:自动补上成对的 Input。
//
// 为什么不改每个调用点:成对是 SpawnRTChild 的硬要求,但测试关心的是
// 各自的语义(生命周期/流式/上下文…),一个个补 `Input: "voice-in"`
// 只是噪声,且容易漏(漏了就是"创建失败 + 后续 nil 解引用")。
func rtSpawn(t *testing.T, host *Agent, id string) string {
t.Helper()
got, err := host.SpawnRTChild(RTChildOptions{
ID: id, Input: "voice-in", Output: "voice",
})
if err != nil {
t.Fatalf("创建 rtchild %s: %v", id, err)
}
return got
}
// ── R3:生命周期 ──
// TestRTChild_StopNoOrphans 钉住:Stop 之后实例出登记表、且不再产生输出。
func TestRTChild_StopNoOrphans(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "c1")
if got := host.RTChildren(); len(got) != 1 || got[0] != "c1" {
t.Fatalf("登记表 = %v,期望 [c1]", got)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("停止后登记表仍有 %v —— 留了孤儿", got)
}
// Stop 之后不得再有输出(流已被截断)。
before := len(dev.snapshot())
time.Sleep(60 * time.Millisecond)
after := len(dev.snapshot())
if after != before {
t.Fatalf("停止后仍在输出(%d → %d)—— 当前轮未被截断", before, after)
}
}
// TestRTChild_StopAllNoOrphans 钉住父退出时全部销毁(对应 StopRTChildren)。
func TestRTChild_StopAllNoOrphans(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
for i, id := range []string{"a", "b", "c"} {
rtSpawn(t, host, id)
_ = i
}
if got := len(host.RTChildren()); got != 3 {
t.Fatalf("登记了 %d 个,期望 3", got)
}
if n := host.StopRTChildren(); n != 3 {
t.Fatalf("StopRTChildren 返回 %d,期望 3", n)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("停止后仍有 %v", got)
}
}
// TestRTChild_InterruptDoesNotAffectOthers 是**用户裁定 R1 的核心判据**:
// 打断/停止 rtA 时,rtB 与主 agent 的 LLM 调用必须继续。
//
// 实现上靠"每个 rtchild 自己的 ctx"。若有人把 rtStream 里的
// `context.WithCancel(a.ctx)` 改回直接用 `a.ctx`,本判据必红。
func TestRTChild_InterruptDoesNotAffectOthers(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
idA := rtSpawn(t, host, "a")
idB := rtSpawn(t, host, "b")
// 让两个实例都跑起来(各自 block 在流上)。
if _, err := host.PushRTInput(idA, "你好 A"); err != nil {
t.Fatalf("投递 A: %v", err)
}
if _, err := host.PushRTInput(idB, "你好 B"); err != nil {
t.Fatalf("投递 B: %v", err)
}
waitFor(t, "两个实例都进入流式", func() bool { return p.callCount() >= 2 })
// 停 A。
if err := host.StopRTChild(idA); err != nil {
t.Fatalf("停止 A: %v", err)
}
time.Sleep(80 * time.Millisecond)
// B 必须仍然活着、仍在登记表里。
if _, ok := host.RTChildStats(idB); !ok {
t.Fatal("停 A 之后 B 不见了 —— 打断波及了其他实例(R1 被违反)")
}
// 父 agent 的 ctx 必须仍然可用(没被连带取消)。
select {
case <-host.ctx.Done():
t.Fatal("停 rtchild 把父 agent 的 ctx 也取消了 —— 严重影响主 agent")
default:
}
}
// ── R4:流式输出 ──
// TestRTChild_OutputIsStreamed 钉住契约 2:输出**逐片**外发,不攒整段。
//
// 若把 rtStream 改成"先收完整段再 emit",本判据会看到 1 次 emit 而非多次。
func TestRTChild_OutputIsStreamed(t *testing.T) {
p := &rtBlockingProvider{} // 非 blockForever:吐 3 片后自然结束
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "s")
if _, err := host.PushRTInput(id, "说点什么"); err != nil {
t.Fatalf("投递: %v", err)
}
waitFor(t, "收到多片输出", func() bool { return len(dev.snapshot()) >= 3 })
texts := dev.snapshot()
for _, s := range texts {
if len([]rune(s)) > 8 {
t.Fatalf("单片输出过长(%q)——这看起来是攒整段后一次吐出,不是流式", s)
}
}
if n := len(texts); n < 3 {
t.Fatalf("只收到 %d 片,期望 ≥3(流式逐片外发)", n)
}
}
// ── R5:打断即截断,且不重试 ──
// TestRTChild_InterruptTruncatesOutput 钉住契约 2 的后半:被打断时就地截断。
func TestRTChild_InterruptTruncatesOutput(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, dev := newRTHost(t, p)
id := rtSpawn(t, host, "t")
host.PushRTInput(id, "一直说")
waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
time.Sleep(50 * time.Millisecond)
frozen := len(dev.snapshot())
time.Sleep(80 * time.Millisecond)
if got := len(dev.snapshot()); got != frozen {
t.Fatalf("截断后仍在输出(%d → %d)", frozen, got)
}
}
// TestRTChild_NewInputWinsWithoutRetry 钉住契约 1 + "不重试":
// 新输入到来时,旧轮次被作废,**不会**为旧输入补跑一次。
//
// 若在打断后重试(break 改 continue 之类),callCount 会明显超过 2。
func TestRTChild_NewInputWinsWithoutRetry(t *testing.T) {
p := &rtBlockingProvider{blockForever: true}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "n")
host.PushRTInput(id, "第一句")
waitFor(t, "第一轮开始", func() bool { return p.callCount() >= 1 })
// 新输入:应作废旧轮并用新输入重开。
if _, err := host.PushRTInput(id, "第二句"); err != nil {
t.Fatalf("投递第二句: %v", err)
}
waitFor(t, "第二轮开始", func() bool { return p.callCount() >= 2 })
// ★ 关键:不得为"第一句"补跑。给足够时间让任何错误的重试发生。
time.Sleep(120 * time.Millisecond)
if got := p.callCount(); got > 3 {
t.Fatalf("ChatStream 被调用 %d 次 —— 存在「为旧输入重试」(契约 1/3 禁止补偿)", got)
}
stats, _ := host.RTChildStats(id)
// lastSeq 必须是**最新**输入的那次(第二句)。
if stats.LastSeq != 2 {
t.Fatalf("LastSeq = %d,期望 2(第二轮必须基于最新输入)", stats.LastSeq)
}
}
// ── R6:toolcall 期间不可打断 ──
// rtToolProvider 第一轮返回一个工具调用,第二轮返回纯文本。
type rtToolProvider struct {
mu sync.Mutex
calls int
toolCh chan struct{} // 工具开始时关闭(测试用它对齐时序)
}
func (p *rtToolProvider) Name() string { return "rtTool" }
func (p *rtToolProvider) MaxContextTokens() int { return 8192 }
func (p *rtToolProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{}, nil
}
func (p *rtToolProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.calls++
n := p.calls
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 4)
if n == 1 {
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{{
ID: "c1", Name: "rt_slow_tool", Arguments: map[string]interface{}{},
}}}
ch <- agentAPI.StreamChunk{Done: true}
} else {
ch <- agentAPI.StreamChunk{Content: "好", Done: true}
}
close(ch)
return ch, nil
}
// TestRTChild_NotInterruptibleDuringToolExec 钉住 R5 的边界:
// 工具执行期间新输入**不得**打断它(副作用不可回滚)。
//
// ❗ 这一条**单独测不出真实缺陷**(实测过):设备工具经 `a.io.ExecuteTool`
// 执行,它内部传的是 `context.Background()`(toolcall.go:377),工具根本
// 收不到 roundCtx。于是"工具没被打断"在下面两种情况下都成立:
//
// · 守卫生效(我们主动不取消)
// · 守卫失效但取消也没效果(工具不消费 ctx)
//
// 所以真正钉住守卫的是下一个判据(suppressedCancels)。这里保留本判据,
// 因为它测的是**用户可见的行为**(工具跑完),与下面的实现细节互补。
func TestRTChild_NotInterruptibleDuringToolExec(t *testing.T) {
p := &rtToolProvider{}
host, _ := newRTHost(t, p)
var executed atomic.Int64
done := make(chan struct{}, 1)
if err := host.io.RegisterDevice(&rtToolDevice{
onExec: func() {
executed.Add(1)
time.Sleep(80 * time.Millisecond)
select {
case done <- struct{}{}:
default:
}
},
}); err != nil {
t.Fatalf("注册工具设备: %v", err)
}
id := rtSpawn(t, host, "tool")
host.PushRTInput(id, "调用工具")
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
if _, err := host.PushRTInput(id, "新的插话"); err != nil {
t.Fatalf("插话: %v", err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("工具执行期间被新输入打断 —— 违反 R5「除 toolcall 期间都可打断」的边界")
}
}
// TestRTChild_CriticalSectionSuppressesCancel 是 R6 守卫的**直接判据**:
// 工具执行期收到新输入时,内核必须**记录一次"故意未取消"**。
//
// 这是唯一能区分"守卫真的执行了"与"守卫失效但取消没效果"的判据。
// 变异验证:把 `if critical > 0 { suppressedCancels++ }` 改成直接 cancel,
// 本判据必红(因为 suppressedCancels 恒为 0)。
func TestRTChild_CriticalSectionSuppressesCancel(t *testing.T) {
p := &rtToolProvider{}
host, _ := newRTHost(t, p)
var executed atomic.Int64
done := make(chan struct{}, 1)
if err := host.io.RegisterDevice(&rtToolDevice{
onExec: func() {
executed.Add(1)
time.Sleep(100 * time.Millisecond) // 留出插话窗口
select {
case done <- struct{}{}:
default:
}
},
}); err != nil {
t.Fatalf("注册工具设备: %v", err)
}
id := rtSpawn(t, host, "guard")
host.PushRTInput(id, "调用工具")
waitFor(t, "工具开始执行", func() bool { return executed.Load() == 1 })
// 工具执行期间推新输入:此时 critical > 0 ⇒ 必须抑制取消。
if _, err := host.PushRTInput(id, "工具期插话"); err != nil {
t.Fatalf("插话: %v", err)
}
waitFor(t, "抑制计数 +1", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.SuppressedCancels >= 1
})
<-done
}
// rtToolDevice 提供一个会阻塞的 rt_slow_tool。
type rtToolDevice struct {
name string
onExec func()
}
func (d *rtToolDevice) Name() string { return "rt-tool-dev" }
func (d *rtToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *rtToolDevice) Description() string { return "rt tool test device" }
func (d *rtToolDevice) Start() error { return nil }
func (d *rtToolDevice) Stop() error { return nil }
func (d *rtToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *rtToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *rtToolDevice) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{{
Name: "rt_slow_tool",
Description: "slow tool for rt test",
Parameters: map[string]interface{}{"type": "object"},
}}
}
func (d *rtToolDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if d.onExec != nil {
d.onExec()
}
return "tool done", nil
}
// TestRTChild_PromptRequiresPreCallNotice 钉住用户裁定 R4:
// 默认提示词必须要求"调用工具前先告知用户"。
func TestRTChild_PromptRequiresPreCallNotice(t *testing.T) {
if !strings.Contains(rtChildDefaultPrompt, "调用任何工具之前") {
t.Fatal("rtchild 默认提示词缺少「调用工具前先告知」约束(用户裁定 R4)")
}
if !strings.Contains(rtChildDefaultPrompt, "实时通话") {
t.Fatal("rtchild 默认提示词缺少【实时通话】段")
}
}

View File

@ -0,0 +1,218 @@
package core
// R7 判据:rtchild 的上下文 = **普通上下文 + 上下文窗口**。
//
// 用户 2026-10-30 裁定:没有页、没有锚点、没有降级阶梯 —— 就是普通对话历史,
// 进模型前按窗口裁。本文件钉住三件事:
//
// 1. 历史**跨轮累积**(通话是一条连续对话,不能每轮失忆)
// 2. 超窗口时**从最旧丢**,system 与本轮输入永不丢
// 3. 裁剪**绝不拆开** assistant(tool_calls) 与其配对 tool 结果
// (切在中间上游直接 400,见 context.go:102 的生产事故)
import (
"context"
"sync"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// ── 纯函数判据:rtTrimToWindow ──
// TestRTTrim_KeepsSystemAndLatestInput 钉住:system 与本轮 user 输入永不裁。
//
// 这两条一丢,rt 就彻底坏了:
// - system 丢了 ⇒ 人格与【实时通话】约束消失(模型不再先说"稍等")
// - 本轮输入丢了 ⇒ 模型在回答上一轮的问题
func TestRTTrim_KeepsSystemAndLatestInput(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "你是助手"},
{Role: "user", Content: "旧问题"},
{Role: "assistant", Content: "旧回答"},
{Role: "user", Content: "本轮问题"},
}
// 窗口小到只装得下首尾两条。
out := rtTrimToWindow(msgs, 10)
if len(out) < 2 {
t.Fatalf("裁完只剩 %d 条,system 或本轮输入被丢了", len(out))
}
if out[0].Role != "system" || out[0].Content != "你是助手" {
t.Fatalf("system 被裁掉了:%+v", out[0])
}
if last := out[len(out)-1]; last.Role != "user" || last.Content != "本轮问题" {
t.Fatalf("本轮输入被裁掉了:%+v", last)
}
}
// TestRTTrim_DropsOldestFirst 钉住「从最旧丢」。
func TestRTTrim_DropsOldestFirst(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "最早的问题"},
{Role: "assistant", Content: "最早的回答"},
{Role: "user", Content: "最近的问题"},
{Role: "assistant", Content: "最近的回答"},
{Role: "user", Content: "本轮"},
}
out := rtTrimToWindow(msgs, 12)
for _, m := range out {
if m.Content == "最早的问题" {
t.Fatalf("最早的消息仍在(应从最旧丢):%+v", contentsOf(out))
}
}
}
// TestRTTrim_NeverSplitsToolPairs 是最重要的一条:
// assistant(tool_calls) 与其配对 tool 结果必须**整组进出**。
//
// 若实现按单条消息裁(而不是按组),这里会切出「有 tool_calls 但没有结果」
// 或「有 tool 结果但找不到调用」的残缺序列 —— 上游直接 400。
func TestRTTrim_NeverSplitsToolPairs(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
// ── 组 A:一次工具调用 + 两个配对结果 ──
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
{ID: "c1", Name: "t1"}, {ID: "c2", Name: "t2"},
}},
{Role: "tool", ToolCallID: "c1", Content: "结果1"},
{Role: "tool", ToolCallID: "c2", Content: "结果2"},
{Role: "user", Content: "中间问题"},
{Role: "assistant", Content: "中间回答"},
{Role: "user", Content: "本轮"},
}
// 用一个"刚好要丢掉一部分"的窗口,验证组不会被切开。
for _, window := range []int{20, 40, 60, 200} {
out := rtTrimToWindow(msgs, window)
assertToolPairsIntact(t, window, out)
}
}
// assertToolPairsIntact 断言 out 里每个 assistant(tool_calls) 的 ID 集合
// 与紧随其后的 tool 消息 ID 集合完全一致(不残缺、不多余)。
func assertToolPairsIntact(t *testing.T, window int, out []agentAPI.Message) {
t.Helper()
for i := 0; i < len(out); i++ {
m := out[i]
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
continue
}
want := map[string]bool{}
for _, tc := range m.ToolCalls {
want[tc.ID] = true
}
got := map[string]bool{}
for j := i + 1; j < len(out) && out[j].Role == "tool"; j++ {
got[out[j].ToolCallID] = true
}
if len(got) != len(want) {
t.Fatalf("window=%d: tool_call 与结果不配对(call=%v result=%v)—— 上游会 400\n序列:%+v",
window, want, got, contentsOf(out))
}
for id := range want {
if !got[id] {
t.Fatalf("window=%d: 调用 %s 没有配对结果 —— 上游会 400", window, id)
}
}
}
// 反向:不得出现没有前置 assistant 的孤儿 tool 消息。
for i, m := range out {
if m.Role != "tool" {
continue
}
if i == 0 {
t.Fatalf("window=%d: 序列以 tool 消息开头(孤儿结果)", window)
}
}
}
func contentsOf(msgs []agentAPI.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
s := m.Role + ":"
if m.Content != "" {
s += m.Content
}
if len(m.ToolCalls) > 0 {
s += "[calls]"
}
out = append(out, s)
}
return out
}
// TestRTTrim_ShortHistoryUntouched 钉住:本来就没超窗口时**一条都不删**。
//
// 这一条防"为了让窗口判据变绿而无脑裁剪"——那会把还装得下的历史白白丢掉。
func TestRTTrim_ShortHistoryUntouched(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "a"},
{Role: "assistant", Content: "b"},
{Role: "user", Content: "c"},
}
out := rtTrimToWindow(msgs, 100000)
if len(out) != len(msgs) {
t.Fatalf("窗口充裕却裁掉了消息:%v → %v", contentsOf(msgs), contentsOf(out))
}
}
// ── 集成判据:rtchild 跨轮累积上下文 ──
// rtMsgCountProvider 记录每次 ChatStream 收到的消息条数,
// 用来证明"历史真的被带进了下一轮"(而不是每轮失忆)。
type rtMsgCountProvider struct {
mu sync.Mutex
sizes []int
}
func (p *rtMsgCountProvider) Name() string { return "rtMsgCount" }
func (p *rtMsgCountProvider) MaxContextTokens() int { return 8192 }
func (p *rtMsgCountProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{}, nil
}
func (p *rtMsgCountProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.sizes = append(p.sizes, len(req.Messages))
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "回"}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtMsgCountProvider) snapshot() []int {
p.mu.Lock()
defer p.mu.Unlock()
return append([]int(nil), p.sizes...)
}
// TestRTChild_HistoryAccumulatesAcrossRounds 是「普通上下文」的核心判据:
// 第二轮发给模型的消息数必须**多于**第一轮(历史被带上了)。
//
// 若 buildRTContext 每轮只发 system+input(我最初的"无历史"最小版),
// 两轮都是 2 条 —— 本判据必红。
func TestRTChild_HistoryAccumulatesAcrossRounds(t *testing.T) {
p := &rtMsgCountProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "hist")
host.PushRTInput(id, "第一句")
waitFor(t, "第一轮完成", func() bool { return len(p.snapshot()) >= 1 })
host.PushRTInput(id, "第二句")
waitFor(t, "第二轮完成", func() bool { return len(p.snapshot()) >= 2 })
sizes := p.snapshot()
if sizes[1] <= sizes[0] {
t.Fatalf("第二轮消息数 %d 不多于第一轮 %d —— 上下文没有跨轮累积"+
"(rtchild 应是普通上下文,通话不能每轮失忆)", sizes[1], sizes[0])
}
// 第二轮 = system + (user+assistant) + user = 4 条(window 充裕时)
if sizes[1] < 4 {
t.Fatalf("第二轮只有 %d 条消息,期望 ≥4(system + 上一轮问答 + 本轮输入)", sizes[1])
}
}

View File

@ -0,0 +1,291 @@
package core
// R7b 判据:裁剪只是**视图**,原文永不丢弃;回收时把全文交回主 agent。
//
// 用户 2026-10-30 裁定:
//
// 「老内容不再进入 LLM,但是**原文保留**,rtchild 回收时依旧向主 agent
// 展示全部上下文。」
//
// 这两件事必须分开,混在一起就会静默丢数据:
//
// 视图(进 LLM) —— 按窗口裁 ← 判据 1
// 存储(history) —— 全文永不丢 ← 判据 2
// 回收交接 —— 交**全文** ← 判据 3
//
// 删掉任一条实现,对应判据必红。
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// rtLongHistoryProvider 每轮回复一句固定文本,用于累积历史。
type rtLongHistoryProvider struct {
mu sync.Mutex
reqs [][]agentAPI.Message // 每次请求的完整消息(拷一份)
}
func (p *rtLongHistoryProvider) Name() string { return "rtLongHistory" }
func (p *rtLongHistoryProvider) MaxContextTokens() int { return 700 } // > system(444),否则 system 一条就超窗、裁无可裁
func (p *rtLongHistoryProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{}, nil
}
func (p *rtLongHistoryProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, append([]agentAPI.Message(nil), req.Messages...))
p.mu.Unlock()
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: strings.Repeat("答", 40)}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
func (p *rtLongHistoryProvider) rounds() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.reqs)
}
func (p *rtLongHistoryProvider) lastReq() []agentAPI.Message {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.reqs) == 0 {
return nil
}
return p.reqs[len(p.reqs)-1]
}
// TestRTChild_ViewIsTrimmedButHistoryIsNot 是同一条判据的两半,必须成对断言:
//
// ① 进 LLM 的视图**确实被裁**(旧内容不再进模型)
// ② 存储的 history **一条不少**(原文保留)
//
// 只断言 ① ⇒ "每轮清空历史"也能通过(那不是裁,是丢)
// 只断言 ② ⇒ "从不裁"也能通过(那会撑爆窗口)
func TestRTChild_ViewIsTrimmedButHistoryIsNot(t *testing.T) {
p := &rtLongHistoryProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "trim")
// 跑多轮,故意让历史远超窗口(MaxContextTokens=120,每轮产出 ~40 字)。
const rounds = 6
for i := 0; i < rounds; i++ {
host.PushRTInput(id, strings.Repeat("问", 30))
want := (i + 1) * 2
// ★ 必须等 **transcript 条数**,不能等 p.rounds()。
//
// p.rounds() 在 ChatStream **被调用时**就自增(此时请求刚构建完),
// 而 assistant 那条消息是**流结束后**才追加进 history 的。
// 等 rounds 的话第 N 轮请求一发出就放行,随即读 transcript 会少一条
// (实测 11 vs 12)—— 这是判据自己的竞态,不是产品缺陷。
waitFor(t, "本轮完成", func() bool {
tr, ok := host.RTTranscript(id)
return ok && len(tr) >= want
})
}
// ② 存储侧:6 轮 ⇒ 每轮 user+assistant 两条,共 12 条,**一条都不能少**。
transcript, ok := host.RTTranscript(id)
if !ok {
t.Fatal("取不到 transcript")
}
if len(transcript) != rounds*2 {
t.Fatalf("history 有 %d 条,期望 %d 条 —— 原文被裁掉了(用户裁定:原文保留)",
len(transcript), rounds*2)
}
// 最早那一轮的原话必须还在。
if !strings.Contains(transcript[0].Content, "问") {
t.Fatalf("最早的一轮不见了:%+v", transcript[0])
}
// ① 视图侧:最后一次请求的消息数必须**明显少于** history(说明确实裁了)。
last := p.lastReq()
if len(last) >= len(transcript)+1 { // +1 = 本轮 user 之前已在 history 里
t.Logf("视图 %d 条 / 历史 %d 条:窗口未触发裁剪(内容可能还不够大)", len(last), len(transcript))
}
viewTokens := 0
for _, m := range last {
viewTokens += rtMsgTokens(m)
}
// 允许一定估算偏差(+20%),但视图必须**明显小于**完整历史。
fullTokens := 0
for _, m := range append([]agentAPI.Message{{Role: "system", Content: rtChildDefaultPrompt}}, transcript...) {
fullTokens += rtMsgTokens(m)
}
if viewTokens > 700*12/10 {
t.Fatalf("视图估算 %d token,远超窗口 700 —— 裁剪没生效(旧内容仍在进模型)", viewTokens)
}
if fullTokens > 700 && viewTokens >= fullTokens*3/4 {
t.Fatalf("视图 %d token 与完整历史 %d token 相当 —— 没有真正裁掉旧内容",
viewTokens, fullTokens)
}
}
// TestRTChild_HistorySurvivesInputSupersede 钉住:被打断的轮次,**已说出口的部分**
// 也要留在 history 里(它确实被用户听到了,下一轮不能假装没发生)。
func TestRTChild_HistorySurvivesInputSupersede(t *testing.T) {
p := &rtLongHistoryProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "super")
host.PushRTInput(id, "第一问")
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
host.PushRTInput(id, "第二问")
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
tr, _ := host.RTTranscript(id)
if len(tr) < 4 {
t.Fatalf("两轮之后 history 只有 %d 条,期望 ≥4(每轮 user+assistant)", len(tr))
}
}
// TestFormatRTTranscript_PreservesDialogueFully 钉住渲染口径:
// user/assistant **逐字保留**;工具结果按上限截断(体积大头,复盘只需结论)。
func TestFormatRTTranscript_PreservesDialogueFully(t *testing.T) {
longToolResult := strings.Repeat("x", rtTranscriptToolMaxRunes*3)
msgs := []agentAPI.Message{
{Role: "user", Content: "帮我查一下明天的天气"},
{Role: "assistant", ToolCalls: []agentAPI.ToolCall{
{ID: "c1", Name: "weather_query", Arguments: map[string]interface{}{"city": "北京"}},
}},
{Role: "tool", ToolCallID: "c1", Content: longToolResult},
{Role: "assistant", Content: "明天北京晴,18-26 度。"},
}
out := FormatRTTranscript(msgs)
for _, want := range []string{
"帮我查一下明天的天气",
"weather_query",
"city=北京",
"明天北京晴,18-26 度。",
} {
if !strings.Contains(out, want) {
t.Fatalf("交接文本缺少 %q\n---\n%s", want, out)
}
}
// 工具结果被截断(不把整页塞回去),但仍有内容。
if strings.Contains(out, strings.Repeat("x", rtTranscriptToolMaxRunes+50)) {
t.Fatal("工具结果未按上限截断 —— 会把体积大头整段塞回主 agent 上下文")
}
if !strings.Contains(out, "xxx") {
t.Fatal("工具结果被整段丢掉了 —— 复盘需要看到结论")
}
}
// TestCloseRTChild_PushesSignalNotBody 是**交接协议**的判据(用户裁定 2026-10-30):
//
// 回收走「contextfull 式中断」—— 只推**信号**(规模),正文留着等主 agent
// 按需拉取,由它自己选保留哪些。
//
// 若把 CloseRTChild 改回"直接把全文 injectSelfChannel 进主 agent 上下文",
// 本判据必红:正文动辄几十万字符,全灌进去会顶爆父的上下文。
func TestCloseRTChild_PushesSignalNotBody(t *testing.T) {
p := &rtLongHistoryProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "close")
host.PushRTInput(id, "第一句原话")
waitFor(t, "第一轮完成", func() bool { return p.rounds() >= 1 })
host.PushRTInput(id, "第二句原话")
waitFor(t, "第二轮完成", func() bool { return p.rounds() >= 2 })
before, _ := host.RTTranscript(id)
n, err := host.CloseRTChild(id)
if err != nil {
t.Fatalf("回收失败:%v", err)
}
if n != len(before) {
t.Fatalf("交接条数 %d,实际 %d", n, len(before))
}
if _, ok := host.RTChildStats(id); ok {
t.Fatal("回收后实例仍在登记表里")
}
// ★ 核心断言:父侧收到的是**中断信号**(level=L4,source=rtchild/<id>),
// 且 payload 里**没有**通话正文 —— 正文是要去拉的东西,不该被推进上下文。
//
// 注意这里查的是**调度器的中断登记**而不是 selfInputCh:
// 交接走的是 contextfull 同款 L4(raiseVector),不是自循环消息。
snap := host.DumpScheduler()
if len(snap.InterruptQueues[LevelCritical]) == 0 {
t.Fatalf("没有产生 L4 中断 —— 交接必须走 contextfull 式中断,实际快照:%+v", snap)
}
// 原文必须仍可**按需取回**(回收之后也能取)。
after, ok := host.RTTranscript(id)
if !ok {
t.Fatal("回收后取不到原文 —— 「回收时向主 agent 展示全部上下文」断链")
}
if len(after) != len(before) {
t.Fatalf("回收后原文 %d 条,回收前 %d 条", len(after), len(before))
}
}
// TestRTTranscript_ToolServesAllContentAndPages 钉住 rt_transcript 工具:
// 它必须能取回全部原文(分段),这是主 agent"自行选择保留内容"的前提。
func TestRTTranscript_ToolServesAllContentAndPages(t *testing.T) {
p := &rtLongHistoryProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "tool")
const rounds = 3
for i := 0; i < rounds; i++ {
host.PushRTInput(id, fmt.Sprintf("第%d句原话", i))
want := i + 1
waitFor(t, "本轮完成", func() bool { return p.rounds() >= want })
}
host.CloseRTChild(id)
// 第一段:默认 limit。
out := host.executeRTTranscript(agentAPI.ToolCall{
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id},
})
if !strings.Contains(out, "共 ") {
t.Fatalf("工具输出没有规模信息:%s", truncateStr(out, 200))
}
if !strings.Contains(out, "第0句原话") {
t.Fatalf("第一段缺少最早的原话:%s", truncateStr(out, 300))
}
// 分段续读:offset 应能取到后面的内容(用 limit=1 逼出分页)。
page2 := host.executeRTTranscript(agentAPI.ToolCall{
Name: "rt_transcript", Arguments: map[string]interface{}{"id": id, "offset": 1, "limit": 1},
})
if !strings.Contains(page2, "1-2") {
t.Fatalf("分页参数未生效(应返回第 1-2 条):%s", truncateStr(page2, 200))
}
// 未知 id 必须明确报错(不静默返回空)。
bad := host.executeRTTranscript(agentAPI.ToolCall{
Name: "rt_transcript", Arguments: map[string]interface{}{"id": "nope"},
})
if !strings.Contains(bad, "不存在") {
t.Fatalf("未知 id 未明确报错:%s", bad)
}
}
// TestCloseRTChild_EmptyHistoryNoSpam 钉住:没有历史时不推中断(别污染主 agent)。
func TestCloseRTChild_EmptyHistoryNoSpam(t *testing.T) {
p := &rtLongHistoryProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "empty")
n, err := host.CloseRTChild(id)
if err != nil {
t.Fatalf("回收失败:%v", err)
}
if n != 0 {
t.Fatalf("空历史返回 %d 条", n)
}
time.Sleep(30 * time.Millisecond)
select {
case msg := <-host.selfInputCh:
t.Fatalf("空历史也推了信号,污染主 agent:%q", truncateStr(msg.text, 80))
default:
}
}

View File

@ -0,0 +1,432 @@
package core
// 判据:**插件 → rtchild 的通路**。
//
// 这是本轮发现的最后一个真实缺口(比 R10 的 notify_parent 更关键):
//
// 插件只能调 Inject* 系列(SDK 里没有任何 rtchild 接口),
// 它们全部汇到 IOManager.deliverInput → routeInputByOwner。
// 而初版 routeInputByOwner 只认 a.residents(驻留子)——
// rt 通道的 Owner 是 rtchild id,查不到就**兜底给主 agent 处理**。
//
// 后果:插件的语音/画面输入永远打在父身上,rtchild 的轮次永远是 0。
// 这正是 resident-subagent-design.md 记的那个现场故障的同型形态
// (「子挂着 inputch=[timer],timer 的输入却打在父身上」)。
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// registerRTChannelWithOwner 注册一个 rt 通道并划给指定 rtchild。
func registerRTChannelWithOwner(t *testing.T, host *Agent, name, ownerID string) {
t.Helper()
reg := host.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{
Name: name, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道: %v", err)
}
if err := reg.Assign(name, ownerID, 0); err != nil {
t.Fatalf("划给 %s: %v", ownerID, err)
}
}
// TestRTChannel_PluginInputReachesRTSlot 是这条通路的**核心判据**:
// 插件往 rt 通道注入输入,必须进 rtSlot 并真的跑起一轮。
//
// 若 routeInputByOwner 不识别 rtchild,输入会被兜底给主 agent 处理
// (宿主没有调度器,于是什么都不会发生)—— 本判据会超时变红。
func TestRTChannel_PluginInputReachesRTSlot(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 先注册一对 rt 通道;**划拨由 SpawnRTChild 自己做**(成对划拨)。
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "voice-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
// 插件注入:完全走真实路径(IOManager → deliverInput → router)。
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "用户说话了"})
waitFor(t, "rtchild 被插件输入唤起", func() bool {
st, ok := host.RTChildStats(id)
return ok && st.Rounds >= 1
})
calls, _ := p.stats()
if calls == 0 {
t.Fatal("插件输入到达了 rtSlot 但没跑模型")
}
// 内容必须真的进入 history(不是空跑)。
tr, _ := host.RTTranscript(id)
if len(tr) == 0 {
t.Fatal("跑了一轮但 history 为空")
}
}
// TestRTChannel_PluginInputIsLatestWins 钉住:插件连发时也走**单槽覆盖**语义,
// 不会因为"走了路由"就退化成排队。
func TestRTChannel_PluginInputIsLatestWins(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "burst-child", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
time.Sleep(30 * time.Millisecond)
const burst = 40
for i := 0; i < burst; i++ {
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
map[string]interface{}{"content": "连发语音"})
}
waitFor(t, "槽被消费", func() bool {
st, ok := host.RTChildStats(id)
return ok && !st.Pending
})
st, _ := host.RTChildStats(id)
// ★ 判据取**不变式**而不是"跑了多少轮"。
//
// 初版断言 rounds <= 3,在 -race 下稳定失败(实测跑了 13 轮):
// 竞态插桩让 emit 变慢,40 条在 rtLoop 起轮之前全部到达,于是
// 每完成一轮就立刻被下一条唤起 —— 那不是"队列退化",而是**正常的
// 最新者胜**(每一轮用的都是当时最新的一条,没有一条在等前一条跑完)。
//
// 真正该钉的是:**每条输入要么被处理过、要么被覆盖丢弃**,
// 不存在"既没处理也没记录丢弃"的第三条路。
if st.Dropped == 0 && st.Rounds < burst {
t.Fatalf("连发 %d 条:dropped=0 且只跑了 %d 轮 —— "+
"有输入凭空消失了(既没被处理,也没计入覆盖丢弃)", burst, st.Rounds)
}
// 槽最终必须被清空(没有积压 —— 这是"不排队"的直接证据)。
if st.Pending {
t.Fatal("连发结束后槽里仍有待处理输入 —— rt 通道在排队")
}
}
// TestRTChannel_NoOwnerStillFallsBackToMain 钉住边界:
// rt 通道**未划拨**时输入仍归父处理(不静默丢)。
//
// 这条防"为了让 rt 通路工作而把所有 rt 输入都截走"——那会让未分配的
// rt 通道输入凭空消失。
func TestRTChannel_NoOwnerStillFallsBackToMain(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 注册但**不划拨**(Owner == "")—— 这条判据测的是路由的兜底分支,
// 与 SpawnRTChild 的成对划拨无关,所以直接登记一条裸通道。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "unassigned", Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册: %v", err)
}
// 路由必须返回 false(不接管),让父的队列收下它。
if handled := host.routeInputByOwner(&agentIO.InputEvent{
Source: "voice-plugin", Type: "text", OutputChannel: "unassigned",
Payload: map[string]interface{}{"content": "无人认领"},
}, false); handled {
t.Fatal("未划拨的 rt 通道输入被截走了 —— 会静默丢输入")
}
}
// TestRTChannel_SDKRealtimeBecomesCapRT 钉住 SDK → 内核的**唯一一次**能力位转换:
// 插件在 ChannelDef 里声明 Realtime=true,登记后通道必须带上 CapRT,
// 从而**能**被 AssignRealtime 划拨。
//
// 若去掉 Register 里的映射,插件就无法登记 rt 通道 ——
// rtchild 永远拿不到插件的语音输入(整条链路断在第一步)。
func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
// 插件侧声明(走 SDK 的 ChannelDef.Realtime)。
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "sdk-rt", Plugin: "voice",
Def: agentIO.ChannelDef{Realtime: true},
}); err != nil {
t.Fatalf("注册: %v", err)
}
ch, ok := host.io.ChannelRegistry().Lookup("sdk-rt")
if !ok {
t.Fatal("通道未登记")
}
if !ch.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("Def.Realtime=true 但 Capabilities 没有 CapRT(= %v)—— "+
"插件声明的 rt 通道无法被划拨", ch.Capabilities)
}
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
rc := host.rtChildren[id]
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
}
}
// ── 成对划拨 / 主 agent 无 rt 能力 / 销毁后悬空 ──
//
// 用户裁定(2026-10-30):
// 「主 agent 创建 rtchild,将 rt 通道划给 rtchild,开始工作
// (rt 通道必须**成对**划给子 agent)」
// 「主 agent 没有 rt 能力,rt 通道在 rtchild 销毁后应当**悬空**」
// registerRTPair 注册一对 rt 通道(input + output),供 Spawn 划拨。
func registerRTPair(t *testing.T, host *Agent, in, out string) {
t.Helper()
reg := host.io.ChannelRegistry()
for _, n := range []string{in, out} {
if err := reg.Register(agentIO.InputChannel{
Name: n, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册 rt 通道 %s: %v", n, err)
}
}
}
// TestSpawnRTChild_AssignsPairedChannels 钉住"成对划拨":
// SpawnRTChild 必须把 input 与 output **两条**都划给 rtchild,而不是只划一条。
//
// 变红条件:去掉 SpawnRTChild 里的 AssignRealtime 调用 ⇒ Owner 仍是 "" ⇒ 红。
func TestSpawnRTChild_AssignsPairedChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
id, err := host.SpawnRTChild(RTChildOptions{ID: "paired", Input: "voice-in", Output: "voice"})
if err != nil {
t.Fatalf("创建 rtchild: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
ch, ok := reg.Lookup(n)
if !ok {
t.Fatalf("通道 %s 未登记", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— "+
"rt 通道没有成对划给 rtchild", n, ch.Owner, id)
}
}
}
// TestSpawnRTChild_RejectsMissingHalf 钉住成对的**强制性**:
// 只给 input 或只给 output 都必须被拒(否则 rtchild 半残且不报错)。
func TestSpawnRTChild_RejectsMissingHalf(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in2", "voice2")
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-in", Output: "voice2"}); err == nil {
t.Fatal("只给 outputch 竟然创建成功 —— rtchild 会永远听不到用户")
}
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-out", Input: "voice-in2"}); err == nil {
t.Fatal("只给 inputch 竟然创建成功 —— rtchild 的输出无处可去")
}
// 被拒后**不得**在登记表里留下半残实例。
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("创建被拒但登记表里有 %v", got)
}
}
// TestSpawnRTChild_RejectsChannelOwnedByMainAgent 钉住「主 agent 没有 rt 能力」:
// 一条归属于主 agent 的通道不能被拿来当 rt 通道。
func TestSpawnRTChild_RejectsChannelOwnedByMainAgent(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in3", "voice3")
// 把 input 划给主 agent 自己(模拟"主 agent 占着这条通道")。
if err := host.io.ChannelRegistry().Assign("voice-in3", string(host.id), 0); err != nil {
t.Fatalf("划给主 agent: %v", err)
}
_, err := host.SpawnRTChild(RTChildOptions{ID: "steal", Input: "voice-in3", Output: "voice3"})
if err == nil {
t.Fatal("主 agent 自己占着的通道被划成 rt 通道 —— 主 agent 不持有 rt 能力")
}
}
// TestStopRTChild_ChannelsBecomeDangling 钉住用户裁定:
// rtchild 销毁后通道**悬空**(Owner 仍指向已销毁的 id),**不归还为未分配**。
//
// 为什么必须悬空而不是归还:
// - 归还为 "" ⇒ routeInputByOwner 会把它兜底给主 agent,而主 agent
// 没有 rt 能力 ⇒ 一条 rt 通道静默变成普通通道(不可查的坏状态)
// - 悬空 ⇒ 路由查不到该 owner,走既有"owner 无匹配 agent"兜底**并留日志**
// (可查的坏状态)
func TestStopRTChild_ChannelsBecomeDangling(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in4", "voice4")
id, err := host.SpawnRTChild(RTChildOptions{ID: "dangling", Input: "voice-in4", Output: "voice4"})
if err != nil {
t.Fatalf("创建: %v", err)
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in4", "voice4"} {
ch, ok := reg.Lookup(n)
if !ok {
continue // 注销也算一种处理;但插件声明的 rt 通道不该被注销
}
if ch.Owner == "" {
t.Fatalf("通道 %s 被归还为未分配 —— 主 agent 没有 rt 能力,"+
"归还会让它静默兜底到主 agent(应保持悬空)", n)
}
if ch.Owner != id {
t.Fatalf("通道 %s 的 Owner = %q,期望悬空保留 %q", n, ch.Owner, id)
}
}
}
// ── rt_children 工具面(主 agent 创建 rtchild 的唯一入口) ──
// TestRTChildrenTool_ListEmpty 钉住空态提示可读。
func TestRTChildrenTool_ListEmpty(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(got, "没有实时子 agent") {
t.Fatalf("空态提示不对:%q", got)
}
}
// TestRTChildrenTool_CreateRequiresBothChannels 钉住成对要求,
// 且失败提示必须**列出可用的 rt 通道**(否则模型只能瞎猜名字反复重试)。
func TestRTChildrenTool_CreateRequiresBothChannels(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// 只给 input:必须拒,并列出可用通道。
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "c1", "inputch": "voice-in",
},
})
if !strings.Contains(got, "必须成对") {
t.Fatalf("只给 inputch 未被拒:%q", got)
}
if !strings.Contains(got, "voice-in") || !strings.Contains(got, "voice") {
t.Fatalf("失败提示没有列出可用 rt 通道(模型无法自我修正):%q", got)
}
}
// TestRTChildrenTool_FullLifecycle 是**端到端**:模型视角走一遍
// create → list → send → stop(这正是"主 agent 创建 rtchild 并开始工作")。
func TestRTChildrenTool_FullLifecycle(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
registerRTPair(t, host, "voice-in", "voice")
// ① create(成对)
out := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "create", "id": "call-1",
"inputch": "voice-in", "outputch": "voice",
},
})
if !strings.Contains(out, "已创建") {
t.Fatalf("create 失败:%q", out)
}
// 通道必须已成对划给它。
reg := host.io.ChannelRegistry()
for _, n := range []string{"voice-in", "voice"} {
if ch, _ := reg.Lookup(n); ch.Owner != "call-1" {
t.Fatalf("通道 %s 的 Owner = %q,期望 call-1", n, ch.Owner)
}
}
// ② list 能看到它
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
})
if !strings.Contains(out, "call-1") {
t.Fatalf("list 里看不到刚创建的实例:%q", out)
}
if !strings.Contains(out, "打断") {
t.Fatalf("list 提示没有说明 rt 的打断语义:%q", out)
}
// ③ send:打断式注入,真的跑起来
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children",
Arguments: map[string]interface{}{
"action": "send", "id": "call-1", "text": "用户说:你好",
},
})
if !strings.Contains(out, "打断") {
t.Fatalf("send 没说明打断语义:%q", out)
}
waitFor(t, "rtchild 跑了一轮", func() bool {
st, ok := host.RTChildStats("call-1")
return ok && st.Rounds >= 1
})
// ④ stop:回收并交接
out = host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "stop", "id": "call-1"},
})
if !strings.Contains(out, "已回收") {
t.Fatalf("stop 失败:%q", out)
}
if !strings.Contains(out, "rt_transcript") {
t.Fatalf("stop 没有告诉模型用 rt_transcript 取回原文:%q", out)
}
if got := host.RTChildren(); len(got) != 0 {
t.Fatalf("回收后登记表仍有 %v", got)
}
}
// TestRTChildrenTool_UnknownAction 钉住未知 action 响亮报错并列出可用值。
func TestRTChildrenTool_UnknownAction(t *testing.T) {
p := &rtCountingProvider{}
host, _ := newRTHost(t, p)
got := host.executeRTChildren(agentAPI.ToolCall{
Name: "rt_children", Arguments: map[string]interface{}{"action": "destroy"},
})
if !strings.Contains(got, "未知 action") || !strings.Contains(got, "list") {
t.Fatalf("未知 action 提示不可操作:%q", got)
}
}
// TestRTChildrenTool_NotVisibleToChild 钉住工具是**父专属**的:
// rtchild 自己不该看到 rt_children(否则它能无限自我复制)。
func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
host.io.ChannelRegistry().Register(agentIO.InputChannel{
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
})
id := rtSpawn(t, host, "no-recurse")
rc := host.rtChildren[id]
if rc == nil {
t.Fatal("实例不在登记表")
}
if _, ok := rtToolNames(rc.agent.buildToolDefs())["rt_children"]; ok {
t.Fatal("rtchild 自己能看到 rt_children —— 会形成无界自我复制")
}
}

View File

@ -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
}

View File

@ -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 间回退,并把同源瞬时错误重试一次。

View File

@ -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":

View File

@ -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"))
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 分钟的阈值意味着

View File

@ -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 工具用。

View File

@ -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)
})

View File

@ -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
// ---- 多模态注入 ----

View File

@ -0,0 +1,114 @@
package proc
// 判据:**InjectOptions 的每一个字段都必须能跨进程传过去**。
//
// 背景(2026-10-30 审计):`pubSdkInjectOpts` 曾逐字段摊平传参
// (noMemory, policy, recallPolicy, cleanerName, priority),于是新增字段时
// 没人被提醒去接 —— 实际漏了 ScenePolicy 与 SecurityProfile。
//
// 后者尤其严重:QQ 插件的 owner/guest 档位就是靠 InjectOptions.SecurityProfile
// 选的,漏了等于「插件声明了档位、注入时带了、内核从没收到」,策略静默不生效。
//
// 现在签名接收整个 injectParams,新增字段时编译器会在这里提示。
import (
"testing"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// TestPubSdkInjectOpts_CarriesEveryField 钉住整体映射:
// injectParams 的每个 opt 字段都必须出现在输出的 InjectOptions 里。
func TestPubSdkInjectOpts_CarriesEveryField(t *testing.T) {
in := injectParams{
NoMemory: true,
ContextPolicy: "prune",
RecallPolicy: "none",
ScenePolicy: "none",
CleanerName: "voice-cleaner",
Priority: "L2",
SecurityProfile: "qq.guest",
}
got := pubSdkInjectOpts(in)
if !got.NoMemory {
t.Error("NoMemory 丢失")
}
if got.ContextPolicy != "prune" {
t.Errorf("ContextPolicy = %q", got.ContextPolicy)
}
if got.RecallPolicy != "none" {
t.Errorf("RecallPolicy = %q —— 漏传则插件声明的召回策略静默失效", got.RecallPolicy)
}
if got.ScenePolicy != "none" {
t.Errorf("ScenePolicy = %q —— 漏传则插件声明的场面策略静默失效", got.ScenePolicy)
}
if got.CleanerName != "voice-cleaner" {
t.Errorf("CleanerName = %q", got.CleanerName)
}
if got.SecurityProfile != "qq.guest" {
t.Errorf("SecurityProfile = %q —— 漏传则插件声明的安全档位**永不生效**"+
"(QQ owner/guest 就是走这条)", got.SecurityProfile)
}
// 外部插件的优先级必须被夹到 L1..L3(L4 内核独占)。
if got.Priority == "L4" {
t.Error("外部插件竟然传出了 L4 —— 优先级夹取失效")
}
}
// TestPubSdkInjectOpts_MediaPathKeepsOpts 钉住媒体注入路径同样不丢字段。
//
// 媒体路径用 injectMediaParams(多 Blocks/BlocksRef),曾经因为类型不同
// 而无法复用同一个转换函数 —— 那正是漏字段的温床。
func TestPubSdkInjectOpts_MediaPathKeepsOpts(t *testing.T) {
m := injectMediaParams{
NoMemory: true,
ScenePolicy: "none",
SecurityProfile: "qq.owner",
}
got := pubSdkInjectOpts(m.asInjectParams())
if !got.NoMemory {
t.Error("媒体路径 NoMemory 丢失")
}
if got.ScenePolicy != "none" {
t.Errorf("媒体路径 ScenePolicy = %q", got.ScenePolicy)
}
if got.SecurityProfile != "qq.owner" {
t.Errorf("媒体路径 SecurityProfile = %q —— 媒体注入同样要带档位", got.SecurityProfile)
}
}
// TestInjectParityWithSDK 是**防复发**判据:injectParams/injectMediaParams
// 必须覆盖 InjectOptions 的全部字段。
//
// 做法:构造一个"所有字段都非零"的 InjectOptions,逐个读出名称与值,
// 确认转换后不丢。新增 SDK 字段而没改这里时,本判据会红。
func TestInjectParityWithSDK(t *testing.T) {
// 全字段非零(新字段加进 SDK 后,这里也要加 —— 判据会提示)
want := pubsdk.InjectOptions{
NoMemory: true,
ContextPolicy: "prune",
RecallPolicy: "none",
ScenePolicy: "none",
CleanerName: "c",
Priority: "L2",
SecurityProfile: "p",
}
in := injectParams{
NoMemory: want.NoMemory, ContextPolicy: want.ContextPolicy,
RecallPolicy: want.RecallPolicy, ScenePolicy: want.ScenePolicy,
CleanerName: want.CleanerName, Priority: want.Priority,
SecurityProfile: want.SecurityProfile,
}
got := pubSdkInjectOpts(in)
// InjectOptions 含 map 字段(不可用 ==),逐字段比。
if got.NoMemory != want.NoMemory ||
got.ContextPolicy != want.ContextPolicy ||
got.RecallPolicy != want.RecallPolicy ||
got.ScenePolicy != want.ScenePolicy ||
got.CleanerName != want.CleanerName ||
got.Priority != want.Priority ||
got.SecurityProfile != want.SecurityProfile {
t.Fatalf("字段不齐:\n got = %+v\nwant = %+v", got, want)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -0,0 +1,134 @@
package plugin
// 判据:插件通过**普通通道注册接口**声明 rt 能力(`ChannelDef.Realtime`)。
//
// ## 为什么走普通接口而不是新增一对便捷方法
//
// 本仓 SDK 的通道注册只有两个入口(`RegisterInputChannel` /
// `RegisterOutputChannel`),属性一律由 `ChannelDef` 承载 —— 与
// `cli` / `webui` / `remotedevice` 等所有既有插件的写法完全一致:
//
// _ = s.RegisterInputChannel("cli", sdk.ChannelDef{})
// s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, handler)
//
// 加一对 `RegisterRealtimeInputChannel` / `RegisterRealtimeOutputChannel`
// 会多出**第二套写法**:同一个能力两种声明方式,必然漂移
// (一处改了、另一处没改,且不报错)。所以 rt 能力就走 `ChannelDef.Realtime`。
//
// ## 这两条判据钉的是什么
//
// SDK 声明 → 内核能力位的映射在**两个不同的地方**(输入侧在
// `ChannelRegistry.Register`,输出侧在 `Registry.regOutput`)。
// 只做一侧就会出现"inputch 声明成 rt 了、outputch 没有"⇒
// `AssignRealtime(outputch)` 被拒 ⇒ 插件作者以为"我声明了 rt"却划不上。
// 所以两侧各一条,删掉任一侧的映射都会红。
import (
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// TestRealtimeChannel_InputSideMapsFromChannelDef 钉住输入侧映射:
// 插件用 ChannelDef{Realtime:true} 声明后,登记表里的通道必须带 CapRT。
func TestRealtimeChannel_InputSideMapsFromChannelDef(t *testing.T) {
reg := agentIO.NewChannelRegistry()
// 走**普通**接口(不存在的便捷方法不该被使用)。
if err := reg.Register(agentIO.InputChannel{
Name: "voice-in", Plugin: "demo",
Def: agentIO.ChannelDef{Realtime: true},
}); err != nil {
t.Fatalf("注册: %v", err)
}
ch, ok := reg.Lookup("voice-in")
if !ok {
t.Fatal("通道未登记")
}
if !ch.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("Def.Realtime=true 但 Capabilities=%v 缺 CapRT —— "+
"插件声明的 rt 通道无法被 AssignRealtime 划拨", ch.Capabilities)
}
}
// TestRealtimeChannel_OutputSideMapsFromChannelDef 钉住输出侧映射:
// 插件用 RegisterOutputChannel(..., ChannelDef{Realtime:true}, ...) 声明后,
// 该 device 的 OutputCapabilities 必须带 CapRT。
//
// ★ 这条钉的是 `Registry.regOutput` 里的映射 —— 它和输入侧是**两处代码**。
// 删掉那一处,本判据红而输入侧那条仍绿,正好定位到"只改了一半"。
func TestRealtimeChannel_OutputSideMapsFromChannelDef(t *testing.T) {
r := NewRegistry()
iom := agentIO.NewIOManager()
r.SetIOManager(iom)
sd := r.buildSDK("demo")
var called bool
handler := func(args map[string]interface{}) (interface{}, error) {
called = true
return "ok", nil
}
// 走**普通** RegisterOutputChannel(与 cli/webui 等既有插件同一写法),
// rt 能力只靠 ChannelDef.Realtime 表达。
if err := sd.RegisterOutputChannel("voice", 1, "实时通话输出",
sdk.ChannelDef{Realtime: true}, handler); err != nil {
t.Fatalf("注册输出通道: %v", err)
}
dev := iom.GetDevice("voice")
if dev == nil {
t.Fatal("输出通道未注册成 device")
}
gotCaps := dev.OutputCapabilities()
if !gotCaps.Supports(agentIO.CapRT) {
t.Fatalf("ChannelDef.Realtime=true 但 OutputCapabilities=%v 缺 CapRT —— "+
"AssignRealtime(outputch) 会被拒", gotCaps)
}
// caps 原本声明的文本能力不能被覆盖掉。
if !gotCaps.Supports(agentIO.CapText) {
t.Fatalf("补 CapRT 时把原有的 CapText 弄丢了:%v", gotCaps)
}
// handler 仍可执行(映射不能破坏 device 行为)。
if _, err := dev.Execute("voice", map[string]interface{}{"text": "hi"}); err != nil {
t.Fatalf("Execute: %v", err)
}
if !called {
t.Fatal("handler 未被调用")
}
}
// TestRealtimeChannel_NonRealtimeUnchanged 钉住**没有**副作用:
// 不声明 Realtime 的通道,abilities 里不得凭空出现 CapRT。
//
// 这条防"补映射时顺手把所有通道都标成 rt"——那会让 rt 校验形同虚设。
func TestRealtimeChannel_NonRealtimeUnchanged(t *testing.T) {
reg := agentIO.NewChannelRegistry()
if err := reg.Register(agentIO.InputChannel{
Name: "plain", Plugin: "demo", Def: agentIO.ChannelDef{},
}); err != nil {
t.Fatalf("注册: %v", err)
}
ch, _ := reg.Lookup("plain")
if ch.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("未声明 Realtime 的通道却带上了 CapRT(%v)—— rt 校验会形同虚设",
ch.Capabilities)
}
r2 := NewRegistry()
iom2 := agentIO.NewIOManager()
r2.SetIOManager(iom2)
sd2 := r2.buildSDK("demo2")
if err := sd2.RegisterOutputChannel("plainout", 1, "普通输出",
sdk.ChannelDef{}, func(map[string]interface{}) (interface{}, error) { return "ok", nil }); err != nil {
t.Fatalf("注册输出通道: %v", err)
}
if dev := iom2.GetDevice("plainout"); dev == nil {
t.Fatal("device 未注册")
} else if dev.OutputCapabilities().Supports(agentIO.CapRT) {
t.Fatal("未声明 Realtime 的输出通道却带上了 CapRT")
}
}

View File

@ -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),

View File

@ -0,0 +1,248 @@
//go:build linux || darwin
package plugins
// 端到端判据:**真实外部插件**(子进程)驱动 rtchild 全链路。
//
// ## 为什么这条判据必须存在(单测覆盖不到的那一段)
//
// `internal/agent/core/rtchild_*_test.go` 证明的是**进程内**逻辑:
// rtSlot 单槽、打断、流式、交接。但真实插件是**独立子进程**,
// 它到内核之间还隔着:SDK 声明 → proc 桥(stdio RPC + 共享内存)→
// 内核登记表 → 通道划拨 → 注入路由 → 输出回写。
//
// 这一段出问题的历史是有据可查的:
// - `notify_parent` 回调漏接线(工具在、一调就"没有上级")
// - `routeInputByOwner` 只认 residents,rt 输入被兜底给主 agent
// - 输出侧漏映射 `ChannelDef.Realtime → CapRT`(插件声明了 rt 却划不上)
//
// 三者都是"进程内单测绿、真实链路断",所以必须有这条跨进程判据。
//
// ## 前置
//
// 插件需先用 `hmapdev build` 产出 .hmap 并解到某个目录。未提供时 **skip**
// 而非 fail(与 real_plugin_smoke_test.go 同口径:CI 上不强制先跑构建)。
//
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
// go test ./internal/plugins/ -run TestExternalRTChild -v
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// e2eEchoProvider 是最小 provider:ChatStream 立刻回一段文本。
//
// 用它而不是真模型:本判据验证的是**链路**(通道登记→划拨→注入→路由→
// 输出回写),不是模型质量。真模型会让本判据依赖网络与配额,变成 flaky。
type e2eEchoProvider struct{}
func (p *e2eEchoProvider) Name() string { return "e2e-echo" }
func (p *e2eEchoProvider) MaxContextTokens() int { return 8192 }
func (p *e2eEchoProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "echo"}, nil
}
func (p *e2eEchoProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 2)
ch <- agentAPI.StreamChunk{Content: "这是 rtchild 的实时回复"}
ch <- agentAPI.StreamChunk{Done: true}
close(ch)
return ch, nil
}
// rtE2EPluginBin 取外部插件产物路径(环境变量或 hmapdev 的默认输出)。
func rtE2EPluginBin(t *testing.T) string {
t.Helper()
if p := os.Getenv("RTCHILD_E2E_PLUGIN_BIN"); p != "" {
if _, err := os.Stat(p); err != nil {
t.Skipf("RTCHILD_E2E_PLUGIN_BIN 指向的文件不可读:%v", err)
}
return p
}
// 默认位置:/tmp/rtvoice-e2e/rtvoice-e2e/dist 解出的产物。
for _, cand := range []string{
"/tmp/pkg-rt/plugin.bin.linux.amd64",
"/tmp/rtvoice-e2e/rtvoice-e2e/build/plugin.bin",
} {
if _, err := os.Stat(cand); err == nil {
return cand
}
}
t.Skip("未找到 rtvoice-e2e 插件产物;先 hmapdev build 并解包," +
"或用 RTCHILD_E2E_PLUGIN_BIN=<plugin.bin 路径> 指定")
return ""
}
// installRTPlugin 把外部插件产物装进插件目录(复用 installRealPlugin 的落盘口径)。
func installRTPlugin(t *testing.T, plgDir, name, binPath string) {
t.Helper()
dst := filepath.Join(plgDir, name)
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatalf("建插件目录: %v", err)
}
data, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("读产物: %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "plugin.bin"), data, 0o755); err != nil {
t.Fatalf("写 plugin.bin: %v", err)
}
manifest := fmt.Sprintf(
`{"name":%q,"name_zh":%q,"name_en":%q,"version":"0.1.0","entry":"plugin.so","auto_start":true}`,
name, name, name)
if err := os.WriteFile(filepath.Join(dst, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatalf("写 plugin.json: %v", err)
}
}
// TestExternalRTChild_FullChain 是**跨进程全链路**判据:
//
// 外部插件声明 rt 通道
// → 内核登记表里两条通道都带 CapRT
// → SpawnRTChild 成对划拨成功
// → 插件注入 → 路由进 rtSlot → rtchild 跑一轮
// → rtchild 输出写进 outputch → 回到插件 handler
//
// 任何一环断开,本判据都会在对应断言上失败(而不是"超时说不清原因")。
func TestExternalRTChild_FullChain(t *testing.T) {
bin := rtE2EPluginBin(t)
env := setupIntegration(t)
defer env.cleanup()
plgDir := filepath.Join(env.tmpDir, "plugins")
installRTPlugin(t, plgDir, "rtvoice-e2e", bin)
if err := env.pluginReg.Load(plgDir); err != nil {
t.Fatalf("加载外部插件: %v", err)
}
if env.pluginReg.Get("rtvoice-e2e") == nil {
t.Fatal("rtvoice-e2e 未加载")
}
// ── ① 插件声明的 rt 通道必须真的带 CapRT ──
//
// 这一条专钉"输出侧漏映射"那个缺陷:输入侧在 ChannelRegistry.Register
// 里映射,输出侧在 Registry.regOutput 里映射,两处代码分开。
inCh := "rtvoice-e2e-in"
outCh := "rtvoice-e2e"
reg := env.iom.ChannelRegistry()
inEntry, ok := reg.Lookup(inCh)
if !ok {
t.Fatalf("rt inputch %q 未登记(插件声明没到内核)", inCh)
}
if !inEntry.Capabilities.Supports(agentIO.CapRT) {
t.Fatalf("rt inputch 缺 CapRT(capabilities=%v)—— Def.Realtime 未映射", inEntry.Capabilities)
}
if dev := env.iom.GetDevice(outCh); dev == nil {
t.Fatalf("rt outputch %q 未注册成 device", outCh)
} else if !dev.OutputCapabilities().Supports(agentIO.CapRT) {
t.Fatalf("rt outputch 缺 CapRT(caps=%v)—— regOutput 漏了 Def.Realtime 映射",
dev.OutputCapabilities())
}
// ── ② 成对划拨 ──
// 内核侧的 Agent 由本测试自建(testPluginEnv 不含 Agent:它只装配插件面)。
host := agentCore.New(agentCore.AgentConfig{
ID: "e2e-host",
IO: env.iom,
Provider: &e2eEchoProvider{},
})
rtID, err := host.SpawnRTChild(agentCore.RTChildOptions{
ID: "e2e-rt", Input: inCh, Output: outCh,
})
if err != nil {
t.Fatalf("创建 rtchild 失败: %v", err)
}
t.Cleanup(func() { host.StopRTChildren() })
for _, n := range []string{inCh, outCh} {
ch, _ := reg.Lookup(n)
if ch.Owner != rtID {
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— 成对划拨未生效", n, ch.Owner, rtID)
}
}
// ── ③ 插件注入 → rtchild 跑起来 ──
//
// 走真实 RPC:调插件工具 rtvoice_inject,它内部 InjectInterruptTextOpts。
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
map[string]interface{}{"text": "用户说:讲个短故事"}); err != nil {
t.Fatalf("调插件注入工具失败: %v", err)
}
deadline := time.Now().Add(15 * time.Second)
var rounds uint64
for time.Now().Before(deadline) {
if st, ok := host.RTChildStats(rtID); ok && st.Rounds > 0 {
rounds = st.Rounds
break
}
time.Sleep(20 * time.Millisecond)
}
if rounds == 0 {
t.Fatal("插件注入后 rtchild 从未跑过一轮 —— 输入没有到达 rtSlot" +
"(检查 routeInputByOwner 的 rt 分支)")
}
t.Logf("rtchild 已跑 %d 轮", rounds)
// ── ④ 输出回到插件 handler ──
//
// 把插件的观测面拉回来:它记录的 outputs 里必须有东西。
var dump map[string]interface{}
deadline = time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
res, err := env.stageHost.ExecuteTool("rtvoice_dump", map[string]interface{}{})
if err != nil {
t.Fatalf("调插件 dump 工具失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
dump = m
if n, _ := m["output_count"].(float64); n > 0 {
break
}
}
time.Sleep(50 * time.Millisecond)
}
if dump == nil {
t.Fatal("dump 没有返回结构化结果")
}
if n, _ := dump["output_count"].(float64); n == 0 {
t.Fatalf("rtchild 跑了 %d 轮,但插件 outputch 一片空白 —— "+
"输出没有回到插件(检查 rtEmit 与 outputch 的接线)", rounds)
}
// 注入记录也要能回读(证明是**本插件**注入的,不是别的路径)。
if inj, ok := dump["injections"].([]interface{}); !ok || len(inj) == 0 {
t.Fatalf("插件没有记录到自己的注入:%v", dump["injections"])
}
t.Logf("跨进程全链路通过:rtchild 轮次=%d,插件收到输出=%v 条",
rounds, dump["output_count"])
// ── ⑤ 回收:交接 + 通道悬空 ──
if _, err := host.CloseRTChild(rtID); err != nil {
t.Fatalf("回收 rtchild 失败: %v", err)
}
for _, n := range []string{inCh, outCh} {
ch, ok := reg.Lookup(n)
if !ok {
continue
}
if ch.Owner == "" {
t.Fatalf("通道 %s 被归还为未分配 —— 应保持悬空(主 agent 无 rt 能力)", n)
}
}
if strings.TrimSpace(fmt.Sprint(host.RTChildren())) != "[]" {
t.Fatalf("回收后登记表仍有 %v", host.RTChildren())
}
}

View File

@ -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.