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