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

179 lines
6.9 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 的**更新语义**。
//
// ## 为什么必须成组断言(2026-11-04 实测缺陷)
//
// 原规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件:
//
// 用户改过的版本 ⇒ 应该保留
// 内核自己写过的旧版 ⇒ 应该随内核更新
//
// 两者都表现为「目录已存在」,于是内核改名/改文档后,内置 skill
// **永远更新不到已部署机器**。
//
// 实测后果:b850fea 把 `memory_recall` 改成 `stablemem_recall`,
// 生产机的 kernel-memory 一直停在旧名 —— 模型照着旧名调工具必然失败,
// 而日志里一个字都没有。
//
// ## 所以这里必须四条一起断言
//
// ① 首次安装:写入 + 写 stamp
// ② 未被改动 ⇒ 覆盖成新版(这是修掉的那个缺陷)
// ③ 用户改过 ⇒ **不**覆盖(保住原设计意图)
// ④ 没有 stamp ⇒ 不覆盖(保守:老机器上的目录可能是人的)
//
// 只断言 ② 会把 ③ 破坏掉(那就成了"内核覆盖用户修改")。
// 只断言 ③ 则缺陷依旧(那就是原来的行为)。
import (
"os"
"path/filepath"
"testing"
)
// writeBuiltinFixture 在临时目录里造一个 skillsDir。
func writeBuiltinFixture(t *testing.T) string {
t.Helper()
return t.TempDir()
}
// putSkill 写一份 SKILL.md + 可选 stamp。
func putSkill(t *testing.T, dir, content string, withStamp bool) {
t.Helper()
skillDir := filepath.Join(dir, "kernel-memory")
if err := os.MkdirAll(skillDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(content), 0644); err != nil {
t.Fatalf("write skill: %v", err)
}
if withStamp {
writeBuiltinStamp(skillDir, []byte(content))
}
}
// TestMaterializeBuiltinSkills_FirstInstall ①:首次安装写入并留 stamp。
func TestMaterializeBuiltinSkills_FirstInstall(t *testing.T) {
dir := writeBuiltinFixture(t)
n := materializeBuiltinSkills(dir)
if n == 0 {
t.Fatal("首次安装应当写入内嵌 skill,实际写入 0 个")
}
stamp := filepath.Join(dir, "kernel-memory", builtinStampName)
if _, err := os.Stat(stamp); err != nil {
t.Fatalf("首次安装后没有 stamp 文件(%v)—— 下次就无法判断'有没有人改过'", err)
}
if !builtinSkillUnmodified(filepath.Join(dir, "kernel-memory")) {
t.Fatal("刚写入的 skill 却被判定为'已被改动'")
}
}
// TestMaterializeBuiltinSkills_OverwritesUnmodified ②:**核心判据**。
//
// 变红条件:把覆盖逻辑改回"目录已存在就 continue" ⇒ 红。
func TestMaterializeBuiltinSkills_OverwritesUnmodified(t *testing.T) {
dir := writeBuiltinFixture(t)
// 先装一次(拿到真 stamp),再把内容改成"旧版"并同步 stamp,
// 模拟"内核上次写的是旧内容,但用户没动过"。
if n := materializeBuiltinSkills(dir); n == 0 {
t.Fatal("前置:首次安装失败")
}
skillDir := filepath.Join(dir, "kernel-memory")
const stale = "# 旧版内容\n旧工具名 memory_recall\n"
if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(stale), 0644); err != nil {
t.Fatalf("写旧版: %v", err)
}
writeBuiltinStamp(skillDir, []byte(stale)) // 用户没动过 ⇒ stamp 与内容一致
n := materializeBuiltinSkills(dir)
if n == 0 {
t.Fatal("★ 内核自己写的旧版没有被更新 —— 内置 skill 的修复永远到不了已部署机器" +
"(这正是 memory_recall → stablemem_recall 那次改名失联的原因)")
}
got, err := os.ReadFile(filepath.Join(skillDir, skillFileName))
if err != nil {
t.Fatalf("读回: %v", err)
}
if string(got) == stale {
t.Fatal("★ 返回了'已更新'但内容还是旧的")
}
// 更新后 stamp 必须跟着变,否则下次会被误判成"用户改过"。
if !builtinSkillUnmodified(skillDir) {
t.Fatal("更新后 stamp 没同步 —— 下次启动会认为这是用户版本,从此不再更新")
}
}
// TestMaterializeBuiltinSkills_KeepsUserEdits ③:用户改过的不覆盖。
//
// 这是原设计意图,不能被 ② 的修复破坏。
func TestMaterializeBuiltinSkills_KeepsUserEdits(t *testing.T) {
dir := writeBuiltinFixture(t)
if n := materializeBuiltinSkills(dir); n == 0 {
t.Fatal("前置:首次安装失败")
}
skillDir := filepath.Join(dir, "kernel-memory")
const userEdit = "# 我自己改的\n这一行是用户加的\n"
if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(userEdit), 0644); err != nil {
t.Fatalf("写用户版: %v", err)
}
// ★ 不动 stamp:模拟"用户手改了文件,但没更新 .builtin-sha256"。
materializeBuiltinSkills(dir)
got, err := os.ReadFile(filepath.Join(skillDir, skillFileName))
if err != nil {
t.Fatalf("读回: %v", err)
}
if string(got) != userEdit {
t.Fatalf("★ 用户的修改被内核覆盖了(现在内容 %q)—— "+
"内置 skill 的更新必须以'不覆盖人的改动'为前提", string(got))
}
}
// TestMaterializeBuiltinSkills_NoStampIsConservative ④:没有 stamp 视为用户版本。
//
// 老机器上(功能上线前装的目录)没有 stamp。此时代码无从判断,
// 必须选保守侧:不覆盖。代价是那些机器需要手动重装一次。
func TestMaterializeBuiltinSkills_NoStampIsConservative(t *testing.T) {
dir := writeBuiltinFixture(t)
const unknown = "# 来历不明的旧内容\n"
putSkill(t, dir, unknown, false) // 有内容、无 stamp
// ★ 不能断言返回值为 0:内置 skill 有多个,其余几个目录不存在会被正常写入。
// 第一版这么断言,判据红了但代码是对的 —— 这个坑记在这里:
// 计数是"本次写入总数",不是"是否覆盖了这一个"。
materializeBuiltinSkills(dir)
got, err := os.ReadFile(filepath.Join(dir, "kernel-memory", skillFileName))
if err != nil {
t.Fatalf("读回: %v", err)
}
if string(got) != unknown {
t.Fatalf("没有 stamp 的 kernel-memory 被覆盖了(现在 %q)—— "+
"无法确认那份是不是人改的,必须保守", string(got))
}
}
// TestMaterializeBuiltinSkills_Idempotent 内容最新时不重写(保住 mtime)。
func TestMaterializeBuiltinSkills_Idempotent(t *testing.T) {
dir := writeBuiltinFixture(t)
if n := materializeBuiltinSkills(dir); n == 0 {
t.Fatal("前置:首次安装失败")
}
before, err := os.Stat(filepath.Join(dir, "kernel-memory", skillFileName))
if err != nil {
t.Fatalf("stat: %v", err)
}
// 同 ④:返回值是"本次写入总数",此时全部已存在且最新 ⇒ 应为 0。
if n := materializeBuiltinSkills(dir); n != 0 {
t.Fatalf("内容已最新时不该再写(写了 %d 个)—— 会无谓改动 mtime,干扰运维判断", n)
}
after, err := os.Stat(filepath.Join(dir, "kernel-memory", skillFileName))
if err != nil {
t.Fatalf("stat: %v", err)
}
if !after.ModTime().Equal(before.ModTime()) {
t.Fatal("内容未变却重写了文件(mtime 变了)")
}
}