Files
HomeAgent/internal/agent/core/interrupt_vectors.go
JianFeeeee eb1deab5dd 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
2026-10-30 17:00:47 +08:00

195 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// 中断向量表 —— 中断源的**统一登记处**。
//
// ## 为什么需要它(这是中断子系统独立出来的真正目的)
//
// 拆分之前,"有哪些内核中断、各自什么语义"散落在三个裸调用点上:
//
// raiseKernelInterrupt("kernel", "kernel", "任务 #N panic…")
// raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
// raiseKernelInterrupt("child/"+rc.id, "kernel", "驻留子已满…")
//
// 三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
// 最能说明问题的是 panic 的递归保护:
//
// if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
// // 否则同一个 panic 会自我放大成中断风暴
//
// 这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
// **新增一种内核中断时,没人会记得要不要也加递归保护**——这就是要向量化的理由。
//
// ## 边界
//
// 向量表属于**进入中断**的入口定义,因此住在中断管理器这一侧
// (用户裁定:中断管理器负责"进入中断")。它只回答"这次中断长什么样、
// 谁能产生它",**不参与**排队、优先级比较、恢复选择。
//
// ## 加一种新的内核中断
//
// 只改这一个文件:在 VectorID 里加常量、在 interruptVectors 里加一条。
// 规则(级别/前置判定/载荷构造/来源标识)随条目声明,不再靠调用方自律。
import "fmt"
// VectorID 是中断源的**稳定标识**。
//
// 与 source 字符串的区别:source 是给人和日志看的(如 "child/xxx"),
// 会随实例变化;VectorID 是稳定的枚举值,可被诊断面、指标、日志字段引用,
// 也可用来回答"现在一共登记了几种内核中断"。
type VectorID string
const (
// VectorTaskPanic:任务 panic。带递归保护(见 interruptVectors)。
VectorTaskPanic VectorID = "task_panic"
// VectorContextOverflow:上下文超页,需要提示模型"内容被移走了"。
VectorContextOverflow VectorID = "ctx_overflow"
// VectorChildContextFull:驻留子上下文已满,父据此决定压缩/回收/销毁。
VectorChildContextFull VectorID = "child_ctxfull"
// VectorRTChildClosed:rtchild 回收,父据此**自行选择**要保留哪些上下文。
VectorRTChildClosed VectorID = "rtchild_closed"
)
// vectorArgs 是一次中断向量调用的全部入参。
//
// 用显式结构体而不是 `any`:每种中断用哪些字段一眼可见,
// 且新增字段时编译器会提醒所有构造点。
type vectorArgs struct {
// Task 是发生 panic 的任务(VectorTaskPanic 使用)。
Task *Task
// Panic 是 recover() 取到的值(VectorTaskPanic 使用)。
Panic any
// ChildID 是驻留子标识(VectorChildContextFull 使用)。
ChildID string
// RTID 是 rtchild 标识(VectorRTChildClosed 使用)。
RTID string
// RTCount / RTChars 是本次交接的规模(VectorRTChildClosed 使用),
// 让主 agent 判断"值不值得去取全文"。
RTCount int
RTChars int
}
// interruptVector 是一个中断源的**完整契约**。
type interruptVector struct {
// ID 是稳定标识。
ID VectorID
// Level 是该源产生的中断级别。内核源恒为 L4(LevelCritical)。
Level Level
// Source 构造来源标识(写进 payload 的 interrupt_source)。
// 需要动态前缀的源(如 child/<id>)在闭包里拼。
Source func(a vectorArgs) string
// Guard 是登记前的**前置判定**,返回 false 表示本次不产生中断。
//
// 这是原先散落在调用方里的规则的收容处——例如 panic 的递归保护:
// 若 panic 的任务本身就是 L4 内核中断,就不能再产生新的 L4,
// 否则同一个 panic 会自我放大成中断风暴。
Guard func(a vectorArgs) bool
// Build 构造中断正文(模型能看到的唯一内容)。
Build func(a vectorArgs) string
}
// interruptVectors 是中断源的登记表。
//
// 判据(interrupt_vectors_test.go):
// - 表内 ID 必须与 interruptVector.ID 一致(防手滑)
// - 每条必须有 Source/Guard/Build,且 Level 必须是内核级
// - panic 条目必须能挡住"L4 panic 再产生 L4"(递归保护)
var interruptVectors = map[VectorID]interruptVector{
VectorTaskPanic: {
ID: VectorTaskPanic,
Level: LevelCritical,
Source: func(vectorArgs) string { return "kernel" },
Guard: func(a vectorArgs) bool {
// 递归保护是**结构性**的(原先硬编码在 reportTaskPanic 里):
// 若 panic 的任务本身就是 L4 内核中断,则不再产生新的 L4——
// 否则同一个 panic 会自我放大成中断风暴,与"内核事件"的语义相反。
if a.Task == nil {
return false
}
return !(a.Task.Class == TaskInterrupt && a.Task.Level >= LevelCritical)
},
Build: func(a vectorArgs) string {
return fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)",
a.Task.ID, a.Panic)
},
},
VectorContextOverflow: {
ID: VectorContextOverflow,
Level: LevelCritical,
Source: func(vectorArgs) string { return "kernel/overflow" },
Guard: func(vectorArgs) bool { return true },
// ★ 空中断:**不携带任何消息**(2026-10-25 按原始设计修正)。
//
// 原始设计里这个 L4 的用途是「**触发**语义相关性裁剪」——它本身
// 只是一次调度信号,不是一条给模型的通知。此前的实现把它变成了
// 「裁剪完事后发一条回执」(先在 raise 之前 pruneByQuery,再让
// Build 生成「已裁剪 N 条…」的说明文本),顺序与语义都反了。
//
// 现在:Build 返回空串 ⇒ payload 里没有 content ⇒ 任务是空中断;
// 裁剪由任务真正被调度执行时(rebalanceFrameContextPrefix)触发。
//
// 为什么必须带空串而不是省略 Build:lookupVector 的契约要求 Build
// 存在(否则调用方 nil panic),"没有正文"要显式表达成 ""。
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,
Source: func(a vectorArgs) string { return "child/" + a.ChildID },
Guard: func(vectorArgs) bool { return true },
Build: func(a vectorArgs) string {
// 父子协议:只推信号,细节靠"查看"拉状态面。
return fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。"+
"请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", a.ChildID)
},
},
}
// lookupVector 取一个向量;未登记时返回 false(**不静默降级**)。
//
// 未登记必须响亮失败而不是悄悄用默认级别——否则新增中断源时打错 ID
// 会变成"中断发了但没人认领",比不编译还难查。
func lookupVector(id VectorID) (interruptVector, bool) {
v, ok := interruptVectors[id]
return v, ok
}
// raiseVector 是登记一次内核中断的**统一入口**。
//
// 与直接调用 raiseKernelInterrupt 的区别:级别、来源标识、前置判定、
// 正文构造全部来自向量表,调用点只声明"哪一类中断 + 载荷"。
//
// 返回 true 表示中断真的占了 immediate 槽(会立即生效)。
func (a *Agent) raiseVector(id VectorID, args vectorArgs) bool {
v, ok := lookupVector(id)
if !ok {
// 未登记是**编码错误**:宁可响亮失败,也不要静默发出一个没人认领的中断。
panic(fmt.Sprintf("core: 未登记的中断向量 %q(请在 interrupt_vectors.go 里登记)", id))
}
if v.Guard != nil && !v.Guard(args) {
return false
}
return a.raiseKernelInterruptAt(v.Source(args), "kernel", v.Build(args), v.Level)
}