mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
三个不变量(各有判据钉住):
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 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
425 lines
11 KiB
Go
425 lines
11 KiB
Go
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)
|
||
}
|