mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
真机实跑(独立实例)发现:模型把 group 的 `in` 传成字符串 "{}",
拿到的是 encoding/json 的原始报错:
json: cannot unmarshal string into Go struct field rawSeq.groups.0.in
of type map[string]string
这句说的是**事实**(string 解不成 map)而不是**该怎么做**
(in 应该写成对象 {"键":"类型"});残留的 `rawSeq` / `Go struct field`
更是 Go 内部实现细节,对模型无意义且会误导它去猜一个叫 rawSeq 的东西。
模型为此**重试了 3 次**才改对。
这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 →
cmd_run 失败率 34%。**报事实不报改法,模型只能猜。**
改动(parse.go):新增 friendlyJSONError,把原始报错翻译成可执行文案
· in / out 类型不符 ⇒ 说明"应写成对象 {键:类型};无入参请写 {}"
· tools 类型不符 ⇒ 说明"应写成字符串(内容是 ; 分隔的 JSON 对象)"
· groups / name / when / missing / timeout / on_error ⇒ 逐个说明期望
· 未知字段 ⇒ 列出 group 允许的全部字段名(拼写错误最常见)
· shortFieldName 剥掉 `rawSeq` 这类包内类型名前缀
· jsonKind / goTypeName 把 Go 类型翻译成模型看得懂的说法
判据(parse_test.go,2 条):
· in 传字符串 ⇒ 错误须指名字段、须说明该传对象、**且不得残留 Go 内部类型名**
· tools 传数组 ⇒ 错误须指明 tools 且说明它是字符串
★ 变异验证时踩了一次坑:第一次变异让 friendlyJSONError 不被调用,
结果**编译失败**(函数变成未使用),判据压根没跑,我却看到 "ok"。
改用可编译的变异(函数保留、开头直接 return err)后判据正确 FAIL。
★ 教训:**"变异后判据通过"要先确认变异真的生效**——编译失败 ≠ 判据通过。
真机复验:模型读一次即懂,并明确说"提示里的意思很明确";
修复前它为此重试 3 次。
回归:internal/plugins/... internal/agent/core internal/sdk 全绿。
488 lines
15 KiB
Go
488 lines
15 KiB
Go
// Package seq 实现「工具序列」:把可复用的多步工具流程固化为可命名、
|
||
// 可复用、可删除的对象。
|
||
//
|
||
// 边界:本包是**插件**,不是内核。内核只提供并行执行这一项基础设施
|
||
// (见 core 的 batchRunnable),序列的全部语义——分组、具名槽、条件、
|
||
// 调用图——都在本包内自建,不要求内核开任何新接口。
|
||
//
|
||
// 能力边界与设计文档 docs/zh/toolcall-contract-and-sequence-design.md §7/§8
|
||
// 对应。核心不变量:
|
||
// 1. 存的是**解析后的 AST**,执行期不再碰原始文本
|
||
// 2. 一切静默降级都视为缺陷:格式错、槽未声明、目标不存在都必须**报错**
|
||
package seq
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"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", friendlyJSONError(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] + "..."
|
||
}
|
||
|
||
// friendlyJSONError 把 encoding/json 的原始报错翻译成**模型可执行**的话。
|
||
//
|
||
// 为什么必须翻译(真机实跑证据):模型把 group 的 `in` 传成字符串 "{}"
|
||
// 时,原始报错是
|
||
//
|
||
// json: cannot unmarshal string into Go struct field rawSeq.groups.0.in
|
||
// of type map[string]string
|
||
//
|
||
// 这句话说的是"事实"(string 解不成 map),不是"该怎么做"
|
||
// (in 应该写成对象 {"键":"类型"})。模型为此重试了 **3 次**才改对。
|
||
// 残留的 `rawSeq` / `Go struct field` 更是 Go 内部实现细节,
|
||
// 对模型无意义且会误导它去猜一个叫 rawSeq 的东西。
|
||
//
|
||
// 这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 →
|
||
// cmd_run 失败率 34%。**报事实不报改法,模型只能猜。**
|
||
func friendlyJSONError(err error) error {
|
||
// ① 未知字段:拼写错误最常见,且必须显式(DisallowUnknownFields 已启用)
|
||
if strings.Contains(err.Error(), "unknown field") {
|
||
return fmt.Errorf("%w(注意字段名拼写;每个 group 允许的字段为 "+
|
||
"name/description/in/out/when/parallel/missing/timeout/on_error/retries/tools)", err)
|
||
}
|
||
|
||
// ② 类型不匹配:给出该字段**应该**是什么
|
||
var te *json.UnmarshalTypeError
|
||
if errors.As(err, &te) {
|
||
field := shortFieldName(te.Field)
|
||
switch field {
|
||
case "in", "out":
|
||
return fmt.Errorf("group 的 %s 应写成**对象**,形如 {\"键\":\"类型\"}"+
|
||
"(键=参数名,值=类型如 string/bool/integer/array);"+
|
||
"收到的是 %s —— 无入参请写 {},不要写成字符串", field, jsonKind(te.Value))
|
||
case "tools":
|
||
return fmt.Errorf("group 的 tools 应写成**字符串**(内容是若干以 ';' 分隔的 JSON 对象),"+
|
||
"而不是数组;例如 \"{\\\"tool\\\":\\\"cmd_run\\\",\\\"args\\\":{},\\\"as\\\":\\\"x\\\"} ;\";"+
|
||
"收到的是 %s", jsonKind(te.Value))
|
||
case "groups":
|
||
return fmt.Errorf("groups 应是数组,形如 [ {…}, {…} ];收到的是 %s", jsonKind(te.Value))
|
||
case "name", "description", "when", "missing", "timeout", "on_error":
|
||
return fmt.Errorf("%s 应写成字符串;收到的是 %s", field, jsonKind(te.Value))
|
||
default:
|
||
return fmt.Errorf("%s 的类型不对(应为 %s,收到 %s)",
|
||
field, goTypeName(te.Type), jsonKind(te.Value))
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
|
||
// shortFieldName 把 "rawSeq.groups.0.in" 缩成 "in"。
|
||
//
|
||
// 剥掉 Go 内部类型名(rawSeq):那是本包的实现细节,模型无从得知,
|
||
// 照抄反而会去猜"rawSeq 是什么"。
|
||
func shortFieldName(f string) string {
|
||
if f == "" {
|
||
return "某个字段"
|
||
}
|
||
if i := strings.LastIndex(f, "."); i >= 0 {
|
||
f = f[i+1:]
|
||
}
|
||
// 去掉数组下标:groups.0.in → in(上面已取最后一段,兜底再剥一次)
|
||
f = strings.TrimSuffix(f, "]")
|
||
return f
|
||
}
|
||
|
||
// jsonKind 描述**收到的** JSON 值长什么样(用模型看得懂的说法)。
|
||
func jsonKind(v string) string {
|
||
switch v {
|
||
case "string":
|
||
return "字符串"
|
||
case "array":
|
||
return "数组"
|
||
case "object":
|
||
return "对象"
|
||
case "number":
|
||
return "数字"
|
||
case "bool":
|
||
return "布尔值"
|
||
}
|
||
return v
|
||
}
|
||
|
||
// goTypeName 去掉包名前缀(map[string]string → map)。
|
||
func goTypeName(t reflect.Type) string {
|
||
if t == nil {
|
||
return "未知"
|
||
}
|
||
if t.Kind() == reflect.Map {
|
||
return "对象"
|
||
}
|
||
if t.Kind() == reflect.Slice {
|
||
return "数组"
|
||
}
|
||
s := t.String()
|
||
if i := strings.LastIndex(s, "."); i >= 0 {
|
||
s = s[i+1:]
|
||
}
|
||
return s
|
||
}
|