fix(expr): $NODE/$GLOBAL 里含 {% ... %} 的数据字符串被按父容器当上下文二次求值——引用该字段即抛误导性 KeyError 或静默串值(A4 告警只覆盖未匹配分支)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 52/100
Research direction
Start in plaita/core/expression_parser.py at the path-recursion branch (around lines 406-415), where a string attribute that contains {% is re-evaluated against the parent object. Reproduce with the $NODE.llm.text snippet in the issue, then run tests/unit/test_expression_parser_path_recursion.py and tests/unit/test_expression_cache.py. Done means the data text is no longer evaluated as a template or raises the misleading KeyError, the existing nested-expression tests still pass, and the design choice between the narrowing approach and the logging approach is settled.
Written by the indexing model from the issue text.
Description
优先级 P2 · 依赖:无
基线 HEAD = 65f48f2
Summary
plaita/core/expression_parser.py 的变量路径求值在走到字符串属性值时,会把该字符串当作表达式,
并以持有它的父容器(不是流程根上下文)为 context 递归求值。于是只要 $NODE.<id> 的结果里
某个字段是含 {% ... %} 的数据文本(LLM/agent 输出原文、文件内容、prompt 回显),下游
$NODE.<id>.<field> 就只有两种结局:求值失败 → 抛出归因错误的 KeyError(说某个根键不存在,
而它明明在顶层上下文里);求值成功 → 静默返回被改写后的值(模板文本变成 'True' 之类)。
{% 的告警只在「零匹配」分支触发,本条路径是「匹配成功但求值成功/失败」,一条 WARNING 都没有。
plaita/core/expression_parser.py:406-415—— 递归求值点:attr是字符串属性值时,把父对象obj
(而不是根 context)交给parser.evaluate当上下文:
attr = _get_attr(obj, target)
# 仅当字符串属性值本身是表达式($ 前缀变量 / {% %} 模板)时才递归
# 求值——这是"嵌套表达式字符串"的历史语义。任意普通字符串(可能含
# [tag]、引号、换行等元字符)不再二次解析,否则节点输出一旦被下游
# $NODE 路径引用就会因内容触发误解析(如 "[promo] ..." 被当列表)。
if (isinstance(attr, str)
and (attr.startswith(prefix) or "{%" in attr)):
obj = parser.evaluate(attr, obj, registry)
else:
obj = attr
return obj
plaita/core/expression_parser.py:386-396—— 失败时抛的KeyError文案把矛头指向「上下文里没有这个
根键」,而真实原因是「刚才那个字符串不是表达式、父容器里也没有它引用的根」:
try:
obj = context[root_key]
except KeyError:
# 保留 KeyError 类型(语义:流程逻辑错误),但给出可用根清单——
# 历史上裸 KeyError: '$node' 让 AI 作者无从自纠
# (2026-09 LLM 作者模拟 P1-2)。
raise KeyError(
f"{root_key!r} not found in expression context. "
f"Available roots (with {parser.prefix!r} prefix): "
"INPUT, NODE, GLOBAL, PARENT, ENV, FLOW_ID, FLOW(alias of FLOW_ID)"
) from None
plaita/core/expression_parser.py:573-578与:104-115——{%的告警只在「一个插值都没匹配上」
时触发;本条是匹配成功、内层表达式正常求值(成功或抛错),守卫完全不覆盖:
segs = self._cached_compile(value, self._compile_template)
if not any(seg_thunk is not None for _, _, seg_thunk in segs):
# 含 `{%` 但没有任何插值匹配成功(内层表达式不合法/未闭合)——
# 保持原样返回(不抛错,避免破坏存量语义),补告警留痕。
_warn_unmatched_template(value)
return value
def _warn_unmatched_template(value: str) -> None:
if value in _warned_unmatched_templates:
return
if len(_warned_unmatched_templates) > 512:
_warned_unmatched_templates.clear()
_warned_unmatched_templates.add(value)
logger.warning(
"expression %r contains '{%%' but no valid {%% ... %%} expression matched "
"(inner expression is malformed or unclosed); it was returned AS-IS without "
"evaluation — fix the inner expression (e.g. {%% $INPUT.x %%}).",
value if len(value) < 120 else value[:117] + "...",
)
plaita/core/expression_parser.py:21-24—— 模块 docstring 把「用父对象当上下文」写成有意的历史
语义,所以本条不是「递归」本身引入的回归,而是这套语义对数据文本缺少边界与可见性:
* root variable lookup raises ``KeyError`` for a missing context key
* intermediate field accesses recurse through ``evaluate`` with the **parent
object** as the context (so nested expression strings resolve against the
parent, not the root)
plaita/core/state.py:516-521——$NODE的形状是node_id → 节点返回值本身(不包装、不拷贝),所以
「父对象」就是节点返回值那个 dict;$NODE.<id>.<field>正是文档化的取值方式:
def update_node_result(self, node_id: str, result: Any) -> None:
"""Record a node's result under ``$NODE`` (creating the map if absent)."""
key = f"{self.prefix}{self.node_name}"
node_results = self.get(key, {})
node_results[node_id] = result
self[key] = node_results
plaita-nodes/src/plaita_nodes/llm.py:142—— 触发本条的数据源在真实流程里很常见:LLM 节点把模型
原文原样放进text字段(同形的还有agent_run.py:479/:657的 agentrun):
return {"text": text, "model": model,
"usage": data.get("usage"), "dry_run": False}
docs-site/docs/scenarios/sandbox-coding.md:115-119—— 仓内自带场景就是这么取值的(fix节点 prompt
模板里引用$NODE.plan.text,而plan是上一步 agentrun 的模型/agent 原文):
{
"type": "agentrun", "id": "fix", "agent": "coder", "workspace": "main",
"prompt": "按计划修复,跑通测试后 git push 出活:{% $NODE.plan.text %}",
"next": "end"
}
plaita/core/runner.py:315-317+:134-145—— 求值抛出的KeyError被节点错误分发接住,默认
(无errorHandler)走 abort:节点报NodeExecutionError、flow 以错误收尾,作者看到的是
执行节点<name>出错了: KeyError: ...:
except (Exception, NodeException) as e:
if attempt == max_retries:
return _handle_node_error(flow, node, error_handler, e)
if not error_handler or strategy == ErrorStrategy.ABORT:
# abort:紧接着 raise,完整 traceback 由异常本身携带——这里再打一份
# exc_info 是双份噪音,故只留 raise。
code = DEFAULT_NODE_ABORT_CODE if not error_handler else error_handler.error_code
message = f"执行节点{node.name or node.id}出错了: {type(e).__name__}: {e}{_node_loc_suffix(node)}"
- 复现 A(实跑,
/tmp/plaita-audit-venv/bin/python,Python 3.12,plaitaeditable 安装)——
$NODE.llm.text崩,而同一个字符串在顶层直接求值是正常的:
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita.core.expression_parser import ExpressionParser
p = ExpressionParser.for_prefix('$')
ctx = {'$NODE': {'llm': {'text': 'see {% $INPUT.secret %} here'}}, '$INPUT': {'secret': 'S3CRET'}}
try:
print('via $NODE:', repr(p.evaluate('$NODE.llm.text', ctx)))
except Exception as e:
print('EXC', type(e).__name__, e)
print('direct template:', repr(p.evaluate('see {% $INPUT.secret %} here', ctx)))
ctx2 = {'$NODE': {'llm': {'text': '{% $F.eq(1,1) %}'}}}
print('silent rewrite:', repr(p.evaluate('$NODE.llm.text', ctx2)))
EOF
--- 输出 ---
EXC KeyError "'$INPUT' not found in expression context. Available roots (with '$' prefix): INPUT, NODE, GLOBAL, PARENT, ENV, FLOW_ID, FLOW(alias of FLOW_ID)"
direct template: 'see S3CRET here'
silent rewrite: 'True'
- 复现 B(实跑)—— 换成
docs-site/docs/scenarios/sandbox-coding.md:117的真实形态(prompt 模板引用
$NODE.plan.text,而 plan 文本里含{% ... %}):整条 flow 死于「$INPUT不存在」,但
$INPUT.issue就在上下文里:
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita.core.expression_parser import ExpressionParser
p = ExpressionParser.for_prefix('$')
ctx = {'$INPUT': {'issue': '#7'},
'$NODE': {'plan': {'text': '计划:先改 {% $INPUT.issue %} 的调用点', 'cli': 'claude'}}}
try:
print(repr(p.evaluate('按计划修复,跑通测试后 git push 出活:{% $NODE.plan.text %}', ctx)))
except Exception as e:
print('EXC', type(e).__name__, e)
EOF
--- 输出 ---
EXC KeyError "'$INPUT' not found in expression context. Available roots (with '$' prefix): INPUT, NODE, GLOBAL, PARENT, ENV, FLOW_ID, FLOW(alias of FLOW_ID)"
- 复现 C(实跑)—— 父容器里恰好有同名根键时不报错,静默取到父容器的值;并且全程零 WARNING
(只有一条来自parse_function的 DEBUG):
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
import logging
from plaita.core.expression_parser import ExpressionParser
from plaita.logger import logger
records = []
class Cap(logging.Handler):
def emit(self, r): records.append((r.levelname, r.getMessage()[:90]))
logger.addHandler(Cap()); logger.setLevel(logging.DEBUG)
p = ExpressionParser.for_prefix('$')
ctx = {'$NODE': {'llm': {'text': '{% $INPUT.secret %}', '$INPUT': {'secret': 'PARENT-VALUE'}}},
'$INPUT': {'secret': 'TOP-LEVEL-VALUE'}}
print('$INPUT.secret ->', repr(p.evaluate('$INPUT.secret', ctx)))
print('$NODE.llm.text ->', repr(p.evaluate('$NODE.llm.text', ctx)))
ctx2 = {'$NODE': {'llm': {'text': '{% $F.eq(1,1) %}'}}}
print('silent rewrite ->', repr(p.evaluate('$NODE.llm.text', ctx2)))
print('warnings captured:', records)
EOF
--- 输出 ---
$INPUT.secret -> 'TOP-LEVEL-VALUE'
$NODE.llm.text -> 'PARENT-VALUE'
silent rewrite -> 'True'
warnings captured: [('DEBUG', 'parse_function: func_name=eq, args=[1, 1]')]
查重:gh issue list -R jeffkit/plaita --state all --limit 100(全量 33 条)逐条比对,
无表达式模板 / 嵌套求值 / {% 相关条目。
影响
- 谁踩到:任何把含
{%(或$前缀)的文本写进$NODE/$GLOBAL的上游节点之后的链路。
生产上最典型的是llm(证据 6)与agentrun——模型/编码 agent 的输出里出现{% ... %}
很常见(模板教程、Jinja 片段、plaita 自身 prompt 示例的回显),而$NODE.<id>.text是仓内文档
场景的写法(证据 7)。同一个 thunk 也服务$GLOBAL.<field>/$PARENT.<field>,凡路径走到
字符串字段处都同规则。 - 三种代价:
- 误导性报错 + 流程中断:
KeyError: '$INPUT' not found in expression context. Available roots ...
把作者推向「$INPUT拼写 / start 节点输入」方向排查,实际根因是某个 LLM 正文里的{%。
默认 errorHandler 下节点直接 abort(证据 8),整条 flow 以错误收尾,重试同样失败。 - 静默串值 / 串上下文:父容器里恰好有同名根键时(例如节点输出里也带了
$INPUT快照,
或字段名本身就叫$INPUT——tests/unit/test_expression_cache.py:112就是这个形态),求值不报错,
下游拿到的是父容器的值而不是顶层上下文的值(复现 C:PARENT-VALUEvsTOP-LEVEL-VALUE);
{% $F.eq(1,1) %}这类则把业务文本直接变成'True'。返回值里看不出发生过求值。 - 不可观测:告警只在「
{%零匹配」时触发(证据 3),本条路径一条 WARNING 都没有(复现 C)。
- 误导性报错 + 流程中断:
- 可达性:核心求值器层,任何走表达式的部署形态都可达,无部署档位前置条件;也不需要作者写任何
「危险表达式」——只要 LLM 输出里出现一次{%即可。没有开关/白名单能关闭这类隐式求值。 - 不修的后果:「节点输出就是数据」这条基本假设不成立——数据文本里的一个
{%既能让整条 flow 带着
错误归因的KeyError死掉,也能让下游静默消费到别的容器的值,且事后从日志里查不到线索。
建议
- 修复方向(
plaita/core/expression_parser.py的thunk,:411-413 递归分支):- 方案 A(推荐,收窄递归到「父容器能支撑这次求值」):递归前对
attr做一次轻量根键扫描,
取出它引用的$INPUT/$NODE/$GLOBAL/$PARENT/$ENV/$FLOW_ID;只有当这些根键
全部存在于父对象obj时才递归求值,否则按普通数据原样返回。这样
tests/unit/test_expression_parser_path_recursion.py:19-22({% $F.concat(...) %},不引用根键)
与tests/unit/test_expression_cache.py:104-135(父对象自带$INPUT/$GLOBAL)保持通过,
而$NODE.llm.text这类数据文本不再变'True'也不再崩。注意这是「从抛错改为按数据返回」的
可见语义变化,需在MIGRATION.md/ CHANGELOG 里写明。 - 方案 B(最小、只改可见性):保留现有递归,但任何隐式递归都留痕——沿用
_warn_unmatched_templates的「同串只告警一次」口径,新增一条 WARNING,带上触发求值的完整路径与
字段值摘要,让静默串值可查。 - 无论选哪个::392-396 的
KeyError都要补上归因——说明是「解析$NODE.<id>.<field>时该字段的
字符串值被当作表达式二次求值」、父对象里有哪些根键可用。
- 方案 A(推荐,收窄递归到「父容器能支撑这次求值」):递归前对
- 最小修法(一处):把 :413 的
parser.evaluate(attr, obj, registry)包一层try/except,在
raise KeyError前补上「哪个路径段、哪个字段、父对象可用根键」的信息即可止血掉最贵的排查成本;
方案 A 的判定再叠加在这处。 - 验收条件(可执行、可断言):
- 在
tests/unit/test_expression_parser_path_recursion.py新增用例:
{"$NODE": {"llm": {"text": "see {% $INPUT.secret %} here"}}, "$INPUT": {"secret": "S3CRET"}}
上求值$NODE.llm.text不得抛出「$INPUTnot found in expression context」的KeyError;
按方案 A 应返回该字符串原文。 - 断言
{"$NODE": {"llm": {"text": "{% $F.eq(1,1) %}"}}}上$NODE.llm.text != "True";
若采用方案 B,则用caplog断言产生了「字段值被二次求值」的 WARNING 且返回原文。 - 回归不得误伤:
tests/unit/test_expression_cache.py::test_nested_attr_uses_parent_context_on_hit、
::test_nested_recursion_registry_passthrough、::test_nested_recursion_tracks_changing_parent
与tests/unit/test_expression_parser_path_recursion.py::test_template_like_attr_still_parsed
必须继续通过;tests/unit/test_expression_golden.py与make coverage门禁不变红。
- 在
边界
- 不改
expression_parser.py:21-24记载的「嵌套表达式字符串用父对象求值」本身:那是被
tests/unit/test_expression_cache.py:104-135锁定的历史语义(形如$INPUT.name的值就是
"$NODE.first"的显式用法)。本单只处理数据文本被隐式当表达式与报错归因这两件事。 - 不改
:407-410已经收窄过的「普通字符串不再二次解析」——那部分([promo] ...等)已修好,
本条是同一处代码里{%分支的漏网情形。同一行的attr.startswith(prefix)分支($前缀数据串)
不在本单范围。 - 不扩大范围:不引入通用配额/沙箱,不改
_warn_suspicious_literal的特征集,不重构表达式编译缓存,
也不处理「LLM 输出诱导模板求值」的更高层议题(模板求值不引入新能力,函数注册表未变)。 - 与相邻 issue 的差异:
gh issue list -R jeffkit/plaita --state all --limit 100共 33 条,无同根条目。
#23(已关闭,worker XCLAIM 重派导致双跑)在调度层,#29(已关闭)/#30(已关闭)/#39(已关闭)在节点安全与 env 层,
#31在 HTTP 节点层,均不涉及表达式模板求值;本单的触发点在core/expression_parser.py的
路径递归,修复面不重叠,故非重复。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 33m
- Merged PRs (30d)
- 9
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/plaita
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
jeffkit/plaita#331 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
jeffkit/plaita#330 · 1 comment ·
Maintainers usually reply within 1 day
-
keeper-ignore
Difficulty 5/5 Over a week Newbie friendliness 22/100
jeffkit/plaita#329 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
jeffkit/plaita#327 · 2 comments ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 1/5 1-3 hours Newbie friendliness 85/100
WLAN-Pi/wlanpi-profiler#341 ·
Maintainers usually reply within 1 day
-
Memory leak: the shared `validation_aborted_error` keeps every traceback it is raised throughPossibly taken @Fire162 claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
graphql-python/graphql-core#287 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
roman-corgi/ctc-dip#160 ·
Maintainers usually reply within 1 day
-
type-bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
render_findings.py reads and writes in the host's default encoding, so findings.html breaks on a cp1252 Windows hostPossibly taken @Aaqibhafeezkhan claimed this today. Openbug good first issue mentored python size: XS up-for-grabs
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
dheerajjha/mcp-migrate#366 · 1 comment ·
Maintainers usually reply within 1 day