Files
HomeAgent/internal/plugins/skillmgr/builtin.go
JianFeeeee 387ae945df fix(agent): 子 agent 继承技能索引 —— 修 agent 把终端当命令执行器用
## 缺陷(生产实测)

`SetSkillIndexProvider` 只在 cmd/homed/bootstrap.go 里对**根 agent** 调了一次。
rtchild / 驻留子各自 `New(AgentConfig{...})` 构造载体,都没传这个字段 ⇒
子的 system prompt 里**完全没有 skill 索引**。

症状(日志实录):A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有
`cmd_run`(cmd 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着
「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create` 当成
命令执行器:每条 `cd … && echo … && grep …` 开一个新终端,终端随命令结束退出,
`terminal_read` 立刻撞 `read /dev/ptmx: input/output error`,然后为下一条命令
再开一个 —— 终端列表被填满,每一条都死在半路。

与本仓 Security 那次同型:父有的能力,子没继承。

## 修法

rtchild 与驻留子的 AgentConfig 各补一行 `SkillIndexProvider: a.skillIndex`,
与 `SystemPrompt` / `StageHost` / `Security` 同一组「必须继承父」的字段。

## 顺带两件事

### 1. 内置 skill 的更新永远到不了已部署机器

原落盘规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件:

    用户改过的版本     ⇒ 应该保留
    内核自己写过的旧版 ⇒ 应该随内核更新

两者都表现为「目录已存在」,于是 b850fea 把 `memory_recall` 改成
`stablemem_recall` 后,生产机的 kernel-memory 一直停在旧名,模型照着
旧名调工具必然失败,而日志里一个字都没有。

改为用 `.builtin-sha256` 摘要区分:
  当前内容 == stamp  ⇒ 没人动过 ⇒ 可安全覆盖成新版
  当前内容 != stamp  ⇒ 用户改过 ⇒ 保留
  没有 stamp(老目录)⇒ 保守视为用户版本,不覆盖

### 2. 新增 kernel-terminal skill

工具描述只说「适用于交互式程序」,没说何时**不该**用;`command` 参数又鼓励
传整条命令。补一条内置 skill 讲清 cmd_run 与 terminal_create 的边界、
以及终端的 input/key/notify 用法。描述控制在 80 字符内(进索引不截断)。

## 判据(均经变异验证)

  TestRTChild_InheritsSkillIndex        删掉接线 ⇒ 红(实测确认)
  TestResident_InheritsSkillIndex       同上(生产实际中招的那一类)
  TestRootAgent_SkillIndexStillInjected 确认没改坏根 agent 路径
  TestChildWithoutSkillIndexIsNotBroken 父无索引时子不 panic
  TestMaterializeBuiltinSkills_*        5 条:覆盖语义四条成组断言
                                        (改回旧行为 ⇒ OverwritesUnmodified 红,实测确认)

判据测的是**生成的 prompt**,不是 `skillIndex != nil`:
后者接上了但注入那段被删照样绿。

## 验证

  go build ./...                       PASS
  go vet ./...                         PASS
  gofmt -l                             干净
  go test ./... -count=1               62 包全过,0 FAIL
  干净检出(git worktree)单独验证      PASS
2026-11-04 10:17:45 +08:00

162 lines
6.0 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
// 内核自带的 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"
"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) {
continue // 用户改过(或无法确认):保留人的版本
}
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[:])
}