Files
AgentLab/backend/tests/test_code_output.py
ThearchyHelios 7fb2976f29 代码节点的变量不再带着 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。
2026-09-24 23:15:19 +08:00

106 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""代码节点的输出契约。
`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"] == ""