From 116dc413f0f5dd730c435f90aca0ffd600a04890 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 13:48:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(seq):=20=E6=96=B0=E5=A2=9E=20seq=5Fhelp=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=A0=BC=E5=BC=8F=E8=AF=B4=E6=98=8E=20+?= =?UTF-8?q?=20=E5=8F=AF=E7=85=A7=E6=8A=84=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对 ① tools 漏末尾的 ';' → 「末尾缺少 ';'」 ② group 的 in 传成字符串 → 重试 3 次 ③ as 指向未声明的 out 槽 → 静态校验拦下 这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。 散落在七个描述里等于没有集中入口。 实现(help.go + tools.go + plugin.go): · seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式) · 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、 每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、 groups 与 file 二选一、组内并行组间串行 · 「条件 when」列出支持的表达式形态 · 「可照抄的完整示例」给一行**单行紧凑**的合法序列 ★ 判据(plugin_test.go,3 条): · seq_help 已注册、有 description、不声明并发安全 · 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么") · ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里 抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。 而"从文本里有没有某个词"是看不出这类 bug 的。 过程中判据自己错了两次(都被这条示例判据照出来): 1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的** 示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。 2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。 ⇒ 重写为全程在同一 base 上定位。 ★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**, 它出错时报出的信息极具误导性(看起来像实现有 bug)。 示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时 免去缩进/换行带来的额外风险。 变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。 另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)—— 这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。 回归:go build ./... 通过;internal/plugins/... core sdk 全绿。 --- internal/plugins/seq/help.go | 101 ++++++++++++++++++ internal/plugins/seq/plugin.go | 3 + internal/plugins/seq/plugin_test.go | 157 ++++++++++++++++++++++++++++ internal/plugins/seq/tools.go | 9 ++ 4 files changed, 270 insertions(+) create mode 100644 internal/plugins/seq/help.go diff --git a/internal/plugins/seq/help.go b/internal/plugins/seq/help.go new file mode 100644 index 0000000..07dae81 --- /dev/null +++ b/internal/plugins/seq/help.go @@ -0,0 +1,101 @@ +package seq + +// 本文件提供 seq_help 的文本:格式说明 + 可照抄的完整示例。 +// +// 为什么要有它(真机实跑的直接动机):模型写序列时踩了三个坑,各试了 +// 1~3 次才改对 —— +// ① tools 漏末尾的 ';' → 「末尾缺少 ';'」 +// ② group 的 in 传成字符串 → 重试 3 次 +// ③ as 指向未声明的 out 槽 → 静态校验拦下 +// 这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰恰是模型最容易 +// 错的地方。散落在六个描述里等于没有集中入口。 +// +// ⚠️ 下面的示例**由判据校验其自身能被 Parse 接受** +// (plugin_test.go: TestSeqHelpIncludesCopyableExample)—— +// 模型是照抄的,示例自己解析不过就是给模型挖坑。 + +// seqHelpText 返回帮助文本。 +func seqHelpText() string { + return helpHeader + helpFormat + helpCondition + helpExample +} + +const helpHeader = `【工具序列 seq】 + +一条序列 = 若干 group,**组内并行、组间串行**。每个 group 有独立签名 +(in 入参 / out 出参),可被 seq_call 按名调用。 +序列存的是**解析后的 AST**:保存时做完全部静态校验,执行期不再解析文本。 + +常用操作: + seq_list 列出全部序列及其签名 + seq_create 新建/更新(groups 传参 或 file 加载,二选一) + seq_run 执行(按 groups 数组顺序逐组跑) + seq_call 按名调用某个 group 或某条序列 + seq_when_call 条件调用,when 为真才执行 + seq_delete 删除 +` + +const helpFormat = ` +【格式要点 —— 这几处最容易错】 + +1. 顶层是 JSON:{"name":…, "description":…, "groups":[…]},groups 至少一个。 + +2. 每个 group 的字段: + name 必填,组名,全局唯一(它是签名名) + in 入参声明,**对象**,如 {"host":"string"};无入参写 {} + out 出参声明,**对象**,如 {"summary":"string"};无出参写 {} + when 条件屏障,默认 "true",只可读 $args.* + parallel 默认 true;置 false 则组内串行(保序场景用) + missing 工具不存在时的行为:fail(默认)/ skip / degrade + timeout 本组墙钟上限,如 "30s" + on_error abort(默认)/ continue / retry + tools **字符串**(不是数组!),见下 + +3. ⚠️ tools 是**字符串**,内部是若干以 ';' 分隔的 JSON 对象: + 每个对象形如 {"tool":"cmd_run","args":{…},"as":"槽名"} + - 每个 tool 后**必须**跟 ';',**包括最后一个**。漏了报「末尾缺少 ';'」。 + - 相邻两个 tool 之间也要有 ';'。漏了会让下一个工具被**静默吞掉**。 + - 键必须带引号(是合法 JSON):{"tool":…} 而不是 {tool:…}。 + - args 里可用 $args.<键> 引用入参;整值引用保留类型。 + +4. ⚠️ as 写的槽名**必须已在 out 里声明**,否则保存时报错。 + 非 array 的槽被同名 as 写多次也报错(组内并行会数据竞争); + 需要累加就把该槽声明成 "array"。 + +5. groups 与 file **二选一**:短序列用 groups 直接传;长序列写文件后用 file + 传路径(长参数会被 max_tokens 截断,写文件更稳)。 +` + +const helpCondition = ` +【条件 when】 + +只可读本组的 $args.*(即 in 里声明过的入参),不能读别组的出参。 +求值失败会**报错**(不会静默当成假),因为静默跳过会让序列少做一步而你以为跑完了。 + "$args.flag == true" 布尔比较 + "$args.n > 3" 数值比较 + "$args.s contains \"err\"" 文本包含 + "$args.host != \"\"" 非空判断 + "true" / "false" 恒真/恒假 +` + +const helpExample = ` +【可照抄的完整示例】 + +下面这一行是**完整合法**的序列(单行紧凑,直接照抄即可): + +{"name": "巡检三节点", "description": "并行拉取三台节点状态,异常时展开", "groups": [{"name": "拉取单台", "description": "拉取一台节点的 uptime 与负载", "in": {"host": "string"}, "out": {"summary": "string", "load": "string"}, "tools": "{\"tool\":\"cmd_run\",\"args\":{\"command\":\"uptime\"},\"as\":\"summary\"} ; {\"tool\":\"cmd_run\",\"args\":{\"command\":\"date\"},\"as\":\"load\"} ;"}, {"name": "异常展开", "in": {"host": "string"}, "out": {"detail": "string"}, "when": "$args.host != \"\"", "tools": "{\"tool\":\"cmd_run\",\"args\":{\"command\":\"journalctl -x\"},\"as\":\"detail\"} ;"}]} + +拆开看(仅为阅读方便,实际照抄上面那一行): + + name / description 序列名与说明 + groups[0] 拉取单台 组内两个工具**并行**(都声明了才并发,否则整批串行) + in {"host":"string"} ← 对象,不是字符串 + out {"summary":…,"load":…} ← as 要写的槽必须在这里声明 + tools 字符串里两个 {...} 之间、以及**最后一个之后**,都有 ';' + groups[1] 异常展开 when 条件:只读本组 in 声明过的 $args.* + +对应调用: + seq_create(name="巡检三节点", groups=[上面那个数组]) + seq_run(name="巡检三节点", args={"host":"node-a"}) + +⚠️ 这段示例由判据校验其**自身能被解析器接受**(model 照抄不会踩坑)。 +` diff --git a/internal/plugins/seq/plugin.go b/internal/plugins/seq/plugin.go index 2dada17..9936800 100644 --- a/internal/plugins/seq/plugin.go +++ b/internal/plugins/seq/plugin.go @@ -165,6 +165,9 @@ func (p *Plugin) dispatch(name string, args map[string]interface{}) (interface{} switch name { case "seq_create": return p.seqCreate(args) + case "seq_help": + // 纯查询:返回格式说明 + 可照抄示例(示例由判据校验其**自己解析得过**) + return seqHelpText(), nil case "seq_list": return p.seqList() case "seq_delete": diff --git a/internal/plugins/seq/plugin_test.go b/internal/plugins/seq/plugin_test.go index 78f2863..80d6d3b 100644 --- a/internal/plugins/seq/plugin_test.go +++ b/internal/plugins/seq/plugin_test.go @@ -1,6 +1,7 @@ package seq import ( + "fmt" "strings" "testing" ) @@ -17,6 +18,7 @@ func TestAllSixSeqToolsRegistered(t *testing.T) { p := newTestPlugin(t) want := []string{ "seq_create", "seq_list", "seq_delete", "seq_run", "seq_call", "seq_when_call", + "seq_help", // 格式说明与可照抄示例(真机实跑后加:模型踩格式坑各试 1~3 次) } defs := p.toolDefs() for _, name := range want { @@ -158,3 +160,158 @@ func newTestPlugin(t *testing.T) *Plugin { t.Helper() return &Plugin{name: "seq", store: NewStore(t.TempDir())} } + +// ⑦ seq_help 必须存在,且**只返回文本**(与仓内 output_send__*_help 同范式)。 +// +// 动机来自真机实跑:模型在写序列时踩了三个坑,各试了 1~3 次才改对 +// +// ① tools 漏末尾的 ';' → 「末尾缺少 ';'」 +// ② group 的 in 传成字符串 → 重试 3 次 +// ③ as 指向未声明的 out 槽 → 静态校验拦下 +// +// 这三处的**格式细节**都适合集中在一处可查的地方,而不是散在六个工具 +// 描述里(描述有长度限制,细节写不进去)。 +func TestSeqHelpRegistered(t *testing.T) { + p := newTestPlugin(t) + def, ok := p.toolDefs()["seq_help"] + if !ok { + t.Fatalf("seq_help 未注册(已注册:%v)", keysOf(p.toolDefs())) + } + if strings.TrimSpace(def.Description) == "" { + t.Error("seq_help 的 description 为空 —— 模型不知道该什么时候查它") + } + if def.ParallelSafe { + t.Error("seq_help 不该声明并发安全(它是纯查询)") + } +} + +// ⑧ ★ seq_help 的内容必须覆盖真机踩过的**每一个**坑。 +// +// 判据从"坑"出发而非从"我打算写什么"出发:下面每一项都对应一次真实失败。 +func TestSeqHelpCoversRealPitfalls(t *testing.T) { + p := newTestPlugin(t) + out, err := p.dispatch("seq_help", map[string]interface{}{}) + if err != nil { + t.Fatalf("seq_help 失败: %v", err) + } + text, _ := out.(string) + if strings.TrimSpace(text) == "" { + t.Fatal("seq_help 返回空") + } + need := []struct{ key, want string }{ + {"①tools 是字符串且 ; 结尾", ";"}, + {"②in/out 是对象", "对象"}, + {"③as 必须在 out 声明", "out"}, + {"④groups 与 file 二选一", "file"}, + {"⑤组内并行组间串行", "并行"}, + {"⑥when 条件", "when"}, + } + for _, n := range need { + if !strings.Contains(text, n.want) { + t.Errorf("seq_help 缺少要点「%s」(应含 %q)", n.key, n.want) + } + } +} + +// ⑨ seq_help 必须给一个**可直接照抄**的完整例子。 +// +// 实跑里模型是照着自己理解拼 JSON 的,踩了两次格式坑。 +// 一个正确样例比三段描述更有用。 +func TestSeqHelpIncludesCopyableExample(t *testing.T) { + p := newTestPlugin(t) + out, err := p.dispatch("seq_help", map[string]interface{}{}) + if err != nil { + t.Fatalf("seq_help 失败: %v", err) + } + text, _ := out.(string) + if !strings.Contains(text, `"groups"`) { + t.Fatalf("seq_help 未包含示例: %s", truncateForMsg(text, 300)) + } + // 例子必须能被本包自己的解析器接受 —— 判据直接拿它过一遍 Parse。 + _ = err + if err := validateHelpExample(text); err != nil { + t.Errorf("seq_help 里的示例**自己解析不过**(模型照抄必然失败): %v", err) + } +} + +func truncateForMsg(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// validateHelpExample 从 seq_help 文本里抽出示例并交给**本包自己的解析器**校验。 +// +// 为什么要这么判:seq_help 是**给模型照抄的**。如果示例本身解析不过 +// (例如 tools 少一个 ';'、in 写成了字符串),模型照抄必然失败 —— 而这类 +// bug 从"文本里有没有某个词"是看不出来的。 +// +// 做法:从文本里取第一个含 `"groups"` 的 JSON 对象(花括号配平扫描), +// 直接喂给 Parse。 +func validateHelpExample(help string) error { + // ⚠️ 两个坑(都踩过): + // 1. 不能用 strings.Index(help, `{"name"`):帮助文本的「格式要点」里 + // 也有一段 `{"name":…, "groups":[…]}` 示意(有意写的),先命中它 + // 会截到非示例的片段,报出莫名其妙的 invalid character。 + // 2. 基准必须统一。下面全程在**同一个**子串 base 上做偏移, + // 绝不把 base 的下标拿去切 help。 + const marker = "【可照抄的完整示例】" + mi := strings.Index(help, marker) + if mi < 0 { + return fmt.Errorf("help 里缺少【可照抄的完整示例】小节") + } + base := help[mi+len(marker):] + + // 找第一行"整行就是一个 JSON 对象"的内容 + var line string + for _, l := range strings.Split(base, "\n") { + t := strings.TrimSpace(l) + t = strings.Trim(t, "`") + if strings.HasPrefix(t, `{"name"`) { + line = t + break + } + } + if line == "" { + return fmt.Errorf("示例小节里找不到一整行的 JSON 序列") + } + + // 在 base 上定位该行,再做括号配平扫描(全程同一基准) + off := strings.Index(base, line) + depth := 0 + inStr := false + esc := false + for i := off; i < len(base); i++ { + c := base[i] + switch { + case esc: + esc = false + case c == '\\' && inStr: + esc = true + case c == '"': + inStr = !inStr + case inStr: + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + _, err := Parse([]byte(base[off : i+1])) + if err != nil { + return fmt.Errorf("示例解析失败: %w(示例前 120 字:%s)", + err, truncateForMsg(base[off:min(i+1, off+120)], 120)) + } + return nil + } + } + } + return fmt.Errorf("示例 JSON 括号未配平") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/plugins/seq/tools.go b/internal/plugins/seq/tools.go index 43dca6e..11f2265 100644 --- a/internal/plugins/seq/tools.go +++ b/internal/plugins/seq/tools.go @@ -43,6 +43,15 @@ func seqToolDefs() []toolDefInfo { "required": []string{"name"}, }, }, + { + Name: "seq_help", + Description: "查看工具序列的完整格式说明与可照抄的示例。**写序列前先查这个** —— " + + "tools 字段的分隔符、in/out 的写法、as 与 out 的关系都在这里。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, { Name: "seq_list", Description: "列出全部可用序列:名称、描述、组数与各组的签名(in/out)。" +