Files
HomeAgent/internal/plugins/agentcli/skill_test.go
JianFeeeee a8ce5ece30 feat(agentcli): 补 skill 文档 —— 终端 vs 一次性命令的边界
## 为什么补这个

真实生产事故:agent 把 terminal_create 当成一次性命令执行器用。

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "=== 已安装的 dsh 包 ===" && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

三层成因:

  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run,需先 plugin_start cmd
  ② terminal_create 的 command 参数描述写着"要执行的命令",语义上像"跑一条命令"
  ③ 没有文档说清"一次性命令该用 cmd_run、终端是给要持续输入的程序用的"

①③ 已分别修掉(cmd-tools skill + 本文件)。② 是设计上的引力:
参数名叫 command、描述说"要执行的命令",模型自然会塞复合命令进去。
文档要正面抵消。

## 实现方式

按 seq 的既有模式:内置插件实现 plugin.SkillProvider,
由内核统一写盘/幂等/事件/停用清理(internal/plugin/builtin_skill.go)。

  · 新增 internal/plugins/agentcli/skill.go
  · BuiltinSkills() 交出正文,生命周期与插件同生共死
    (禁用插件后 skill 还留着 = 模型看得到说明、点进去工具却不存在)

## 文档的核心:可操作判据,不是抽象词

一句判据:**跑完就结束 → cmd_run。要一直活着、要看屏幕、要按键 → terminal。**

关键的三条(缺任何一条模型都会退回旧行为):

  · 点明 cmd 插件要 plugin_start(否则模型找不到 cmd_run 就用终端)
  · 点明复合命令(cd && echo && ls)属于一次性,不是"所以要用终端"
  · 解释 /dev/ptmx 那个真实报错的含义(命令跑完终端已退出),
    否则模型会以为终端坏了

索引行(模型每轮唯一能看到的一行)也带边界:
"交互式终端(vim/ssh/top/长驻进程)。一次性的 shell 命令不要用终端,用 cmd_run。"

## 判据(均经变异验证)

  TestSkillProviderContract               编译期断言 SkillProvider + 正文非空
  TestSkillSeparatesTerminalFromOneShot   ★ 六条必备内容缺一即红
  TestSkillIndexLineStatesBoundary        索引行必须指出替代方案
  TestSkillBodyFirstParagraphStandalone   正文首段(真正注入提示词的那段)能独立看懂

变异验证(实测):把整份文档换成"终端可以执行各种命令"的弱化版本
⇒ 3 条同时红。

★ 第一版变异是"只改一句话",判据没红 —— 因为 cmd_run 在文中另有 13 处。
  那次变异无效,不算验证。改成整份替换后才是真验证。

## 端到端验证(独立实例 + 假 LLM 捕获真实请求)

  [plugin] agentcli: installed skill "agentcli-terminal" -> .../skills/agentcli-terminal
  [clawhubadapter] skill agentcli-terminal detected, handed off to skillmgr

