代码节点的变量不再带着 print 的换行:一个变量不该有两种判定

运行 d47bfcb03dc747658c2d63f8755992b9 交出来的是自相矛盾的结果——
"已重试 2 次仍未成功,流程终止",而最后输出是 {"ok": true, "result":
"1+1 = 2"}。看着像运行失败了,其实 status=succeeded。"已重试 2 次"是图里
写死的文案,跟实际跑了几轮、跟成败都无关。

真正的毛病在事件流里:judge 节点(code)的 vars 是 {"ok": "ok\n"}。
assign_to 拿的是**原始 stdout**,而 print() 一定补一个换行,于是
`print('ok')` 写进变量的是 "ok\n"。后果是同一个变量、同一个表达式被两个
控制节点判出相反结论:

    loop  retry          vars.ok == 'fail'  → False  循环正常退出(看着像成功)
    branch check_outcome vars.ok == 'ok'    → False  所有条件都不满足 → default → 失败

这不是个例,是代码节点的默认用法:print 是它唯一的产出通道,写完再比字符串
是标准套路,所以只要这么写就必然踩上,而且**静默**——没有一个地方会说
"这个值多了个换行"。

同一个函数里本来就为了"stdout 是不是 JSON"算过一次 strip(),只是没拿它当值
用。现在统一成一份:

- payload["text"] 和 assign_to 都用去掉尾部空白的文本。只削尾不削头:首行的
  缩进是内容(逐行打印一段缩进文本时削掉就毁了),而尾部换行是 print 的产物
- payload["stdout"] 保留原始输出——审计"到底跑了什么"只能看它
- stdout 是 JSON 时仍解析成对象,既有约定不变
- {{ vars.x }} 和 {{ nodes.x.text }} 必须是同一个值,两种写法给不同的值比
  原来的毛病更难查,所以一起改

顺带在 Copilot 的 NODE_REFERENCE 里写明代码节点的输出契约(尾换行已去掉;
stdout 是 JSON 就变对象,那时要用字段引用而不是比字符串),否则模型会继续
生成下一个变种。

新增 tests/test_code_output.py:五条,覆盖尾换行、多行内容不被削坏、JSON
仍解析成对象、text 与 vars 一致、纯空白输出归一成空串。修之前红四条。

验证做过变异:把改动还原,同一张图回到"走了失败分支",而 print('ok',
end='') 仍是成功分支——换行就是原因。把用户那张图原样重跑,judge 的 vars
是 {"ok": "ok"},check_outcome 命中 "vars.ok == 'ok'" 走成功出口。

