mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
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:
124
internal/plugins/agentcli/skill.go
Normal file
124
internal/plugins/agentcli/skill.go
Normal file
@ -0,0 +1,124 @@
|
||||
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`" + `
|
||||
`
|
||||
148
internal/plugins/agentcli/skill_test.go
Normal file
148
internal/plugins/agentcli/skill_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user