diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 836746f2..dccd9545 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -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} diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index f5270e6c..a2d5aa93 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -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 diff --git a/backend/tests/test_code_output.py b/backend/tests/test_code_output.py new file mode 100644 index 00000000..91fc44f3 --- /dev/null +++ b/backend/tests/test_code_output.py @@ -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"] == ""