实际注入到 system prompt 的 [Skills] 段:

    agentcli-terminal v1.0.0 - 终端给**要持续输入**的程序用(vim、ssh、top、REPL、
    要按键确认的安装器);一次性的 shell 命令用 **cmd_run**(cmd 插件,需…

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + 判据全绿
  独立实例实测                 真机装盘 + 真机注入提示词(见上)
2026-11-04 13:22:07 +08:00

149 lines
5.3 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 agentcli
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
)
// 编译期断言:agentcli 必须实现 plugin.SkillProvider,
// 否则内核的 InstallBuiltinSkill 会因类型断言失败而**静默跳过** ——
// 模型永远看不到这份文档,而工具照样存在。
//
// 这个断言本身就是判据:删掉 BuiltinSkills 方法 ⇒ 编译失败。
var _ plugin.SkillProvider = (*Plugin)(nil)
func TestSkillProviderContract(t *testing.T) {
p := New("agentcli")
skills := p.BuiltinSkills()
if len(skills) == 0 {
t.Fatal("BuiltinSkills 返回空 —— 模型永远看不到 agentcli 的使用说明")
}
body, ok := skills[builtinSkillName]
if !ok {
t.Fatalf("缺 %q 条目", builtinSkillName)
}
if len(body) < 500 {
t.Fatalf("SKILL 正文过短(%d 字节),不足以讲清终端与一次性命令的边界", len(body))
}
// frontmatter 的 name 必须与 map 的键一致(内核按它建目录)。
if !strings.Contains(body, "name: "+builtinSkillName) {
t.Fatalf("frontmatter 的 name 必须等于 %q(否则 directory 名与索引对不上)", builtinSkillName)
}
}
// TestSkillSeparatesTerminalFromOneShot 是本文件最重要的判据。
//
// ## 它守的是什么(真实生产事故)
//
// 用户报告:agent 把 terminal_create 当成一次性命令执行器用。日志实录:
//
// terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "..." && ls -d */ | head -60 && …')
// → read /dev/ptmx: input/output error
// → 再开 term_7、term_8、term_9 …
//
// 那份文档必须同时说清三件事,缺任何一条都会让模型退回旧行为:
//
// ① 一次性命令该用 cmd_run(并说明 cmd 插件要先 plugin_start)
// ② 终端的真正用途是"要持续输入的程序"
// ③ 复合命令也属于"一次性",不是"所以要用终端"
//
// **特别是第 ③ 条**:`command` 这个参数名天然诱导模型往里面塞
// `cd && echo && grep`。文档必须正面抵消这个引力。
func TestSkillSeparatesTerminalFromOneShot(t *testing.T) {
body := New("agentcli").BuiltinSkills()[builtinSkillName]
musts := []struct {
substr string
why string
}{
{"cmd_run", "必须点名一次性命令该用 cmd_run,否则模型只知道有终端"},
{"plugin_start", "必须说明 cmd 插件是按需加载(auto_start=false),否则模型找不到 cmd_run 就用终端"},
{"vim", "必须给出终端的正面用例(vim/ssh/top 这类要持续输入的程序)"},
{"&&", "必须点明复合命令(cd && echo && ls)属于一次性,不是终端的理由"},
{"跑完就结束", "必须给出可操作的判据,而不是只说「交互式/非交互式」这种抽象词"},
{"/dev/ptmx", "必须解释那个真实报错的含义(命令跑完终端已退出),否则模型会以为终端坏了"},
}
for _, m := range musts {
if !strings.Contains(body, m.substr) {
t.Fatalf("SKILL 缺少 %q ——\n%s", m.substr, m.why)
}
}
}
// TestSkillIndexLineStatesBoundary 钉住索引行(模型每轮唯一能看到的一行)。
//
// 索引行是 `[Skills]` 段里的那条摘要。如果它只写"交互式终端",
// 模型无从知道"一次性命令别用我"——而那正是要拦的行为。
func TestSkillIndexLineStatesBoundary(t *testing.T) {
body := New("agentcli").BuiltinSkills()[builtinSkillName]
// 取 frontmatter 的 description(这是内核 extractDescription 的
// 主要来源之一;另一条路是正文首段,见下一个判据)。
var desc string
lines := strings.Split(body, "\n")
inFM := false
for i, l := range lines {
t := strings.TrimSpace(l)
if i == 0 && t == "---" {
inFM = true
continue
}
if inFM {
if t == "---" {
break
}
if strings.HasPrefix(t, "description:") {
desc = strings.TrimSpace(strings.TrimPrefix(t, "description:"))
}
}
}
if desc == "" {
t.Fatal("frontmatter 缺 description —— 索引行会退化成正文首段")
}
if !strings.Contains(desc, "cmd_run") {
t.Fatalf("索引行必须指出替代方案(cmd_run),实际:%q\n"+
"只说「交互式终端」不够:模型看到那一行仍不知道一次性命令该用什么。", desc)
}
if !strings.Contains(desc, "一次性") {
t.Fatalf("索引行必须点明边界(一次性命令别用终端),实际:%q", desc)
}
}
// TestSkillBodyFirstParagraphStandalone 钉住正文首段能独立看懂。
//
// 内核的 extractDescription **跳过 frontmatter**,取正文第一个非空
// 非标题段落 —— 所以首段才是真正注入系统提示词的那一行。
func TestSkillBodyFirstParagraphStandalone(t *testing.T) {
body := New("agentcli").BuiltinSkills()[builtinSkillName]
lines := strings.Split(body, "\n")
inFM, started := false, false
first := ""
for i, l := range lines {
t := strings.TrimSpace(l)
if i == 0 && t == "---" {
inFM = true
continue
}
if inFM {
if t == "---" {
inFM, started = false, true
}
continue
}
if started && t != "" && !strings.HasPrefix(t, "#") {
first = t
break
}
}
if first == "" {
t.Fatal("正文首段为空 —— 索引行会缺失")
}
// 首段必须自带边界,不能只靠标题或后文。
if !strings.Contains(first, "cmd_run") {
t.Fatalf("正文首段必须提到 cmd_run(它才是注入提示词的那段),实际:%q", first)
}
}