Files
HomeAgent/internal/plugins/seq/exec.go
JianFeeeee 7532af7e9b feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)
三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00

425 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// toolRunner 是执行引擎对「执行一个工具」的依赖。
//
// ⚠️ 它**必须**接收 args:插值是本阶段的核心能力之一,若接口不给出实际
// 收到的参数,插值就无法被任何判据观察(我第一版写成 call(name) 时,
// 插值判据就成了摆设——它永远"通过")。
type toolRunner interface {
call(name string, args map[string]interface{}) (string, error)
}
// GroupResult 是一组的执行结果。
type GroupResult struct {
Group string
Skipped bool // 条件为假而整组跳过
Slots map[string]interface{}
Tools []ToolRun
Err error
}
// ToolRun 是组内单个工具的执行记录。
type ToolRun struct {
Name string
Result string
Err error
Order int // 在 tools 数组中的位置(合并按它,不按完成顺序)
}
// execGroup 执行一组工具。
//
// 三个不变量(各自有判据钉住):
// 1. **组内并行、组间串行**:parallel=true 时各工具并发跑。
// 2. **合并按声明顺序**:结果写入各自槽位的"暂存区",
// 组屏障处按 tools 数组顺序**一次性**合并。
// 并行下完成顺序不确定;若按完成顺序合并,同样的输入会产出不同的
// 序列输出 —— 整条流程不可复现。
// 顺序合并同时解决了并发写 map 的问题:**执行期不写共享 map**。
// 3. **条件求值失败必须报错**,不得降级成"条件为假"。
func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupResult, error) {
res := GroupResult{Group: g.Name, Slots: map[string]interface{}{}}
// ① 条件求值(在任何执行之前)
ok, err := evalCond(g.When, args)
if err != nil {
return res, fmt.Errorf("group %q 的条件求值失败: %w", g.Name, err)
}
if !ok {
res.Skipped = true
return res, nil // 槽**不赋值**:后续组引用它时由静态校验/调用方发现
}
n := len(g.Tools)
results := make([]ToolRun, n)
run := func(i int) {
tc := g.Tools[i]
// 插值:把 $args.* 换成实参。整值引用保留原始类型。
realArgs := substituteArgs(tc.Args, args)
out, err := runner.call(tc.Tool, realArgs)
results[i] = ToolRun{Name: tc.Tool, Result: out, Err: err, Order: i}
}
// ② 执行
if g.Parallel && n > 1 {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
run(idx)
}(i)
}
wg.Wait()
} else {
for i := 0; i < n; i++ {
run(i)
}
}
// ③ 按**声明顺序**合并槽位(不按完成顺序 —— 见函数注释)
failed := false
var firstErr error
for _, r := range results {
res.Tools = append(res.Tools, r)
if r.Err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("工具 %s 失败: %w", r.Name, r.Err)
}
if g.OnError != "continue" {
failed = true
}
}
tc := g.Tools[r.Order]
if tc.As == "" {
continue
}
// 失败时也留槽(记错误文本),否则后续组读到的是"缺失",
// 而"缺失"与"值为空"在下游难以区分。
val := interface{}(r.Result)
if r.Err != nil {
val = "错误:" + r.Err.Error()
}
if isArrayType(g.Out[tc.As]) {
cur, _ := res.Slots[tc.As].([]interface{})
res.Slots[tc.As] = append(cur, val)
} else {
res.Slots[tc.As] = val
}
}
if failed {
res.Err = firstErr
return res, firstErr
}
return res, nil
}
// substituteArgs 把 args 里的 $args.* 替换为实参值。
//
// 两种替换形态(缺一不可):
//
// · **整值引用**:"$args.count" 整个值就是引用 ⇒ 替换为**原始值**,
// 保留类型(数字仍是数字)。否则模型会收到字符串 "3" 而非数字。
// · **文本内插值**:"ssh $args.host" ⇒ 在字符串内替换。
func substituteArgs(args map[string]interface{}, scope map[string]interface{}) map[string]interface{} {
if args == nil {
return nil
}
out := make(map[string]interface{}, len(args))
for k, v := range args {
out[k] = substituteValue(v, scope)
}
return out
}
func substituteValue(v interface{}, scope map[string]interface{}) interface{} {
switch t := v.(type) {
case string:
// 整值引用
if key, ok := wholeRef(t); ok {
if val, present := scope[key]; present {
return val // 保留原始类型
}
return t // 未提供则原样保留(静态校验已保证它被声明过)
}
// 文本内插值
return interpolate(t, scope)
case map[string]interface{}:
return substituteArgs(t, scope)
case []interface{}:
arr := make([]interface{}, len(t))
for i, e := range t {
arr[i] = substituteValue(e, scope)
}
return arr
}
return v
}
// wholeRef 判断字符串是否**整体**是一个 $args.<键> 引用。
func wholeRef(s string) (string, bool) {
refs := parseArgRefs(s)
if len(refs) == 1 {
trimmed := strings.TrimSpace(s)
if strings.HasPrefix(trimmed, "$args."+refs[0]) &&
strings.TrimSuffix(trimmed, "$args."+refs[0]) == "" {
return refs[0], true
}
}
return "", false
}
// interpolate 在文本内把 $args.<键> 替换为实参的**字符串形式**。
func interpolate(s string, scope map[string]interface{}) string {
refs := parseArgRefs(s)
if len(refs) == 0 {
return s
}
var sb strings.Builder
rest := s
for {
i := strings.Index(rest, "$args.")
if i < 0 {
sb.WriteString(rest)
return sb.String()
}
sb.WriteString(rest[:i])
rest = rest[i+len("$args."):]
end := 0
for end < len(rest) && isRefChar(rest[end]) {
end++
}
if end == 0 {
continue
}
key := rest[:end]
rest = rest[end:]
if val, present := scope[key]; present {
sb.WriteString(scalarToString(val))
} else {
sb.WriteString("$args." + key)
}
}
}
// scalarToString 渲染标量值。
//
// ⚠️ 对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")——那会产出
// `map[k:v]` 这种模型读不懂的 Go 语法(core 的 renderToolResult 同样约定)。
func scalarToString(v interface{}) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
return strconv.FormatBool(t)
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'g', -1, 64)
case int:
return strconv.Itoa(t)
default:
return compactJSON(t)
}
}
// evalCond 求值 `when` 条件。
//
// 支持(L1+L2,见设计文档 §5):
// - `true` / `false`
// - `$args.key`(布尔真值)
// - `$args.key` 存在性与非空判断:!= "" 、== ""
// - 比较:== / != / > / < / >= / <=(标量)
// - `contains`:文本包含
//
// ⚠️ **求值出错必须返回 error**,不得当作 false。
// 把失败降级成"跳过"= 序列安静地少做一步,而模型以为跑完了 ——
// 与「静默吞工具」同族。
func evalCond(cond string, args map[string]interface{}) (bool, error) {
c := strings.TrimSpace(cond)
if c == "" || c == "true" {
return true, nil
}
if c == "false" {
return false, nil
}
// 形如 "$args.flag == true" / "$args.n > 3" / "$args.s contains x"
if left, op, right, ok := splitComparison(c); ok {
lv, err := resolveOperand(left, args)
if err != nil {
return false, err
}
rv, err := resolveOperand(right, args)
if err != nil {
return false, err
}
return compare(lv, op, rv)
}
// 裸引用:真值判断
if key, ok := wholeRef(c); ok {
v, present := args[key]
if !present {
return false, fmt.Errorf("引用了未提供的入参 $args.%s", key)
}
return truthy(v), nil
}
return false, fmt.Errorf("无法解析的条件表达式 %q"+
"(支持:true/false、$args.x、$args.x == 值、$args.x contains \"子串\"、大小比较)", cond)
}
// splitComparison 拆出 `左 op 右`(op 需含空格,避免与 contains 前缀混淆)。
func splitComparison(c string) (left, op, right string, ok bool) {
ops := []string{" contains ", " >= ", " <= ", " == ", " != ", " > ", " < "}
for _, o := range ops {
if i := strings.Index(c, o); i >= 0 {
return strings.TrimSpace(c[:i]), strings.TrimSpace(o), strings.TrimSpace(c[i+len(o):]), true
}
}
return "", "", "", false
}
// resolveOperand 解析操作数:字面量或 $args 引用。
func resolveOperand(s string, args map[string]interface{}) (interface{}, error) {
s = strings.TrimSpace(s)
if key, ok := wholeRef(s); ok {
v, present := args[key]
if !present {
return nil, fmt.Errorf("引用了未提供的入参 $args.%s", key)
}
return v, nil
}
// 字面量
if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] {
return s[1 : len(s)-1], nil
}
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
}
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n, nil
}
return s, nil // 裸文本
}
// compare 按运算符比较两个标量。
func compare(l interface{}, op string, r interface{}) (bool, error) {
if op == "contains" {
ls, lok := l.(string)
rs, rok := r.(string)
if lok && rok {
return strings.Contains(ls, rs), nil
}
// 非字符串:退化为字符串化包含
return strings.Contains(scalarToString(l), scalarToString(r)), nil
}
if op == "==" || op == "!=" {
eq := scalarToString(l) == scalarToString(r)
// 布尔与字符串宽松比较:true == "true"
if !eq {
eq = strings.EqualFold(scalarToString(l), scalarToString(r))
}
if op == "==" {
return eq, nil
}
return !eq, nil
}
// 大小比较:两侧都必须是数值
lf, lok := toFloat(l)
rf, rok := toFloat(r)
if !lok || !rok {
return false, fmt.Errorf("运算符 %q 两侧必须是数值(得到 %T 与 %T)", op, l, r)
}
switch op {
case ">":
return lf > rf, nil
case "<":
return lf < rf, nil
case ">=":
return lf >= rf, nil
case "<=":
return lf <= rf, nil
}
return false, fmt.Errorf("未知运算符 %q", op)
}
func toFloat(v interface{}) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int64:
return float64(t), true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
return f, err == nil
}
return 0, false
}
// truthy 标量真值:非零数字、true、非空文本、非空集合。
func truthy(v interface{}) bool {
switch t := v.(type) {
case nil:
return false
case bool:
return t
case string:
return t != ""
case float64:
return t != 0
case int:
return t != 0
case []interface{}:
return len(t) > 0
case map[string]interface{}:
return len(t) > 0
}
return true
}
// groupTimeout 返回本组的墙钟上限(Group.Timeout 形如 "30s")。
func groupTimeout(g Group) time.Duration {
if g.Timeout == "" {
return 0 // 由调用方决定默认
}
d, err := time.ParseDuration(g.Timeout)
if err != nil {
return 0
}
return d
}
// compactJSON 渲染结构化值为紧凑 JSON。
//
// ⚠️ 绝不用 fmt.Sprintf("%v"):那会产出 `map[k:v]` 这种模型读不懂的
// Go 语法(core 的 renderToolResult 同样约定,见设计文档 §5.2)。
func compactJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}