mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
964eeaa829
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
275 lines
9.6 KiB
Python
275 lines
9.6 KiB
Python
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
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 安全表达式求值(分支条件用)
|
|
# --------------------------------------------------------------------------
|
|
|
|
_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: operator.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
|
|
|
|
|
|
class ExpressionError(ValueError):
|
|
pass
|
|
|
|
|
|
def eval_expression(expr: str, ctx: dict[str, Any]) -> Any:
|
|
"""求值一个受限的 Python 表达式。
|
|
|
|
只放行字面量、比较、布尔/算术运算、下标、属性访问和一小撮白名单函数。
|
|
没有 import、没有属性魔法、没有函数定义,所以用户在画布上写条件是安全的。
|
|
"""
|
|
expr = (expr or "").strip()
|
|
if not expr:
|
|
return None
|
|
try:
|
|
tree = ast.parse(expr, mode="eval")
|
|
except SyntaxError as e:
|
|
raise ExpressionError(f"表达式语法错误:{e.msg}") from e
|
|
|
|
if sum(1 for _ in ast.walk(tree)) > _MAX_NODES:
|
|
raise ExpressionError("表达式太复杂")
|
|
|
|
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
|