Files
HomeAgent/internal/plugins/skillmgr/skillmgr_test.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

423 lines
13 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 skillmgr
import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// ---- 测试辅助 ----
type nilSettings struct{}
func (m *nilSettings) DataDir() string { return "/tmp/mock_data" }
func (m *nilSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) Set(string, interface{}) error { return nil }
func (m *nilSettings) SetSecret(string, interface{}) error { return nil }
func (m *nilSettings) GetSecret(string) (string, error) { return "", nil }
// IsSecret:E-3 起 SettingsAPI 要求实现(判断某键是否按密文存储)。
func (m *nilSettings) IsSecret(string) bool { return false }
func (m *nilSettings) List(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetCore(string, interface{}) error { return nil }
func (m *nilSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *nilSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *nilSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *nilSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *nilSettings) RegisterDef(pubsdk.ConfigDef) {}
func (m *nilSettings) Defs(string) []*pubsdk.ConfigDef { return nil }
func (m *nilSettings) Dump() map[string]interface{} { return nil }
func (m *nilSettings) Plugins() []string { return nil }
func (m *nilSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *nilSettings) Remove(string) error { return nil }
func (m *nilSettings) RemoveCore(string) error { return nil }
func (m *nilSettings) RemovePlugin(string, string) error { return nil }
// toolSpy 记录注册的工具,并可按名调用 handler。
type toolSpy struct {
mu sync.Mutex
handlers map[string]func(args map[string]interface{}) (interface{}, error)
}
func newToolSpy() *toolSpy {
return &toolSpy{handlers: map[string]func(map[string]interface{}) (interface{}, error){}}
}
func (s *toolSpy) Register(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers[name] = h
return nil
}
func (s *toolSpy) call(t *testing.T, name string, args map[string]interface{}) (interface{}, error) {
t.Helper()
s.mu.Lock()
h, ok := s.handlers[name]
s.mu.Unlock()
if !ok {
t.Fatalf("tool %q not registered", name)
}
return h(args)
}
// newTestPlugin 构造带真实事件总线的 skillmgr 实例。
func newTestPlugin(t *testing.T) (*Plugin, *toolSpy, *events.Bus) {
t.Helper()
dir := t.TempDir()
bus := events.NewBus()
p := New("skillmgr", dir)
reg := func(name string, def interface{}, h func(args map[string]interface{}) (interface{}, error)) error {
_ = name
_ = def
_ = h
return nil
}
_ = reg // 占位避免误用
spy := newToolSpy()
cfgSDK := sdk.New("skillmgr", sdk.SDKConfig{
RegTool: func(name string, def sdk.ToolDef, h sdk.ToolHandler) error {
return spy.Register(name, def, func(args map[string]interface{}) (interface{}, error) {
return h(args)
})
},
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(cfgSDK); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { p.Stop() })
return p, spy, bus
}
func writeSkill(t *testing.T, dir, name, description string) string {
t.Helper()
sd := filepath.Join(dir, name)
os.MkdirAll(sd, 0755)
content := "---\nname: " + name + "\nversion: 1.0.0\n---\n\n# " + name + "\n\n" + description + "\n"
if err := os.WriteFile(filepath.Join(sd, SkillFileName), []byte(content), 0644); err != nil {
t.Fatal(err)
}
return sd
}
const sampleSkillContent = `---
name: demo-skill
version: 0.2.0
author: tester
---
# demo-skill
用于测试的演示技能。
## 使用时机
测试时使用。
## 操作步骤
### step-1
第一步说明。
- target: 目标参数
执行示例:
` + "```bash\necho demo\n```" + `
`
// ---- 用例 ----
func TestScanExistingLoadsPureSkillsOnly(t *testing.T) {
dir := t.TempDir()
writeSkill(t, dir, "alpha", "alpha desc")
writeSkill(t, dir, "beta", "beta desc")
// sidecar 目录(main.js)应被跳过
scDir := filepath.Join(dir, "sidecar-thing")
os.MkdirAll(scDir, 0755)
os.WriteFile(filepath.Join(scDir, "main.js"), []byte("//"), 0644)
// OC plugin 目录应被跳过
ocDir := filepath.Join(dir, "oc-thing")
os.MkdirAll(ocDir, 0755)
os.WriteFile(filepath.Join(ocDir, "openclaw.plugin.json"), []byte("{}"), 0644)
// 隐藏目录跳过
hd := filepath.Join(dir, ".hidden")
os.MkdirAll(hd, 0755)
os.WriteFile(filepath.Join(hd, SkillFileName), []byte("x"), 0644)
p := New("skillmgr", dir)
bus := events.NewBus()
sdkInst := sdk.New("skillmgr", sdk.SDKConfig{
Settings: &nilSettings{},
EventBus: bus,
})
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
defer p.Stop()
names := map[string]bool{}
for _, e := range p.snapshot() {
names[e.sk.Name()] = true
}
if !names["alpha"] || !names["beta"] {
t.Fatalf("expected alpha+beta loaded, got %v", names)
}
if names["sidecar-thing"] || names["oc-thing"] || names[".hidden"] {
t.Fatalf("sidecar/OC/hidden should not load, got %v", names)
}
}
func TestSkillDetectedEventHandoff(t *testing.T) {
p, _, bus := newTestPlugin(t)
// 模拟 clawhubadapter 移交事件
dir := p.skillsDir
sd := filepath.Join(dir, "handed")
os.MkdirAll(sd, 0755)
os.WriteFile(filepath.Join(sd, SkillFileName), []byte(sampleSkillContent), 0644)
bus.Publish(&events.Event{
Type: detectEvent,
Source: "clawhubadapter",
Payload: map[string]interface{}{"path": sd},
Timestamp: time.Now().Unix(),
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := p.get("handed"); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
if _, ok := p.get("handed"); !ok {
t.Fatal("handed-off skill not registered after event")
}
}
func TestCreateValidateLoadFlow(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 两步式:先骨架
out, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"description": "天气通知",
})
if err != nil {
t.Fatalf("create skeleton: %v", err)
}
_ = out
if !fileExists(filepath.Join(p.skillsDir, "weather-notify", SkillFileName)) {
t.Fatal("skeleton SKILL.md missing")
}
// 第二步:完整内容覆盖 → 自动加载
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "weather-notify",
"content": sampleSkillContent,
}); err != nil {
t.Fatalf("create full: %v", err)
}
if _, ok := p.get("weather-notify"); !ok {
t.Fatal("full-content skill not loaded under its own name")
}
// 空白 content 走模板分支:生成骨架而非报错
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "bad-skill",
"description": "",
}); err != nil {
t.Fatalf("skeleton-only create should succeed: %v", err)
}
if !fileExists(filepath.Join(p.skillsDir, "bad-skill", SkillFileName)) {
t.Fatal("bad-skill skeleton missing")
}
// 有内容但缺描述的 content 被校验拒绝
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "no-desc",
"content": "---\nname: no-desc\n---\n\n## 步骤\n",
}); err == nil {
t.Fatal("content without description should be rejected by ValidateSKILLContent")
}
// 非法名称被拒
if _, err := spy.call(t, "skill_create", map[string]interface{}{"name": "../evil"}); err == nil {
t.Fatal("path traversal name should be rejected")
}
}
func TestExportInstallRoundTrip(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// 先创建并加载一个 skill
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "roundtrip",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "roundtrip"),
}); err != nil {
t.Fatal(err)
}
outDir := filepath.Join(p.skillsDir, "..", "exports")
packPath := filepath.Join(outDir, "roundtrip.skm")
if _, err := spy.call(t, "skill_export", map[string]interface{}{"name": "roundtrip"}); err != nil {
t.Fatalf("export: %v", err)
}
if !fileExists(packPath) {
t.Fatalf("pack missing at %s", packPath)
}
// 卸载 + 删文件后从包重装
if _, err := spy.call(t, "skill_unload", map[string]interface{}{"name": "roundtrip", "delete_files": true}); err != nil {
t.Fatal(err)
}
if _, ok := p.get("roundtrip"); ok {
t.Fatal("skill should be unloaded")
}
if fileExists(filepath.Join(p.skillsDir, "roundtrip")) {
t.Fatal("files should be deleted")
}
if _, err := spy.call(t, "skill_install", map[string]interface{}{"source": packPath}); err != nil {
t.Fatalf("install: %v", err)
}
e, ok := p.get("roundtrip")
if !ok {
t.Fatal("reinstalled skill not loaded")
}
if e.sk.Version() != "0.2.0" {
t.Fatalf("unexpected version %q", e.sk.Version())
}
}
func TestEnableDisable(t *testing.T) {
p, spy, _ := newTestPlugin(t)
writeSkill(t, p.skillsDir, "toggle", "toggle desc")
p.scanExisting()
if _, err := spy.call(t, "skill_disable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); e.enabled {
t.Fatal("should be disabled")
}
if _, err := spy.call(t, "skill_enable", map[string]interface{}{"name": "toggle"}); err != nil {
t.Fatal(err)
}
if e, _ := p.get("toggle"); !e.enabled {
t.Fatal("should be enabled")
}
}
func TestPackTarSlipRejected(t *testing.T) {
tmp := t.TempDir()
// 构造带 .. 逃逸条目的恶意包
packPath := filepath.Join(tmp, "evil.skm")
f, _ := os.Create(packPath)
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
hdr := &tar.Header{Name: "../../../evil.txt", Mode: 0644, Size: 3}
tw.WriteHeader(hdr)
tw.Write([]byte("bad"))
tw.Close()
gz.Close()
f.Close()
dst := filepath.Join(tmp, "out")
if _, err := unpackSkill(packPath, dst); err == nil {
t.Fatal("tar slip entry must be rejected")
}
}
func TestSkillIndex(t *testing.T) {
p, spy, _ := newTestPlugin(t)
// The plugin now materializes the four kernel skills before scanning. They are
// part of the built-in skill index, so an empty user registry is not empty.
idx := p.buildIndex()
for _, want := range []string{"kernel-memory", "kernel-output", "kernel-plugins", "kernel-tool-order"} {
if !strings.Contains(idx, want) {
t.Fatalf("built-in skill missing from index: %q", idx)
}
}
if _, err := spy.call(t, "skill_create", map[string]interface{}{
"name": "idx-test",
"content": strings.ReplaceAll(sampleSkillContent, "demo-skill", "idx-test"),
}); err != nil {
t.Fatal(err)
}
idx = p.buildIndex()
if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") {
t.Fatalf("index missing name/desc: %q", idx)
}
// 禁用后不出现在索引里
spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"})
if idx := p.buildIndex(); strings.Contains(idx, "idx-test") {
t.Fatalf("disabled skill should be excluded from index: %q", idx)
}
}
// ── 索引行截断必须按字符,不能按字节 ──
//
// 真实缺陷(e2e 实测发现):原实现 `desc[:80]` 按字节切,
// 中文占 3 字节 ⇒ 切点落在多字节字符中间 ⇒ 产生非法 UTF-8。
//
// 后果不止是"难看":截断吃掉了"什么时候该用"那部分,
// 模型只看到半句 + 乱码尾部,于是不知道这个 skill 何时适用。
// e2e 实证:多对象重复读文件的场景,模型没用 seq,改去调 file-tools。
//
// 本仓 25 个 skill 中 8 个(32%)曾被此 bug 破坏。
func TestSkillIndexTruncatesByRune(t *testing.T) {
long := strings.Repeat("序列加速能力说明", 20) // 160 个中文字符,远超 80
got := truncateDescForTest(long)
// ① 必须仍是合法 UTF-8(按字节切会产生非法序列)
if !utf8.ValidString(got) {
t.Fatalf("截断结果不是合法 UTF-8(模型会看到乱码):%q", got)
}
// ② 截断长度按 rune 计,80 字符 + "..."
if n := len([]rune(got)); n != 83 {
t.Fatalf("应按字符截断到 80+3 个 rune,实际 %d 个", n)
}
// ③ 不得出现替换字符
if strings.ContainsRune(got, '\uFFFD') {
t.Fatalf("截断结果含替换字符 U+FFFD:%q", got)
}
}
// 短描述不应被截断。
func TestSkillIndexKeepsShortDescIntact(t *testing.T) {
short := "把同类操作编成序列执行"
if got := truncateDescForTest(short); got != short {
t.Fatalf("短描述不该被改动:期望 %q,实际 %q", short, got)
}
}
// truncateDescForTest 直接调生产实现,避免测试里复制一份逻辑(两处真相会漂移)。
func truncateDescForTest(desc string) string { return truncateDesc(desc) }