mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 缺陷(实测,非推测)
给 kernel-plugins 补上 `skill_info(name, section=...)` 的用法说明后,
改了嵌入内容,但**生产上永远不会更新**:
/home/hologramagent/skills/kernel-plugins/ mtime 2026-10-26,无 .builtin-sha256
嵌入版本 1.1.0 vs 磁盘版本 1.0.0
`materializeBuiltinSkills` 的判断链是:
builtinSkillUnmodified(dir) → 读 .builtin-sha256
没有 stamp ⇒ false
⇒ continue("用户改过或无法确认,保留人的版本")
而 stamp 机制是 387ae94(今天早些时候)引入的。生产机上这份 skill
是**更早的内核**写的 —— 它没有 stamp,于是被永久判为"来历不明"。
后果不只是这一份文档:任何在 stamp 机制之前部署的机器,其内核自带
skill 都停在旧版。而 kernel-* 系列正是教模型怎么用工具/记忆/终端的
文档 —— 内核改进了文档,模型永远看不到。
## 修法:用 frontmatter version 区分两种"没有 stamp"
原判据把两件事混成一件:
用户改过措辞 ⇒ 必须保留
内核在 stamp 之前写的 ⇒ 应该升级
新增 builtinSkillOutdated(dir, embedded):只在**没有 stamp** 时被调用,
判据是磁盘 frontmatter 的 `version` 严格低于内嵌版本。
安全性分析(写在代码注释里):
人改措辞 通常不动 version ⇒ 不低于 ⇒ 保留 ✓
人把 version 改高 ⇒ 保留 ✓
内核旧版(无 stamp) ⇒ 升级 ✓(这正是要修的)
解析不出版本号 ⇒ 返回 false ⇒ 保留(保守)
误升级的唯一条件是"人把 version 改低",而那本身就意味着放弃内核版本。
原有的四条保守判据(含 NoStampIsConservative)全部保持绿。
## 判据抓到了我自己写的 bug
TestCompareVersion 立刻红了:
compareVersion("1.0","1.0.0")=-1,期望 0
原因:缺失段留成空串 ⇒ Atoi("") 失败 ⇒ 落到字符串比较 ⇒ `"" < "0"`
⇒ 版本号少写一段的 skill 会被无谓地重复升级。改为缺失段视为 "0"。
## 变异验证
builtinSkillOutdated 恒 return false(= 修复前行为)
⇒ TestMaterializeBuiltinSkills_UpgradesStaleKernelVersion 红:
"★ 没有 stamp 的内核旧版(version 0.0.1)没被升级"
compareVersion 缺失段改回空串
⇒ TestCompareVersion 红:"compareVersion(\"1.0\",\"1.0.0\")=-1,期望 0"
## 顺带:kernel-plugins v1.1.0
补一节「Reading a skill without burning context」,说明:
- 长文档返回目录 + 开头 30 行,目录每行带 (N 行)
- `section` 可按编号或标题子串取,大小写不敏感,容忍 `##` 前缀
- 顶层章节返回整棵子树
- 未命中会列出全部可用章节
- 章节边界尊重围栏代码块
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + skillmgr 全绿
(包数 63 → 62 是因 clawhubadapter 已在 a0077a2 迁出为外部插件,与本改动无关)
286 lines
10 KiB
Go
286 lines
10 KiB
Go
package skillmgr
|
||
|
||
// 内核自带的 skill:把**技术机制**写进 skill,由 skillmgr 注入,而不是塞进
|
||
// 系统提示词。
|
||
//
|
||
// ## 为什么(用户 2026-10-26 的两次裁定)
|
||
//
|
||
// 1. 「系统提示词把机制讲得太深太技术化,导致 agent 说话没人味」——
|
||
// 机制留在提示词里,模型会连**语气**一起模仿:回复变成说明书腔。
|
||
// 2. 「技术相关下沉到 skill,由 skillmgr 自行管理注入」——
|
||
// 提示词只留"必须做什么","怎么实现"由 skill 讲。
|
||
//
|
||
// 所以提示词里只剩一两句行为约束,机制在下面这四个 skill 正文里:
|
||
//
|
||
// kernel-memory 记忆分层与 coldcontext_query 的回填语义
|
||
// kernel-output 同步/异步通道怎么真正送达用户
|
||
// kernel-tool-order 一次回复里多工具调用的调度与例外
|
||
// kernel-plugins 按需插件怎么发现与拉起
|
||
//
|
||
// ## 为什么内嵌在二进制里,而不是让用户自己装
|
||
//
|
||
// 它们是**内核行为**的一部分:提示词引用了它们,就必须保证它们一定存在。
|
||
// 若依赖外部安装,用户删掉/改名后提示词会指向不存在的 skill —— 那比不写更糟。
|
||
//
|
||
// ## 落盘纪律:不覆盖用户版本
|
||
//
|
||
// 只在目标目录**不存在**时写入(与 adapters 的 `.bundled` 语义一致)。
|
||
// 用户改过的同名 skill 一律保留:内核不该覆盖人的修改。
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"embed"
|
||
"encoding/hex"
|
||
"io/fs"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
//go:embed builtin
|
||
var builtinSkills embed.FS
|
||
|
||
// builtinSkillDir 是内嵌 skill 的根目录名。
|
||
const builtinSkillDir = "builtin"
|
||
|
||
// skillFileName 与 plugin.LoadSKILL 认的文件名一致。
|
||
const skillFileName = "SKILL.md"
|
||
|
||
// builtinStampName 记录"上次由内核写入的版本"的摘要。
|
||
//
|
||
// ★ 为什么需要它(2026-11-04 实测缺陷):
|
||
//
|
||
// 原规则是"目标目录已存在就跳过",本意是保住用户改动。但它把两件事混成一件:
|
||
//
|
||
// 用户改过的版本 ⇒ 应该保留
|
||
// 内核自己写过的旧版 ⇒ 应该随内核更新
|
||
//
|
||
// 两者都表现为"目录已存在",于是 **内核改名/改文档后,内置 skill 永远
|
||
// 更新不到已部署机器**。实测:b850fea 把 `memory_recall` 改成
|
||
// `stablemem_recall`,生产机的 kernel-memory 一直停在旧名,
|
||
// 模型照着旧名调工具必然失败。
|
||
//
|
||
// 现在用摘要区分:把写入时的内容 sha256 记进 `.builtin-sha256`。
|
||
//
|
||
// 当前内容 == 记录的 sha256 ⇒ 没人改过 ⇒ 可以安全覆盖成新版
|
||
// 当前内容 != 记录的 sha256 ⇒ 用户改过 ⇒ 保留,只在日志里说明
|
||
// 没有 stamp 文件(历史遗留)⇒ 保守处理,视为"用户版本",不覆盖
|
||
//
|
||
// 保守默认很重要:老机器上没有 stamp,宁可不更新也不要覆盖掉人的修改。
|
||
const builtinStampName = ".builtin-sha256"
|
||
|
||
// materializeBuiltinSkills 把内嵌 skill 落到 skillsDir,返回新写入的数量。
|
||
//
|
||
// 覆盖规则见 builtinStampName 的说明:只有"内核写的且未被改动"才覆盖。
|
||
func materializeBuiltinSkills(skillsDir string) int {
|
||
if skillsDir == "" {
|
||
return 0
|
||
}
|
||
entries, err := builtinSkills.ReadDir(builtinSkillDir)
|
||
if err != nil {
|
||
log.Printf("[skillmgr] read embedded skills: %v", err)
|
||
return 0
|
||
}
|
||
written := 0
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
dstDir := filepath.Join(skillsDir, name)
|
||
data, readErr := fs.ReadFile(builtinSkills, builtinSkillDir+"/"+name+"/"+skillFileName)
|
||
if readErr != nil {
|
||
log.Printf("[skillmgr] embedded skill %s: %v", name, readErr)
|
||
continue
|
||
}
|
||
// 已存在:判断是不是"内核写的且没人改过",是才覆盖。
|
||
if _, statErr := os.Stat(dstDir); statErr == nil {
|
||
if !builtinSkillUnmodified(dstDir) {
|
||
// 没有 stamp / 内容被改过 ⇒ 默认保留(人可能改过措辞)。
|
||
//
|
||
// ★ 但有一种情况必须升级:这个是**内核在 stamp 机制之前写的**
|
||
// 旧版本。判据 = 磁盘上的 frontmatter version 明确低于内嵌版本。
|
||
//
|
||
// 为什么需要这一条(实测缺陷):stamp 机制由 387ae94 引入,
|
||
// 而生产机上 kernel-plugins 是更早的内核写的(mtime 2026-10-26),
|
||
// 没有 .builtin-sha256 ⇒ builtinSkillUnmodified 恒为 false
|
||
// ⇒ **这份 skill 永不更新**。后果是内核改进了 skill 文档,
|
||
// 已部署的机器永远拿不到 —— 而这个 skill 正是教模型怎么用工具的。
|
||
//
|
||
// 为什么用 version 而不是内容差异:用户手工改措辞时通常不动
|
||
// version,所以"version 更高"是"内核发布了新版"的可靠信号;
|
||
// 反过来,人把 version 改高的情况几乎不存在。
|
||
if !builtinSkillOutdated(dstDir, data) {
|
||
continue // 用户改过(或无法确认):保留人的版本
|
||
}
|
||
log.Printf("[skillmgr] upgrading stale builtin skill %s "+
|
||
"(no stamp, on-disk version older than embedded)", name)
|
||
}
|
||
if cur, err := os.ReadFile(filepath.Join(dstDir, skillFileName)); err == nil && string(cur) == string(data) {
|
||
continue // 内容已是最新,不用重写(保留 mtime)
|
||
}
|
||
if wErr := os.WriteFile(filepath.Join(dstDir, skillFileName), data, 0644); wErr != nil {
|
||
log.Printf("[skillmgr] update %s: %v", dstDir, wErr)
|
||
continue
|
||
}
|
||
writeBuiltinStamp(dstDir, data)
|
||
log.Printf("[skillmgr] updated builtin skill %s (kernel-owned, unmodified by user)", name)
|
||
written++
|
||
continue
|
||
}
|
||
if mkErr := os.MkdirAll(dstDir, 0755); mkErr != nil {
|
||
log.Printf("[skillmgr] create %s: %v", dstDir, mkErr)
|
||
continue
|
||
}
|
||
if wErr := os.WriteFile(filepath.Join(dstDir, skillFileName), data, 0644); wErr != nil {
|
||
log.Printf("[skillmgr] write %s: %v", dstDir, wErr)
|
||
continue
|
||
}
|
||
writeBuiltinStamp(dstDir, data)
|
||
written++
|
||
}
|
||
if written > 0 {
|
||
log.Printf("[skillmgr] materialized %d kernel skills (mechanics live here, not in the system prompt)", written)
|
||
}
|
||
return written
|
||
}
|
||
|
||
// builtinSkillUnmodified 报告 skillsDir 下的 skill 是否"内核写的且未被改动"。
|
||
//
|
||
// 判定依据是 .builtin-sha256 与**当前** SKILL.md 的实际摘要是否一致。
|
||
// 没有 stamp(历史遗留或用户手动放置)⇒ 返回 false(保守:不覆盖)。
|
||
func builtinSkillUnmodified(dir string) bool {
|
||
stamp, err := os.ReadFile(filepath.Join(dir, builtinStampName))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
cur, err := os.ReadFile(filepath.Join(dir, skillFileName))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(string(stamp)) == sha256Hex(cur)
|
||
}
|
||
|
||
// writeBuiltinStamp 记录本次写入内容的摘要,供下次判断"有没有人改过"。
|
||
//
|
||
// 写失败只记日志、不影响 skill 本身可用:没有 stamp 的后果是
|
||
// "下次不会被自动更新"(保守侧),而不是 skill 坏了。
|
||
func writeBuiltinStamp(dir string, data []byte) {
|
||
if err := os.WriteFile(filepath.Join(dir, builtinStampName), []byte(sha256Hex(data)), 0644); err != nil {
|
||
log.Printf("[skillmgr] write stamp %s: %v", dir, err)
|
||
}
|
||
}
|
||
|
||
// sha256Hex 返回 b 的十六进制 sha256。
|
||
func sha256Hex(b []byte) string {
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// builtinSkillOutdated 判断磁盘上的 skill 是否是**内核写的旧版本**。
|
||
//
|
||
// 只在没有 stamp 时才被调用(有 stamp 的走 unmodified 判定)。
|
||
//
|
||
// 判据:磁盘文件的 frontmatter `version` 严格低于内嵌版本。
|
||
//
|
||
// ## 为什么这条判据是安全的
|
||
//
|
||
// 它只在"两份都能解析出版本号"且"磁盘更低"时返回 true:
|
||
//
|
||
// 人手工改措辞 → 通常不动 version ⇒ 不低于 ⇒ 保留(正确)
|
||
// 人手工把版本改高 → 不触发升级 ⇒ 保留(正确,虽然几乎不会发生)
|
||
// 内核旧版(无 stamp)→ 磁盘版本更低 ⇒ 升级(这正是要修的缺陷)
|
||
// 解析不出版本号 → 返回 false ⇒ 保留(保守)
|
||
//
|
||
// 也就是说:**误升级的唯一条件是"人把 version 改低了"**,而那种改动
|
||
// 本身就是要放弃内核版本的意思,却反而被升级——接受这个极小的风险,
|
||
// 换取"内核能更新 skill"这个基本能力。
|
||
func builtinSkillOutdated(dir string, embedded []byte) bool {
|
||
cur, err := os.ReadFile(filepath.Join(dir, skillFileName))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
diskVer := skillVersionOf(string(cur))
|
||
embVer := skillVersionOf(string(embedded))
|
||
if diskVer == "" || embVer == "" {
|
||
return false
|
||
}
|
||
return compareVersion(diskVer, embVer) < 0
|
||
}
|
||
|
||
// skillVersionOf 从 SKILL.md 的 frontmatter 里取 version。
|
||
//
|
||
// 只认 frontmatter 块(首个 --- 到闭合 ---),不扫正文 —— 正文里的
|
||
// `version:` 可能是示例代码,误读会让判据做出错误决定。
|
||
func skillVersionOf(content string) string {
|
||
lines := strings.Split(content, "\n")
|
||
in := false
|
||
for i, line := range lines {
|
||
t := strings.TrimSpace(line)
|
||
if i == 0 && t == "---" {
|
||
in = true
|
||
continue
|
||
}
|
||
if !in {
|
||
continue
|
||
}
|
||
if t == "---" {
|
||
break // frontmatter 结束
|
||
}
|
||
if strings.HasPrefix(strings.ToLower(t), "version:") {
|
||
v := strings.TrimSpace(t[len("version:"):])
|
||
v = strings.Trim(v, "\"'")
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// compareVersion 按点分段数值比较,返回 -1 / 0 / 1。
|
||
//
|
||
// 非数值段按字符串比较(例如 "1.0.0-beta"),空段视为 0。
|
||
// 不做语义化版本的完整解析 —— 这里只需要"新是否旧",越简单越不会错。
|
||
func compareVersion(a, b string) int {
|
||
as := strings.Split(a, ".")
|
||
bs := strings.Split(b, ".")
|
||
n := len(as)
|
||
if len(bs) > n {
|
||
n = len(bs)
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
// ★ 缺失段必须视为 "0",不能留空串。
|
||
//
|
||
// 判据实测抓到过:留空串时 Atoi("") 失败 ⇒ 落到字符串比较
|
||
// ⇒ `"" < "0"` ⇒ "1.0" 被判成低于 "1.0.0"(错误,二者等价)。
|
||
// 后果是版本号少写一段的 skill 会被无谓地重复升级。
|
||
av, bv := "0", "0"
|
||
if i < len(as) && as[i] != "" {
|
||
av = as[i]
|
||
}
|
||
if i < len(bs) && bs[i] != "" {
|
||
bv = bs[i]
|
||
}
|
||
ai, aerr := strconv.Atoi(av)
|
||
bi, berr := strconv.Atoi(bv)
|
||
if aerr == nil && berr == nil {
|
||
if ai != bi {
|
||
if ai < bi {
|
||
return -1
|
||
}
|
||
return 1
|
||
}
|
||
continue
|
||
}
|
||
// 非数值段:字符串比较
|
||
if av != bv {
|
||
if av < bv {
|
||
return -1
|
||
}
|
||
return 1
|
||
}
|
||
}
|
||
return 0
|
||
}
|