mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
代码节点的变量不再带着 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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == ""
|
||||
Reference in New Issue
Block a user