@flow DSL¶
@flow 是 plaita 的首选 Python API,它把普通函数体通过 ast 编译为 Flow IR,表达式用真实 Python 语法,IDE 补全最好,AI 生成最友好。
flowchart LR
Codeflow["@flow (推荐)"] --> IR["Flow IR"]
Sexpr["S-expr (高级)"] --> IR
IR --> Runtime["执行引擎"]
AI Agent 场景
@flow + flow_from_source 最适合 LLM 运行期生成流程(编译期校验 + 无源文件依赖)。端到端场景见 应用场景 - Agent 编排。
写业务 flow 前先读编写规范
语法之外还有一组编译期不拦的作者约束(跨分支同名赋值禁止、集合子流程作用域边界、params= 运行语义)与业务集成知识(plaita-nodes 节点激活、业务 flow 项目结构、发布 console 的 worker 节点注入)。权威单源:plaita-ai/plaita_ai/skills/flow-coder/references/authoring-spec.md;skill 安装见 AI 与 Skill。
AST Python DSL(@flow)¶
plaita.dsl.codeflow 是「极致」形态:用户写一个普通 Python 函数,@flow 用 ast 解析函数体,把 if/for/return/赋值翻译成 flow 节点,把 INPUT.age >= 18 编译成 $INPUT.age 表达式串。函数体从不被当作 Python 执行——它只是一段被静态分析的语法树。
from plaita.dsl.codeflow import flow, F, NODE, MAP, HTTP, ErrorHandler
@flow("create_user", desc="创建用户")
def create_user(INPUT):
if INPUT.age >= 18:
resp = HTTP.post(
url="https://api.example.com/users",
body={"name": INPUT.name},
timeout="PT5S",
on_error=ErrorHandler("continue_with", default={"data": None}),
)
return resp.data
return "未成年"
create_user.run(name="alice", age=20)
关键点:
- 写起来就是纯 Python:有补全、有类型、写错编译期就报。
INPUT/F/HTTP这些名字不需要 import——它们在函数体里只是语法占位,AST 编译期被识别,运行期不会真正查这几个名字(可选 import 仅用于 IDE 提示)。- 编译产物仍是
FlowIR,与 JSON/YAML/Builder 完全等价。
input_type / output_type 已废弃
@flow / @childflow 的 input_type / output_type 参数已废弃且被忽略:$INPUT 恒为 run() 传入的 dict,无需声明。示例统一写作 @flow("id");旧代码里写了也会被忽略(flow_from_source 路径发 DeprecationWarning,直接装饰器调用会 TypeError)。
表达式映射¶
| 你写的 Python | 编译成的表达式串 |
|---|---|
INPUT.age |
$INPUT.age |
F.upper(INPUT.name) |
$F.upper($INPUT.name) |
resp.data |
$NODE.resp.data |
INPUT.items[0] |
$INPUT.items[0] |
a + b / a - b / a * b / a % b |
$F.add(a,b) / $F.sub / $F.mul / $F.mod |
len(x) / abs(x) |
$F.len(x) / $F.abs(x) |
if a >= 18 and b == 1: |
条件组 {relation: and, ...} |
if not (x == "blocked"): |
取反 {relation: not, ...} |
比较与
and/or/not只能出现在if判断位置;算术+ - * / %编译成$F.add/sub/mul/div/mod(表达式语言没有中缀运算符)。
表达式语义边界¶
@flow 用 Python 语法做皮、子集做骨:函数体不被当 Python 执行,只走静态 AST 编译。能编译的写法与精确语义如下,写法不在表里就会在构建 Flow 前报错(带行号与重写提示)。
支持的 Python 表达式
| 写法 | 编译产物 | 精确语义 |
|---|---|---|
字面量 1 "x" True None [1,2] {"a":1} |
原值 | 原样;dict key 必须是常量 |
INPUT.x / NODE.r.data / GLOBAL.k / PARENT.x / ENV.K |
$INPUT.x / $NODE.r.data / … |
命名空间变量解析 |
obj.path[0] / obj.path[-1] |
$obj.path[0] / $obj.path[-1] |
仅整数常量下标 |
F.foo(a, b) |
$F.foo(a, b) |
调用注册的表达式函数 |
len(x) / abs(x) / round(x) |
$F.len / $F.abs / $F.round |
映射到真 Python 内置, 语义精确 |
str(x) |
$F.concat(x) |
近似映射: concat = "".join(str(a) for a in args), 单参时与 str() 等价; str 当类型转换的意图被实现成"拼成字符串" |
a + b a - b a * b a / b a % b a ** b |
$F.add/sub/mul/div/mod/pow |
直接对应 Python 运算符 (a + b), 因此 + 对 int/str/list 都按 Python + 多态——类型决定语义, 编译期不做类型检查 |
a == b != > >= < <= in not in |
$F.eq/ne/gt/gte/lt/lte/in/notIn(…) |
表达式位置的比较落到注册表同名比较函数(函数名与 if 条件算子一致);链式比较 a < b < c 编译为 $F.and($F.lt(a,b), $F.lt(b,c)) |
a and b / a or b / not x |
$F.and(…) / $F.or(…) / $F.not(…) |
表达式位置的布尔运算。or 返回第一个真值(Python 真值语义);and 链按 Python 结合律左折叠嵌套。均急切求值(无短路),$F 函数皆纯故无副作用 |
a if c else b |
$F.ifelse(c, a, b) |
三元。急切求值:两支都会被求值 |
不支持、会报错的 Python 写法(含重写提示)
| 写法 | 报错提示 |
|---|---|
f-string f"hi {name}" |
用 F.concat('hi ', INPUT.name) |
| 列表/集合/字典推导式 | 用 MAP/FILTER 节点 |
lambda / await / 海象 := / *args 解包 |
不支持, 拆成节点或普通赋值 |
字面量方法调用 "x".upper() / 任意非 F.* 非 len/abs/round/str 调用 |
表达式里只能调 F.xxx(...) 或上述内置 |
集合字面量 {1,2} |
用列表 |
关于
+的多态陷阱:name + "!"编译成$F.add($INPUT.name, "!"),运行时+对字符串是拼接、对数字是加——结果取决于运行时类型, 编译期不拦。若要强制字符串拼接且对非字符串报错, 显式用F.concat(...)。
if / elif / else、return、赋值¶
@flow("grade")
def grade(INPUT):
if INPUT.score >= 90:
return "A"
elif INPUT.score >= 60:
return "B"
else:
return "C"
grade.run(score=95) # -> "A"
赋值会生成 assignment 节点,后续用变量名引用其输出:
@flow("greet")
def greet(INPUT):
name = F.upper(INPUT.name)
return F.concat("hi ", name)
greet.run(name="alice") # -> "hi ALICE"
# name = $F.upper($INPUT.name); return = $F.concat("hi ", $NODE.name)
end(...):error / nop 结束出口¶
return 恒定产出 resultType="success" 的 end 节点。要以业务错误结束(引擎抛
FlowResultError,外层得到 FlowExecutionException(error_type=ERROR_RESULT))或
静默结束(nop)时,用 end(...) 终止语句——与 return 同级(之后语句不可达),
差别是能显式声明 result_type 与 error 载荷:
@flow("create_user")
def create_user(INPUT):
if INPUT.age >= 18:
return "ok"
end(result_type="error", error={"code": 4001, "message": "参数非法"})
create_user.run(age=17) # -> 抛 FlowExecutionException(error_type=ERROR_RESULT)
- 参数:
output=(可选,也可作唯一位置参数)、result_type=("success"默认 /"error"/"nop",须为字符串字面量)、error=(dict 字面量,习惯写{"code": ..., "message": ...})。 result_type=的取值域是小写success/error/nop(发动机口径,大小写敏感):"bogus"、"ERROR"等域外值编译期硬错,不会被静默降级成success。error=是纯字面数据,引擎从不求值它(End.execute直接self.error.get(...)): 含字面$的字符串/键名(如{"message": "$100 fee missing"}、{"$ref": "x"}) 原样保留、不参与表达式编译;写成表达式(如{"message": INPUT.msg})会在编译期报错。 它也不参与$NODE/$F引用扫描——载荷里写"$NODE.e"只是文本,不会被判悬空引用。end(...)是语句形态而非占位符,无需 import(@flow函数体只被静态解析、 从不执行);result_type="nop"时流程静默结束并丢弃output。
集合节点¶
map / filter / find / loop 用 for x in MAP(...) 语法,子流程直接写在函数体里:
@flow("double_numbers")
def double_numbers(INPUT):
for x in MAP(INPUT.numbers, id="dbl"):
return F.mul(x, 2)
return NODE.dbl
double_numbers.run(numbers=[1, 2, 3, 4]) # -> [2, 4, 6, 8]
filter / find 的子流程需要返回 bool,惯例是 if ... return True / return False:
@flow("first_even")
def first_even(INPUT):
for x in FIND(INPUT.nums, id="fd"):
if F.mod(x, 2) == 0:
return True
return False
return NODE.fd
first_even.run(nums=[1, 3, 4, 6]) # -> 4
作用域:循环体是子流程——循环目标名(x / acc, it)映射子流程输入;
外层已赋值变量自动映射为 $PARENT.NODE.<名> 快照引用(与 while 同一约定),
体内可读集合节点执行前已确定的父侧变量,写不回父 context。聚合语义用
REDUCE(累积值经 return 串联,结果经 NODE.<id> 引用)或对
NODE.<集合节点id>(MAP 的输出即结果列表)的下游表达式表达——不要试图在
集合子流程里引用集合节点自身的结果(父侧此刻尚未写回)。裸 INPUT 在体内指
子流程输入;要读外层原始输入须显式写 PARENT.INPUT.<名>。
循环之后,集合节点的 id= 同样进入名字作用域:上面的 return NODE.dbl
可直接写成 return dbl(解析为 $NODE.dbl)。emit_source 发射下游
$NODE.<id> 时用的正是这个裸名形态,因此 IR → @flow → IR 往返依赖它解析回
同一个引用(plaita#220-L1)。命名 WHILE 的 for-head(for item in WHILE(...,
id="w"))同理。
@flow("scaled_sum")
def scaled_sum(INPUT):
limit = F.mul(INPUT.base, 100) # 外层赋值,体内可读
for acc, it in REDUCE(INPUT.items, initial=[], id="rd"):
return F.append(acc, F.mul(it, limit))
return NODE.rd
scaled_sum.run(base=2, items=[1, 2, 3]) # -> [200, 400, 600]
子流程 @childflow + CHILD¶
@childflow 装饰一个子流程函数,父流程用 CHILD(...) 引用:
from plaita.dsl.codeflow import flow, childflow, CHILD, F
@childflow()
def double_each(INPUT):
return F.mul(INPUT.item, 2)
@flow("double_via_child")
def double_via_child(INPUT):
r = CHILD(input={"item": INPUT.payload}, flow=double_each)
return r
double_via_child.run(payload=21) # -> 42
CHILD的input要与子流程的输入形态匹配:$INPUT恒为 dict,input就传 dict。
@childflow 与 @flow 同形制:位置参数是子流程的 flow_id,desc 是纯关键字参数(位置串永远是 id,不会被当成 desc):
@childflow("cf") # 位置 id -> 子流程 IR flow_id == "cf"
@childflow(desc="说明") # 只有说明 -> flow_id is None(历史写法,行为不变)
@childflow("cf", desc="说明") # 两者
@childflow() # 匿名 -> flow_id is None
flow_id不参与节点寻址 / 查找:plaita/core/flow.py里它的引用全在日志与元数据路径上(5 处logger.warning:flow.py:178/250/281/311/343,以及已废弃的Flow.id访问器与 IR 键归一化)。它不是路由键,也不是跨流程引用名(引用用源码函数名flow=<name>)。 但它会被写进执行上下文,因此是可观测的:plaita/core/context.py:314→$GLOBAL.flow_id;context.py:331/state.py:632→$FLOW_ID(别名$FLOW)。实测(子end的 output 写$FLOW_ID):@childflow("cf")得到'cf';裸@childflow得到父流程的 id(子作用域为None,context.py:137-148回落到父上下文)——所以给不给 id 会改变$FLOW_ID的值,按它分支的表达式会走不同分支。 不给 id 就是None,不回落函数名——内联子流程没有可用函数名,回落会让同名子流程撞 id。emit_source→compile_source往返保真这个值(#145);id 只按类型把关(任何str都收,含""/纯空白;非 str 如@childflow(123)编译期抛_CodeflowError);同一文档内两个不同子流程撞 id 只发UserWarning(同一子流程被多处引用不告警)。源码字符串路径只认位置实参(flow_id=/ 第二个位置实参被忽略)。
并行分支字段(PARALLEL)与赋值字段(ASSIGN)¶
PARALLEL(branches={...}) 的分支值通常是 @childflow 函数;分支需要自己的输入或
条件时,值写成显式 dict(键:flow 必填,input / condition 可选;不带附加
字段的分支保持原写法):
r = PARALLEL(branches={
"hot": {"flow": hot_path, "input": INPUT.item, "condition": INPUT.priority > 5},
"cold": cold_path,
}, conditional=True, join=["hot", "cold"])
分支字段的数据口径(plaita#220/L2):
input与condition的值在 sexpr 链上走数据发射器——字面字符串"true"/"42"/"nil"恒加引号,读回 时不会漂成bool/int/None($开头仍按表达式)。条件覆盖引擎全部 12 个算子;contains/notContains的 @flow 拼写是F.contains(field, value)/not F.contains(field, value)(参数序即value in field),发射器与编译器对称识别,不再以「不支持的条件算子」拒绝。
赋值节点的两个字段在 n = expr 语句形态里没有位置:output_type(运行期类型
校验)与 upstream_output(多 upstream 时按 last_node_id 选择取值)。需要它们时
改用 ASSIGN(...)(普通赋值仍写 n = expr,行为不变):
from plaita.dsl.codeflow import ASSIGN
x = ASSIGN(output=INPUT.raw,
output_type={"dataType": "string"},
upstream_output=[{"upstream": "prev", "value": NODE.prev}])
两种写法都只影响 IR 形状:
ASSIGN编译出的仍是type="assignment"节点。emit_source在 IR 带这两个字段时自动选ASSIGN(...)发射,保证compile_source(emit_source(ir))不丢字段。
编译期校验¶
@flow 在构建 Flow 前做静态分析,报行号:
| 反模式 | 报错 |
|---|---|
| 用了未定义的名字 | [codeflow] 第 N 行: 未知名字 'xxx' |
| 节点调用嵌在表达式里 | HTTP(...) 是节点调用,只能作为语句或赋值右侧 |
not 出现在非条件位置 |
not 要用在条件位置(if/while 判断) |
| 赋值后悬空(无 return/后续) | 赋值 xxx 之后悬空:请补 return 或后续语句 |
| f-string / 推导式 / lambda 等不支持写法 | 带重写提示(见上文「表达式语义边界」) |
非 F.* 的方法/函数调用 |
不支持的调用 Xxx.yyy(...):… |
只编译不构建:compile_func(fn, flow_id) 返回 IR dict,便于审查 / 序列化 / 生成器回写。
语义化节点 id 与 name/desc¶
无赋值名的节点(if/while/return)不再产出 _n1 合成 id:编译器按条件语义自动命名(INPUT.score >= 90 → id score_ge_90,同 slug 冲突加 _2.._5 后缀;纯中文等无 ASCII 语义时回退 _n{n}),并写入 name(短标签)与 desc(带源码行号),console 画布 / dry-run / 运行期报错据此可读。规则详见 flow-coder skill 的 references/authoring-spec.md §2.6。
from plaita.dsl.codeflow import compile_func
print(compile_func(create_user.__wrapped__, "create_user"))
自定义节点(注册的 Node 子类)¶
@flow 不止能调内置占位符。任何注册到 NodeRegistry 的自定义 Node 子类,都能在源码里用 node_type 大写化 的名字作为占位符调用。这让 AI 生成的 @flow 可以直接用业务自定义动作节点(LLM、检索、工具、领域 action),而不必降级到 JSON IR。
from typing import Any, ClassVar, Optional
from plaita import Node
from plaita.node import get_default_registry
from plaita.dsl.codeflow import flow_from_source
class LLMNode(Node):
node_type: ClassVar[str] = "llm"
prompt: Optional[str] = None
system: Optional[str] = None
model: Optional[str] = None
def execute(self, execution):
prompt = execution.evaluate(self.prompt) if self.prompt else ""
...
class RetrieveNode(Node):
node_type: ClassVar[str] = "retrieve"
query: Optional[Any] = None
library: Optional[str] = None
top_k: int = 3
def execute(self, execution):
q = execution.evaluate(self.query)
return [f"doc:{q}#{i}" for i in range(self.top_k)] # 演示用假检索
get_default_registry().register(LLMNode)
get_default_registry().register(RetrieveNode)
src = '''
@flow("answer", desc="带资料回答")
def answer(INPUT):
retrieved = RETRIEVE(query=INPUT.question, library="kb", top_k=2)
ans = LLM(model="responder", system="只根据资料回答",
prompt="资料:{% $NODE.retrieved %}\\n问题:{% $INPUT.question %}")
return ans
'''
flow_from_source(src).run(question="plaita 是什么")
规则:
- 占位符名 =
node_type大写(llm→LLM、retrieve→RETRIEVE、tool→TOOL)。编译期查 registry:名字全大写且.lower()是已注册 node_type 即识别为该类型的节点调用。 - 只能作语句或赋值右侧,和
HTTP/CHILD一样不能嵌在表达式里(return LLM(...)要拆成r = LLM(...); return r)。 - 只接受关键字参数(
字段名=值),不支持位置参数。字段名须与Node子类的 pydantic 字段(snake_case)一致。 - 字段值经表达式编译:
INPUT.x→$INPUT.x、字面量原样、含{% ... %}的模板字符串原样透传给节点execute自行求值。 - 节点 id:赋值时用变量名(
a = LLM(...)→ ida,后续a引用为$NODE.a);表达式语句可用id="name"命名。赋值时不要同时传id=。 - 通用字段:
id=、timeout=、on_error=ErrorHandler(...)对所有自定义节点有效(走基类Node的timeout/errorHandler);其余 kwargs 按名进 IR。 - 未注册的大写名 → 编译期报错并列出可用类型(供 AI 自纠):
未注册的自定义节点 FOO(...):node_type 'foo' 不在 registry 中。可用类型:['llm', 'retrieve', ...]。 - 内置专用占位符优先:
HTTP/CODE/EVENT/CHILD/REFERENCE/PARALLEL/MAP/... 仍走各自的硬编码分支,不会被当自定义节点;不要注册同名的自定义 node_type。
类型提示:自定义节点的 pydantic 字段若是
int/bool等强类型,传入表达式串($INPUT.x)会在构建Flow时被 pydantic 拒绝。要让字段能接表达式,声明成Optional[Any]/Optional[str](如LLMNode.prompt),或在外层用字面量。
运行期错误回标到源码行¶
@flow 不仅在编译期报行号,还会把每个节点对应的源码行号写进 IR(source_line 字段)。这样运行期某个节点抛错时,错误也能定位回你写的 Python 源码行,而不只是节点 id:
- 编译期:
if/return/ 赋值 / 节点调用(HTTP/CODE/CHILD/...)/ 集合节点(MAP/FILTER/...)及其子流程节点都会带上source_line。合成的start节点没有源码对应,不带该字段。 - 运行期:节点抛错且策略为
abort时,NodeExecutionError(及NodeTimeoutError/ErrorResultException/ResumeError)会带上source_line属性,消息末尾追加(源码第 N 行):
from plaita.core.errors import NodeExecutionError
try:
flow.run(n=1)
except NodeExecutionError as e:
print(e.source_line) # 2
print(e.message) # ... (源码第 2 行)
JSON / S-expr / Builder 前端不产生
source_line,对应节点该字段为None,运行期错误消息不附加行号后缀——仍按节点 id 定位。
运行期生成:flow_from_source¶
@flow 装饰器依赖 inspect.getsource,要求函数定义在真实 .py 源文件里。运行期动态生成的场景(AI 拼一段源码字符串、立刻编译执行)走装饰器会失败——动态函数没有源文件。
为此提供源码模式入口 flow_from_source / compile_source:直接对字符串 ast.parse,绕开 inspect.getsource:
from plaita.dsl.codeflow import flow_from_source
src = '''
@childflow()
def double_each(INPUT):
return F.mul(INPUT.item, 2)
@flow("double_via_child")
def double_via_child(INPUT):
r = CHILD(input={"item": INPUT.payload}, flow=double_each)
return r
'''
flow = flow_from_source(src) # 字符串进,Flow 出
flow.run(payload=21) # -> 42
要点:
- 源码里可含多个函数:
@childflow装饰的子流程会被收集进注册表,供主流程用flow=<name>引用(不需要真正执行装饰器)。 - 主流程是
@flow装饰的函数,或唯一一个非 childflow 函数;有多个候选时用flow_from_source(src, flow_id="bar")指定。 @flow("id", desc=...)装饰器参数会被自动提取;显式传给flow_from_source的同名参数覆盖装饰器值。@childflow("<id>")的位置参数(子流程flow_id)同样会被提取并写进子流程 IR;不给即flow_id is None,不回落函数名(此时$FLOW_ID回落到父 id)。见 子流程 @childflow + CHILD。input_type/output_type参数已废弃且被忽略:$INPUT恒为run()传入的 dict,无需声明。源码里若仍写了(如 AI 生成的旧式代码),编译期会发DeprecationWarning并忽略。compile_source(src)只编译成 IR dict 不构建,便于审查/序列化。
装饰器参数只支持字面量(字符串、数字、dict 字面量),非字面量参数会被跳过。
反向生成:emit_source(JSON → @flow 源码){ #emit_source }¶
emit_source(ir) 是 compile_source 的逆操作:把 JSON/YAML/builder 产出的 Flow IR dict 重构为可读的 @flow Python 源码(含被引用的 @childflow 函数),存量 JSON flow 由此迁移到 codeflow DSL:
from plaita.dsl.codeflow import compile_source, emit_source, flow_from_source
ir = {"runtime": "python", "flow_id": "legacy",
"inputType": {"dataType": "object"},
"nodes": [
{"type": "start", "id": "start", "next": "fetch"},
{"type": "http", "id": "fetch", "method": "GET",
"url": "https://api.example.com", "next": "ret"},
{"type": "end", "id": "ret", "output": "$NODE.fetch.status",
"resultType": "success"},
]}
src = emit_source(ir)
# @flow('legacy')
# def legacy(INPUT):
# fetch = HTTP(method='GET', url='https://api.example.com')
# return fetch.status
flow = flow_from_source(src) # 迁移后走同一执行入口
语义保证与边界:
- 语义等价而非逐字节等价:
compile_source(emit_source(ir))与ir在「剥派生注解(name/desc/source_line)+ 非语义 id 规范化」口径下相等。变量名 id 与显式id=钉住的 id 恒定;if/return 的派生 id、无名节点_n{n}计数 id 由重编译时的语句形态决定,可能变化。 - 子流程
flow_id保真:内联子图带flow_id时发射为位置参数@childflow('<id>')(非 str 值按str()归一,保证发射产物可再编译),重编译后回到同一个flow_id;不带则不发射 id(保持匿名,不拿child_N产物名回填)。flow_id不参与节点寻址(flow.py的引用全在 warning 里),但会写进执行上下文($FLOW_ID/$GLOBAL.flow_id)——详见上文「子流程 @childflow + CHILD」。 - 不可表达构造抛
EmitError(不产出错误代码):switch/bool合成节点(builder 专属)、非 objectinputType、空真分支或两分支同址的 if、无法结构化的任意图。 - IR 里以
$开头的字符串一律按表达式逆映射;原始字面量恰以$开头的场景编译期已丢失区分,属已知边界。 - 事件节点的键名两套都收:event 节点按
eventType(编译器自产 IR,优先)→event_type(存量 JSON flow 的权威键名)回退读取,过滤器同理eventFilter→event_filter;两种写法的同一份 IR emit 出同一份源码。 - 非 success 结束出口发射为
end(...)终止语句:resultType为error/nop(或带error载荷)的 end 节点不能用return表达——return在重编译时被还原成resultType="success",会静默把失败出口洗成成功。emit 改发上文「end(...)」一节的一等终止语句,resultType与error载荷原样活过 round-trip。 - 非取值域的
resultType先按发动机口径归一:不在success/error/nop内的值(非字符串1/True、未知字面量、大小写变体、空串)在引擎End.setup_end里都会被降级成success;emit 据此归一后再发射(发射器永不产出自己吃不回去的result_type=),保证「发射器输出永远可重编译」。 - 发动机双键都收(camel 优先、snake 兜底):
resultType/result_type、errorHandler/error_handler、childFlow/child_flow、eventType/event_type、eventFilter/event_filter在引擎侧是同一字段的两种拼写(End.setup_end、Node.setup_error_handler、FlowNode.setup_child_flow都按camel or snake读取)。发射器按同一口径读取,只读一边会让 snake 存盘 flow(如tests/integration/server/test_flow.json)的失败出口/错误策略/内联子图静默丢失。 - 错误策略按发动机的真值判定:
errorHandler/error_handler为{}/[]/""(假值)时等同"没配"(退回默认 handler),{'retryTimes': 2}合法(strategy默认abort),真值但非 dict 或域外strategy(如"bogus")直接EmitError(不产出不可重编译源码、也不抛裸KeyError/TypeError)。childFlow/child_flow传 JSON 字符串时按发动机口径解析(非法 JSON 才报EmitError)。 - 验收测试口径见
tests/unit/test_codeflow_emit.py(round-trip 语料 + 手写 JSON IR + 负面路径)。
Agent 编排:把 flow_from_source 当输出沙箱¶
flow_from_source 是 plaita 与 LLM Agent 结合的标准入口:让 LLM 直接产出 @flow 源码,运行期编译并执行。相比让 LLM 生成可执行 Python,它有两道安全护栏——
- AST 静态校验:悬空引用、未知名字、节点调用嵌在表达式里、
if缺else都会在构建Flow前被拦掉,并报行号。 - 能力受限的表达式层:函数体不被当 Python 执行,
HTTP/CHILD/PARALLEL等只能作语句或赋值右侧,无法逃逸到任意代码。
一个典型的 Agent 规划-执行闭环:
from plaita.dsl.codeflow import flow_from_source, compile_source
def plan_and_run(user_task: str, planner_llm, tools_hint: str):
# 1. LLM 规划:根据任务 + 工具说明生成 @flow 源码
src = planner_llm.generate(prompt=build_prompt(user_task, tools_hint))
# 2. 先只编译,拿到 IR 供审计/记录
ir = compile_source(src) # 校验失败会抛 _CodeflowError,带行号
log_plan(ir) # 持久化计划,便于回放与审计
# 3. 构建 + 执行
flow = flow_from_source(src)
return flow.run(task=user_task)
校验失败时把错误回灌给 LLM 让它修复后重试,就是一个自我纠错的 Agent 规划器(类似 Agent 平台对 JSON actions 的 jsonpatch 修复循环,但作用在 @flow 源码上)。
完整的 Agent 编排模式(多步工具链、条件路由、子 Agent、并行、HITL、trace)见 应用场景 - Agent 编排。
S-表达式 DSL(高级用法)¶
S-expr 为 experimental(非一等路径)
S-expr 已冻结为一等作者体验:新节点类型与自定义节点优先落在 @flow /
FlowBuilder;sexpr 不保证与内置节点表同步,也不支持自定义节点占位符。
AI 生成与生产路径请用 flow_from_source 或 JSON/YAML。
仍保留的价值:flow_to_sexpr(data) 可逆回写,适合审查 / 实验。
plaita.dsl.sexpr 是可选前端,纯 Python 实现,无额外依赖。
from plaita.dsl.sexpr import parse_sexpr
src = """
(flow adult_check :input-type object :desc "判断成年"
(start -> check_age)
(if :id check_age (cond "$INPUT.age" >= 18) -> adult :else minor)
(end adult :output "成年")
(end minor :output "未成年"))
"""
flow = parse_sexpr(src)
flow.run(age=20) # -> "成年"
flow.run(age=15) # -> "未成年"
语法元素¶
| 写法 | 含义 |
|---|---|
(flow <id> :input-type ... :desc ... <节点>...) |
顶层 flow 定义 |
(start -> <id>) |
start 节点,-> 即 next |
(if :id <id> (cond ...) -> <then> :else <else>) |
if 节点,-> 是真分支,:else 是假分支 |
(end <id> :output <expr> :result-type error :error (dict :code 4001 :message bad)) |
end node; resultType defaults to success, error carries the error-exit payload (malformed dict literals here now raise -- see the note below) |
:timeout <dur> |
per-node timeout, accepted by every node type in the sexpr node table (19 types; see the scope note below), e.g. PT30S |
:on-error (on-error <strategy> ...) |
per-node error handler, accepted by the same 19 node types |
:on-timeout (on-timeout <strategy> ...) |
per-node timeout handler (Node.timeout_handler;#238 补齐读写两侧) |
:item-type <type> |
collection/while 的循环元素类型声明(itemType;完整 Property 走 (dict …)) |
:content-type <text> |
HTTP Content-Type 头(content_type / legacy contentType;#238) |
(while :id w :when <cond> :max-iterations N :child (childflow ...) -> <next>) |
while 节点(plaita.node.loop.While;#238 补入节点表) |
(cond <field> <op> <value>) |
单条件,op 支持 >= == != in … |
(and <cond>...) / (or ...) / (not <cond>) |
条件组 / 取反 |
(dict :k v ...) / (dict ("k" v) ...) |
dict 字面量(headers/body 用);键不能裸写成 :k 时由发射侧改走恒引号元组(plaita#148,见下) |
(list ...) |
list 字面量 |
:child (childflow ...) |
集合节点内联子流程 |
(childflow :flow-id "cf" ...) |
内联子流程自身的 flow_id(plaita#149);不写则保持缺失,不凭空造名 |
(parallel :id p :mode thread :join-branches (b1 b2) :is-conditional true (pbranch b1 :flow (childflow ...) [:input <expr>] [:when <cond>]) ... -> <next>) |
parallel 节点:(pbranch ...) 是位置参数,可重复(plaita#150,见下) |
:id <name> |
显式节点 id(被分支/$NODE 引用时需要) |
不写 :id 时自动分配 _n1/_n2/…;end/assignment 支持把第一个位置参数当 id((end adult :output "成年"))。
集合节点 / while¶
(map ...) / (filter ...) / (find ...) / (loop ...) / (reduce ...) /
(while ...) 用 :child 内联子流程。集合节点用 :item-type 声明元素类型;
while 用 :when 条件 + 可选 :max-iterations(对应 IR 的 maxIterations),
没有 :collection:
src = """
(flow double_numbers :input-type object
(start -> dbl)
(map :id dbl :collection "$INPUT.numbers"
:child (childflow :input-type object
(start -> e)
(end e :output "$F.mul($INPUT.item, 2)"))
-> end)
(end end :output "$NODE.dbl"))
"""
parse_sexpr(src).run(numbers=[1, 2, 3, 4]) # -> [2, 4, 6, 8]
内联子流程自身的 id 用 :flow-id 给出(plaita#149)。(child :id c ...) 里的
:id 已被节点自身占用,故不复用;:flow-id 与 :result-type / :event-filter
同为连字符风格,落在 (childflow ...) 形内。它对应 IR 里的
childFlow.flow_id,即 codeflow 路径 @childflow("cf") 位置参数的同一语义:
src = """
(flow with_child_id :input-type object
(start -> c)
(child :id c :input "$INPUT.payload"
:child (childflow :flow-id "cf" :input-type object
(start -> ce)
(end ce :output "$INPUT.item"))
-> end)
(end end :output "$NODE.c"))
"""
判据是双向的:compile_sexpr 读 :flow-id 写进子图 IR,flow_to_sexpr
在子图 flow_id 非空时发射该键。与 codeflow 路径一致,不给就不发射、保持缺失
(flow_id is None,不凭空造名;也不会发射成 :flow-id nil)。
switch / case¶
src = """
(flow router :input-type object
(start -> route)
(switch :id route :output-type string
(branch a :next end_a :when (cond "$INPUT.type" == "A")
:label "A 类" :desc "类型 A")
(branch b :next end_b :when (cond "$INPUT.type" == "B"))
(branch dft :next end_dft :default true))
(end end_a :output "A")
(end end_b :output "B")
(end end_dft :output "other"))
"""
http / 错误处理¶
src = """
(flow create_user :input-type object
(start -> call_api)
(http :id call_api :method POST :url "https://api.example.com/users"
:headers (dict :Content-Type "application/json")
:body (dict :name "$INPUT.name")
:timeout "PT5S"
:on-error (on-error continue_with :default (dict :data nil))
-> end)
(end end :output "$NODE.call_api.data"))
"""
http节点需要pip install plaita[http]。
Emitted syntax of flow_to_sexpr (plaita#136)¶
flow_to_sexpr(ir) is the inverse of compile_sexpr. For the flat node types
(start, assignment/assign, event, http, code, the collection nodes,
child/reference) it emits, in this order:
- the node type and
:id <id>; - the common keywords --
:timeoutand:on-error(only when the IR carries them); - the node-specific keyword arguments and positional forms;
-> <next>, with every keyword before it.
Keyword position is not what makes a field readable. _split_args scans
the whole flat argument list, so a flat :kw value pair is recognised on
either side of -> <next>: (assignment :id a 1 -> e :timeout 5) yields
timeout == 5, and (event :id ev :type t -> e :filter (dict :a 1)) yields
eventFilter == {'a': 1}. What is not a keyword is a nested list form
written outside a keyword slot -- that is collected as a positional argument.
The pre-#136 corruption came from exactly that: the handler was written as a
trailing (on-error abort :retry 2), which the reader took as the node's
output (output == ['on-error', 'abort', 'retry', 2], retryTimes reset to
0; on assignment it raised "assign only accepts one output expression").
The emitter keeps keywords before the arrow for stability -- it is the shape
maintainer 7d0add9 established for event's :filter -- not because the
reader requires it. Pinned by
TestKeywordOrderingGuard::test_flat_keyword_after_arrow_is_still_read and
::test_nested_form_outside_keyword_slot_is_not_a_keyword.
if and switch are the exception, by their own grammar: their branch
keywords are emitted after the arrow, e.g.
(if :id i (cond "$INPUT.age" >= 18) -> adult :else minor) and
(branch b -> end_b :when (cond "$INPUT.type" == "B")). That is the shape the
reader accepts; pinned by
TestKeywordOrderingGuard::test_if_and_switch_branch_keywords_follow_arrow
(the generic guard regex in the maintainer's own test only covers
start|end|event|http|assignment|code, so it never sees these two).
| IR field | Emitted form | Applies to |
|---|---|---|
timeout |
:timeout PT30S |
the sexpr node table (19 node types, see scope note below) |
errorHandler |
:on-error (on-error <strategy> [:retry N] [:default V] [:code N] [:msg S]) |
the sexpr node table (19 node types) |
timeoutHandler |
:on-timeout (on-timeout <strategy> …) (#238) |
the sexpr node table (19 node types) |
resultType |
:result-type error / :result-type nop (omitted when success) |
end |
error |
:error (dict :code 4001 :message "bad") (any dict literal; data strings are quoted; {} becomes :error (dict)) |
end |
itemType |
:item-type string (simple dataType) / :item-type (dict …) (full Property) |
collection nodes |
content_type |
:content-type "text/plain" (always quoted) |
http |
outputType |
:output-type string (full Property → (dict …)) |
switch, case, assignment |
branch label / desc |
:label "…" / :desc "…" |
switch branches |
next |
-> <id> (also on case since #238) |
all nodes |
flow_id (nested) |
:flow-id "cf" (always quoted; omitted entirely when the child has no id) |
inline childFlow ((childflow ...)) |
The nested childFlow.flow_id (plaita#149) is emitted right after the
childflow head, before :input-type, and only when non-None: a child
without an id must keep emitting the same bytes as before #149 (verified by
TestSexprChildFlowId::test_no_id_emission_is_byte_identical_to_shapeless_child
and ::test_no_id_is_absent_on_write_and_roundtrip). The id is always
quoted (:flow-id "cf", not :flow-id cf): the value is an identifier, and
the bare form would read nil / true / :x back as a different atom
(nil → None, true → True, :x → x), breaking the round-trip
depending on the id's spelling. The reader accepts both spellings
(:flow-id "cf" and :flow-id cf).
The error handler is emitted as the value of the :on-error keyword, never
as a trailing positional form (see above).
Scope: which node types :timeout / :on-error cover
"Every node type" means the 19 node types in the sexpr node table:
start, end, assignment (spelled assign or assignment), if,
switch, case, loop, map, filter, find, reduce, while,
child, reference, parallel, code, http, event, mock
(mock was added by
#146, while by #238).
Model node types with no sexpr compiler -- ignore and the non-registry
calculate / redis -- are out of scope: the writer cannot emit those
nodes at all, so :timeout never appears for them.
Behaviour change: malformed dict literals in :error now raise
The end node's :error value goes through the same _value()
dict-literal compiler as :headers / :body / :filter. A malformed
dict literal in that slot used to be accepted silently and wrapped into a
garbage message string; it now raises a clear SyntaxError:
| Source | Before #136 | After #136 |
|---|---|---|
(end e :error (dict :code)) |
error == {"message": "['dict', 'code']"} |
SyntaxError: dict 关键字 :code 后缺少值 |
(end e :error (dict :code 1 :dangling)) |
error == {"message": "['dict', 'code', 1, 'dangling']"} |
SyntaxError: dict 关键字 :dangling 后缺少值 |
Only the :error slot changed: :headers / :body / :filter already
behaved this way.
# A complete error exit, exactly as emitted:
# (end :id bad :output no :result-type error :error (dict :code 4001 :message bad))
#
# A node with a timeout and a retrying error handler, exactly as emitted:
# (http :id h :method GET :url http://x :timeout PT5S
# :on-error (on-error continue_with :retry 2 :default fallback) -> e)
Flow-level fields of (flow …) / (childflow …) (plaita#147)¶
compile_sexpr reads flow-level fields above the node level; flow_to_sexpr
must emit every one of them, or a source round-trip silently drops it. Before
147 the root writer emitted only inputType and desc, and the child writer¶
only flow_id (#149) and inputType.
| IR field | Emitted form | Where |
|---|---|---|
inputType |
:input-type object (the bare dataType; the inverse of _type_spec) |
root + child |
outputType |
:output-type string |
root + child |
desc |
:desc <text> |
root + child |
version |
:version "1.2" (always quoted) |
root |
author |
:author "me" (always quoted) |
root |
timeout |
:timeout 30 |
root |
globalContext |
:global-context (dict …) |
root |
metadata |
:metadata (dict …) |
root |
flow_id (nested) |
:flow-id "cf" (always quoted; omitted when the child has no id) |
child |
The reader's accepted spellings (its _opt(kw, …) names) and the writer's
emitted spellings are kept in lock-step by two tables in sexpr.py
(_FLOW_LEVEL_FIELDS, _CHILD_FLOW_LEVEL_FIELDS), pinned by the structural
test TestFlowLevelFieldSymmetry: a future reader-side field that the writer
does not emit turns that test red immediately.
globalContext / metadata are data fields: since #238 the reader runs
them through _value(), so (dict :env "prod") becomes the dict
{"env": "prod"} instead of the raw list ["dict", "env", "prod"]. Type
declarations with more than dataType (children / itemType / constraints) are
emitted as a full (dict …) and read back verbatim; the simple
{"dataType": "object"} form keeps the bare object spelling.
Field order follows the reader: input-type, output-type, desc, version,
author, timeout, global-context, metadata (children: flow-id first,
then the same subset). A field whose IR value is None/absent emits nothing
-- never :field nil -- and a flow/child carrying none of them is byte-identical
to its pre-#147 emission (pinned by
TestFlowLevelFieldsRoundTrip::test_absent_root_fields_emit_nothing_and_are_byte_identical_to_base
and ::test_absent_child_fields_emit_nothing_and_are_byte_identical_to_base).
version and author are str-typed in the IR and go through the same
always-quoted helper as :flow-id (_string_to_src): the bare-atom form would
read "1.2" back as a float, "true" as a bool, "nil" as None. desc
keeps the pre-#147 _expr_to_src spelling (:desc hello), so its bytes are
unchanged.
Reader-side gap fixed in #238
147 originally recorded that _compile_flow did not run¶
:metadata / :global-context through _value(), so (dict :env "prod")
landed in the IR as the raw list ["dict", "env", "prod"] and
parse_sexpr then failed Flow validation (both fields are dict-typed).
238 applies _value() on both slots: the IR now holds¶
{"env": "prod"} and the full round-trip is a dict; pinned by
TestFlowLevelFieldsRoundTrip::test_root_flow_level_fields_round_trip.
Dict keys in (dict …) (plaita#148)¶
flow_to_sexpr must never emit source that cannot be read back (the invariant
established for #76). A dict key is the sharp edge: before #148 the writer
always emitted the bare keyword form :key, so a key containing a space, (,
), " or ; produced text the reader rejects (SyntaxError) or silently
misreads ({"a b": 1} → (dict :a b 1) → ["dict", "a", "b", 1]).
The writer now picks one of two spellings, paired one-to-one with the reader's
_compile_dict_literal:
| Key | Emitted spelling | Reader |
|---|---|---|
non-empty str whose characters are all in the bare-atom set (no whitespace, (), ", ;) |
:key value |
:key entry → Keyword(key) |
every other key (space, parens, ", ;, empty string, "1" / "nil" / "true" …) |
("key" value) (tuple entry, always quoted) |
(key value) entry → _dict_entry_key → _decode_string |
The bare form is byte-identical to the pre-#148 emission for keys that were
already correct, so no existing corpus changes. The quoted form must not use
_expr_to_src's bare-atom shortcut: a bare key 1 would read back as int,
nil as None, :x as x.
Non-str scalar keys (bool / int / decimal float / None) are emitted as
tuple entries too ((1 v), (nil v), (true v)) and round-trip with their type
intact. Keys that cannot be represented faithfully — compound keys (tuple /
frozenset / bytes / …) and floats whose str() is not a reader number literal
(inf, nan, 1e20) — make flow_to_sexpr raise the named SexprEmitError
before any text is produced, rather than write corrupted source. The reader
matches this boundary: a compound tuple key raises a named SyntaxError
("dict 键必须是字符串或标量").
Interaction with #147's metadata / global-context reader gap
The reader does not run _value() over :metadata / :global-context
(see the note in the #147 section above). For dict-origin metadata, #148
therefore guarantees the weaker — but honest — property: the emitted source
re-parses without error and the key survives as a single element
({'q"x': 1} → (dict ("q\"x" 1)) → ["dict", ["q\"x", 1]], no longer a
lexical error; {"a b": 1} → ["dict", ["a b", 1]], no longer split into
["dict", "a", "b", 1]). Recovering the full dict there still needs the
reader-side _value() fix tracked with #147.
Pinned by TestDictKeySpellingRoundTrip in tests/unit/test_sexpr.py: a 24-key
corpus × 4 dict carriers (including the issue-named :error path), the
spelling boundary, base byte-identity, fail-closed, the two #147 hazard cases,
and a class-level "every emission re-parses" guard.
Node-type coverage: writer ⊇ reader (plaita#150)¶
The reader's node dispatch (_NODE_COMPILERS) and the writer
(_node_to_src) are two independent type sets. If the reader grows a type the
writer has never handled, nothing turns red -- the node type can still
round-trip while everything inside the node is dropped. Before #150 a
parallel node emitted as the hollow shell (parallel :id p): the
pbranch children, :join-branches, :is-conditional, :mode and -> next
all vanished, the recompiled graph was disconnected, and no error was raised.
This is the same rule #76 / #136 / #147 / #148 established (the emitter must
not produce source that is not a faithful program), sharpened: here the
unfaithful source does re-parse, which is why a type-level check missed it.
parallel / pbranch are now emitted in full:
| IR field | Emitted form |
|---|---|
branches[] (positional) |
(pbranch <name> :flow (childflow …) [:input <expr>] [:when <cond>]) — :input 与 :when 的值按数据发射(#220/L2):标量形状的字面字符串恒加引号,$ 开头才是表达式 |
mode |
:mode thread (omitted when absent; thread is the model default) |
joinBranches |
:join-branches (b1 b2) (uniform list spelling, so the empty join set is :join-branches ()) |
isConditional |
:is-conditional true (only when truthy -- the reader sets True for any truthy value, so false would be a lie) |
next |
-> <next> (last, like every other node) |
The (pbranch …) forms are positional arguments -- _c_parallel rejects
any positional that is not one -- so every :kw value pair precedes them, and
-> next stays last. Pinned by
TestNodeTypeRoundTripCorpus::test_parallel_node_survives_with_all_of_its_parts.
The same all-fields audit (populate every field the reader can read for a type,
then round-trip) found seven more writer-side omissions for node types the
writer did claim to handle. All are declared model fields whose loss changed
IR (and for priority, runtime behaviour):
| Node | Field | Emitted form | Why it matters |
|---|---|---|---|
switch |
node-level next |
-> <next> |
successor lost |
switch branch |
priority |
:priority 2 (only when present) |
Switch.execute matches branches in priority order -- dropping it changes which branch wins |
case match |
name |
:name "m1" |
otherwise re-read as str(value) |
assignment |
outputType |
:output-type string |
declared output type lost |
map |
maxConcurrent |
:max-concurrent 3 (only when present) |
concurrency cap lost |
code |
input |
:input <expr> (only when present) |
node input lost |
code |
sandbox_backend |
:sandbox-backend "subprocess" (always quoted; only when the IR carries it) |
per-node isolation level lost -- the round-trip silently fell back to the process default (docker), a security downgrade (plaita#220-L3) |
http |
input |
:input <expr> (only when present) |
node input lost |
end |
node-level next |
-> <next> |
successor lost |
Two deliberate "do not invent bytes" convergences keep the pre-#150 emissions
byte-identical: case's :name is emitted only when it differs from the
reader's default (str(value) -- _c_match derives the name from the
positional value when :name is absent), and every other new token only
appears when the IR actually carries the field. Measured over every
(flow …) string literal in tests/unit/** (171 compilable): shared=171,
unchanged=152, changed=19 -- the 19 are exactly the entries whose base
emission dropped a field or was a hollow parallel, with zero unrelated byte
drift.
code's sandbox_backend was found later (plaita#220, face L3, the same
"populate every field, then round-trip" audit) and follows the same rule: no
keyword when absent, and the value is only ever emitted as a quoted string
(_string_to_src) -- true / 1 / nil written bare would re-read as
bool / int / None. CodeNode.execute reads the attribute directly (no
expression evaluation), so both sides fail closed on anything that is not a
non-empty string literal instead of silently falling back to the default
isolation level. Pinned by tests/unit/test_issue220_sexpr_code_sandbox.py.
The issue's "21 reader types" are 18 dispatch keys + 3 non-nodes
150 reported "the reader accepts 21 types". That count came from including¶
three keys that are not node types and have no _NODE_COMPILERS entry:
flow (the top-level (flow <id> …) form name, enforced by
_compile_flow), runtime (a child-flow IR field, hard-coded by
_compile_childflow as {"runtime": "python"}) and flow_id (a flow-level
field name, read as a positional argument, see the #147/#149 sections).
The real dispatch table has 18 keys = 17 node implementations + 1 spelling
alias (assign and assignment both point at _c_assignment). Pinned by
TestWriterNodeTypeSymmetry::test_issue_150_count_is_18_reader_keys_plus_3_non_nodes.
The structural guard the maintainer asked for is
TestWriterNodeTypeSymmetry: it parses sexpr.py with ast, collects the
_NODE_COMPILERS literal and the t == "x" / t in (…) discriminants of
_node_to_src, groups the reader by handler implementation (so the
assign/assignment alias is not mistaken for an uncovered type) and asserts
writer ⊇ reader. A future reader-side type that the writer does not emit
turns it red immediately. It also asserts the reverse (the writer never emits a
type the reader rejects), pins the discriminant (t = n.get("type")) so the
guard cannot silently no-op, and carries an explicit -- currently empty --
exemption table (_WRITER_NODE_TYPE_EXCLUSIONS) so an intentional omission has
to be written down with a reason.
Residuals (measured, deliberately out of scope here)
- Value-side
_expr_to_srclossiness is unchanged: a numeric-looking string value such as(code :code "1")still re-reads as the int1(the round-trip corpus therefore uses"return 1"). A branch/match name colliding with an atom literal (nil/true/1.5) shares this residual; thecasewriter sidesteps it by using_string_to_src, theswitchbranch-name spelling is left exactly as base for byte-identity. - The reader-side
_value()gap for:metadata/:global-contextrecorded in the #147 section above is untouched. parallelbranches outsidejoinBranches(fire-and-forget) have no separate IR spelling; that is the reader's shape, not a writer gap.
静态校验与可逆¶
parse_sexpr 在构建 Flow 前先跑静态校验,把常见反模式提前拦掉:
| 反模式 | 报错 |
|---|---|
节点 id 重复 |
节点 id 重复: [...] |
-> 指向不存在的节点 |
节点 'x' 的 next 指向不存在的节点 id 'y' |
if 缺 :else |
if 节点 'x' 缺少假分支目标(else_next) |
switch 缺 :default |
switch 节点 'x' 缺少 :default 分支 |
只编译不构建:compile_sexpr(src) 返回 IR dict;反编译:flow_to_sexpr(data) 把 IR dict 转回 S-expr 源码,双向互转,便于审查与生成器回写。
from plaita.dsl.sexpr import compile_sexpr, flow_to_sexpr
d = compile_sexpr(src)
print(flow_to_sexpr(d)) # 回到可读的 S-expr
对比:@flow vs S-expr vs Builder¶
| 维度 | Builder(plaita.dsl) |
S-expr(sexpr) |
@flow(codeflow) |
|---|---|---|---|
| 写法 | .add(if_(...)).add(...) 链 |
(if :id ... (cond ...) -> ...) |
纯 Python 函数 |
| 表达式 | 字符串 "$INPUT.age" |
字符串 "$INPUT.age" |
真 Python INPUT.age |
| 条件 | cond("$INPUT.age", ">=", 18) |
(cond "$INPUT.age" >= 18) |
INPUT.age >= 18 |
| 拓扑 | 显式 next / linear 隐式 |
显式 -> / :else |
由 if/for/return 推导 |
| IDE 补全 | 好(关键字参数) | 一般 | 最好(就是 Python) |
| AI 生成友好度 | 中 | 高(结构清晰) | 最高(语言模型最熟) |
| 可逆 | 可导出 JSON/YAML | flow_to_sexpr 双向 |
compile_func 出 IR |
| 构建期校验 | ✅ | ✅ | ✅(带行号) |
| 动态修改流程 | ✅(最适合) | — | — |
| 运行期生成 | — | compile_sexpr(str) |
flow_from_source(str) |
| 推荐场景 | 程序化构建/修改流程 | 可逆回写、结构可审查 | AI 生成、Python 手写 |
已知边界¶
reduce:子流程输入命名为first(累积值)和second(当前元素)。可选initial指定初始值(注意:0/[]/""这类 falsy 值也是有效初始值)。@flow函数须定义在模块级:inspect.getsource才能取到源码做 AST 编译(函数体内定义的局部函数无法编译)。运行期动态生成请改用flow_from_source(src),它直接解析字符串,无源文件依赖。- 节点调用位置受限:
HTTP/CODE/EVENT/CHILD/REFERENCE/PARALLEL/ 集合调用 / 自定义节点(LLM/RETRIEVE/... 等大写占位符) 只能作为语句或赋值右侧,不能嵌在表达式里(如return HTTP.post(...)需拆成resp = HTTP.post(...); return resp.data)。 - 自定义节点字段类型:
@flow现在支持直接调用注册的自定义Node子类(占位符名 =node_type大写)。但 pydantic 字段若是int/bool等强类型,传表达式串会被拒——接表达式的字段声明成Optional[Any]/Optional[str],详见上文「自定义节点」。 str(x)是近似映射:编译成$F.concat(x)("".join(str(a) for a in args)),单参与str()等价,但语义是"拼成字符串"而非"类型转换"。+多态、编译期不查类型:a + b编译成$F.add(a, b)即 Pythona + b,对 int/str/list 按 Python+各自语义;要强制字符串拼接请显式用F.concat(...)。完整的支持/不支持写法与精确语义见上文「表达式语义边界」。
往返 conformance(#238)¶
DSL 往返保真从「逐 issue 补丁」升级为 registry 驱动的系统性 conformance:
tests/unit/test_issue238_dsl_conformance.py 以 NodeRegistry 为权威清单,
对每个节点类型 × 每个 model_fields 字段 × 两条链(JSON → @flow → JSON
与 JSON → sexpr → JSON)逐格断言,并在 CI 里单独成步(也随 unit 分块执行)。
- 新增字段必须登记:registry 的节点类多出一个
model_field而矩阵没有 对应条目时,test_every_registry_field_is_classified直接红——新字段不会 再成为下一个静默丢字段面。 - 每个格子有状态:
OK(必须逐值往返)、LOST(链无法表达,fixture 里 给出非默认值并反向断言仍丢失——将来补上支持而不更新矩阵也会红)、INERT(模型有该字段但本节点类型无运行期/作者面)、DERIVED(validator 合成或编译期派生注解)、TYPE_LOST(整个类型在该链不可表达,发射必须 fail-closed)。 - 已知不可表达项显式登记:@flow 的
switch/case(无语句形态)、start/if的基类timeout/handler(语句位不存在)、命名引用reference.flow_id/flow_version(调度器注入形状)、集合节点的input与非输出承载节点的output(引擎不读)等,每格都有原因说明。 - 归一化比较的口径:剥
name/desc/source_line派生注解、统一 snake/camel 拼写、按图序规范化非语义 id($NODE.引用到的 id 保持原名)。
API 速查¶
| 入口 | 作用 |
|---|---|
plaita.dsl.codeflow.flow(flow_id, ...) |
装饰器:Python 函数 → Flow(需模块级源文件) |
plaita.dsl.codeflow.flow_from_source(src, ...) |
源码字符串 → Flow(运行期生成,无源文件依赖) |
plaita.dsl.codeflow.compile_source(src, ...) |
源码字符串 → IR dict(不构建) |
plaita.dsl.codeflow.childflow(flow_id, *, desc=...) |
装饰器:子流程函数(flow_id 可省;会写进执行上下文,见 §子流程 @childflow + CHILD) |
plaita.dsl.codeflow.compile_func(fn, flow_id) |
Python 函数 → IR dict(不构建) |
plaita.dsl.codeflow.HTTP / CODE / EVENT / MAP / FILTER / FIND / LOOP / REDUCE / CHILD / REFERENCE / PARALLEL / ASSIGN |
内置节点调用占位符 |
| 自定义节点占位符 | node_type 大写(如 LLM/RETRIEVE/TOOL),须先 get_default_registry().register(NodeSubclass) |
plaita.dsl.codeflow.F / INPUT / NODE / GLOBAL / PARENT / ENV |
命名空间占位符 |
plaita.dsl.codeflow.ErrorHandler(...) |
错误处理策略构造器 |
plaita.dsl.sexpr.parse_sexpr(src) |
S-expr 源码 → Flow(含静态校验) |
plaita.dsl.sexpr.compile_sexpr(src) |
S-expr 源码 → IR dict(不构建) |
plaita.dsl.sexpr.flow_to_sexpr(data) |
IR dict → S-expr 源码(反编译) |