290 passed。
This commit is contained in:
ThearchyHelios
2026-09-24 23:15:19 +08:00
parent 75be5f485e
commit 7fb2976f29
3 changed files with 128 additions and 5 deletions
+3
View File
@@ -120,6 +120,9 @@ NODE_REFERENCE = """\
- supervisor:多 agent 协作。config: {goal, agents:[{name, description, system, tools, model}], max_rounds}
- tool:直接调一个工具。config: {tool: 工具名, args: {...}, assign_to}
- code:沙箱里跑代码。config: {language: python|bash|node, code, timeout, network, assign_to}
assign_to 拿到的是 stdout(尾部的换行已去掉)。**stdout 是 JSON 时会解析成对象**,
这时下游要用 {{ vars.x.字段 }} 取字段,拿它比字符串永远不成立。要判一个简单结论,
就 print 一个短字符串(比如 print('ok'))并让下游比它
- branch:条件分支。config: {mode: expression|llm, cases:[{key, condition, label}]}
- loop:循环。config: {mode: foreach|while, items, item_var, condition, max_iterations}
- retrieve:知识库检索。config: {query, collection, limit, assign_to}
+20 -5
View File
@@ -180,19 +180,34 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
)
payload = result.model_dump()
# 节点的"文本输出"要去掉 print() 补的那个换行。
#
# 代码节点只有 print 一条产出通道,而 print / echo / console.log 一定会补
# 一个换行——它是传输的产物,不是值的一部分。以前 assign_to 直接给原始
# stdout,于是 `print('ok')` 写进变量的是 "ok\n":下游 `== 'ok'` 永远不
# 成立,而 `== 'fail'` 也不成立。同一个变量、同一个表达式,循环条件和分支
# 条件得出相反的结论——循环正常退出(看着像成功),分支掉进 default(判成
# 失败),最后交出一份自相矛盾的结果。运行 d47bfcb0 就是这么回事。
#
# 只削尾不削头:首行的缩进是内容(比如逐行打印一段缩进文本),削掉就毁了。
# 原始 stdout 仍在 payload["stdout"] 里,要查"到底跑了什么"看那个。
text = result.stdout.rstrip()
# 如果 stdout 是 JSON,顺手解析出来,下游就能直接用字段而不是再写解析
parsed: Any = None
text = result.stdout.strip()
if text.startswith(("{", "[")):
sniff = result.stdout.strip()
if sniff.startswith(("{", "[")):
try:
parsed = json.loads(text)
parsed = json.loads(sniff)
except json.JSONDecodeError:
parsed = None
payload["data"] = parsed
payload["text"] = result.stdout
payload["text"] = text
updates: dict[str, Any] = {"nodes": {ctx.node.id: payload}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: parsed if parsed is not None else result.stdout}
# {{ vars.x }} 和 {{ nodes.x.text }} 必须是同一个值:两种写法给不同的
# 值比原来的毛病更难查
updates["vars"] = {var_name: parsed if parsed is not None else text}
return updates
+105
View File
@@ -0,0 +1,105 @@
"""代码节点的输出契约。
`print()` 是代码节点唯一的产出通道,而 print 一定会补一个换行。以前
`assign_to` 拿到的是**原始 stdout**,于是 `print('ok')` 写进去的是 `"ok\n"`:
分支条件 vars.verdict == 'ok' → False(走了 default)
循环条件 vars.verdict == 'fail' → False(循环正常退出)
同一个变量、同一个表达式,两个控制节点得出相反的结论——循环那边看着是
"成功了",分支那边判成"失败",最后交出来的是一份自相矛盾的结果。这不是
个例:只要有人写 `print(x)` 再拿它比字符串,就必然踩上。
真实用例见运行 d47bfcb03dc747658c2d63f8755992b9:agent 明明返回了
`{"ok": true, "result": "1+1 = 2"}`,judge 节点也 print 出了 ok,
流程却走到"已重试 2 次仍未成功,流程终止"。
"""
from __future__ import annotations
import asyncio
from app.engine.context import NodeContext, RunContext
from app.engine.nodes.tools import run_code
from app.engine.schema import GraphNode, GraphSpec
from app.sandbox.base import ExecResult
class _FakeSandbox:
"""只把预设的 ExecResult 递回来,不真起沙箱。"""
def __init__(self, result: ExecResult) -> None:
self.result = result
async def run(self, code, **kwargs): # noqa: ANN001, ANN003, ANN201
self.calls = (code, kwargs)
return self.result
def _ctx(monkeypatch, result: ExecResult, **config) -> NodeContext:
import app.engine.nodes.tools as tools
monkeypatch.setattr(tools, "sandbox_manager", _FakeSandbox(result))
node = GraphNode(
id="judge", type="code",
data={"label": "判定", "config": {"language": "python", "code": "print('ok')",
"isolation": "fast", **config}},
)
spec = GraphSpec(nodes=[node], edges=[])
return NodeContext(node=node, run=RunContext(run_id="r1", thread_id="t1", spec=spec))
def _run(state, ctx) -> dict:
return asyncio.run(run_code(state, ctx))
def test_assigned_variable_has_no_print_newline(monkeypatch) -> None:
"""print('ok') 之后,变量里就该是 'ok',不是 'ok\\n'。
这一条是 d47bfcb0 那个自相矛盾结果的直接原因:分支比 'ok' 比不中,
于是从"重试成功"的图里走出了"重试失败"的结局。
"""
ctx = _ctx(monkeypatch, ExecResult(stdout="ok\n"), assign_to="verdict")
updates = _run({"vars": {}}, ctx)
assert updates["vars"]["verdict"] == "ok"
# 分支/循环的条件表达式就是拿它比的,两边必须一致
assert (updates["vars"]["verdict"] == "ok") is True
def test_assigned_variable_is_trimmed_but_keeps_inner_lines(monkeypatch) -> None:
"""只削掉首尾的空白,多行输出里面的换行是内容,不能动。"""
ctx = _ctx(monkeypatch, ExecResult(stdout="第一行\n第二行\n"), assign_to="report")
updates = _run({"vars": {}}, ctx)
assert updates["vars"]["report"] == "第一行\n第二行"
def test_json_stdout_still_becomes_a_parsed_object(monkeypatch) -> None:
"""stdout 是 JSON 时仍然解析成对象——这是"下游直接用字段"的既有约定。"""
ctx = _ctx(monkeypatch, ExecResult(stdout='{"ok": true, "n": 2}\n'), assign_to="data")
updates = _run({"vars": {}}, ctx)
assert updates["vars"]["data"] == {"ok": True, "n": 2}
def test_node_text_matches_the_assigned_variable(monkeypatch) -> None:
"""`{{ nodes.x.text }}` 和 `{{ vars.x }}` 是同一个东西的两种写法。
以前一个带换行一个也带换行,至少还一致;修的时候只修一半的话,两者
会给出不同的值——那比原来的毛病更难查。
"""
ctx = _ctx(monkeypatch, ExecResult(stdout="ok\n"), assign_to="verdict")
updates = _run({"vars": {}}, ctx)
payload = updates["nodes"]["judge"]
assert payload["text"] == updates["vars"]["verdict"] == "ok"
# 原始 stdout 仍然原样留着:要查"到底跑了什么"时只能看它
assert payload["stdout"] == "ok\n"
def test_empty_output_assigns_empty_string_not_whitespace(monkeypatch) -> None:
ctx = _ctx(monkeypatch, ExecResult(stdout="\n\n"), assign_to="verdict")
updates = _run({"vars": {}}, ctx)
assert updates["vars"]["verdict"] == ""