mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
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 防腐判据(部分已随本次落地)
423 lines
13 KiB
Go
423 lines
13 KiB
Go
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) }
|