feat(agent/core): 中断向量表 —— 三个 L4 源统一登记,行为零变化

用户原话:「需要添加中断向量系统,独立成中断子模块就是为了方便添加这个
中断向量系统」。本次落地该目的。

## 为什么需要向量表

拆分前"有哪些内核中断、各自什么语义"散落在三个裸调用点上:

    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 里。
**新增一种内核中断时,没人会记得要不要也加递归保护**。

## 设计

interruptVector 声明一条中断源的完整契约:
  ID / Level / Source / Guard / Build

- Level 由条目自己声明(不再写死 L4)
- Guard 收容前置判定 —— panic 的递归保护落在这里
- Build 构造正文(模型唯一可见内容)
- 未登记 ID **panic**,不静默降级(打错 ID 会变成"中断发了但没人认领")

## 改动

新增 interrupt_vectors.go + interrupt_vectors_test.go(7 条判据)
三个调用点全部改为 raiseVector(id, args)
新增 raiseKernelInterruptAt / newKernelInterruptTaskAt 以表达条目声明的级别

## 行为零变化(刻意)

- 三个源的 source/正文/级别与改动前逐字一致
- ExpectsReply 字段**未实现**:按计划 §7.5/§7.6,它属于行为变更
  (内核提示不再引起模型整轮回复),必须单独提交、单独验证

## 变异验证

- 去掉 panic 递归保护 ⇒ TestVectors_PanicGuardBlocksRecursiveL4 红
- 调用点绕过向量表 ⇒ TestVectors_PanicSourceComesFromTable 红
  (source="kernel-direct",期望 "kernel")

## 验证

go build ./... / go test ./internal/agent/core/ / go test -race 全通过
This commit is contained in:
JianFeeeee
2026-10-25 14:45:36 +08:00
parent 01bef35c89
commit 8d5e31f7c0
6 changed files with 390 additions and 12 deletions

View File

@ -1181,7 +1181,17 @@ func newSelfTask(msg selfInputMsg) *Task {
// 这是 L4 的唯一来源:panic 中断、内核事件中断(selfip)。
// 插件永远拿不到这个入口——它不经 InjectOptions,也不经 proc 桥。
func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()}
return newKernelInterruptTaskAt(evt, LevelCritical)
}
// newKernelInterruptTaskAt 按指定级别构造内核中断任务。
//
// 内核源不夹取级别(与插件源不同):向量表里登记的级别就是最终级别。
func newKernelInterruptTaskAt(evt *agentIO.InputEvent, level Level) *Task {
if level < LevelBackground || level > LevelCritical {
level = LevelCritical
}
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
}
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
@ -1350,8 +1360,20 @@ func (a *Agent) pumpInbox() {
// 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤
// (与 interceptLoop 对插件中断的处理完全一致)。
func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
a.raiseKernelInterruptAt(source, channel, text, LevelCritical)
}
// raiseKernelInterruptAt 是 raiseKernelInterrupt 的**带级别**版本。
//
// 级别由调用方给出(中断向量表里登记的那个),而不是在这里写死 L4:
// 向量表的每条都声明了自己的 Level,这条通道必须能表达它,
// 否则"级别由向量声明"就成了一句空话。
//
// 注意:外部插件走的不是这条路(它们经 interruptLevel 解析 + clampPluginLevel
// 夹取),这里只服务内核自己登记的中断源。
func (a *Agent) raiseKernelInterruptAt(source, channel, text string, level Level) bool {
if a.sched == nil {
return
return false
}
evt := &agentIO.InputEvent{
Source: source,
@ -1365,9 +1387,11 @@ func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
"kernel": true,
},
}
if a.sched.requestKernelPreempt(evt) {
armed := a.sched.registerInterrupt(newKernelInterruptTaskAt(evt, level))
if armed {
a.cancelCurrentLLM()
}
return armed
}
// reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。
@ -1375,11 +1399,9 @@ func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
// 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的
// L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。
func (a *Agent) reportTaskPanic(t *Task, r interface{}) {
if t.Class == TaskInterrupt && t.Level >= LevelCritical {
return
}
a.raiseKernelInterrupt("kernel", "kernel",
fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)", t.ID, r))
// 递归保护("L4 panic 不再产生 L4")已收进向量表的 Guard,
// 不再硬编码在这里——新增中断源时不会漏掉这条规则。
a.raiseVector(VectorTaskPanic, vectorArgs{Task: t, Panic: r})
}
func (a *Agent) executeTask(t *Task) {