diff --git a/internal/plugins/seq/parse.go b/internal/plugins/seq/parse.go new file mode 100644 index 0000000..5bc38cd --- /dev/null +++ b/internal/plugins/seq/parse.go @@ -0,0 +1,387 @@ +// Package seq 实现「工具序列」:把可复用的多步工具流程固化为可命名、 +// 可复用、可删除的对象。 +// +// 边界:本包是**插件**,不是内核。内核只提供并行执行这一项基础设施 +// (见 core 的 batchRunnable),序列的全部语义——分组、具名槽、条件、 +// 调用图——都在本包内自建,不要求内核开任何新接口。 +// +// 能力边界与设计文档 docs/zh/toolcall-contract-and-sequence-design.md §7/§8 +// 对应。核心不变量: +// 1. 存的是**解析后的 AST**,执行期不再碰原始文本 +// 2. 一切静默降级都视为缺陷:格式错、槽未声明、目标不存在都必须**报错** +package seq + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Sequence 是一条序列(已解析、已校验的 AST)。 +type Sequence struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Groups []Group `json:"groups"` +} + +// Group 是一组工具:**组内并行、组间串行**,且拥有独立签名。 +type Group struct { + // Name 是签名名,全局唯一,可被 seq_call 按名调用。 + Name string `json:"name"` + Description string `json:"description,omitempty"` + // In 是入参声明 {键: 类型};组内用 $args.<键> 读取。 + In map[string]string `json:"in,omitempty"` + // Out 是出参声明 {键: 类型};组内用 as:<键> 写入。 + Out map[string]string `json:"out,omitempty"` + // When 是条件屏障(默认 true),**只可读 $args.***。 + When string `json:"when,omitempty"` + // Parallel 为 false 时组内退化为串行(逃生舱)。 + Parallel bool `json:"parallel"` + // Missing 声明「目标工具不存在」时的行为:fail(默认)/ skip / degrade。 + Missing string `json:"missing,omitempty"` + // Timeout 是本组墙钟上限(如 "30s")。 + Timeout string `json:"timeout,omitempty"` + // OnError: abort(默认)/ continue / retry + OnError string `json:"on_error,omitempty"` + Retries int `json:"retries,omitempty"` + Tools []ToolCall `json:"tools"` +} + +// ToolCall 是组内的一个工具调用。 +type ToolCall struct { + Tool string `json:"tool"` + Args map[string]interface{} `json:"args,omitempty"` + // As 是写入本组 out 具名槽的键名(不含 $ 前缀)。 + As string `json:"as,omitempty"` + // Fallback 是 missing=degrade 时使用的兜底值(紧凑 JSON 文本)。 + Fallback string `json:"fallback,omitempty"` +} + +// missing 取带默认值的缺失策略。 +func (g Group) missingPolicy() string { + if g.Missing == "" { + return "fail" + } + return g.Missing +} + +// 合法取值表(错误文案要列出合法值,而不是当默认值蒙过去)。 +var ( + validMissing = []string{"fail", "skip", "degrade"} + validOnError = []string{"abort", "continue", "retry"} +) + +// rawGroup 是 JSON 解码的中间形态:tools 是**字符串**。 +type rawGroup struct { + Name string `json:"name"` + Description string `json:"description"` + In map[string]string `json:"in"` + Out map[string]string `json:"out"` + When string `json:"when"` + Parallel *bool `json:"parallel"` + Missing string `json:"missing"` + Timeout string `json:"timeout"` + OnError string `json:"on_error"` + Retries int `json:"retries"` + Tools string `json:"tools"` +} + +type rawSeq struct { + Name string `json:"name"` + Description string `json:"description"` + Groups []rawGroup `json:"groups"` +} + +// Parse 把序列文本解析为 AST 并完成**全部静态校验**。 +// +// 校验在此处一次做完(而不是留到执行期),因为 group 拥有独立签名: +// 具名槽、写错的目标、同名 as 都能**构建期**发现——这正是具名槽相对 +// 自动编号($0/$1)的全部价值。 +func Parse(data []byte) (*Sequence, error) { + // DisallowUnknownFields:拼错 parallel 必须是**报错**,不能静默取默认。 + // 参照 internal/plugin/manifest.go 记的教训(该仓无此选项,字段被静默丢弃)。 + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + var raw rawSeq + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("序列 JSON 解析失败: %w", err) + } + + if strings.TrimSpace(raw.Name) == "" { + return nil, fmt.Errorf("序列缺少 name") + } + if len(raw.Groups) == 0 { + return nil, fmt.Errorf("序列 %q 没有任何 group", raw.Name) + } + + seq := &Sequence{Name: raw.Name, Description: raw.Description} + seenGroup := map[string]bool{} + for gi, rg := range raw.Groups { + g, err := buildGroup(rg) + if err != nil { + return nil, fmt.Errorf("第 %d 个 group: %w", gi+1, err) + } + if seenGroup[g.Name] { + return nil, fmt.Errorf("group 名 %q 重复(它是签名名,必须唯一才能按名调用)", g.Name) + } + seenGroup[g.Name] = true + seq.Groups = append(seq.Groups, g) + } + return seq, nil +} + +// buildGroup 解析并校验单个 group。 +func buildGroup(rg rawGroup) (Group, error) { + if strings.TrimSpace(rg.Name) == "" { + return Group{}, fmt.Errorf("缺少 group 名") + } + g := Group{ + Name: rg.Name, + Description: rg.Description, + In: rg.In, + Out: rg.Out, + When: rg.When, + Parallel: true, // 默认并行 + Missing: rg.Missing, + Timeout: rg.Timeout, + OnError: rg.OnError, + Retries: rg.Retries, + } + if rg.Parallel != nil { + g.Parallel = *rg.Parallel + } + if g.When == "" { + g.When = "true" + } + if g.In == nil { + g.In = map[string]string{} + } + if g.Out == nil { + g.Out = map[string]string{} + } + if g.Missing != "" && !containsStr(validMissing, g.Missing) { + return Group{}, fmt.Errorf("group %q 的 missing=%q 非法,合法取值:%s", + g.Name, g.Missing, strings.Join(validMissing, "/")) + } + if g.OnError != "" && !containsStr(validOnError, g.OnError) { + return Group{}, fmt.Errorf("group %q 的 on_error=%q 非法,合法取值:%s", + g.Name, g.OnError, strings.Join(validOnError, "/")) + } + + tools, err := splitToolList(rg.Tools) + if err != nil { + return Group{}, fmt.Errorf("group %q 的 tools: %w", g.Name, err) + } + if len(tools) == 0 { + return Group{}, fmt.Errorf("group %q 没有任何工具", g.Name) + } + + // 槽校验 + 组内 $args 引用校验 + asCount := map[string]int{} + for ti, t := range tools { + if strings.TrimSpace(t.Tool) == "" { + return Group{}, fmt.Errorf("group %q 第 %d 个工具缺少 tool 字段", g.Name, ti+1) + } + if t.As != "" { + if _, ok := g.Out[t.As]; !ok { + return Group{}, fmt.Errorf( + "group %q 第 %d 个工具的 as=%q 未在 out 中声明(out 现有:%s)", + g.Name, ti+1, t.As, keyList(g.Out)) + } + asCount[t.As]++ + } + // args 里只能引用已声明的入参 + for k, v := range t.Args { + for _, ref := range argRefs(v) { + if _, ok := g.In[ref]; !ok { + return Group{}, fmt.Errorf( + "group %q 第 %d 个工具的 args.%s 引用了未声明的入参 $args.%s(in 现有:%s)", + g.Name, ti+1, k, ref, keyList(g.In)) + } + } + } + } + // 非 array 槽被同名 as 写多次 ⇒ 组内并发时数据竞争 + for slot, n := range asCount { + if n > 1 && !isArrayType(g.Out[slot]) { + return Group{}, fmt.Errorf( + "group %q 的 out 槽 %q(类型 %s)被 as 写了 %d 次;组内并行下同名写入是数据竞争。"+ + "若要累加请把该槽声明为 array", + g.Name, slot, g.Out[slot], n) + } + } + g.Tools = tools + return g, nil +} + +// splitToolList 把 `;` 分隔的 tools 字符串解析为若干工具调用。 +// +// ⚠️ `;` **仅在 brace/bracket 深度为 0 且不在字符串内**时才是分隔符。 +// 这一点是必需的、不是装饰:线上实测模型写出的 command 参数里**大量**含分号 +// (见 core/stream_accumulate_test.go 里取自日志原文的 fixture),裸切分会把 +// 一条命令切成六个工具。被 `{…}` 包裹后,命令里的分号在字符串内 ⇒ 天然无歧义。 +func splitToolList(s string) ([]ToolCall, error) { + if strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("没有工具——需要至少一个 `{\"tool\":\"...\",\"args\":{...}} ;` 形式的工具") + } + var pieces []string + depth := 0 + inStr := false + esc := false + start := 0 + + flush := func(end int) error { + p := strings.TrimSpace(s[start:end]) + if p == "" { + return fmt.Errorf("位置 %d:空的工具(连续或多余的分号)", end) + } + pieces = append(pieces, p) + return nil + } + + for i, r := range s { + switch { + case esc: + esc = false + case r == '\\' && inStr: + esc = true + case r == '"': + inStr = !inStr + case inStr: + // 字符串内不参与深度计算 + case r == '{' || r == '[': + depth++ + case r == '}' || r == ']': + depth-- + if depth < 0 { + return nil, fmt.Errorf("位置 %d:多余的 %q", i, r) + } + if depth == 0 { + // 回到顶层:其后必须紧跟 ';',否则下一个工具会被**静默吞掉** + nxt := strings.TrimSpace(s[i+1:]) + if nxt != "" && !strings.HasPrefix(nxt, ";") { + return nil, fmt.Errorf( + "位置 %d:`{…}` 之后缺少 ';' 分隔符(不留分隔符会让下一个工具被静默吞掉)", i+1) + } + } + case r == ';' && depth == 0: + if err := flush(i); err != nil { + return nil, err + } + start = i + 1 + } + } + if inStr { + return nil, fmt.Errorf("字符串未闭合(引号不成对)") + } + if depth != 0 { + return nil, fmt.Errorf("结构未闭合(括号深度 %d)", depth) + } + if tail := strings.TrimSpace(s[start:]); tail != "" { + return nil, fmt.Errorf("末尾缺少 ';':最后一个工具未被分隔") + } + if len(pieces) == 0 { + return nil, fmt.Errorf("没有任何工具") + } + + out := make([]ToolCall, 0, len(pieces)) + for i, p := range pieces { + var tc ToolCall + d := json.NewDecoder(strings.NewReader(p)) + d.DisallowUnknownFields() + if err := d.Decode(&tc); err != nil { + return nil, fmt.Errorf("第 %d 个工具解析失败: %w(内容:%s)", i+1, err, trunc(p, 120)) + } + if strings.TrimSpace(tc.Tool) == "" { + return nil, fmt.Errorf("第 %d 个工具缺少 tool 字段", i+1) + } + out = append(out, tc) + } + return out, nil +} + +// argRefs 收集一个 args 值里出现的所有 $args.<键> 引用。 +// 深度遍历(args 本身可能是嵌套对象/数组)。 +func argRefs(v interface{}) []string { + var out []string + var walk func(interface{}) + walk = func(x interface{}) { + switch t := x.(type) { + case string: + out = append(out, parseArgRefs(t)...) + case map[string]interface{}: + for _, vv := range t { + walk(vv) + } + case []interface{}: + for _, vv := range t { + walk(vv) + } + } + } + walk(v) + return out +} + +// parseArgRefs 从一段文本里取出全部 $args.<键>。 +func parseArgRefs(s string) []string { + const marker = "$args." + var out []string + rest := s + for { + i := strings.Index(rest, marker) + if i < 0 { + return out + } + rest = rest[i+len(marker):] + end := 0 + for end < len(rest) && isRefChar(rest[end]) { + end++ + } + if end == 0 { + // 形如 "$args." 后面没有键名 —— 不是合法引用,跳过避免死循环 + continue + } + out = append(out, rest[:end]) + rest = rest[end:] + } +} + +func isRefChar(b byte) bool { + return b == '_' || b == '-' || + (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} + +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func isArrayType(t string) bool { + t = strings.ToLower(strings.TrimSpace(t)) + return t == "array" || strings.HasPrefix(t, "array<") || strings.HasPrefix(t, "[]") +} + +func keyList(m map[string]string) string { + if len(m) == 0 { + return "(无)" + } + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return strings.Join(ks, ", ") +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/plugins/seq/parse_test.go b/internal/plugins/seq/parse_test.go new file mode 100644 index 0000000..6828bbf --- /dev/null +++ b/internal/plugins/seq/parse_test.go @@ -0,0 +1,274 @@ +package seq + +import ( + "encoding/json" + "strings" + "testing" +) + +// 阶段 P1:序列文本 → AST 的解析与静态校验。 +// +// 判据优先原则:先写、必须能失败、再实现。 +// +// 本文件钉死四条最容易出错、且出错后**静默**的地方: +// 1. `tools` 是**字符串**,`;` 分隔的每个 `{…}` 才是分隔单位 +// 2. 分隔符 `;` 只在 brace 深度 0 且不在字符串内时才算 +// 3. 漏 `;` / 漏末尾 `;` / 连续 `;` 必须**硬报错**,不得静默吞工具 +// 4. 具名槽校验:`as` 必须在 `out` 声明,`$args.` 必须在 `in` 声明 + +// validSeq 是一条合法的序列文本(引用来源见 tests/fixtures)。 +const validSeq = `{ + "name": "巡检三节点", + "description": "并行拉取三台节点状态", + "groups": [ + { + "name": "拉取单台", + "description": "拉取一台节点的 uptime", + "in": { "host": "string" }, + "out": { "summary": "string" }, + "tools": "{\"tool\":\"cmd_run\",\"args\":{\"command\":\"uptime\"},\"as\":\"summary\"} ;" + } + ] +}` + +// ① 合法文本必须解析成功。 +func TestParseValidSequence(t *testing.T) { + seq, err := Parse([]byte(validSeq)) + if err != nil { + t.Fatalf("合法序列解析失败: %v", err) + } + if seq.Name != "巡检三节点" { + t.Errorf("name = %q", seq.Name) + } + if len(seq.Groups) != 1 { + t.Fatalf("groups = %d,期望 1", len(seq.Groups)) + } + g := seq.Groups[0] + if g.Name != "拉取单台" { + t.Errorf("组名 = %q", g.Name) + } + if len(g.Tools) != 1 { + t.Fatalf("组内工具 = %d,期望 1", len(g.Tools)) + } + if g.Tools[0].Tool != "cmd_run" { + t.Errorf("工具名 = %q", g.Tools[0].Tool) + } + if g.Tools[0].As != "summary" { + t.Errorf("as = %q,期望 summary", g.Tools[0].As) + } +} + +// ② ★ `;` 必须在字符串内不生效。 +// +// 真实 fixture:模型的 command 参数里大量含分号(取自仓库 +// stream_accumulate_test.go 的线上日志原文)。被 `{…}` 包裹后, +// 命令里的分号在**字符串内** ⇒ 切分器不得碰它。 +func TestParseToolCommandContainingSemicolons(t *testing.T) { + // 命令含 5 个分号,且含转义引号 + // ★ 用 json.Marshal **分层构造**,不用手写多层转义。 + // + // 为什么必须这样:tools 的值是一段"内含引号的 JSON 文本",它本身还要被 + // 放进外层 JSON 字符串里 ⇒ 两层转义。手写转义时我曾把内层引号写成了 + // \"(一层),导致分词器永远进不了字符串态 —— 判据成了摆设 + //(实测:删掉分词器的字符串跟踪,本用例仍全绿)。 + // 用 Marshal 构造就没有手写出错的机会。 + command := `echo "=== raw ==="; cat /tmp/x.json; echo; echo "=== ps ==="; ` + + `ps aux | grep -c "[r]un.py"; echo "=== log ==="; cat /tmp/run.log` + toolJSON, err := json.Marshal(map[string]interface{}{ + "tool": "cmd_run", + "args": map[string]interface{}{"command": command, "timeout": "30s"}, + "as": "summary", + }) + if err != nil { + t.Fatalf("构造 fixture 失败: %v", err) + } + // 组文本:内层用 `;` 分隔的两个结构,第二个故意也含分号 + toolsStr := string(toolJSON) + " ;" + groupText, err := json.Marshal(map[string]interface{}{ + "name": "g", + "in": map[string]string{}, + "out": map[string]string{"summary": "string"}, + "tools": toolsStr, + }) + if err != nil { + t.Fatalf("构造 group 失败: %v", err) + } + seqText := `{"name":"t","groups":[` + string(groupText) + `]}` + + seq, err := Parse([]byte(seqText)) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + tools := seq.Groups[0].Tools + if len(tools) != 1 { + t.Fatalf("含 5 个分号的命令被切成了 %d 个工具(期望 1)", len(tools)) + } + cmd, _ := tools[0].Args["command"].(string) + for _, want := range []string{"cat /tmp/x.json", "ps aux", "cat /tmp/run.log"} { + if !strings.Contains(cmd, want) { + t.Errorf("命令被截断了,缺少 %q: %q", want, cmd) + } + } + // 分号必须**原样**保留在 command 里 + if cmd != command { + t.Errorf("命令被破坏:\n得到 %q\n期望 %q", cmd, command) + } +} + +// ③ ★ 漏分隔符 / 漏末尾 / 连续分号:必须硬报错。 +// +// 这一条是「静默吞工具」的防线:若漏一个 `;` 却不报错,序列会少执行 +// 一个工具,而模型以为跑完了 —— 与本仓反复吃亏的「静默降级」同族。 +func TestParseRejectsMalformedToolSeparators(t *testing.T) { + cases := []struct { + name string + tools string + wantSub string + }{ + {"漏中间分隔符", + `{"tool":"cmd_run","args":{"command":"a"},"as":"x"} {"tool":"cmd_run","args":{"command":"b"},"as":"y"} ;`, + ";"}, + {"漏末尾分号", + `{"tool":"cmd_run","args":{"command":"a"},"as":"x"}`, + ";"}, + {"连续分号", + `{"tool":"cmd_run","args":{"command":"a"},"as":"x"} ; ; {"tool":"cmd_run","args":{"command":"b"},"as":"y"} ;`, + "空"}, + {"结构未闭合", + `{"tool":"cmd_run","args":{"command":"a"},"as":"x"`, + "闭合"}, + {"空 tools", + ``, + "工具"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + text := `{"name":"t","groups":[{"name":"g","in":{},"out":{"x":"string","y":"string"},"tools":` + + strconvQuote(c.tools) + `}]}` + if _, err := Parse([]byte(text)); err == nil { + t.Fatalf("畸形 tools 未被拒绝: %q", c.tools) + } else if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("错误信息应提到 %q,实际: %v", c.wantSub, err) + } + }) + } +} + +// ④ 具名槽校验:`as` 必须在 `out` 声明过。 +// 这是具名槽设计的**全部价值所在**——写错能在构建期发现。 +func TestParseRejectsUndeclaredOutSlot(t *testing.T) { + text := `{"name":"t","groups":[{"name":"g","in":{},"out":{"summary":"string"},` + + `"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"not_declared\"} ;"}]}` + if _, err := Parse([]byte(text)); err == nil { + t.Fatal("as 指向未声明的 out 槽却通过了 —— 具名槽失去意义") + } +} + +// ⑤ `$args.` 必须在 `in` 声明过。 +func TestParseRejectsUndeclaredArg(t *testing.T) { + text := `{"name":"t","groups":[{"name":"g","in":{"host":"string"},"out":{"summary":"string"},` + + `"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"$args.other\"},\"as\":\"summary\"} ;"}]}` + if _, err := Parse([]byte(text)); err == nil { + t.Fatal("引用了未声明的入参却通过了") + } +} + +// ⑥ 组名重复必须报错(它是签名名,必须唯一才能按名调用)。 +func TestParseRejectsDuplicateGroupName(t *testing.T) { + text := `{"name":"t","groups":[` + + `{"name":"g","in":{},"out":{"x":"string"},"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"x\"} ;"},` + + `{"name":"g","in":{},"out":{"y":"string"},"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"b\"},\"as\":\"y\"} ;"}]}` + if _, err := Parse([]byte(text)); err == nil { + t.Fatal("组名重复却通过了 —— 按名调用会歧义") + } +} + +// ⑦ 非 array 槽被同名 as 写多次必须报错(组内并行 ⇒ 数据竞争)。 +func TestParseRejectsDuplicateAsOnScalarSlot(t *testing.T) { + text := `{"name":"t","groups":[{"name":"g","in":{},"out":{"x":"string"},` + + `"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"x\"} ; {\"tool\":\"cmd_run\",\"args\":{\"command\":\"b\"},\"as\":\"x\"} ;"}]}` + if _, err := Parse([]byte(text)); err == nil { + t.Fatal("标量槽被同名 as 写两次却通过了") + } +} + +// ⑧ 反例:array 槽允许同名 as(在组屏障按顺序追加)。 +func TestParseAllowsDuplicateAsOnArraySlot(t *testing.T) { + text := `{"name":"t","groups":[{"name":"g","in":{},"out":{"xs":"array"},` + + `"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"xs\"} ; {\"tool\":\"cmd_run\",\"args\":{\"command\":\"b\"},\"as\":\"xs\"} ;"}]}` + if _, err := Parse([]byte(text)); err != nil { + t.Fatalf("array 槽的同名 as 应被允许: %v", err) + } +} + +// strconvQuote 用 JSON 字符串字面量包裹一段文本。 +func strconvQuote(s string) string { + var sb strings.Builder + sb.WriteByte('"') + for _, r := range s { + switch r { + case '"': + sb.WriteString(`\"`) + case '\\': + sb.WriteString(`\\`) + case '\n': + sb.WriteString(`\n`) + default: + sb.WriteRune(r) + } + } + sb.WriteByte('"') + return sb.String() +} + +// ②' ★ 隔离「字符串内不参与深度计算」这条规则。 +// +// 前一条 fixture(TestParseToolCommandContainingSemicolons)**验证不到** +// 这条规则:它的分号落在 args 对象的花括号内部,depth > 0 就足以保护, +// 即使分词器完全不懂字符串也能切对。实测删掉字符串跟踪后该用例仍全绿。 +// +// 本条用**字符串里的花括号**做判别:command 的值里含 `{` 与 `}`,它们在 +// JSON 字符串内,不得影响 depth。若不跟踪字符串态,这些花括号会把 depth +// 推离 0,随后的 ';' 就被当成顶层分隔符 ⇒ 工具被切碎。 +// +// ⚠️ 必须用 json.Marshal 构造内层 JSON:手写转义会把引号写成 \", +// 于是字符串里根本没有未转义引号,分词器永远进不了字符串态—— +// 判据就成了摆设(这一点我踩过两次)。 +func TestBracesInsideStringDoNotAffectDepth(t *testing.T) { + // ⚠️ 花括号必须**不成对**(这里只有一个 '{')。这是本判据能隔离规则的 + // 唯一原因:成对时("echo {x; y}")删掉字符串跟踪也能切对 —— depth 被 + // 推高又回落,净效果为零,判据形同虚设(我第一次就是这么写的,变异测不出来)。 + // 不成对时,字符串外的分词器会被 depth 卡住无法归零 ⇒ 必然报错。 + // 场景取自实际用法:grep 统计字面量花括号。 + command := "grep -o '{' /etc/hosts" + toolJSON, err := json.Marshal(map[string]interface{}{ + "tool": "cmd_run", + "args": map[string]interface{}{"command": command}, + "as": "summary", + }) + if err != nil { + t.Fatalf("构造 fixture 失败: %v", err) + } + groupText, err := json.Marshal(map[string]interface{}{ + "name": "g", + "in": map[string]string{}, + "out": map[string]string{"summary": "string"}, + "tools": string(toolJSON) + " ;", + }) + if err != nil { + t.Fatalf("构造 group 失败: %v", err) + } + seqText := `{"name":"t","groups":[` + string(groupText) + `]}` + + seq, err := Parse([]byte(seqText)) + if err != nil { + t.Fatalf("字符串内的 {} ; 影响了分词: %v", err) + } + if len(seq.Groups[0].Tools) != 1 { + t.Fatalf("应解析为 1 个工具,实际 %d —— 字符串内的分号被切开了", len(seq.Groups[0].Tools)) + } + got, _ := seq.Groups[0].Tools[0].Args["command"].(string) + if got != command { + t.Errorf("command 被破坏: %q(期望 %q)", got, command) + } +}