mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 缺陷(生产实测)
`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
148 lines
5.6 KiB
Go
148 lines
5.6 KiB
Go
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] 段")
|
||
}
|
||
}
|