from __future__ import annotations import json import time from typing import Any from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from langgraph.func import task from app.core.artifact_store import canonical_json, content_hash from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError, NodeRejected from app.engine.evidence import ledger_enabled, next_exec, query_entry_fields, schema_ledger_entry from app.engine.replay import ask, once from app.engine.state import GraphState from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager from app.engine.sql_problems import event_checks from app.capabilities.registry import is_platform_query, snapshot_owned from app.data import catalog_view from app.tools.datasource import (DATA_UNAVAILABLE, SCHEMA_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions) from app.tools.registry import ( ToolArgsError, ToolBuildError, ToolContext, build_tools, call_is_dangerous, call_tool, get_spec, ) from app.tools.trust import ask_gate, asks_trustable, call_policy, node_task #: 数据源查询工具没查成时交回的话的开头(tools/datasource.py)。agent 里原话喂回模型让它改 SQL; #: tool 节点没有下一轮可改,照常往下走的话,下游拿到的是一句报错当查询结果。 #: DATA_UNAVAILABLE 是数据本身用不了(这次运行固定的版本没了、表格文件被删),改 SQL 没用 QUERY_FAILED = ("查询失败:", "SQL 被拒绝:", DATA_UNAVAILABLE) #: code 节点在证据链里的角色:取数(产出本身就是源数据)还是计算。缺省按计算算 EVIDENCE_ROLES = ("source", "compute") def _tool_ctx(ctx: NodeContext) -> ToolContext: # 遮罩、只读的运行:记忆范围、知识库只认运行定下的,节点配置、图级 defaults 里写的都不算(tools/registry.ToolContext.fixed_scope) fixed = run_scope_fixed(ctx.run) return ToolContext( run_id=ctx.run.run_id, node_id=ctx.node.id, sandbox_session=ctx.run.thread_id, memory_scope=ctx.run.memory_scope if fixed else ctx.cfg("memory_scope") or ctx.run.memory_scope, collection=ctx.run.collection if fixed else ctx.cfg("collection") or ctx.run.collection, data_versions=run_versions(ctx.run), mask_results=run_masks_results(ctx.run), force_readonly=run_reads_only(ctx.run), fixed_scope=fixed, # 读哪一份数据目录:发起时定下的(data/catalog_view.py),审批恢复、续跑都沿用 catalog_view=catalog_view.of_run(ctx.run), # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, ) async def _dynamic_tool(name: str, ctx: NodeContext) -> Any: """数据源、MCP、自定义这类动态工具得先建出来,才问得到它危不危险、时限多长。 内置工具的这两件事看 ToolSpec 和参数就够,不用建。 """ if get_spec(name) is not None: return None async with SessionLocal() as session: try: # 写入能力只能放在调用工具节点里(应用平台 A5):只有这里传 allow_write=True tools = await build_tools([name], _tool_ctx(ctx), session=session, allow_write=True) except ToolBuildError as e: # 工具自己的配置写坏了:说清是哪个、去哪里改,不再往下走到 call_tool 里炸 raise NodeError(ctx.node.id, str(e)) from e return tools[0] if tools else None async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """直接调用一个工具。参数里的 {{ }} 会先用当前状态渲染。""" name = ctx.cfg("tool", "") if not name: raise NodeError(ctx.node.id, "「调用工具」节点尚未选择工具") args = ctx.render(ctx.cfg("args", {}) or {}, state) if not isinstance(args, dict): raise NodeError(ctx.node.id, "工具参数必须是 JSON 对象") # 能力治理后效果为写入的:走动作的路径(先留生效记录再发送,app/process/actions.py)。登记里没有、或不是写入的, # 照以前的路径执行 if (capability := await _write_capability(name, ctx)) is not None: if run_reads_only(ctx.run): # 只读的运行(使用者的问数据、个人应用的刷新、个人蓝图的试运行,auth/chat_policy.py)什么都不写:能力治理里 # 改成「写入」的工具也不进动作流程(探索运行在那里会演练、交回一张假回执),同 Agent 建工具时拒写入能力 raise NodeError(ctx.node.id, f"这次运行只能读数据:「{name}」是写入能力,不能调用。需要写入的," "请联系搭建者做成应用") return await _run_write(state, ctx, name, args, capability) # 审批通过后节点会整个重放、再算一遍要不要问人,所以这里只能取决于 name 和 args。 # 节点没配时取全局设置(「危险工具默认需要人工确认」),见 NodeContext.approval_mode tool = await _dynamic_tool(name, ctx) approval = ctx.approval_mode() policy = ("ask" if approval == "always" else "safe" if approval == "never" else call_policy(tool, name, args, ctx.run.tool_trust)) if policy == "gate": @task async def gate_step(call_args: dict[str, Any]) -> bool: # 门控的判定进 checkpoint:审批恢复重放时不再问一遍,免得这次判得不一样 gate = await ask_gate(tool=tool, args=call_args, node_title=ctx.node.title, task=node_task(ctx.config)) ctx.emit(EventType.TOOL_GATED, tool=name, **gate.event()) return gate.allowed policy = "safe" if await gate_step(args) else "ask" if policy == "ask": trust_key = asks_trustable(tool, ctx.run.tool_trust) if approval == "dangerous" else None trustable = {"trust_key": trust_key} if trust_key else {} decision = read_decision(await ask( ctx, {"kind": "tool_approval", "node_id": ctx.node.id, "tool": name, "args": args, "title": f"是否允许调用 {name}?", **trustable}, mode="approve", tool=name, args=args, title=f"是否允许调用 {name}?", **trustable, )) if decision.args is not None: args = decision.args always = decision.always and bool(trust_key) await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note, actor=ctx.actor(), **({"always": True} if always else {})) if not decision.approved: raise NodeRejected.of(ctx, f"工具 {name} 的调用已被驳回" + (f":{decision.note}" if decision.note else ""), kind="tool", decision=decision) limit = limit_of(tool, name, args) ctx.emit(EventType.TOOL_START, tool=name, args=args, **limit_fields(limit)) started = time.perf_counter() def _fixed(note: str) -> None: # 替它跑通了这一次,但节点配置里那个错的参数名原封不动,下次还会踩。 # 所以纠正必须留一条看得见的痕迹,而不是安静地把事办了 ctx.emit(EventType.LOG, level="warn", message=f"工具 {name}:{note}。请在节点中改正。", code="tool_args_fixed") async def _call() -> Any: async with SessionLocal() as session: return await call_tool(name, args, _tool_ctx(ctx), session=session, on_fix=_fixed) try: result = await run_bounded(_call(), limit, name) except ToolTimeout as e: # 到点就收回控制权,驱动的收尾在后台做完。报错里写的是实际等了多久 elapsed = int((time.perf_counter() - started) * 1000) ctx.emit(EventType.TOOL_ERROR, tool=name, error=str(e), timed_out=True, duration_ms=elapsed) if ctx.cfg("fail_fast", True): raise NodeError(ctx.node.id, f"{e}。数据量较大时请在参数中缩小范围;如为服务暂时无响应," "可稍后点「继续运行」") from e result = {"error": str(e), "timed_out": True} except KeyError as e: # str(KeyError) 是带引号的 repr,直接拼会多出一对引号 reason = e.args[0] if e.args else f"找不到工具 {name}" raise NodeError(ctx.node.id, f"{reason}。工具可能已被删除或重命名,请在节点中重新选择") from e except ToolArgsError as e: # 参数对不上且没有唯一候选可纠。报错里已经写清楚该填什么, # 不要再套一层 "执行失败:" 把它推远 ctx.emit(EventType.TOOL_ERROR, tool=name, error=str(e)) if ctx.cfg("fail_fast", True): raise NodeError(ctx.node.id, f"工具 {name}:{e}") from e result = {"error": str(e)} except Exception as e: # noqa: BLE001 # 首行写原因、不带异常类名和内部符号;原始异常进 detail 给排查的人 reason = describe_exception(e) ctx.emit(EventType.TOOL_ERROR, tool=name, error=reason, detail=raw_detail(e)) if ctx.cfg("fail_fast", True): raise NodeError( ctx.node.id, f"工具 {name} 执行失败:{reason}。请检查节点中填写的参数;如参数无误," "则是该工具本身出错,请换一个工具或联系管理员", ) from e result = {"error": reason, "detail": raw_detail(e)} elapsed = int((time.perf_counter() - started) * 1000) evidence_on = ledger_enabled(ctx.run) # 查表结构的工具只在数据本身用不了时算失败:它别的回话(「结构信息不可用」之类)是正常的结果 failed = isinstance(result, str) and ( (is_platform_query(name) and result.startswith(QUERY_FAILED)) or (name.startswith(SCHEMA_PREFIX) and result.startswith(DATA_UNAVAILABLE))) if evidence_on and failed: # 没查成:原话就是报错(「查询失败:no such table: x」),照写,别再套一层 ctx.emit(EventType.TOOL_ERROR, tool=name, error=result[:2000], duration_ms=elapsed) if ctx.cfg("fail_fast", True): raise NodeError(ctx.node.id, _query_failure(name, result)) result = {"error": result} # 取数快照:query(args)和结果集一起进工件库,完整出具时数字回指的就是它 from app.core.artifact_store import put_json try: snapshot_id = await put_json( {"tool": name, "args": args, "result": result}, kind="tool_snapshot", run_id=ctx.run.run_id, node_id=ctx.node.id, ) except Exception: # noqa: BLE001 snapshot_id = None # 只认平台执行的查询(数据源查询、登记里声明了 tabular 的端点读取,capabilities.registry.is_platform_query): # 别的工具(MCP、自定义)拼出同样形状的 JSON,里面的工件 id 不是我们落的。按身份认,不看结果长得像不像(F11) query = query_entry_fields(result) if evidence_on and is_platform_query(name) else None # 端点读取另核一道(同 nodes/llm.py):名字对了,交回的工件还得是这次运行落下的端点快照。数据源查询的快照由数据层 # 自己落,snapshot_owned 对它们直接放行 if query and not await snapshot_owned(query["artifact"], ctx.run.run_id, name): query = None schema = query.get("schema_artifact") if query else None # 这次查询的 SQL 检查结果单独带上:preview 只有前 2000 个字符,checks 排在 rows 后面,通常被截掉 checks = event_checks(result) if is_platform_query(name) else [] ctx.emit(EventType.TOOL_END, tool=name, duration_ms=elapsed, preview=json.dumps(result, ensure_ascii=False, default=str)[:2000], artifact=snapshot_id, **({"query_artifact": query["artifact"]} if query else {}), **({"schema_artifact": schema} if schema else {}), **({"checks": checks} if checks else {})) updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} if evidence_on: # 证据台账:工具快照一条;查库的再记一条查询快照(外面包着的是工具快照)。报告按台账编 Q1… exec_no = next_exec(state, ctx.node.id) entries: list[dict[str, Any]] = [] if snapshot_id: entries.append({"kind": "tool", "node_id": ctx.node.id, "exec": exec_no, "artifact": snapshot_id, "tool": name}) if query: # 查询当时的表结构快照另记一条 schema 条目;取不回来的话查询条目也不带它(不启用实体核对) frozen = schema_ledger_entry(schema, node_id=ctx.node.id, exec_no=exec_no) if schema else None entries.append({"kind": "query", "node_id": ctx.node.id, "exec": exec_no, "artifact": query["artifact"], **({"via": snapshot_id} if snapshot_id else {}), "tool": name, "source": query["source"], "columns": query["columns"], "rows": query["rows"], "truncated": query["truncated"], **({"schema_artifact": schema, "tables": query["tables"]} if frozen else {})}) if frozen: entries.append(frozen) if entries: updates["evidence"] = entries var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: result} return updates def _governance(name: str, ctx: NodeContext) -> dict[str, Any]: """这次运行发起时固定的能力治理快照(Run.options["capabilities"],见 capabilities.registry.capability_snapshot)。 恢复、重放用同一份:停在确认上期间有人改了治理,这次运行仍按发起时的。""" options = ctx.run.extra.get("run_options") or {} return ((options.get("capabilities") or {}).get(name)) or {} async def _write_capability(name: str, ctx: NodeContext) -> Any | None: """写外部系统的能力登记(capabilities.registry.writes_externally:治理后效果为 write,或绑定端点、视参数而定); 不是写入(或登记里没有)返回 None。效果和绑定的端点按发起时的治理快照认。""" from app.capabilities.registry import effective_effect, get_capability, writes_externally async with SessionLocal() as session: capability = await get_capability(session, name) if capability is None: return None governance = _governance(name, ctx) effect = governance.get("effect") or effective_effect(capability) endpoint_id = governance["endpoint_id"] if "endpoint_id" in governance else capability.endpoint_id return capability if writes_externally(name, effect, endpoint_id) else None async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[str, Any], capability: Any) -> dict[str, Any]: """写外部系统(§3.8)。顺序是升级安全的关键:查生效记录 → 需要确认时在 tool.start 之前停下(节点边界上, 只有 ask 的记录 task)→ 恢复后重算哈希 → 先写记录再发送。发送前一定先查记录,节点重新执行(恢复、接着跑、 跨重放协议)时已生效的交回回执、已发出的转核实,不会重发。""" from app.engine.nodes.human import resolve_assignee from app.process import actions exec_no = next_exec(state, ctx.node.id) # 幂等键按这次运行里的节点 id 算(子工作流里的写「sub/pay」):以前按子图里的 id(pay)算,父图里同名的节点、套同一个 # 工作流的两个子工作流节点撞同一个键,后发的那一笔交回先发的回执、不发送。升级前子工作流里的写入按子图里的 id 记过键: # 写入节点本身重新执行时(结果未知核实之后、失败后接着跑;审批恢复时子图从自己的断点接着跑,不走到这里)照旧认那个键 # (legacy_node_id),是不是这个节点的那一笔、是不是这一次的内容,由 actions.prepare 按那条记录的事件和内容指纹认 node_id, legacy = _action_ids(ctx) try: prep = await actions.prepare(run_id=ctx.run.run_id, node_id=node_id, exec_no=exec_no, capability=capability, args=args, legacy_node_id=legacy) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e if prep.receipt is not None: ctx.emit(EventType.LOG, level="info", code="action_already_applied", message=f"「{name}」这次写入已经生效,交回存下的回执,不再发送") return _write_updates(ctx, prep.receipt) governance = _governance(name, ctx) unattended = governance.get("unattended_write", getattr(capability, "unattended_write", False)) confirm = (governance.get("approval") or getattr(capability, "approval", "inherit")) == "confirm" \ or not (ctx.approval_mode() == "never" and unattended) confirmer: str | None = None if confirm: title = f"确认发送:{getattr(capability, 'title', '') or name}" card = {"kind": "action_confirm", "node_id": ctx.node.id, "mode": "approve", "title": title, "tool": name, "capability": name, "preview": prep.preview, "payload_hash": prep.payload_hash, "environment": prep.environment, "endpoint": prep.endpoint, "attempt": prep.attempt} if assignee := await resolve_assignee(state, ctx): card["assignee"] = assignee answer = await ask(ctx, card, **card) decision = read_decision(answer) confirmed = answer.get("payload_hash") if isinstance(answer, dict) else None await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note, actor=ctx.actor(), payload_hash=confirmed) if not decision.approved: # 驳回是人的决定,不是故障:抛 NodeRejected,说法写成「未确认(被驳回)」,实例页、通知按同一个结论说 # (apps/resolve.instance_state)。抛在发送之前:画布上接了「驳回」出口的沿它往下走(engine/compiler),什么都 # 没发;没接的运行到这里结束,收成 cancelled + end_reason=rejected raise NodeRejected.of(ctx, f"「{ctx.node.title}」未确认(被驳回),没有发送" + (f":{decision.note}" if decision.note else ""), kind="action_confirm", decision=decision) if confirmed != prep.payload_hash: # 动作确认不接受改参数:要改在上游的表单里改。确认之后上游的数据变了,看过的就不是要发的,拒发 raise NodeError(ctx.node.id, f"「{name}」确认时看到的内容和现在要发送的不一致(内容指纹对不上)," "已拒绝发送。请重新发起运行,确认新的内容") confirmer = ctx.actor() or await _confirmed_by(ctx.run.run_id, ctx.node.id) # 身份断言(D-b):sub 一律是运行的发起人——这件事是替他办的;确认的人放在 act。以前恢复时写的是确认人, # 外部系统会把审批人当成申请人;无人值守的写入没有人确认,不写 act on_behalf = {"sub": prep.run.get("started_by"), "act": confirmer} try: result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=node_id, exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf, confirm_node=ctx.node.id) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e return _write_updates(ctx, result) def _action_ids(ctx: NodeContext) -> tuple[str, str | None]: """写入的幂等键按哪个节点 id 算:(这次运行里的 id, 升级前按子图里的 id 记过的那种;最外层的为 None)。""" node_id = ctx.run.global_id(ctx.node.id) return node_id, ctx.node.id if node_id != ctx.node.id else None async def _confirmed_by(run_id: str, node_id: str) -> str | None: """确认这次发送的人:节点重新执行(结果未知核实后接着跑、重启后恢复)时 ctx.actor() 已经不是确认的那次恢复, 按待办上记的处理人认(会签时是凑齐的那一票)。""" from sqlalchemy import select from app.db.models import Task async with SessionLocal() as session: return await session.scalar(select(Task.resolved_by).where( Task.run_id == run_id, Task.node_id == node_id, Task.kind == "action_confirm", Task.status == "resolved").order_by(Task.created_at.desc(), Task.id.desc()).limit(1)) def _write_updates(ctx: NodeContext, result: dict[str, Any]) -> dict[str, Any]: updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} if var_name := ctx.cfg("assign_to", ""): updates["vars"] = {var_name: result} return updates def _query_failure(name: str, result: str) -> str: """调用工具节点上查询没查成时的报错。 数据本身用不了(DATA_UNAVAILABLE):原因里已经写了怎么办(重新发起运行、重新上传),照交。 不能再加「修改节点中的 SQL」——改 SQL、继续运行都没用,只会再失败一次。SQL 的错才让人改 SQL。 原话开头的「查询失败:」去掉,免得和前面的「查询失败:」叠成两遍。 """ if result.startswith(DATA_UNAVAILABLE): return f"工具 {name} 无法使用:{result.removeprefix(DATA_UNAVAILABLE)}" return f"工具 {name} 查询失败:{result.removeprefix('查询失败:')}。请据此修改节点中的 SQL 后重新运行" async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """沙箱代码节点。代码本身支持模板插值,可以把上游结果直接嵌进去。""" code = ctx.render_str(ctx.cfg("code", ""), state) if not code.strip(): raise NodeError(ctx.node.id, "「沙箱代码」节点的代码为空") evidence_on = ledger_enabled(ctx.run) role = str(ctx.cfg("evidence_role", "") or "compute") if evidence_on and role not in EVIDENCE_ROLES: raise NodeError(ctx.node.id, f"「证据角色」只能是「取数」或「计算」,当前为「{role}」") language = ctx.cfg("language", "python") limits = SandboxLimits( timeout=int(ctx.cfg("timeout", 30) or 30), memory_mb=int(ctx.cfg("memory_mb", 512) or 512), network=bool(ctx.cfg("network", False)), ) if ctx.cfg("approval", "never") == "always": decision = read_decision(await ask( ctx, {"kind": "code_approval", "node_id": ctx.node.id, "code": code, "language": language, "title": "是否执行这段代码?"}, mode="approve", title="是否执行这段代码?", code=code[:4000], language=language, )) if decision.code: code = decision.code # 允许人工改完再跑 await once(ctx, EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note, actor=ctx.actor()) if not decision.approved: raise NodeRejected.of(ctx, "代码执行已被驳回" + (f":{decision.note}" if decision.note else ""), kind="code", decision=decision) # 隔离档位:strict 要硬件级(microVM),fast 要低延迟(Seatbelt/bwrap), # 留空跟随整机默认。要的那档不可用时会安静退回默认,不阻断运行。 isolation = ctx.cfg("isolation", "") or None # 把真正要跑的那份代码发出来。用户在编辑器里写的是带 {{ }} 的模板, # 送进沙箱的是渲染后的字符串——两者可能差很远:一个带引号或换行的值 # 插进字符串字面量里就会把程序写坏,而报错指的是渲染后的行号,用户对着 # 自己那份数怎么也对不上。这条事件是他们唯一能看到"实际跑了什么"的地方。 ctx.emit(EventType.SANDBOX_START, language=language, limits=limits.model_dump(), isolation=isolation or "auto", code=code[:8000], code_truncated=len(code) > 8000, interpolated="{{" in str(ctx.cfg("code", ""))) result = await sandbox_manager.run( code, language=language, limits=limits, session_id=ctx.run.thread_id, files=ctx.render(ctx.cfg("files", {}) or {}, state), backend=isolation, ) if isolation == "strict" and result.backend != "microvm": # 要了硬件隔离却没拿到,必须说出来——否则用户以为自己在 VM 里跑 ctx.emit(EventType.LOG, level="warn", message=f"节点要求 strict 隔离,但 microVM 未就绪,实际使用了 {result.backend}", code="isolation_fallback") if result.session_reset: # 会话工作区被清空了。同一会话的下游节点会突然读不到自己写的文件, # 不说一声的话那个 FileNotFoundError 根本无从追查 ctx.emit(EventType.LOG, level="warn", message="沙箱会话已重置,此前在该会话写入的文件已丢失" "(多为超时重建,或同会话中各节点的内存限额不一致)", code="sandbox_reset") ctx.emit( EventType.SANDBOX_END, ok=result.ok, exit_code=result.exit_code, duration_ms=result.duration_ms, backend=result.backend, stdout=result.stdout[:4000], stderr=result.stderr[:2000], ) if not result.ok and ctx.cfg("fail_fast", True): raise NodeError( ctx.node.id, f"代码执行失败(退出码 {result.exit_code}):{result.error or result.stderr[:500]}", ) 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 sniff = result.stdout.strip() if sniff.startswith(("{", "[")): try: parsed = json.loads(sniff) except json.JSONDecodeError: parsed = None payload["data"] = parsed payload["text"] = text updates: dict[str, Any] = {"nodes": {ctx.node.id: payload}} if evidence_on: # 实际送进沙箱的那份代码(渲染之后、审批改过之后)的指纹:同一张图两次运行算出不同的数, # 先看这个就知道代码本身变没变 payload["code_sha"] = content_hash(code) # 台账的工件就是节点产出工件:外层包装按同样的内容落盘,id 相同 updates["evidence"] = [{"kind": "node_output", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id), "artifact": content_hash(canonical_json(payload)), "code_sha": payload["code_sha"], "role": role, "language": language}] var_name = ctx.cfg("assign_to", "") if var_name: # {{ vars.x }} 和 {{ nodes.x.text }} 必须是同一个值:两种写法给不同的 # 值比原来的毛病更难查 updates["vars"] = {var_name: parsed if parsed is not None else text} return updates