Files
AgentLab/backend/app/engine/expressions.py
T
ThearchyHelios 0997603ccf 画完先自查:条件写错在校验时就拦下,Copilot 交付前按同一套规则查一遍、查出问题先改
开发库里四次运行死在同一句"循环条件写错了:表达式里不允许出现 Set"。条件原文是
`{{ vars.gate }} != "ok"`——把模板写法带进了表达式:`{{ x }}` 在 Python 里是"装着
一个集合的集合"。Copilot 的提示词写着"模板语法在任意字符串里用",模型照做而已;
校验又不看表达式,写错了只有跑到那一步才知道,前面的步骤白跑。

1. 表达式:解析和静态检查收成一个 parse_expression,运行时、校验、Copilot 自查共用,
   不会出现"校验说能跑、跑起来报错"。多套的 {{ }} 意思无歧义,在语法树上剥掉(字符串
   里的花括号不动),按 vars.x 理解并提示。真写错的给能照改的提示:| 过滤器 → len(x)、
   集合 → 列表、不认识的函数列出能用的、= 当 ==、引号里的 {{ }} 永远不成立、推导式、
   切片、私有属性。and / or 短路掉的那半边也查。

2. 校验:按节点类型和模式挑出真正求值的字段(分支条件、while 条件、整形表达式、口径卡
   指标、skip_if)。写错是 error、挡住运行;漏写 vars. 的名字提示会取到空值;while 没写
   条件、整形没填表达式也说出来。foreach 的 condition、模板模式的 expression 不求值,不查。

3. 代码节点:assign_to 拿的是 stdout。「测试 1」修好条件后跑通了、结果却是空的——代码
   最后一行是裸表达式,那是 notebook 的行为。设了 assign_to 却找不到任何输出的 Python
   代码,校验直接拦下,提示 print(json.dumps(结果))。宁可漏判不可误判:子进程、exec、
   附带文件里的 print 都算有输出;整行只有一个 {{ }} 的代码是运行时才拿到的(⑤号示例
   整段就是 {{ vars.code }}),看不见,不猜。库里 20 张工作流无一误拦。

4. Copilot 自查:搭完用同一套校验过一遍,error 级的问题连同图交回模型——只修这些、只
   输出改动,最多两轮。改好了才自动运行;改不好照实交付、列出剩下的问题,不自动开跑。
   没问题的图不多花一次调用;只是多套了 {{ }} 的不返工。提示词改成"模板只在普通文本
   字段里用",并列出表达式字段的对 / 错写法和代码节点必须 print。

5. 前端:自查这一段在 Copilot 的过程里看得见——发现几处问题、交回去改、改好了没有;
   改不好标红并说明没有自动运行。"搭好了,共几步"那一行不会被挤掉。

测试:新增 test_expressions(开发库里真实死过的四条条件、静态提示、按模式挑字段、代码
输出、带 {{ }} 的循环真跑一遍)与 test_copilot_selfcheck(脚本化模型:改好 → 自动运行;
改不好 → 不运行、最多两轮;干净的图一次调用;多套 {{ }} 不返工)。test_continue_run 里
用来制造运行时失败的表达式换成 `2 ** 100000 > 1`——原来那条现在在校验阶段就被拦下了。
check-decode 补了自查的解码断言。
2026-09-25 17:04:39 +08:00

