Files
HomeAgent/internal/plugins/agentcli/skill.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

125 lines
6.0 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
// agentcli 的随包 SKILL 文档。
//
// ## 插件这边只做一件事:交出文档正文
//
// 写盘、幂等、事件通知、停用时清理——全部由内核统一处理
// (internal/plugin/builtin_skill.go,挂载在 loadOne / StopPlugin / StopAll)。
//
// 这与 seq 的做法一致,理由见 seq/skill.go 的注释:skill 是插件能力的
// 一部分,生命周期必须与插件同生共死。禁用插件后 skill 还留着,模型会
// 看得到说明、点进去工具却不存在。
//
// ## 为什么 agentcli 必须有 skill(真实生产事故)
//
// 用户报告: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、描述说"要执行的命令",模型自然会塞复合命令进去。
// 文档要正面抵消这个引力。
//
// ## description 的取值规则
//
// 内核的 extractDescription **跳过 frontmatter**,取正文第一个非空非标题
// 段落。所以正文首段必须能独立看懂——它才是注入系统提示词的那一行。
const builtinSkillName = "agentcli-terminal"
// BuiltinSkills 实现 plugin.SkillProvider —— 内核据此让 SKILL 随插件启停出入。
func (p *Plugin) BuiltinSkills() map[string]string {
return map[string]string{
builtinSkillName: agentcliSkillMarkdown,
}
}
// agentcliSkillMarkdown 是 SKILL 正文。
//
// frontmatter 的 name 必须与 map 的键(也就是目录名)一致。
const agentcliSkillMarkdown = `---
name: agentcli-terminal
description: 交互式终端(vim/ssh/top/长驻进程)。一次性的 shell 命令不要用终端,用 cmd_run。
version: 1.0.0
---
终端给**要持续输入**的程序用(vim、ssh、top、REPL、要按键确认的安装器);一次性的 shell 命令用 **cmd_run**(cmd 插件,需先 plugin_start cmd)。
把 ` + "`cd && echo && ls && grep`" + ` 这类复合命令塞进 terminal_create,命令跑完终端就退出,
接下来 terminal_read 只会撞 ` + "`read /dev/ptmx: input/output error`" + `。
## 一句话判据
**跑完就结束 → cmd_run。要一直活着、要看屏幕、要按键 → terminal。**
| 你要做的事 | 用哪个 |
|---|---|
| ` + "`ls`" + ` / ` + "`git status`" + ` / ` + "`go build`" + ` / 一次性脚本 | **cmd_run** |
| ` + "`cd a && echo x && ls`" + ` 这种复合命令 | **cmd_run**(它就是 bash,管道/&&/重定向全支持) |
| 装包时要按 y 确认 | terminal |
| ` + "`vim`" + ` / ` + "`nano`" + ` / ` + "`top`" + ` / ` + "`htop`" + ` | terminal |
| ` + "`ssh`" + ` 进另一台机器再操作 | terminal |
| ` + "`python`" + ` / ` + "`node`" + ` 开 REPL 逐条试 | terminal |
| 长驻服务(` + "`npm run dev`" + `)要看持续输出 | terminal(配 notify) |
判据的关键不是"命令长不长",而是**命令结束后你还需不需要这个会话**。
不需要就是一次性,用 cmd_run。
## cmd_run 不存在时
` + "`cmd`" + ` 插件默认不随内核启动(` + "`auto_start: false`" + `),所以工具表里
可能没有 ` + "`cmd_run`" + `。这是**按需加载**,不是没有这个能力:
` + "```" + `
plugin_start name=cmd
` + "```" + `
之后 ` + "`cmd_run`" + ` 才出现在工具表里。用完可以 ` + "`plugin_stop name=cmd`" + `。
**不要因为找不到 cmd_run 就退而用 terminal_create 跑一次性命令** —— 那正是
本 skill 要拦住的行为。
## 终端的基本用法
| 工具 | 用途 |
|---|---|
| ` + "`terminal_create`" + ` | 建会话,返回 ` + "`terminal_id`" + `。` + "`command`" + ` 是**要跑的程序**(如 ` + "`vim /tmp/a.txt`" + `),不是一串命令 |
| ` + "`terminal_write`" + ` | 送输入。普通文本用 ` + "`input`" + `;特殊键用 ` + "`key`" + `(enter/tab/escape/ctrl_c/up/down/f1…) |
| ` + "`terminal_read`" + ` | 读输出。` + "`mode=new`" + `(默认)只给"上次读之后的新内容";` + "`mode=now`" + ` 给整屏当前画面 |
| ` + "`terminal_watch`" + ` | 设提醒规则,避免长任务的输出造成通知风暴 |
| ` + "`terminal_close`" + ` | 关会话,释放资源 |
## 两个必须知道的坑
**① 先读再写。** 开完终端先 ` + "`terminal_read`" + ` 看屏幕当前是什么,
再决定发什么。盲写经常打在还没准备好的界面上(比如 vim 还在启动、
或者 shell 提示符还没出来)。
**② 终端默认 5 分钟自动关闭。** 长任务要显式给 ` + "`timeout`" + `
(如 ` + "`30m`" + `)。默认**不主动推送**,输出靠主动 ` + "`terminal_read`" + `;
要推送就显式声明 ` + "`notify`" + `(` + "`exit`" + ` / ` + "`interval=30s`" + ` / ` + "`buffer=8192`" + `)。
## 退出交互程序
vim / less 之类先发 ` + "`key=escape`" + ` 回到命令模式,再发 ` + "`input=\":q!\"`" + ` 加
` + "`key=enter`" + `。ctrl_c 对 ` + "`top`" + ` 有效,对 vim 要先 escape。
## 常见误用(都是真的发生过)
- ❌ 用 terminal_create 跑 ` + "`ls -la`" + ` —— 一条 cmd_run 就够了
- ❌ 每条命令开一个新终端,然后不复用 —— 终端是**会话**,不是命令管道
- ❌ 拿 terminal_read 的报错 ` + "`read /dev/ptmx: input/output error`" + ` 当成"终端不能用"
—— 那通常是**命令已经跑完、终端已退出**,说明一开始就该用 cmd_run
- ❌ 用 terminal 做文件读写 —— 用 files 插件的 ` + "`files_read`" + `/` + "`files_write`" + `
`