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 + 判据全绿
  独立实例实测                 真机装盘 + 真机注入提示词(见上)
This commit is contained in:
JianFeeeee
2026-11-04 13:22:07 +08:00
parent 01c7c0145e
commit a8ce5ece30
2 changed files with 272 additions and 0 deletions

View File

@ -0,0 +1,148 @@
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)
}
}