mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
feat(seq): 序列文本 → AST 解析与静态校验(插件线 P1)
插件,不是内核:并行执行是内核提供的**唯一**基础设施(core 的
batchRunnable);分组 / 具名槽 / 条件 / 调用图全部在本包内自建,
**不要求内核开任何新接口**。
实现(parse.go):
· Sequence / Group / ToolCall 三个 AST 类型
· Parse:JSON → AST + **全部**静态校验一次做完
(而非留到执行期——group 有独立签名,具名槽的价值就在于构建期就能
查出错写的槽)
· splitToolList:`;` 仅在 brace 深度 0 且**不在字符串内**时才是分隔符
· 枚举/未知字段一律硬报错:DisallowUnknownFields + 显式校验
(missing / on_error 报错时列出合法取值,不当默认值蒙过去)
静态校验规则:
· `as:X` 未在 out 声明 ⇒ 报错(具名槽的核心价值)
· `$args.X` 未在 in 声明 ⇒ 报错
· group 名重复 ⇒ 报错(签名名必须唯一才能按名调用)
· 非 array 槽被同名 as 写多次 ⇒ 报错(组内并行下同名写入是数据竞争);
array 槽则允许(组屏障按序追加)
· tools 分隔符畸形(漏中间 / 漏末尾 / 连续 / 未闭合)⇒ **硬报错**,
绝不静默吞掉一个工具
判据(parse_test.go,9 条),★ 两条最关键:
· 含分号的真实 command(取自 core 里那份线上日志 fixture)保持为 1 个工具
· ★ TestBracesInsideStringDoNotAffectDepth:字符串里的**不成对**花括号
不得影响 depth
★ 本阶段的三次自伤(都靠变异测试暴露,不是靠判据变红):
1. **格式本身是错的**:我在设计文档里写的 `{tool:cmd_run,...}` 根本不是
合法 JSON(键没引号),encoding/json 直接解析失败。判据一跑就暴露
——"写了格式却从没验证它能解析"。已改为要求合法 JSON(键带引号),
这也是 DisallowUnknownFields 能生效的前提。
2. **判据验证的不是它声称验证的规则**:原本那条"分号在字符串内"的用例,
分号其实落在 args 对象的**花括号内部**,depth>0 就足以保护 ⇒ 删掉
分词器的字符串跟踪后**仍然全绿**。反复两次才找到真正的判别点:
必须用**不成对**花括号在 depth 恰为 0 处,才只有字符串态能救它。
3. 手写多层转义把引号写成 \",使分词器永远进不了字符串态。改用
json.Marshal **分层构造** fixture——手写转义没有不出错的机会。
变异验证(两轮,均能检出):
· 删掉"回到顶层必须紧跟 ;"检查 ⇒ 漏中间分隔符用例 FAIL
· 删掉字符串跟踪 ⇒ TestBracesInsideStringDoNotAffectDepth FAIL
("结构未闭合(括号深度 1)")
回归:internal/agent/... internal/plugins/... 全绿。
This commit is contained in:
387
internal/plugins/seq/parse.go
Normal file
387
internal/plugins/seq/parse.go
Normal file
@ -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] + "..."
|
||||
}
|
||||
274
internal/plugins/seq/parse_test.go
Normal file
274
internal/plugins/seq/parse_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user