mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:41:59 +00:00
7fb2976f29
运行 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。
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""代码节点的输出契约。
|
||
|
||
`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"] == ""
|