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

@ -104,6 +104,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
log.Printf("[skillmgr] detected skill %s: %v", path, err)
return
}
p.publishIndex()
log.Printf("[skillmgr] registered via %s event: %s", detectEvent, filepath.Base(path))
})
@ -112,11 +113,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// 后续事件重复到达时 loadOne 幂等跳过)
n := p.scanExisting()
p.registerTools()
// 首次声明:把当前索引推给内核的统一注册表。
// 不调它的后果:模型看不到任何 skill 索引,而 skill_list 仍能列出
// —— "工具能列但模型不知道",是最难查的一类降级。
p.publishIndex()
log.Printf("[skillmgr] started, dir=%s, loaded %d native skills", p.skillsDir, n)
return nil
}
func (p *Plugin) Stop() error {
// 先撤声明再退订:顺序反过来的话,退订期间仍有事件在加载 skill,
// 那部分会来不及进索引(虽然进程即将退出,但热重载路径下不退出)。
p.unpublishIndex()
if p.unsub != nil {
p.unsub()
p.unsub = nil
@ -250,9 +258,26 @@ func (p *Plugin) get(name string) (*SkillEntry, bool) {
return e, ok
}
// SkillIndex 实现 agentCore.SkillIndexProvider:返回已启用技能的精炼索引。
// 每次 LLM 调用都会执行,必须轻量(只读锁 + 字符串拼接,无 IO)。
func (p *Plugin) SkillIndex() string {
// skillSectionName 是本插件声明的系统提示词段名。
//
// 命名规则由 SDK 强制:`<owner>.<名字>`,owner 必须等于插件名。
// 因此别的插件无法覆盖本段(attempting 会被内核拒绝)。
const skillSectionName = "skillmgr.skills"
// skillSectionOrder 让技能索引排在**工具与技能使用说明**区段(100–199)。
//
// 为什么不是更靠前:索引里列的是工具名,模型要先知道"这轮有哪些工具"
// 再读"怎么用"才有意义。顺序约定见 sdk/systemprompt.go 的 PromptSection.Order。
const skillSectionOrder = 100
// buildIndex 生成已启用技能的精炼索引(名称 + 版本 + 一行描述)。
//
// 这是**声明内容**,由 publishIndex 推给内核;不再是让内核来 pull 的接口。
func (p *Plugin) buildIndex() string {
const header = "[Skills] Usage manuals for plugins. Before calling a plugin's tool " +
"(or when it fails), read the same-named skill with skill_info(\"skill-name\") " +
"and follow it. Never guess from the name."
entries := p.snapshot()
var b strings.Builder
for _, e := range entries {
@ -266,7 +291,70 @@ func (p *Plugin) SkillIndex() string {
desc = p.truncateDesc(desc)
fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc)
}
return strings.TrimRight(b.String(), "\n")
list := strings.TrimRight(b.String(), "\n")
if list == "" {
// 没有技能时**不声明**(而不是声明一段只有标题的空段)。
return ""
}
return header + "\n\n" + list
}
// publishIndex 把当前技能索引声明到内核的**统一系统提示词注册表**。
//
// ## 为什么是"声明"而不是"让内核来问"
//
// 此前是内核定义 `SkillIndexProvider` 接口、skillmgr 实现它、bootstrap
// 手工把它接给根 agent。那条路有三个问题:
//
// 1. **双向耦合**:内核硬编码插件的产出,插件又 import 内核接口
// 2. **子 agent 漏接**:rtchild / 驻留子 / 工作式子各自构造,都要记得传;
// 漏传不报错也不崩,只是那个 agent 的提示词里一条 skill 都没有
// 3. **顺序不可控**:插件内容只能整块插在内核硬拼流程的某个固定位置
//
// 现在是声明式:插件声明自己的段,内核每轮重建时按 Order 渲染。
// 任何 agent(含未来新类型)读同一份注册表 —— **零接线**。
//
// ## 为什么每个变更点都要调
//
// 段内容是**静态字符串快照**(不是回调),所以技能集合变了必须重新声明。
// 漏调的症状是"新装的 skill 在提示词里看不到",而 skill_list 工具却能列出
// —— 模型于是不知道有这个 skill 可用。这是本仓踩过的同类问题
// (built-in skill 不更新:机制在,但没推给读到的地方)。
//
// 内核侧同段重复声明是**覆盖**(幂等),所以无条件调用是安全的。
func (p *Plugin) publishIndex() {
if p.sdk == nil {
return
}
api := p.sdk.SystemPrompt()
if api == nil {
return
}
content := p.buildIndex()
if err := api.DeclarePromptSection(sdk.PromptSection{
Section: skillSectionName,
Owner: p.name,
Order: skillSectionOrder,
Content: content,
Volatile: true, // 内容随 skill 增删/启停变化(诊断用)
}); err != nil {
// 不静默:声明失败意味着模型看不到任何 skill 索引,
// 而 skill_list 仍能列出 —— 这种"工具能列但模型不知道"最难查。
log.Printf("[skillmgr] WARN: 系统提示词段声明失败(模型将看不到技能索引): %v", err)
}
}
// unpublishIndex 撤销本插件的声明(Stop / 停用时)。
//
// 不撤销的后果:插件已停用,但提示词里还留着"用 skill_info 读某 skill",
// 模型于是去调一个不存在的工具。那比"没有说明"更糟。
func (p *Plugin) unpublishIndex() {
if p.sdk == nil {
return
}
if api := p.sdk.SystemPrompt(); api != nil {
_ = api.RemovePromptSection(skillSectionName)
}
}
// truncateDesc truncates a skill description to 80 runes and appends an ellipsis.