Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(expr): $NODE/$GLOBAL 里含 {% ... %} 的数据字符串被按父容器当上下文二次求值——引用该字段即抛误导性 KeyError 或静默串值(A4 告警只覆盖未匹配分支)

Closed
#104 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
52/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
python
Domain
backend

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 都没有。

  1. 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
  1. 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
  1. 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] + "...",
    )
  1. 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)
  1. 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
  1. 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}
  1. 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"
        }
  1. 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)}"
  1. 复现 A(实跑,/tmp/plaita-audit-venv/bin/python,Python 3.12,plaita editable 安装)——
    $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'
  1. 复现 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)"
  1. 复现 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>,凡路径走到
    字符串字段处都同规则。
  • 三种代价:
    1. 误导性报错 + 流程中断:KeyError: '$INPUT' not found in expression context. Available roots ...
      把作者推向「$INPUT 拼写 / start 节点输入」方向排查,实际根因是某个 LLM 正文里的 {%。
      默认 errorHandler 下节点直接 abort(证据 8),整条 flow 以错误收尾,重试同样失败。
    2. 静默串值 / 串上下文:父容器里恰好有同名根键时(例如节点输出里也带了 $INPUT 快照,
      或字段名本身就叫 $INPUT——tests/unit/test_expression_cache.py:112 就是这个形态),求值不报错,
      下游拿到的是父容器的值而不是顶层上下文的值(复现 C:PARENT-VALUE vs TOP-LEVEL-VALUE);
      {% $F.eq(1,1) %} 这类则把业务文本直接变成 'True'。返回值里看不出发生过求值。
    3. 不可观测:告警只在「{% 零匹配」时触发(证据 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> 时该字段的
      字符串值被当作表达式二次求值」、父对象里有哪些根键可用。
  • 最小修法(一处):把 :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 不得抛出「$INPUT not 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from jeffkit/plaita

All issues in jeffkit/plaita

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.