feat(scheduler): L4 也归内核级插件 —— WebUI 终止按钮可用“立即打断”

上一提交把 L4 写成“内核独占(panic / selfip)”,漏了内核级插件这类来源。
用户澄清:**内核级插件应当能声明 L4,用于实现中断能力**,例如 WebUI 的终止按钮。

判据(两道闸,纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3。在这里夹而不是只按 source 判,
     是因为 source 是插件自报字段、可以冒名;本函数所在位置能确知“来自外部进程”。
  2. core:isKernelLevelSource(source) 查 pluginReg.IsBuiltinPlugin,只有编译期内置
     插件(init() 自注册的工厂)才承认 L4。
source 约定 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段——
否则带设备身份的 WebUI 来源会被误判成外部插件而拿不到 L4。

改动:
- core: interruptLevel(evt, privileged bool);新增 isKernelLevelSource;
  requestPreempt 不再夹取(级别已由 interruptLevel 解析,否则内核级插件的 L4 被削掉)。
- eventloop: 传入 a.isKernelLevelSource(evt.Source)。
- proc 桥: 新增 clampExternalPriority,pubSdkInjectOpts 一律夹取。
- internal/sdk: 再导出 PriorityL1..L4(内置插件用 sdk.PriorityL4)。
- webui handleChatInterrupt(终止按钮)声明 PriorityL4。
- timer 声明 PriorityL3:定时器是“时钟那种实时工作”,比 QQ 那类可无限等待的
  异步消息高(L1)——这是对用户“它不是时钟那种实时工作”的直接推论,可改。
- 测试: L4 特权矩阵(非特权夹取 / 特权承认)、source 判据(内置、内置/实例、
  外部、空、前缀不误匹配)、内核级插件 L4 一路到达调度器、proc 夹取两条。
- 设计稿 §2/§3.2/§11.1/§14/§15 按“L4 = 内核 + 内核级插件”更正。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿(含 webui/timer)。
This commit is contained in:
JianFeeeee
2026-09-13 07:18:03 +08:00
parent cb32032f76
commit f3232000f4
10 changed files with 260 additions and 38 deletions

View File

@ -12,8 +12,10 @@
> | 定位 | 需要及时处理 | 不需要及时处理 |
> | 可被谁打断 | 仅**严格更高级**的中断 | **任何**中断 |
>
> 级别(“这项工作有多不能等”)由插件在 `InjectOptions.Priority` 里声明 L1L3
> **L4 由内核独占**,只经 panic 与内核事件中断selfip产生。
> 级别(“这项工作有多不能等”)由来源在 `InjectOptions.Priority` 里声明
> L1L3 任何插件可声明;**L4 是“立即打断”能力**,只有**内核自身**panic /
> 内核事件 selfip经 `raiseKernelInterrupt`)与**内核级插件**(编译期内置插件,
> 如 WebUI 的终止按钮)能用。外部插件的 L4 会被夹到 L3。
> 类别由**用哪个注入 API**决定与通道名无关——QQ 走的是 `InjectInterruptTextOpts`
> 所以它是**低级别中断L1**,不是排队输入。
@ -137,15 +139,25 @@ Step枚举顺序执行步与步之间是安全点
| 来源 | 可达级别 | 入口 |
|---|---|---|
| 插件声明 | L1L3 | `InjectOptions.Priority`(空/非法 → L1声明 L4 被夹到 L3 |
| 内核 | L4唯一来源 | `(*Agent).raiseKernelInterrupt`panic / selfip |
| 普通插件(外部,独立进程/动态库) | L1L3 | `InjectOptions.Priority`(空/非法 → L1L4 被夹到 L3 |
| **内核级插件**(编译期内置,`init()` 自注册) | L1**L4** | 同上L4 用于实现**中断能力**,例如 WebUI 的终止按钮 |
| 内核自身 | L4 | `(*Agent).raiseKernelInterrupt`panic / selfip |
-**不是运维可调项**。不引入 `core.agent.priority.<channel>` 这类配置键,
也不把 `PriorityLookup` 做成可注入的策略表。
- ✅ 插件**可以声明**自己中断的级别(这不是“把内核内部属性外化”,
而是调用方声明它自己那件事有多不能等),但**内核独占 L4**
`clampPluginLevel` 把越权声明夹到 L3`L4` 在插件可达路径上不存在。
- 定级规则可随内核演进调整,但插件可声明域**始终不含 L4**。
而是调用方声明它自己那件事有多不能等)
-**L4 给“立即打断”能力**内核自身panic / selfip与**内核级插件**
(编译期内置插件,如 WebUI 终止按钮)可声明。为什么必须给内置插件:
用户按下终止按钮时,内核需要一条能立刻打断当前任务的中断;这条能力不能给
外部插件,否则任何第三方插件都能随时打断用户的一切工作。
- **判据是“这个插件是不是编译期内置”,不是它自报的名字**
- 第一道闸在 **proc 桥**(外部进程的唯一入口):走它的一律把 L4 夹到 L3。
在这里夹而不是只按 `source` 判,是因为 `source` 是插件自报字段、可以冒名。
- 第二道闸在 **core**`isKernelLevelSource(source)`
`pluginReg.IsBuiltinPlugin`,只有内置工厂才承认 L4纵深防御
- `source` 的约定是 `插件名``插件名/实例`(如 `webui/<deviceID>`
判据取第一段——否则带设备身份的 WebUI 来源会被误判成外部插件。
### 3.3 抢占判据
@ -437,7 +449,8 @@ v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 st
| P6 | 临界区不可抢占 | running 声明临界区;注入 L4 | 同上L4 请求留在中断队列,临界区结束立即被选中 |
| **P7** | **排队任务被任何中断打断** | running=排队任务;注入 **L1** 中断 | L1 也抢占成功(排队任务有效级 0 |
| **P8** | **排队输入永不抢占** | running=任意任务;注入排队输入 | 不抢占,入排队队列 |
| **P9** | **插件不能声明 L4** | `InjectOptions.Priority="L4"` | 级别被夹到 L3`payload["priority"]` 走同一条路 |
| **P9** | **外部插件不能声明 L4** | 外部来源声明 `Priority="L4"` | 被夹到 L3proc 桥 + core 双重) |
| **P11** | **内核级插件可用 L4** | 内置插件(如 webui声明 `L4` | 得到 L4 并立即打断当前任务(终止按钮) |
| **P10** | **panic 产生 L4 中断** | 任务 panic | 产生一条带 `kernel=true` 的 L4 中断L4 自身 panic 不再递归 |
### 11.2 保存现场与恢复
@ -570,7 +583,7 @@ go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk
| 项 | 内容 | 验收 |
|---|---|---|
| 类别化 | `TaskClass{queued,interrupt}`;类别由注入 API 决定;`newInputTask`/`newSelfTask` 为 queued`newInterruptTask` 为 interrupt | `scheduler_kernel_test.go` P7/P8 |
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt)``payload["priority"]` | P9、Q1 |
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt, privileged)``payload["priority"]` | P9、P11、Q1 |
| L4 内核独占 | `raiseKernelInterrupt`panic/selfip`requestKernelPreempt` 不夹取panic 报告为 L4 且带递归保护 | P10、`TestKernel_PanicRaisesL4Interrupt` |
| 选择结构 | `immediate` + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 `pickTaskIndex`/`taskBefore` 与“同级 pending 优先”补丁 | Q1Q3、Q6 |
| 栈上界 | `maxSuspendDepth`(配置语义)→ `maxInterruptFrames = int(LevelCritical)`(结构推论);删除“超限转 pending”降级 | D1T |
@ -600,8 +613,10 @@ go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk
> **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”,
> 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻:
> **L1L3 就是给插件声明使用的**,只有 L4 归内核独占panic / selfip
> 因此 `InjectOptions.Priority` 已落地§3.2/§13
> **L1L3 就是给插件声明使用的**。L4 的归属后来也明确了——不是“只有
> panic/selfip”而是**内核 + 内核级插件**(编译期内置)都能用,用于实现
> “立即打断”panic、内核事件、WebUI 终止按钮)。因此公开 SDK 同时导出了
> `PriorityL4`(附“仅内核级插件”的说明)。
>
> 仍**不做**的是“运维可调的策略表”(`core.agent.priority.<channel>`)——
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。

View File

@ -54,9 +54,10 @@ func (a *Agent) interceptLoop() {
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
//
// 级别由插件声明InjectOptions.Priority → payload["priority"]L1..L3
// 未声明一律 L1。L4 只能由内核的 raiseKernelInterrupt 产生。
level := interruptLevel(evt)
// 级别由来源声明InjectOptions.Priority → payload["priority"]
// 未声明一律 L1。L4(“立即打断”)只有内核级插件能声明,
// 外部插件即便报了 L4 也会被夹到 L3内核自身另有 raiseKernelInterrupt。
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
if a.sched.requestPreempt(clone, level) {
a.cancelCurrentLLM()
}

View File

@ -14,7 +14,9 @@ package core
//
// 级别只属于中断:
// - L1..L3 由插件在 InjectOptions.Priority 里声明(见 clampPluginLevel
// - L4 由内核独占,只能经 raiseKernelInterrupt 产生(panic / selfip
// - L4 给“立即打断”能力:内核自身(raiseKernelInterruptpanic / selfip
// 与**内核级插件**(编译期内置插件,如 WebUI 终止按钮)可声明;
// 外部插件经 proc 桥被夹到 L3core 里也再判一次来源。
//
// # 选择顺序
//
@ -33,6 +35,7 @@ import (
"fmt"
"log"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
@ -69,8 +72,10 @@ const (
// 取最低级是刻意的:**显式才是特权**,新插件不会默认拿到抢占权。
const DefaultLevel = LevelBackground
// clampPluginLevel 把插件声明的级别夹到允许范围(L1..L3
// L4 是内核的调度内部属性,不接受外部越权。
// clampPluginLevel 把**非内核级**来源声明的级别夹到 L1..L3。
//
// L4 是“立即打断”能力panic / 内核事件 / 内核级插件的终止按钮),
// 只给内核与编译期内置插件;外部插件声明 L4 会被夹到 L3。
func clampPluginLevel(l Level) Level {
if l < LevelBackground {
return DefaultLevel
@ -521,8 +526,12 @@ func (s *scheduler) enqueueInterruptLocked(t *Task) {
//
// 临界区(如记忆整理)内不 arm、不取消中断只入队等临界区结束后的安全点处理
// 这是设计 §4.3 的硬要求——那个位置的“不抢占”不能只是不让位,还必须不取消。
// level 必须是**已解析好**的中断级别(含特权判定):
// 生产路径只有 interruptLoop它用 (*Agent).interruptLevel 得出 level
// 内核自身用 requestKernelPreempt固定 L4。本函数不再夹取
// 否则内核级插件的 L4 会被无辜削掉。
func (s *scheduler) requestPreempt(evt *agentIO.InputEvent, level Level) bool {
return s.registerInterrupt(newInterruptTask(evt, clampPluginLevel(level)))
return s.registerInterrupt(newInterruptTask(evt, level))
}
// requestKernelPreempt 是**内核**中断入口panic / 内核事件 selfip
@ -624,13 +633,15 @@ func (s *scheduler) done(t *Task) {
// 用于在 prepare 段把级别写进帧(抢占比较的基准)。
// interruptLevel 返回一次**中断注入**的级别。
//
// 级别是“这项工作有多不能等”,由插件在 InjectOptions.Priority 里声明
// 级别是“这项工作有多不能等”,由来源在 InjectOptions.Priority 里声明
// (排队注入没有级别,它们的 TaskClass 是 TaskQueued
//
// 取值域 L1..L3空/非法一律降到 DefaultLevelL1
// **L4 不在此处产生**:它由内核独占,经 raiseKernelInterrupt 直接给出
// panic / 内核事件 selfip因此 clampPluginLevel 会把越权声明夹回 L3。
func interruptLevel(evt *agentIO.InputEvent) Level {
// privileged 表示来源是**内核级插件**(编译期内置插件,见 isKernelLevelSource
// - privileged=true → 可用到 L4实现“立即打断”如 WebUI 终止按钮)
// - privileged=false → 夹到 L1..L3空/非法一律降级为 DefaultLevelL1
//
// 另有完全绕过本函数的 L4 来源:内核自身的 raiseKernelInterruptpanic / selfip
func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level {
if evt == nil || evt.Payload == nil {
return DefaultLevel
}
@ -639,9 +650,29 @@ func interruptLevel(evt *agentIO.InputEvent) Level {
if !ok {
return DefaultLevel
}
if privileged {
return l
}
return clampPluginLevel(l)
}
// isKernelLevelSource 报告某来源是否是**内核级插件**(编译期内置插件)。
//
// 只有它们能声明 L4见 interruptLevel。判据是插件注册表里的“内置工厂”
// 而不是插件自报的名字本身——外部插件经 proc 桥时已被夹到 L3这里是第二道闸。
//
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
func (a *Agent) isKernelLevelSource(source string) bool {
if source == "" || a.pluginReg == nil {
return false
}
name := source
if i := strings.IndexByte(name, '/'); i > 0 {
name = name[:i]
}
return a.pluginReg.IsBuiltinPlugin(name)
}
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
// 只认字面量:拼写错误必须降级成默认级而不是被静默当成别的级别。
func parseInterruptLevel(s string) (Level, bool) {

View File

@ -15,12 +15,14 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 插件声明 L4 必须被夹到 L3L4 是内核的调度内部属性,不接受外部越权
func TestKernel_PluginCannotClaimL4(t *testing.T) {
// 非内核级来源声明 L4 必须被夹到 L3;内核级来源(内置插件)可用到 L4
func TestKernel_L4RequiresKernelLevelSource(t *testing.T) {
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
t.Fatalf("插件声明 L4 应被夹到 L3实际 %v", got)
t.Fatalf("非特权声明 L4 应被夹到 L3实际 %v", got)
}
cases := []struct {
declared string
@ -30,7 +32,7 @@ func TestKernel_PluginCannotClaimL4(t *testing.T) {
{"L2", LevelMessage},
{"L3", LevelInteractive},
{"l2", LevelMessage},
{"L4", LevelInteractive}, // 权 → 夹到 L3
{"L4", LevelInteractive}, // 非特权 → 夹到 L3
{"L7", DefaultLevel}, // 未知 → 默认级
{"", DefaultLevel}, // 未声明 → 默认级
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
@ -40,13 +42,62 @@ func TestKernel_PluginCannotClaimL4(t *testing.T) {
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt); got != c.want {
t.Fatalf("声明 %q → 级别 %v期望 %v", c.declared, got, c.want)
if got := interruptLevel(evt, false); got != c.want {
t.Fatalf("非特权声明 %q → 级别 %v期望 %v", c.declared, got, c.want)
}
}
if got := interruptLevel(nil); got != DefaultLevel {
if got := interruptLevel(nil, false); got != DefaultLevel {
t.Fatalf("无事件应为默认级,实际 %v", got)
}
// 特权内核级插件L4 被承认,其余待遇不变。
for _, c := range []struct {
declared string
want Level
}{
{"L4", LevelCritical},
{"L3", LevelInteractive},
{"L1", LevelBackground},
{"", DefaultLevel},
{"L9", DefaultLevel},
} {
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt, true); got != c.want {
t.Fatalf("特权声明 %q → 级别 %v期望 %v", c.declared, got, c.want)
}
}
}
// 内核级 = 插件注册表里的**内置工厂**(编译期自注册),与插件自报名无关;
// source 约定 `插件名` 或 `插件名/实例`(如 webui/<deviceID>)。
func TestKernel_KernelLevelSource(t *testing.T) {
plugin.RegisterFactory("core_test_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := &Agent{pluginReg: plugin.NewRegistry()}
cases := []struct {
source string
want bool
}{
{"core_test_builtin", true},
{"core_test_builtin/dev-1", true}, // 插件名/实例
{"core_test_external", false},
{"webui", false}, // 本测试注册表里没有 webui 工厂
{"", false},
{"core_test_builtinX", false}, // 不做前缀匹配
}
for _, c := range cases {
if got := a.isKernelLevelSource(c.source); got != c.want {
t.Fatalf("source=%q → %v期望 %v", c.source, got, c.want)
}
}
if (&Agent{}).isKernelLevelSource("core_test_builtin") {
t.Fatal("没有插件注册表时不得授予内核级")
}
}
// 排队任务可被**任何**中断打断——包括最低的 L1。
@ -178,10 +229,10 @@ func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
select {
case evt := <-ioM.InputInterruptChan():
if got := interruptLevel(evt); got != LevelInteractive {
if got := interruptLevel(evt, false); got != LevelInteractive {
t.Fatalf("经 io 层后的级别=%v期望 L3payload=%v", got, evt.Payload)
}
task := newInterruptTask(evt, interruptLevel(evt))
task := newInterruptTask(evt, interruptLevel(evt, false))
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
t.Fatalf("中断任务类别/级别=%v/%v期望 interrupt/L3", task.Class, task.Level)
}
@ -204,3 +255,40 @@ func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
t.Fatal("排队输入未到达 inputCh")
}
}
// 内核级插件声明的 L4 必须一路到达调度器(“立即打断”能力,如 WebUI 终止按钮)。
func TestKernel_KernelLevelPluginCanRaiseL4(t *testing.T) {
plugin.RegisterFactory("core_test_l4", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := newPreemptAgent(t, &scriptProvider{})
a.pluginReg = plugin.NewRegistry()
// 先让一个排队任务跑起来(无级别),才能看到“抢占”。
evt, _ := textEvent("qq", "长任务")
if !a.sched.enqueue(newInputTask(evt)) {
t.Fatal("入队失败")
}
a.sched.nextRef()
// 内核级插件(内置)声明 L4 的终止通知。
kevt, _ := textEvent("core_test_l4", "用户按了终止按钮")
kevt.Payload["priority"] = "L4"
level := interruptLevel(kevt, a.isKernelLevelSource(kevt.Source))
if level != LevelCritical {
t.Fatalf("内核级插件声明 L4 应得 L4实际 %v", level)
}
if !a.sched.requestPreempt(kevt, level) {
t.Fatal("L4 应能打断排队任务")
}
if a.sched.immediate == nil || a.sched.immediate.Level != LevelCritical {
t.Fatalf("应有一条 L4 中断在 immediate实际 %+v", a.sched.immediate)
}
// 反例:同样的声明来自外部插件 → 夹到 L3。
eevt, _ := textEvent("core_test_external", "外部插件也想立即打断")
eevt.Payload["priority"] = "L4"
if got := interruptLevel(eevt, a.isKernelLevelSource(eevt.Source)); got != LevelInteractive {
t.Fatalf("外部插件声明 L4 应被夹到 L3实际 %v", got)
}
}

View File

@ -726,7 +726,25 @@ type injectMediaParams struct {
// 与旧三参数注入等价。
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
return pubsdk.InjectOptions{
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName, Priority: priority,
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
Priority: clampExternalPriority(priority),
}
}
// clampExternalPriority 把外部插件声明的优先级夹到 L1..L3。
//
// 走本桥的必然是外部插件(独立进程/动态库),它们**不是内核级插件**
// 因此不能声明 L4——“立即打断”那类能力只属于编译期内置插件如 WebUI 终止按钮)。
//
// 为什么在这里夹而不是只在内核里按 source 判source 是插件自报的字段,
// 外部插件可以冒用 "webui" 之名;而本函数所在的位置能确知“这来自外部进程”。
// 内核侧的 isKernelLevelSource 是第二道闸(纵深防御)。
func clampExternalPriority(priority string) string {
switch priority {
case "L4", "l4":
return pubsdk.PriorityL3
default:
return priority
}
}

View File

@ -0,0 +1,48 @@
package proc
// 外部插件(走 proc 桥的独立进程/动态库)**不是内核级插件**
// 因此不能声明 L4 —— “立即打断”能力只属于编译期内置插件(如 WebUI 终止按钮)。
//
// 在这里夹取而不是只在内核里按 source 判,是因为 source 是插件自报字段、可以冒名;
// 本函数所在位置能确知“这来自外部进程”。内核侧的 isKernelLevelSource 是第二道闸。
import (
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func TestClampExternalPriority_RejectsL4(t *testing.T) {
cases := []struct {
in string
want string
}{
{"L4", pubsdk.PriorityL3}, // 越权 → 夹到 L3
{"l4", pubsdk.PriorityL3}, // 大小写都要夹
{"L3", pubsdk.PriorityL3},
{"L2", pubsdk.PriorityL2},
{"L1", pubsdk.PriorityL1},
{"", ""}, // 未声明保持空(内核按默认级处理)
{"紧急", "紧急"}, // 未知值原样传给内核,由内核降级为 L1 并留痕
{"L9", "L9"}, // 同上
}
for _, c := range cases {
if got := clampExternalPriority(c.in); got != c.want {
t.Fatalf("clampExternalPriority(%q)=%q期望 %q", c.in, got, c.want)
}
}
}
// 贯穿 pubSdkInjectOptsRPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
got := pubSdkInjectOpts(true, "prune", "cleaner", "L4")
if got.Priority != pubsdk.PriorityL3 {
t.Fatalf("经桥后的优先级=%q期望 L3", got.Priority)
}
if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" {
t.Fatalf("其它字段被改动:%+v", got)
}
if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
}
}

View File

@ -108,8 +108,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
case <-time.After(dur):
log.Printf("[timer] firing: %s (%s later)", message, dur)
// NoMemory定时提醒是系统通知不是记忆内容。
// PriorityL3定时器是“时钟那种实时工作”——到点就该处理
// 比 QQ 那类可无限等待的异步消息高L3 vs L1
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
sdk.InjectOptions{NoMemory: true})
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL3})
case <-p.stopCh:
log.Printf("[timer] cancelled: %s", message)
}

View File

@ -1812,8 +1812,12 @@ func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
if body.DeviceID != "" {
source = "webui/" + body.DeviceID
}
// PriorityL4终止按钮必须能立即打断当前任务内核级插件才有的能力
// agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效;
// 但 LLM 流式段会被立刻取消。
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
"content": body.Message,
"content": body.Message,
"priority": sdk.PriorityL4,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
}

View File

@ -53,6 +53,15 @@ type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
type ChannelDef = pubsdk.ChannelDef
// 中断优先级的取值再导出:内置插件用 sdk.PriorityL4 声明“立即打断”,
// 外部插件同名常量会被内核夹到 L3见 core.interruptLevel / proc 桥)。
const (
PriorityL1 = pubsdk.PriorityL1
PriorityL2 = pubsdk.PriorityL2
PriorityL3 = pubsdk.PriorityL3
PriorityL4 = pubsdk.PriorityL4
)
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
// 否则内核要认两份,而漏认会静默丢失标志位。
type InjectOptions = pubsdk.InjectOptions

View File

@ -81,19 +81,25 @@ type InjectOptions struct {
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
//
// 取值 "L1"/"L2"/"L3";空等同 L1。L4 由内核独占panic / 内核事件 selfip
// 插件声明 L4 会被内核夹到 L3——内核的调度内部属性不接受外部越权
// 取值 PriorityL1..PriorityL4空等同 L1默认级
// L4 只有**内核级插件**能用(见 PriorityL4 注释);外部插件的 L4 会被夹到 L3。
//
// 排队注入InjectText*/InjectInputSync没有级别它们本就是“不需及时处理”
// 的那一类,可被任何中断打断。
Priority string
}
// 中断优先级取值插件可用范围。L4 不在其中:它由内核保留
// 中断优先级取值。
//
// L1..L3 任何插件都可声明;**L4 只有内核级插件**(编译期内置插件,
// 如 cli/webui/timer才能声明——它用于实现真正的“立即打断”能力
// 例如 WebUI 的终止按钮。外部插件(走 proc 桥)声明 L4 会被内核夹到 L3。
const (
PriorityL1 = "L1"
PriorityL2 = "L2"
PriorityL3 = "L3"
// PriorityL4 仅内核级(内置)插件可用;外部插件声明会被夹到 L3。
PriorityL4 = "L4"
)
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。