refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段

## 起因(生产故障)

A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

链路拆开四层:
  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
  ② 需要 cmd-tools skill 说明"先 plugin_start cmd"
  ③ 但 skill 索引没进那个 agent 的提示词
  ④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线

④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。

## 环的精确形状(实测,非推测)

kernelboot 因为持有 KernelState 而依赖 19 个业务包:

    kernelboot ──▶ agent/core ──▶ …
               ──▶ plugin
               ──▶ security
               ──▶ memory / knowledge / text / coldcontext / …

而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。

后果是四个底层包各自造通道:

    core      → Agent 字段(每种 agent 构造点都要记得传)
    plugin    → 12 个 SetXxx
    sdk       → SDKConfig 传参
    security  → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()

四条通道是同一个根因的四个表现。

## 本次做的事

### 1. 抽出 internal/kstate(断环)

kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:

    internal/kstate     纯数据 + 唯一挂载点(叶子,谁都能 import)
    internal/kernelboot 装配引擎,零业务包

挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。

验证:core 现在能 import kstate(此前 import cycle not allowed)。

### 2. StageHost / Agent 字段存 any

它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:

    > 是其他包在kstate挂共享内容啊

存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。

★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。

### 3. 插件声明式系统提示词段

SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:

    kstate.State.PromptSections *promptsec.Registry   ← 唯一事实来源
    Agent.renderSystemPrompt() 每轮重建时读它

skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":

    DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}

并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。

### 4. 内核自身的提示词段也走同一管理器

按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。

顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。

## 判据(均经变异验证)

  TestCoreCanImportKstate            真编译探针;环重现则红(实测)
  TestKstateDoesNotDependOnCore      静态版,指向具体字段
  TestKstateIsLeaf                   kstate 不得反依赖上层
  TestRTChildSeesPromptSections      子 agent 能看到插件段
  TestResidentSeesPromptSections     生产实际中招的那一类;变异后红(实测)
  TestRootAgentSeesPromptSections    确认没改坏根路径
  TestNoPromptSectionsMount          未装配时不 panic、不凭空出现段
  TestDeclareTwiceRendersOnce        ★ 回应"会不会多份":声明 5 次渲染 1 份
  TestSectionRemovalTakesEffect      Stop 后段消失
  TestRuntimeUpdateVisibleToExistingAgent  同一实例下一轮看到新内容
  TestSectionOrderIsRespected        Order 生效且稳定

## 验证

  go build ./...                   PASS
  go vet ./...                     PASS
  gofmt -l internal/ cmd/ pkg/     干净
  go test ./... -count=1           63 包全过,0 FAIL
  干净检出(git worktree)           build + 4 包测试全绿

## 未做(见 kernelplan.md)

  T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
  T5 plugin Setter 收敛
  T6 防腐判据(部分已随本次落地)
This commit is contained in:
JianFeeeee
2026-11-04 11:23:05 +08:00
parent 387ae945df
commit cc0eb51286
63 changed files with 1734 additions and 494 deletions

View File

