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

@ -266,11 +266,14 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
IO: childIO,
StageHost: a.stageHost,
LightMemory: light, // 轻量内核:只有图记忆共同面
AllowedOutputs: opts.AllowedOutputs,
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
// ★ 技能索引必须继承父的:不继承时子的 system prompt 里没有索引,
// 模型看不到"按需插件要先 plugin_start"这类说明(同 rtchild)。
SkillIndexProvider: a.skillIndex,
LightMemory: light, // 轻量内核:只有图记忆共同面
AllowedOutputs: opts.AllowedOutputs,
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
// ★ 安全运行时必须继承(2026-10-30 补)。
//
// 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、

View File

@ -627,9 +627,17 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
IO: childIO,
StageHost: a.stageHost,
AllowedOutputs: []string{opts.Output},
KernelSource: string(a.id),
ParentID: string(a.id),
// ★ 技能索引必须**继承父的**(2026-11-04 修)。
//
// 不继承的后果与 Security 那次同型:子 agent 的 system prompt 里
// **完全没有 skill 索引**,于是它不知道该先读哪个 skill 就直接调工具。
// 实测症状:A2A 分诊助手手上没有 cmd_run(cmd 插件 auto_start=false),
// 又看不到 cmd-tools skill 里"先 plugin_start cmd"的说明,
// 就把 terminal_create 当成命令执行器用,一条命令开一个终端。
SkillIndexProvider: a.skillIndex,
AllowedOutputs: []string{opts.Output},
KernelSource: string(a.id),
ParentID: string(a.id),
// ★ 安全运行时**必须继承父的**(2026-10-30 补)。
//
// rtchild 是高频、低延迟的执行体,且它照样能调工具(R3:rt 流不禁工具)。

View File

@ -0,0 +1,147 @@
package core
// 判据:**子 agent 必须继承技能索引**(2026-11-04 生产缺陷)。
//
// ## 缺陷原貌
//
// `SetSkillIndexProvider` 只在 `cmd/homed/bootstrap.go` 里对**根 agent** 调了一次:
//
// if sp := pluginReg.Get("skillmgr"); sp != nil {
// if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
// agent.SetSkillIndexProvider(prov)
// }
// }
//
// 而 rtchild / 驻留子 / 转投驻留子 各自 `New(AgentConfig{...})` 构造载体,
// 都**没有**传 `SkillIndexProvider`。于是子的 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 那次同型
//
// 父有的能力,子没继承。`Security` 那次也是"只修了主 agent",
// 后来补成"构造期落位 + 各子构造点显式传"。
//
// ## 判据为什么测 prompt 而不是测字段
//
// 断言 `carrier.skillIndex != nil` 是结构判据:字段接上了,但
// `buildSystemPrompt` 里注入那段被删了照样绿。所以直接看**生成的提示词**。
import (
"strings"
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// fakeSkillIndex 是最小 SkillIndexProvider 替身。
type fakeSkillIndex struct{ content string }
func (f *fakeSkillIndex) SkillIndex() string { return f.content }
// rtHostWithSkills 造一个带技能索引的父 agent + 成对 rt 通道。
func rtHostWithSkills(t *testing.T, idx SkillIndexProvider) *Agent {
t.Helper()
pio := agentIO.NewIOManager()
host := New(AgentConfig{
ID: "si-host", IO: pio, Provider: &rtBlockingProvider{},
SkillIndexProvider: idx,
})
if err := pio.RegisterDevice(&rtRecordingDevice{name: "siout"}); err != nil {
t.Fatalf("注册输出设备: %v", err)
}
for _, n := range []string{"siout", "siin"} {
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
Name: n, Plugin: "sitest",
Capabilities: agentIO.CapText | agentIO.CapRT,
}); err != nil {
t.Fatalf("注册通道 %s: %v", n, err)
}
}
return host
}
// TestRTChild_InheritsSkillIndex 钉住 rtchild 继承技能索引。
//
// 变红条件:从 SpawnRTChild 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。
func TestRTChild_InheritsSkillIndex(t *testing.T) {
const marker = "kernel-terminal v1.0.0 - Terminal waits for input"
host := rtHostWithSkills(t, &fakeSkillIndex{content: marker})
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{ID: "si-rt", Input: "siin", Output: "siout"})
if err != nil {
t.Fatalf("SpawnRTChild: %v", err)
}
carrier := host.rtChildOf(id).agent
prompt := carrier.buildSystemPrompt("", "hi")
if !strings.Contains(prompt, "kernel-terminal") {
t.Fatalf("★ rtchild 的 system prompt 里没有技能索引 —— "+
"它看不到任何 skill,只能凭工具名猜用法(%d 字符的 prompt)", len(prompt))
}
if !strings.Contains(prompt, "/dev/ptmx") && !strings.Contains(prompt, "Terminal waits for input") {
t.Fatalf("★ 索引内容没进 prompt(只匹配到了 skill 名?)")
}
}
// TestResident_InheritsSkillIndex 钉住驻留子继承技能索引。
//
// 这是**生产实际中招的那一类**:A2A 的临时分诊助手就是驻留子。
//
// 变红条件:从 SpawnResident 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。
func TestResident_InheritsSkillIndex(t *testing.T) {
const marker = "cmd-tools v1.0.0 - on-demand: plugin_start cmd first"
parent, _, dir := newRootWith(t, &countingProvider{})
parent.skillIndex = &fakeSkillIndex{content: marker}
spawnTestResident(t, parent, dir, "si-res")
rc := parent.residentOf("si-res")
if rc == nil {
t.Fatal("驻留子没创建成功")
}
prompt := rc.agent.buildSystemPrompt("", "hi")
if !strings.Contains(prompt, "cmd-tools") {
t.Fatalf("★ 驻留子的 system prompt 里没有技能索引 —— "+
"这正是生产上'拿 terminal_create 当 cmd_run 用'的根因"+
"(%d 字符的 prompt)", len(prompt))
}
}
// TestRootAgent_SkillIndexStillInjected 钉住根 agent 不受本次改动影响。
//
// 保守侧:确认修复没有把根 agent 的路径改坏。
func TestRootAgent_SkillIndexStillInjected(t *testing.T) {
const marker = "kernel-memory v1.0.0 - read before memory tools"
a := New(AgentConfig{ID: "root-si", SkillIndexProvider: &fakeSkillIndex{content: marker}})
prompt := a.buildSystemPrompt("", "hi")
if !strings.Contains(prompt, "kernel-memory") {
t.Fatal("根 agent 的技能索引丢了 —— 本次改动改坏了原有路径")
}
}
// TestChildWithoutSkillIndexIsNotBroken 钉住"父没有索引时子也不炸"。
//
// 父可能没接 skillmgr(单测、独立工具)。那种情况下子照常工作,
// 只是 prompt 里没有索引段 —— 不能因为 nil 就 panic。
func TestChildWithoutSkillIndexIsNotBroken(t *testing.T) {
host := rtHostWithSkills(t, nil) // 显式不接
defer host.StopRTChildren()
id, err := host.SpawnRTChild(RTChildOptions{ID: "si-none", Input: "siin", Output: "siout"})
if err != nil {
t.Fatalf("父无技能索引时 SpawnRTChild 不该失败: %v", err)
}
prompt := host.rtChildOf(id).agent.buildSystemPrompt("", "hi")
if strings.Contains(prompt, "[Skills]") {
t.Fatal("父没有技能索引,子的 prompt 却出现了 [Skills] 段")
}
}