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

@ -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.