386 lines
16 KiB
Python
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.
from __future__ import annotations
import ast
import json
import operator
import re
from typing import Any
# --------------------------------------------------------------------------
# 变量插值: {{ input.query }} / {{ nodes.n1.text }} / {{ vars.items | json }}
# --------------------------------------------------------------------------
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
def resolve_path(ctx: dict[str, Any], path: str) -> Any:
"""按点号路径取值,支持 a.b[0].c。取不到返回 None 而不是抛错 —— 编排里
半成品状态很常见,让它渲染成空字符串比中断整个 run 有用。"""
cur: Any = ctx
for raw in path.split("."):
raw = raw.strip()
if not raw:
continue
# 拆出 name[0][1] 形式的下标
m = re.match(r"^([^\[\]]*)((?:\[[^\[\]]+\])*)$", raw)
if not m:
return None
name, idx_part = m.group(1), m.group(2)
if name:
if isinstance(cur, dict):
cur = cur.get(name)
else:
cur = getattr(cur, name, None)
if cur is None:
return None
for idx in re.findall(r"\[([^\[\]]+)\]", idx_part):
idx = idx.strip().strip("'\"")
try:
if isinstance(cur, (list, tuple, str)):
cur = cur[int(idx)]
elif isinstance(cur, dict):
cur = cur.get(idx)
else:
return None
except (ValueError, IndexError, KeyError, TypeError):
return None
return cur
_FILTERS = {
"json": lambda v: json.dumps(v, ensure_ascii=False, indent=2, default=str),
"compact": lambda v: json.dumps(v, ensure_ascii=False, separators=(",", ":"), default=str),
"upper": lambda v: str(v).upper(),
"lower": lambda v: str(v).lower(),
"trim": lambda v: str(v).strip(),
"length": lambda v: len(v) if hasattr(v, "__len__") else 0,
"first": lambda v: v[0] if isinstance(v, (list, tuple)) and v else None,
"last": lambda v: v[-1] if isinstance(v, (list, tuple)) and v else None,
}
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, default=str)
return str(value)
def render_template(text: str, ctx: dict[str, Any]) -> str:
"""把字符串里的 {{ ... }} 全部替换掉。支持 `路径 | 过滤器` 语法。"""
if not text or "{{" not in text:
return text or ""
def _sub(match: re.Match[str]) -> str:
expr = match.group(1).strip()
if not expr:
return ""
parts = [p.strip() for p in expr.split("|")]
value = resolve_path(ctx, parts[0])
for filt in parts[1:]:
fn = _FILTERS.get(filt)
if fn:
try:
value = fn(value)
except Exception: # noqa: BLE001 - 过滤器不该让 run 挂掉
value = None
return _stringify(value)
return _TEMPLATE_RE.sub(_sub, text)
def render_deep(value: Any, ctx: dict[str, Any]) -> Any:
"""递归渲染嵌套结构里的所有模板字符串。"""
if isinstance(value, str):
return render_template(value, ctx)
if isinstance(value, dict):
return {k: render_deep(v, ctx) for k, v in value.items()}
if isinstance(value, list):
return [render_deep(v, ctx) for v in value]
return value
# --------------------------------------------------------------------------
# 安全表达式求值(分支条件用)
# --------------------------------------------------------------------------
# 幂运算的上界。`9**9**9**9` 只有 11 个 AST 节点,轻松过掉节点数护栏,
# 却会让 CPython 无限期地算一个天文数字的大整数 —— 而求值是在事件循环里
# 同步执行的(分支条件、transform、口径卡、每个节点的 skip_if 都走这里),
# 既没有线程池隔离也没有超时,asyncio 的 cancel 打断不了同步 CPU。
# 一条画布上的分支条件就能把整个后端冻死。
_MAX_POW_EXPONENT = 1024
_MAX_POW_BASE = 1e15
def _safe_pow(base: Any, exponent: Any) -> Any:
"""带上界的幂运算:超界直接报错,而不是让进程算到天荒地老。"""
try:
if abs(exponent) > _MAX_POW_EXPONENT:
raise ExpressionError(f"指数超过上限 {_MAX_POW_EXPONENT}")
if abs(base) > _MAX_POW_BASE and exponent > 1:
raise ExpressionError(f"底数超过上限 {_MAX_POW_BASE:g}")
except TypeError as e: # 非数值类型交给 operator.pow 自己报
raise ExpressionError(f"幂运算的操作数必须是数值:{e}") from e
return operator.pow(base, exponent)
_BIN_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: _safe_pow,
}
_CMP_OPS = {
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
}
_UNARY_OPS = {ast.Not: operator.not_, ast.USub: operator.neg, ast.UAdd: operator.pos}
_SAFE_FUNCS: dict[str, Any] = {
"len": len,
"str": str,
"int": int,
"float": float,
"bool": bool,
"abs": abs,
"min": min,
"max": max,
"sum": sum,
"round": round,
"sorted": sorted,
"any": any,
"all": all,
"lower": lambda s: str(s).lower(),
"upper": lambda s: str(s).upper(),
"contains": lambda hay, needle: needle in hay if hay is not None else False,
"startswith": lambda s, p: str(s).startswith(p),
"endswith": lambda s, p: str(s).endswith(p),
"matches": lambda s, p: bool(re.search(p, str(s))),
"get": lambda obj, key, default=None: (obj or {}).get(key, default),
}
_MAX_NODES = 200
#: 表达式能引用的根名字,就是 state.template_context 的那几个键(有测试钉住两边一致)。
#: 不在这里、也不是白名单函数或 true/false/null 的名字,运行时一律取到 None——
#: `gate == "ok"` 这种漏写了 vars. 的条件永远不成立,而且不报错
EXPRESSION_ROOTS = frozenset(
{"input", "nodes", "vars", "output", "loops", "usage", "messages", "last_message"}
)
_LITERAL_NAMES = frozenset({"true", "True", "false", "False", "null", "None"})
_ALLOWED_NODES: tuple[type[ast.AST], ...] = (
ast.Expression, ast.Constant, ast.Name, ast.Load, ast.Attribute, ast.Subscript,
ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.And, ast.Or, ast.Compare, ast.IfExp,
ast.Call, ast.keyword, ast.List, ast.Tuple, ast.Dict,
*_BIN_OPS, *_UNARY_OPS, *_CMP_OPS,
)
_NODE_NAMES = {
"ListComp": "列表推导式", "SetComp": "集合推导式", "DictComp": "字典推导式",
"GeneratorExp": "生成器表达式", "Lambda": "lambda", "JoinedStr": "f-string",
"NamedExpr": ":=", "Slice": "切片 a[i:j]", "Starred": "* 展开", "Await": "await",
}
class ExpressionError(ValueError):
pass
class _Unbrace(ast.NodeTransformer):
"""`{{ vars.x }}` 在 Python 里是"装着一个集合的集合"。表达式不允许集合,所以
它在表达式里只可能是一种笔误:把模板写法带了进来——模型和人都这么写(开发库
里四次运行就死在"表达式里不允许出现 Set")。意思毫无歧义,按 vars.x 理解。
在语法树上剥,不在文本上剥:字符串字面量里的花括号原样保留。
"""
def __init__(self) -> None:
self.found: list[str] = []
def visit_Set(self, node: ast.Set) -> ast.AST:
self.generic_visit(node)
inner = node.elts[0] if len(node.elts) == 1 else None
if isinstance(inner, ast.Set) and len(inner.elts) == 1:
self.found.append(ast.unparse(inner.elts[0]))
return inner.elts[0]
return node
def _check_static(tree: ast.Expression) -> list[str]:
"""不求值,只看结构:违规直接抛 ExpressionError,返回不认识的名字(留给提示用)。
运行时求值会短路(and/or、三元只走一边),没走到的那一半写错了也发现不了;
画布校验和 Copilot 自查要的是"整条表达式都能跑",所以得把整棵树走一遍。
"""
unknown: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.BinOp) and type(node.op) not in _BIN_OPS:
if isinstance(node.op, ast.BitOr):
raise ExpressionError(
"表达式里没有 | 过滤器(那是模板的写法):取长度写 len(x),大小写写 upper(x) / lower(x)"
)
raise ExpressionError(f"不支持的运算符 {type(node.op).__name__}")
if not isinstance(node, _ALLOWED_NODES):
if isinstance(node, ast.Set):
raise ExpressionError("表达式里不支持集合 {…}:要判断是不是其中之一,用列表 x in ['a', 'b']")
name = type(node).__name__
raise ExpressionError(f"表达式里不允许出现 {_NODE_NAMES.get(name, name)}")
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
called = node.func.id if isinstance(node.func, ast.Name) else ast.unparse(node.func)
raise ExpressionError(f"不认识的函数 {called}(),能用的只有:{'、'.join(_SAFE_FUNCS)}")
elif isinstance(node, ast.Attribute) and node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
elif (isinstance(node, ast.Constant) and isinstance(node.value, str)
and _TEMPLATE_RE.search(node.value)):
raise ExpressionError(
f"字符串 {node.value!r} 里的 {{{{ }}}} 不会被渲染——表达式不过模板,直接写路径,比如 vars.x"
)
elif (isinstance(node, ast.Name) and node.id not in EXPRESSION_ROOTS
and node.id not in _SAFE_FUNCS and node.id not in _LITERAL_NAMES):
unknown.append(node.id)
return list(dict.fromkeys(unknown))
def parse_expression(expr: str) -> tuple[ast.Expression, list[str], list[str]]:
"""解析 + 静态检查:(语法树, 被剥掉的 {{ }}, 不认识的名字)。结构违规抛 ExpressionError。
运行时求值、画布校验、Copilot 自查都走这一个入口——三处各写一套规则的话,
迟早出现"校验说能跑、跑起来报错"。
"""
text = (expr or "").strip()
try:
tree = ast.parse(text, mode="eval")
except SyntaxError as e:
hint = "(判断相等要写 ==)" if re.search(r"(?<![=!<>])=(?!=)", text) else ""
raise ExpressionError(f"表达式语法错误:{e.msg}{hint}") from e
unbrace = _Unbrace()
tree = ast.fix_missing_locations(unbrace.visit(tree))
if sum(1 for _ in ast.walk(tree)) > _MAX_NODES:
raise ExpressionError("表达式太复杂")
unknown = _check_static(tree)
return tree, unbrace.found, unknown
def eval_expression(expr: str, ctx: dict[str, Any]) -> Any:
"""求值一个受限的 Python 表达式。
只放行字面量、比较、布尔/算术运算、下标、属性访问和一小撮白名单函数。
没有 import、没有属性魔法、没有函数定义,所以用户在画布上写条件是安全的。
"""
expr = (expr or "").strip()
if not expr:
return None
tree, _, _ = parse_expression(expr)
def _eval(node: ast.AST) -> Any:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
if node.id in ctx:
return ctx[node.id]
if node.id in _SAFE_FUNCS:
return _SAFE_FUNCS[node.id]
if node.id in ("true", "True"):
return True
if node.id in ("false", "False"):
return False
if node.id in ("null", "None"):
return None
return None
if isinstance(node, ast.Attribute):
base = _eval(node.value)
if isinstance(base, dict):
return base.get(node.attr)
if node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
return getattr(base, node.attr, None)
if isinstance(node, ast.Subscript):
base = _eval(node.value)
key = _eval(node.slice)
try:
return base[key]
except (KeyError, IndexError, TypeError):
return None
if isinstance(node, ast.BinOp):
op = _BIN_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的运算符")
return op(_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
op = _UNARY_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的一元运算符")
return op(_eval(node.operand))
if isinstance(node, ast.BoolOp):
# Python 的 and/or 返回的是操作数本身而不是布尔值,并且短路求值。
# `vars.x or '默认值'` 是模板里最常见的写法,返回 True 就全错了。
is_and = isinstance(node.op, ast.And)
result: Any = None
for i, operand in enumerate(node.values):
result = _eval(operand)
truthy = bool(result)
if i < len(node.values) - 1 and (truthy is not is_and):
return result # and 遇假、or 遇真:短路返回该操作数
return result
if isinstance(node, ast.Compare):
left = _eval(node.left)
for op_node, comparator in zip(node.ops, node.comparators):
op = _CMP_OPS.get(type(op_node))
if not op:
raise ExpressionError("不支持的比较运算符")
right = _eval(comparator)
try:
if not op(left, right):
return False
except TypeError:
return False
left = right
return True
if isinstance(node, ast.IfExp):
return _eval(node.body) if _eval(node.test) else _eval(node.orelse)
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
raise ExpressionError("只能调用白名单函数")
args = [_eval(a) for a in node.args]
kwargs = {kw.arg: _eval(kw.value) for kw in node.keywords if kw.arg}
return _SAFE_FUNCS[node.func.id](*args, **kwargs)
if isinstance(node, (ast.List, ast.Tuple)):
return [_eval(e) for e in node.elts]
if isinstance(node, ast.Dict):
return {
_eval(k): _eval(v)
for k, v in zip(node.keys, node.values)
if k is not None
}
raise ExpressionError(f"表达式里不允许出现 {type(node).__name__}")
return _eval(tree)
def eval_condition(expr: str, ctx: dict[str, Any]) -> bool:
"""求值成布尔。条件写错时返回 False 而不是炸掉整个 run。"""
try:
return bool(eval_expression(expr, ctx))
except ExpressionError:
raise
except Exception: # noqa: BLE001
return False