Files
HomeAgent/internal/kernelmod/pluginhost/mod.go
JianFeeeee cc0eb51286 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 防腐判据(部分已随本次落地)
2026-11-04 11:23:05 +08:00

403 lines
17 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 pluginhost
import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"log"
"os"
"path/filepath"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/internal/supervisor"
)
// FacPlugins 是本模块提供的设施名。
//
// 意义:**"谁是最后一批被拉起的插件"** —— 声明 `plugins` 的插件要等所有
// 自动启动的插件都起完才起(如"插件总览"类插件)。
const FacPlugins = "plugins"
// Module 是插件宿主:扫元数据、按形态分流、按依赖时机拉起。
type Module struct{}
func (Module) Name() string { return "plugin-host" }
// Requires:插件**加载**所需的全部前置 —— **刻意不含 agent**。
//
// ★ 这是本模块存在的关键价值(用户方案 A 的核心收益):
// 原先 wirePluginSDK 在 agent 模块里 ⇒ 插件加载被排在 agent 之后,
// 而 agent 层实测 **35 秒**(context dense backfill 750 events + 重建索引)。
//
// 而插件真正需要的只是 stage-host(Registry + StageHost)与几个底层设施。
// agent 相关的 StatusProvider 改为**后补注入**(见
// agent 模块),插件在 Start 阶段不用它们(唯一的 healthcheck 是 nil-guarded)。
func (Module) Requires() []string {
return []string{
kernelmod.FacConfig, kernelmod.FacKernelIO, kernelmod.FacMemory,
kernelmod.FacLua, kernelmod.FacSupervisor, kernelmod.FacTracker,
kernelmod.FacTextMem, kernelmod.FacDocStore, kernelmod.FacKnowledge,
kernelmod.FacStageHost,
}
}
func (Module) Provides() []string { return []string{FacPlugins} }
func (Module) Init(ks *kstate.State) error {
// ── 前置检查(用户要求:"nil 加读取前检查")──
if ks.PluginRegistry == nil || ks.StageHost == nil {
return fmt.Errorf("plugin-host:ks.PluginRegistry/StageHost 为 nil —— " +
"stage-host 模块必须先完成(插件加载的硬前置)")
}
if ks.Cfg == nil {
return fmt.Errorf("plugin-host:ks.Cfg 为 nil —— config 模块必须先完成")
}
if ks.CfgReg == nil || ks.DataDir == "" {
return fmt.Errorf("plugin-host:CfgReg/DataDir 为 nil/空 —— Group 0 必须先完成")
}
// ── ① 注入插件 SDK 的**底层依赖**(不含 agent 相关的两项)──
injectBaseDeps(ks)
// ── ② 扫一次目录 → 元数据缓存 ──
dir := ks.Cfg.Plugin.Dir
os.MkdirAll(dir, 0o755)
catalog, noManifest := scanCatalog(dir, ks.CfgReg.IsPluginDisabled)
ks.PluginCatalog = catalog
// ★ 措辞必须准确(我第一版写成"跳过 N 个非插件目录" ⇒ **误导**):
// 那些目录大多是**内置插件**(编译期工厂,本就没有 plugin.json,
// 由下面的 LoadSkipping 从工厂表加载)。只有既无 manifest
// 又无工厂的才是真的非插件目录。
//
// 这条日志的价值在于:新增外部插件若忘写 plugin.json,会出现在这里
// (那时"无 manifest ⇒ 只能走工厂 ⇒ 找不到 ⇒ 不加载",可据此发现)。
if len(noManifest) > 0 {
log.Printf("[pluginhost] %d directories have no plugin.json (normal for built-in plugins, loaded by compile-time factories): %v",
len(noManifest), noManifest)
}
log.Printf("[pluginhost] external plugin metadata cache ready: %d (dir %s)",
catalog.Size(), dir)
// ── ③ 按**三形态**分流(用户设计要求)──
groups := classify(catalog)
log.Printf("[pluginhost] form routing: auto-start %d / declared deps %d / "+
"enabled-but-not-auto-start %d / persistently disabled %d",
len(groups.autoStart), len(groups.declared),
len(groups.manual), len(groups.disabled))
// ── ④ 拉起:**全部自动启动的插件都交给 Scheduler** ──
//
// ★★ 我第一版在这里犯了一个**违反硬约束**的错,值得完整记录:
//
// 第一版按"本模块依赖是否覆盖插件声明"把插件分成 launchNow / waitLater,
// 并在 Init 里**直接**把 launchNow 全加载了。
// 但**未声明依赖**的插件,`RequiresFacilities` 是空 ⇒ "本模块依赖覆盖空集"
// 恒真 ⇒ 它们被归入 launchNow ⇒ **在第 4 层就被加载**。
//
// 而 §2.6.5 的硬约束是:
//
// 未声明 ⇒ 启动时机**逐字不变**(内核**完全**初始化完成后)
//
// 实测证据(planA4.log):内置插件在 `21:54:37`(第 4 层)被加载,
// 而内核装配完成是 `21:55:12` —— **早了 35 秒**。
// 约束原文写着"静默改变启动时机 = 静默引入竞态",
// 42 个存量插件里任何一个若假设"Start 时 llm 一定已就绪"就会中招。
//
// ⇒ 修法:**不再自己判时机**,全部交给 `kernelboot.Scheduler`
// ——它已正确实现 §2.6.5(空声明 ⇒ 等 `KernelComplete()`)与
// §2.6.6(`!AutoStart` ⇒ 不自动拉起),且有完整判据。
// 我只需要提供"拉起一个插件"的动作。
//
// 这样声明的插件仍在依赖就绪时尽早启动(本步的核心收益不变),
// 而未声明的**恢复原行为**(等内核完全初始化)。
var bus kstate.SyncState
var busConcrete *kstate.SyncBus // 保留具体类型以便订阅 OnUpdate
if ks.SyncBus != nil {
bus = ks.SyncBus
busConcrete = ks.SyncBus
} else {
// ★★ typed-nil 陷阱:`var nb *SyncBus; var i SyncState = nb`
// ⇒ i **非 nil** ⇒ 方法里解引用空指针 panic(实测复现)。
// 所以这里必须显式判 `!= nil` 再赋给接口变量。
log.Printf("[pluginhost] warning: SyncBus is nil -- non-production assembly path, " +
"退化为立即加载(依赖未就绪的插件在 Start 时自行处理)")
fallback := kstate.NewSyncBus()
fallback.MarkKernelComplete()
bus = fallback
busConcrete = fallback
}
sched := kernelboot.NewScheduler(bus, func(spec kernelboot.PluginSpec) error {
return ks.PluginRegistry.LoadOneDir(filepath.Join(dir, spec.Name), spec.Name)
})
sched.SetLaunchErrorHandler(func(spec kernelboot.PluginSpec, err error) {
log.Printf("[pluginhost] warning: failed to start %s: %v", spec.Name, err)
})
ks.PluginScheduler = sched
specs := buildSpecs(catalog, groups)
// Submit 返回"此刻就能拉起"的:
// · 声明了依赖且已全就绪 ⇒ 立即拉起(**本步的核心收益**)
// · 未声明的 ⇒ 不进返回列表(等 KernelComplete,§2.6.5)
// · !AutoStart ⇒ 不进(§2.6.6)
// 同时 Submit 会做拼错校验(声明的设施名内核从未注册 ⇒ 立即报错)。
for _, n := range sched.Submit(specs) {
log.Printf("[pluginhost] plugin started (dependencies ready, before assembly finished): %s", n)
}
// ── ⑤ 加载**内置插件**(编译期工厂,**没有 plugin.json**)──
//
// ★★ 这一步是我第一版漏掉的(实测:19 个内置插件全部没加载 ——
// webui / pluginmgr / cli / files / timer / mcp …,只剩 2 个外部插件起)。
//
// 原因:上面的 catalog 是**扫目录读 plugin.json** 得来的,
// 而内置插件是**编译期注册的工厂**(`plugin.RegisterFactory` 在 init() 里),
// 目录里可能连 plugin.json 都没有 ⇒ 表里根本没有它们。
//
// 走 `LoadSkipping(dir, skip)`:它内部既扫目录(外部插件)
// 也遍历工厂(内置插件),是**唯一完整**的加载入口。
// skip 集合 = 已由本模块调度处理的那些(按三形态分流的),
// 避免重复加载。
// ── ⑤ 其余插件(未声明的 + 内置)→ **等内核完全初始化**后加载 ──
//
// ★ 为什么不能在这里直接 LoadSkipping(我第一版的第二个错):
// plugin-host 在第 4 层,而 agent 与它**并行** ⇒ 此刻内核**尚未**
// 完全初始化。直接加载同样违反 §2.6.5 的"未声明 ⇒ 逐字不变"。
//
// ⇒ 挂到 syncbus 的 `KernelComplete` 上(内核**推**状态,不轮询)。
//
// skip = 已由 Scheduler 接管的(declared ∪ manual ∪ disabled)。
// 不在 skip 里的:未声明的外部插件 + **内置插件**(编译期工厂,
// 不在 catalog 里,只有 LoadSkipping 的工厂分支能加载它们)。
skip := buildSkipSet(catalog, groups)
var loadOnce sync.Once
loadRest := func(st kstate.SyncState) {
if !st.KernelComplete() {
return
}
loadOnce.Do(func() {
if err := ks.PluginRegistry.LoadSkipping(dir, skip); err != nil {
log.Printf("[pluginhost] warning: loading built-in/undeclared plugin: %v", err)
}
ks.PluginsReady = true
log.Printf("[pluginhost] kernel fully initialized -> loaded remaining plugins (%d built-in)",
len(noManifest))
// ★ 发布 tools_ready —— "工具已全部注册完毕"的唯一信号。
//
// 语义只到"工具可调",**不涉及 agent 是否在跑**:
// 依赖完整内核做事的订阅者(seq 开机序列)此刻即可开始,
// 返回值进队列、等 agent 起来后处理即可。
//
// 位置即语义:放在 PluginsReady=true 之后、与内置插件加载同一次
// once 里 —— 早了(如内核装配完成处)还有内置插件未加载,
// 那时工具不全;晚了则订阅者错过这次进程唯一的就绪时刻。
publishToolsReady(ks)
})
}
// 订阅 + 立即评估一次(若 bus 已 complete,比如非生产路径的退化 bus)
// ★ 订阅用**具体类型**的 SyncBus(SyncState 是**只读接口**,
// 故意没有 OnUpdate —— 设计 §2.6.3 "插件管理器对 syncbus 只读")。
// 本模块是内核模块,本就持有具体类型,无需放宽接口。
if busConcrete != nil {
busConcrete.OnUpdate(loadRest)
}
loadRest(bus)
log.Printf("[pluginhost] this stage: %d with declared deps handled by scheduler "+
"(those already ready were started), %d undeclared + %d built-in wait for kernel full init",
len(groups.declared), len(groups.autoStart)-len(groups.declared), len(noManifest))
return nil
}
func init() { kernelmod.Register(Module{}) }
// buildSpecs 把"自动启动"的插件元数据转成调度器声明。
//
// ## ★ 为什么**未声明**的插件也要进来(我第一版漏了这个语义)
//
// 第一版把"未声明依赖"的插件排除在调度器之外、在 Init 里直接加载
// ⇒ 它们在第 4 层(agent 并行期间)就起了,而 §2.6.5 要求"等内核
// **完全**初始化"。实测后果:`healthcheck` 的 `s.Status()` 为 nil
// ⇒ **静默少注册一个工具** `healthcheck_kernel`(86 vs 87,无任何报错)。
//
// ⇒ 全部自动启动的插件都交给调度器;调度器对空声明返回
// `KernelComplete()`(§2.6.5),正是原行为。
func buildSpecs(c *PluginCatalog, g formGroups) []kernelboot.PluginSpec {
var out []kernelboot.PluginSpec
for _, n := range g.autoStart {
m, ok := c.Get(n)
if !ok {
continue
}
out = append(out, kernelboot.PluginSpec{
Name: m.Name,
RequiresFacilities: m.RequiresFacilities,
AutoStart: m.AutoStart,
})
}
return out
}
// buildSkipSet 计算"不能让全量 LoadSkipping 碰"的插件名集合。
//
// ## ★ 为什么抽成独立函数(判据要求)
//
// 初版把这段逻辑内联在 Init 里 ⇒ 判据只能查源码字符串 ⇒
// 删掉代码但注释仍在时**判据不咬**(实测)。
// 抽出来后可**真的调用它**做行为断言(TestBuiltinsAreNotSkipped)。
//
// ## 集合语义(三形态 + 内置插件的边界)
//
// ② auto_start=false ⇒ 必须在集合里(否则会被全量加载 = 违背语义)
// ③ disabled ⇒ 在集合里(显式跳过;registry 还有禁用门兜底)
// ① 声明的 ⇒ 由本模块按依赖时机拉起 ⇒ 必须在集合里(防重复)
// **内置插件** ⇒ **不在**集合里 ⇒ 走 LoadSkipping 的工厂分支被加载
// ★ 这正是我第一版漏掉的关键:内置插件没有 plugin.json,
// 不在 catalog 里,只有 LoadSkipping 的工厂分支能加载它们。
func buildSkipSet(c *PluginCatalog, g formGroups) map[string]bool {
skip := map[string]bool{}
// ★★ 原则:**catalog 里登记过的插件,一律不交给 LoadSkipping**。
//
// ## 为什么不是"只跳过 declared/manual/disabled"(我第一版就是这么写的)
//
// 那会让**未声明的外部插件**同时被两条路加载:
// · 调度器(它管未声明的,等 KernelComplete 后拉起)
// · LoadSkipping(未声明的**不在** skip 里 ⇒ 也加载)
//
// 实测(变异 F,把 LoadSkipping 挪回 Init):`healthcheck` 被 loaded **两次**,
// 工具数 87 → **161**(重复注册)。planA5 侥幸未触发,只因测试目录里
// 那 3 个外部插件恰好都声明了依赖或不自启 —— **不能依赖这种巧合**。
//
// ⇒ 改成**按构造**消除:catalog 里的全部(三形态之一)都由本模块/调度器
// 负责;LoadSkipping 只处理 catalog **之外**的,也就是那些
// **没有 plugin.json 的内置插件**(编译期工厂,只有它能加载)。
//
// 这样两条路**按定义**不相交。
for _, n := range c.Names() {
skip[n] = true
}
// 保底:三形态里若有 catalog 未收录的(理论上不会),也别漏
for _, n := range g.autoStart {
skip[n] = true
}
for _, n := range g.manual {
skip[n] = true
}
for _, n := range g.disabled {
skip[n] = true
}
return skip
}
// injectBaseDeps 注入插件 SDK 的底层依赖(原 wirePluginSDK 的 7 项)。
//
// ★ 这里**刻意不做** SetStatusProvider —— 它依赖 agent,
// 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层:
// 让插件加载不必等 agent 那 35 秒。
func injectBaseDeps(ks *kstate.State) {
reg := ks.PluginRegistry
reg.SetLuaVM(ks.LuaVM)
reg.SetBaseAPIKey(ks.BaseAPIKey)
if ks.Supervisor != nil {
reg.SetSupervisor(supervisor.NewSDKAdapter(ks.Supervisor))
}
reg.SetTracker(ks.Tracker)
reg.SetConfig(ks.Cfg)
// StageHost 在挂载点里存 any(kstate 不依赖 agent/core,否则成环)。
// 断言成 sdk.ToolSource(plugin 真正需要的只读工具视图)。
//
// 断言失败时**不静默跳过**:SetStageHost 不设的后果是插件加载后
// 工具表为空,而那与"插件没注册工具"无法区分。所以这里响亮记录。
// (Init 里已检查过 StageHost != nil,此处失败是类型不合约,属装配缺陷。)
if ts, ok := ks.StageHost.(sdk.ToolSource); ok && ts != nil {
reg.SetStageHost(ts)
} else {
log.Printf("[plugin-host] WARNING: ks.StageHost 未实现 sdk.ToolSource —— 插件工具将不可见")
}
if ks.Memory != nil {
reg.SetIndexer(ks.Memory.Indexer)
}
}
// formGroups 是三形态分流结果。
//
// ★ 用户明确要求的三种形态(§2.6.6):
//
// ① autoStart :自动启动(缺省/true)
// ② manual :**启用但不自动启动**(auto_start=false)⇒ 等显式启用
// ③ disabled :禁用(disabled_plugins 配置)⇒ 不加载
//
// 外加 `declared`:自动启动**且声明了依赖**的子集(决定"何时"拉)。
type formGroups struct {
autoStart []string // ① 自动启动的全部(含 declared)
declared []string // ① 中声明了 requires_facilities 的
manual []string // ② 不自动启动
disabled []string // ③ 持久禁用
}
func classify(c *PluginCatalog) formGroups {
var g formGroups
for _, m := range c.All() {
switch {
case m.Disabled:
// ③ 禁用:最高优先级(不加载)
g.disabled = append(g.disabled, m.Name)
case !m.AutoStart:
// ② 启用但不自动启动:**不自动加载**,等 agent 显式启用
g.manual = append(g.manual, m.Name)
default:
// ① 自动启动
g.autoStart = append(g.autoStart, m.Name)
if len(m.RequiresFacilities) > 0 {
g.declared = append(g.declared, m.Name)
}
}
}
return g
}
// loadOne 加载单个插件(走既有 LoadOneDir 路径,不绕过任何机制)。
func loadOne(ks *kstate.State, dir, name string) (bool, error) {
if err := ks.PluginRegistry.LoadOneDir(filepath.Join(dir, name), name); err != nil {
return false, err
}
return true, nil
}
// planCatalog 取元数据缓存(类型断言集中一处,便于排查"缓存没建起来")。
func planCatalog(ks *kstate.State) *PluginCatalog {
c, _ := ks.PluginCatalog.(*PluginCatalog)
return c
}
// publishToolsReady 广播 tools_ready(全部插件加载完成、工具已注册)。
//
// 失败只记日志、不阻断:系统本身仍可正常接受输入,只是依赖本事件的
// 订阅者(如 seq 的开机序列)不会自动执行。**必须可见** ——
// 静默失败会让"为什么开机序列没跑"完全无从查起。
func publishToolsReady(ks *kstate.State) {
if ks == nil || ks.EventBus == nil {
log.Printf("[pluginhost] tools_ready NOT published: EventBus is nil " +
"(subscribers such as seq boot sequences will not run)")
return
}
var loaded []string
if ks.PluginRegistry != nil {
loaded = ks.PluginRegistry.ListLoadedPlugins()
}
ks.EventBus.Publish(&events.Event{
Type: events.EventToolsReady,
Source: "plugin-host",
Payload: map[string]interface{}{
"plugins": loaded,
"plugin_count": len(loaded),
},
})
log.Printf("[pluginhost] tools_ready published (%d plugins)", len(loaded))
}