mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
This commit is contained in:
424
internal/plugins/seq/exec.go
Normal file
424
internal/plugins/seq/exec.go
Normal file
@ -0,0 +1,424 @@
|
||||
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)
|
||||
}
|
||||
341
internal/plugins/seq/exec_test.go
Normal file
341
internal/plugins/seq/exec_test.go
Normal file
@ -0,0 +1,341 @@
|
||||
package seq
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 阶段 P2:执行引擎(组内并行 + 具名槽 + 条件求值)。
|
||||
//
|
||||
// 三个核心不变量:
|
||||
// 1. **组边界即屏障**:同组内工具互相不可见(并行 ⇒ 无确定写序);
|
||||
// 变量只在组屏障处按 tools 顺序**确定性合并**。
|
||||
// 2. **条件求值失败必须报错**,不得降级成「条件为假」——
|
||||
// 那会让序列安静地少做一步而模型以为跑完了。
|
||||
// 3. 结果**按 tools 数组顺序**合并,与完成顺序无关 ⇒ 可复现。
|
||||
|
||||
// fakeTool 记录一次工具执行,并返回可配置的文本。
|
||||
type fakeTool struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
// results 按工具名给出返回文本;未配置则返回 "ran:<name>"
|
||||
results map[string]string
|
||||
// errs 按工具名给出错误
|
||||
errs map[string]error
|
||||
// delay 用于制造"完成顺序 ≠ 声明顺序"
|
||||
delay map[string]int
|
||||
}
|
||||
|
||||
func newFakeTool() *fakeTool {
|
||||
return &fakeTool{results: map[string]string{}, errs: map[string]error{}, delay: map[string]int{}}
|
||||
}
|
||||
|
||||
func (f *fakeTool) call(name string, _ map[string]interface{}) (string, error) {
|
||||
if d := f.delay[name]; d > 0 {
|
||||
sleepMS(d)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, name)
|
||||
f.mu.Unlock()
|
||||
if e, ok := f.errs[name]; ok {
|
||||
return "", e
|
||||
}
|
||||
if r, ok := f.results[name]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return "ran:" + name, nil
|
||||
}
|
||||
|
||||
func (f *fakeTool) called() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]string, len(f.calls))
|
||||
copy(out, f.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
// ① 具名槽:工具结果按 `as` 写入对应槽。
|
||||
func TestExecWritesNamedSlots(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
g := Group{
|
||||
Name: "g",
|
||||
In: map[string]string{},
|
||||
Out: map[string]string{"summary": "string", "load": "string"},
|
||||
When: "true",
|
||||
Parallel: false,
|
||||
Tools: []ToolCall{
|
||||
{Tool: "uptime", As: "summary"},
|
||||
{Tool: "top", As: "load"},
|
||||
},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
if got, _ := res.Slots["summary"].(string); got != "ran:uptime" {
|
||||
t.Errorf("summary 槽 = %v", res.Slots["summary"])
|
||||
}
|
||||
if got, _ := res.Slots["load"].(string); got != "ran:top" {
|
||||
t.Errorf("load 槽 = %v", res.Slots["load"])
|
||||
}
|
||||
}
|
||||
|
||||
// ② ★ 结果合并必须按 tools 数组顺序,与完成顺序无关。
|
||||
//
|
||||
// 并行下完成顺序不确定;若按完成顺序合并,同样的输入会产出不同的槽内容,
|
||||
// 整条序列**不可复现**。这里让后声明的工具先完成(delay 更短)。
|
||||
func TestExecMergesSlotsInDeclarationOrder(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
ft.delay["first"] = 30 // 先声明但后完成
|
||||
ft.delay["second"] = 1
|
||||
ft.results["first"] = "FIRST"
|
||||
ft.results["second"] = "SECOND"
|
||||
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{},
|
||||
Out: map[string]string{"a": "array", "b": "array"},
|
||||
When: "true", Parallel: true,
|
||||
Tools: []ToolCall{
|
||||
{Tool: "first", As: "a"},
|
||||
{Tool: "second", As: "b"},
|
||||
},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
// 各自的槽内容正确
|
||||
ra, _ := res.Slots["a"].([]interface{})
|
||||
rb, _ := res.Slots["b"].([]interface{})
|
||||
if len(ra) != 1 || ra[0] != "FIRST" {
|
||||
t.Errorf("槽 a = %v", ra)
|
||||
}
|
||||
if len(rb) != 1 || rb[0] != "SECOND" {
|
||||
t.Errorf("槽 b = %v", rb)
|
||||
}
|
||||
// 完成顺序确实被打乱(否则本用例测不到并发)
|
||||
if got := ft.called(); got[0] != "first" || got[1] != "second" {
|
||||
t.Logf("完成顺序 = %v(未打乱,判据可能测不到并发)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ array 槽的同名 as:在组屏障按 tools 顺序**确定性追加**。
|
||||
func TestExecArraySlotAppendsInOrder(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
ft.delay["slow"] = 30
|
||||
ft.results["a"] = "A"
|
||||
ft.results["c"] = "C"
|
||||
// "slow" 用默认返回值即可(ran:slow),它只用来制造"最后完成"
|
||||
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{},
|
||||
Out: map[string]string{"xs": "array"},
|
||||
When: "true", Parallel: true,
|
||||
Tools: []ToolCall{
|
||||
{Tool: "a", As: "xs"},
|
||||
{Tool: "slow", As: "xs"}, // 声明在中间但最后完成
|
||||
{Tool: "c", As: "xs"},
|
||||
},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
xs, _ := res.Slots["xs"].([]interface{})
|
||||
if len(xs) != 3 {
|
||||
t.Fatalf("xs 应有 3 项,实际 %d(%v)", len(xs), xs)
|
||||
}
|
||||
// 必须按 tools 声明顺序:a, slow, c ⇒ A, B, C
|
||||
// 期望按 tools 声明顺序:a → slow → c
|
||||
want := []string{"A", "ran:slow", "C"}
|
||||
for i, w := range want {
|
||||
if xs[i] != w {
|
||||
t.Errorf("xs[%d] = %v,期望 %v(完整 %v)—— 合并顺序依赖了完成顺序", i, xs[i], w, xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 条件为假 ⇒ 整组跳过,**槽不赋值**。
|
||||
func TestExecSkipsGroupWhenConditionFalse(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{"flag": "bool"},
|
||||
Out: map[string]string{"x": "string"},
|
||||
When: "false", Parallel: false,
|
||||
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{"flag": false}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
if !res.Skipped {
|
||||
t.Error("条件为假时应标记 Skipped")
|
||||
}
|
||||
if len(ft.called()) != 0 {
|
||||
t.Errorf("条件为假却执行了工具: %v", ft.called())
|
||||
}
|
||||
if _, ok := res.Slots["x"]; ok {
|
||||
t.Error("条件为假时不应给槽赋值(后续组会读到不存在的值)")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ ★ 条件**求值出错**必须报错,不得降级成「条件为假」。
|
||||
//
|
||||
// 这是本阶段最关键的一条:把求值失败降级为跳过 = 序列安静地少做一步,
|
||||
// 而模型以为跑完了 —— 与「静默吞工具」同族。
|
||||
func TestExecErrorsOnMalformedCondition(t *testing.T) {
|
||||
cases := []string{
|
||||
"$args.", // 空键名
|
||||
"$args.missing == 1", // 引用了未声明的入参(in 里只有 flag)
|
||||
"1 ==", // 语法不完整
|
||||
}
|
||||
for _, cond := range cases {
|
||||
t.Run(cond, func(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{"flag": "bool"},
|
||||
Out: map[string]string{"x": "string"},
|
||||
When: cond, Parallel: false,
|
||||
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
|
||||
}
|
||||
_, err := execGroup(g, map[string]interface{}{"flag": false}, ft)
|
||||
if err == nil {
|
||||
t.Fatalf("畸形条件 %q 未被拒绝(被静默当成假了吗)", cond)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "条件") {
|
||||
t.Errorf("错误信息应提到『条件』,实际: %v", err)
|
||||
}
|
||||
if len(ft.called()) != 0 {
|
||||
t.Errorf("条件求值失败却执行了工具: %v", ft.called())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 条件为真时正常执行。
|
||||
func TestExecRunsWhenConditionTrue(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{"flag": "bool"},
|
||||
Out: map[string]string{"x": "string"},
|
||||
When: "$args.flag == true", Parallel: false,
|
||||
Tools: []ToolCall{{Tool: "uptime", As: "x"}},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{"flag": true}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
if res.Skipped {
|
||||
t.Error("条件为真却跳过了")
|
||||
}
|
||||
if got, _ := res.Slots["x"].(string); got != "ran:uptime" {
|
||||
t.Errorf("x = %v", res.Slots["x"])
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 工具执行失败:on_error=continue 时继续,abort 时整组失败。
|
||||
func TestExecOnErrorPolicy(t *testing.T) {
|
||||
boom := errors.New("boom")
|
||||
|
||||
t.Run("abort", func(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
ft.errs["bad"] = boom
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{},
|
||||
Out: map[string]string{"a": "string", "b": "string"},
|
||||
When: "true", Parallel: false, OnError: "abort",
|
||||
Tools: []ToolCall{{Tool: "bad", As: "a"}, {Tool: "ok", As: "b"}},
|
||||
}
|
||||
if _, err := execGroup(g, map[string]interface{}{}, ft); err == nil {
|
||||
t.Error("on_error=abort 时失败应使整组失败")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("continue", func(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
ft.errs["bad"] = boom
|
||||
g := Group{
|
||||
Name: "g", In: map[string]string{},
|
||||
Out: map[string]string{"a": "string", "b": "string"},
|
||||
When: "true", Parallel: false, OnError: "continue",
|
||||
Tools: []ToolCall{{Tool: "bad", As: "a"}, {Tool: "ok", As: "b"}},
|
||||
}
|
||||
res, err := execGroup(g, map[string]interface{}{}, ft)
|
||||
if err != nil {
|
||||
t.Fatalf("on_error=continue 不应整组失败: %v", err)
|
||||
}
|
||||
if got, _ := res.Slots["b"].(string); got != "ran:ok" {
|
||||
t.Errorf("continue 下后续工具应仍执行,b = %v", res.Slots["b"])
|
||||
}
|
||||
// 失败的槽也要有值(错误文本),否则后续组读到缺失
|
||||
if _, ok := res.Slots["a"]; !ok {
|
||||
t.Error("continue 下失败的工具也应留下槽(记错误文本)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ⑧ 变量插值:`$args.x` 被真实值替换,且**整值引用**保留类型
|
||||
// (数字仍是数字,不是字符串)。
|
||||
func TestExecSubstitutesArgs(t *testing.T) {
|
||||
ft := newFakeTool()
|
||||
g := Group{
|
||||
Name: "g",
|
||||
In: map[string]string{"host": "string", "count": "integer"},
|
||||
Out: map[string]string{"x": "string"},
|
||||
When: "true", Parallel: false,
|
||||
Tools: []ToolCall{{
|
||||
Tool: "cmd",
|
||||
Args: map[string]interface{}{
|
||||
"cmd": "ssh $args.host",
|
||||
"amount": "$args.count",
|
||||
"whole": "$args.host",
|
||||
},
|
||||
As: "x",
|
||||
}},
|
||||
}
|
||||
// 注意:本例只验证 execTool 前的插值由 call 接口承担,
|
||||
// 这里通过 fakeTool 捕获实际收到的 args。
|
||||
capture := &capturingTool{inner: ft}
|
||||
if _, err := execGroup(g, map[string]interface{}{"host": "node-a", "count": 3}, capture); err != nil {
|
||||
t.Fatalf("execGroup: %v", err)
|
||||
}
|
||||
got := capture.lastArgs()
|
||||
if got["cmd"] != "ssh node-a" {
|
||||
t.Errorf("字符串内插值失败: %v", got["cmd"])
|
||||
}
|
||||
// 整值引用必须保留原始类型(数字仍是数字)
|
||||
if n, ok := got["amount"].(int); !ok || n != 3 {
|
||||
t.Errorf("整值引用应保留 int 类型,实际 %#v", got["amount"])
|
||||
}
|
||||
if got["whole"] != "node-a" {
|
||||
t.Errorf("整值引用失败: %#v", got["whole"])
|
||||
}
|
||||
}
|
||||
|
||||
// capturingTool 记录最后一次收到的 args。
|
||||
type capturingTool struct {
|
||||
inner *fakeTool
|
||||
mu sync.Mutex
|
||||
args map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *capturingTool) call(name string, args map[string]interface{}) (string, error) {
|
||||
c.mu.Lock()
|
||||
c.args = args
|
||||
c.mu.Unlock()
|
||||
return c.inner.call(name, args)
|
||||
}
|
||||
func (c *capturingTool) lastArgs() map[string]interface{} {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.args
|
||||
}
|
||||
|
||||
// sleepMS 测试用的短延时(毫秒)。
|
||||
func sleepMS(ms int) { time.Sleep(time.Duration(ms) * time.Millisecond) }
|
||||
|
||||
var _ toolRunner = (*fakeTool)(nil)
|
||||
var _ toolRunner = (*capturingTool)(nil)
|
||||
Reference in New Issue
Block a user