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
This commit is contained in:
JianFeeeee
2026-11-04 10:17:45 +08:00
parent ba8a33dad8
commit 387ae945df
7 changed files with 488 additions and 14 deletions

View File

@ -28,11 +28,14 @@ package skillmgr
// 用户改过的同名 skill 一律保留:内核不该覆盖人的修改。
import (
"crypto/sha256"
"embed"
"encoding/hex"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
)
//go:embed builtin
@ -44,9 +47,32 @@ 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,返回新写入的数量。
//
// 已存在的目录**不覆盖**:那是用户可能改过的版本。返回 0 表示无需写入。
// 覆盖规则见 builtinStampName 的说明:只有"内核写的且未被改动"才覆盖。
func materializeBuiltinSkills(skillsDir string) int {
if skillsDir == "" {
return 0
@ -63,14 +89,28 @@ func materializeBuiltinSkills(skillsDir string) int {
}
name := e.Name()
dstDir := filepath.Join(skillsDir, name)
if _, statErr := os.Stat(dstDir); statErr == nil {
continue // 已存在(用户版本或上次落盘):不覆盖
}
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
@ -79,6 +119,7 @@ func materializeBuiltinSkills(skillsDir string) int {
log.Printf("[skillmgr] write %s: %v", dstDir, wErr)
continue
}
writeBuiltinStamp(dstDir, data)
written++
}
if written > 0 {
@ -86,3 +127,35 @@ func materializeBuiltinSkills(skillsDir string) int {
}
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[:])
}

View File

@ -16,7 +16,7 @@ Four stores, one rule each:
## Retrieving
| Need | Tool |
|---|---|
| --- | --- |
| A past episode in full | `coldcontext_query` |
| A fact or relation | `stablemem_recall` |
| Written reference material | `knowledge_search` |
@ -33,5 +33,5 @@ again later, it lives on in the timeline until pruning moves it back.
## Writing
- Facts worth keeping: `stablemem_commit` (triples).
- Written knowledge: `knowledge_write`.
- Written knowledge: `knowledge_create`.
- Do not commit transient chatter, tool output, or your own status lines.

View File

@ -0,0 +1,65 @@
---
name: kernel-terminal
description: Terminal waits for input; it is NOT cmd_run. Read before using vim/ssh/top.
version: 1.0.0
---
# Terminals vs one-shot commands
Two different tools. Picking the wrong one looks like it works, then fails in a way that
points somewhere else.
| Tool | Use for | Lifetime |
| --- | --- | --- |
| `cmd_run` | **One** command, then done: queries, scripts, `cd && … && grep …`, pipes, redirects, heredocs | Process exits when the command exits |
| `terminal_create` | A program that **keeps running and keeps asking for input**: `vim`, `ssh`, `top`, `less`, an interactive REPL | Stays open until you `terminal_close` or its `timeout` expires |
## The failure this prevents
Running `terminal_create` with a compound one-shot command
(`cd /x && echo … && ls … && grep …`) creates a terminal that exits the moment the command
finishes. Reading it then fails with `read /dev/ptmx: input/output error`. The next instinct is
to create another terminal for the next command — and now every "command" costs a terminal,
fills the terminal list, and each one dies mid-read.
**If the command runs to completion on its own, it belongs in `cmd_run`.** A terminal is only
justified when the thing you are talking to is *still running and waiting for you*.
## Driving a terminal
`terminal_create` returns an ID. From then on the terminal is **silent by default** — it does
not push output at you.
1. `terminal_read(id)` — read what is on screen now. Do this after acting, not on a timer.
2. `terminal_write(id, input=..., key=...)` — send text and/or a key (`Enter`, `Escape`, `C-c`)
in **one call**. `input` without `key` does not submit; the program just sits there.
3. `terminal_close(id)` — release it. Terminals default to a 5-minute `timeout`; leaving them
open wastes a slot.
`notify` is opt-in. Without it you get nothing. Add it only when you genuinely want a wake-up
(`exit` for "tell me when it finished", `buffer=8192` for "tell me when there is a lot of
unread output"). Watching a fast-scrolling program with `interval` produces noise, not signal.
## Sending keystrokes
`key` is for **control**, `input` is for **text**:
| Want | Send |
| --- | --- |
| Submit a command line | `input="ls -la"`, `key="Enter"` |
| Interrupt a running program | `key="C-c"` |
| Leave a pager or editor without saving | `key="Escape"`, then `key="q"` for pagers |
| Quit `vim` without saving | `key="Escape"`, then `input=":q!"`, `key="Enter"` |
## Reading output that scrolled past
`terminal_read` returns what is currently visible plus recent scrollback. If the output you need
scrolled out of range, do not blindly re-read in a loop — either grow `rows` at create time, or
for one-shot inspection prefer `cmd_run`, whose result is returned whole.
## Checklist before reaching for a terminal
- Does the command finish by itself? → `cmd_run`.
- Does it need me to type at it more than once? → `terminal_create`.
- Am I about to create a second terminal for "the next command"? → stop; the first one was
the wrong tool.

View File

@ -0,0 +1,178 @@
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 变了)")
}
}