@ -627,6 +627,7 @@ type PluginSDK struct {
social SocialAPI
secrets SecretsAPI
security SecurityAPI
sysPrompt SystemPromptAPI
rawFiles RawFilesAPI
events EventSubscriber
plgMgr PluginMgrAPI
@ -740,6 +741,24 @@ func (s *PluginSDK) Security() SecurityAPI {
return s.security
}
// SystemPrompt 返回**系统提示词声明面**(插件声明自己的段;渲染由内核做)。
//
// ★ 只声明、不渲染:本接口没有"设置整个 system prompt"的方法。
// 插件能改的只有自己那段(段名以插件名开头,内核强制),
// 否则任一插件都能改写别人的人格/安全说明。
func (s *PluginSDK) SystemPrompt() SystemPromptAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()
return s.sysPrompt
}
// SetSystemPromptAPI 由内核注入声明面的实现(插件不调用)。
func (s *PluginSDK) SetSystemPromptAPI(v SystemPromptAPI) {
s.apiMu.Lock()
defer s.apiMu.Unlock()
s.sysPrompt = v
}
func (s *PluginSDK) Social() SocialAPI {
s.apiMu.RLock()
defer s.apiMu.RUnlock()

View File

@ -0,0 +1,113 @@
package sdk
// SystemPromptAPI 是插件**声明**自身系统提示词段的接口。
//
// ## 用户模型(2026-11-04)
//
// > 每个插件都可以声明 systemprompt,可运行时更新,内核每轮重建。
//
// 于是"某个插件想告诉模型怎么用自己的工具"变成**插件自己声明的一段文本**:
//
// skillmgr 声明 "skillmgr.skills" → 技能索引 + "调工具前先读同名 skill"
// qq 声明 "qq.identity" → 身份绑定规则(哪些操作以谁的身份做)
// 任意插件 声明 "<自己>.<段名>" → 各自的说明
//
// 内核只做两件事:**存**(按段名覆盖)与**渲染**(每轮重建时按 Order 拼进
// system prompt)。内核不认识 skill、不认识 qq,也不判断内容。
//
// ## 分工:插件只声明,内核只渲染
//
// 插件:DeclarePromptSection / RemovePromptSection (只声明)
// 内核:持有注册表 + 每轮重建 system prompt 时渲染 (只渲染)
//
// ★ 为什么声明必须由**内核**持有,而不是各 Agent 自己存一份
//
// 本仓栽过这个坑:技能索引曾经是内核 Agent 上的一个字段,由启动代码
// 手工接线到**根 agent**。于是 rtchild、驻留子各自构造时都要记得再传一遍 ——
// 漏传不报错、不崩,只是那个 agent 的提示词里一条技能说明都没有,
// 模型只能凭工具名猜用法。
//
// 生产症状(真实日志):A2A 分诊助手(驻留子)手上没有 cmd_run
// (cmd 插件 auto_start=false,需先 plugin_start),而 cmd-tools skill
// 正文里写着"先 plugin_start cmd"。它看不到这条 skill,于是把
// terminal_create 当成一次性命令执行器 —— 每条 `cd … && echo … && grep …`
// 开一个新终端,命令结束终端就退出,terminal_read 立刻撞
// `read /dev/ptmx: input/output error`,然后为下一条命令再开一个。
//
// 结论:**能力归属内核(共享注册表),插件只声明**。这样任何 agent
// (含未来新增类型)读同一份注册表,零接线。
//
// ## 幂等语义("可运行时更新"的正确形态)
//
// 同一 Section 重复声明 ⇒ **覆盖**,不是追加。
//
// 这不是风格问题:system prompt 每轮重建,一次任务里 stepPrepare 可能
// 被重入(上下文裁剪后回到该步)。若声明是追加,索引会越堆越多,
// 最终挤掉真正的对话历史。注册表用 map 存段,让"多份"在结构上不可能发生。
//
// ## 典型用法(skillmgr)
//
// // Start:声明一段静态说明 + 当前索引
// s.SystemPrompt().DeclarePromptSection(sdk.PromptSection{
// Section: "skillmgr.skills",
// Owner: "skillmgr",
// Order: 100,
// Content: skillIndexText(),
// })
//
// // 技能增删/启停后:重新声明同一段即可(自动覆盖)
// s.SystemPrompt().DeclarePromptSection(...)
//
// // Stop / 停用:移除,避免模型去调已不存在的 skill
// s.SystemPrompt().RemovePromptSection("skillmgr.skills")
//
// ## 名字约束
//
// 段名必须是 `<owner>.<名字>`,其中 owner 等于插件名。这条在内核侧强制
// (否则插件 A 能声明 `skillmgr.skills` 并夺走插件 B 的段)。
type SystemPromptAPI interface {
// DeclarePromptSection 声明或更新一段系统提示词内容。
//
// 同 Section 重复声明会覆盖(同一 owner 只能覆盖自己的段,
// 不能覆盖别人的)。Content 为空串等价于移除该段。
//
// 返回 error 的情形:
// · Section 不符合 `<owner>.<名字>` 形式
// · Owner 与 Section 前缀不一致(试图声明别人的段)
// · 内核未提供注册表(系统提示词声明面未装配)
DeclarePromptSection(s PromptSection) error
// RemovePromptSection 移除一段声明。
//
// 单独提供而不是"传空 Content":插件 Stop / 被停用时这是常见动作,
// 写成空串容易漏 —— 漏了就会出现"插件已停用,但它的说明还留在
// 提示词里,模型去调一个不存在的工具",比没有说明更糟。
//
// 移除不存在或不属于自己的段是静默成功(幂等,便于无条件收尾)。
RemovePromptSection(section string) error
}
// PromptSection 是一段声明的系统提示词内容。
type PromptSection struct {
// Section 是段名,必须形如 `<owner>.<名字>`(如 "skillmgr.skills")。
Section string
// Owner 是声明者(插件名),必须与 Section 前缀一致。
Owner string
// Order 决定渲染顺序(小的在前)。同 Order 按段名字典序,保证稳定。
//
// 约定给个宽松的区段,便于后续插入:
// 0–99 内核/框架性质(极少用)
// 100–199 工具与技能使用说明(skillmgr 用 100)
// 200–299 身份与权限说明
// 900+ 其他
Order int
// Content 是正文(空串 = 移除该段)。
Content string
// Volatile 表示内容会随运行时频繁变化(如技能索引)。
//
// 只用于诊断(状态面能区分"静态说明"与"每轮重算"),不参与渲染决策。
Volatile bool
}