Files
HomeAgent/internal/agent/core/skillindex_inherit_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

148 lines
5.6 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 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] 段")
}
}