助手的节点手册讲驳回出口:哪些节点能接(审批、补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决),连线写 sourceHandle="rejected",什么时候接(驳回后要通知、退回修改、走另一条路),不接时怎样(运行结束、记为被驳回;审批模式这一路到此为止;写了驳回即终止就别接),驳回那条路必须走到成果节点或结束,失败重试、出错时继续不管驳回。以前手册只有一句审批的 approved / rejected,助手画出来的图驳回就整轮结束

This commit is contained in:
ThearchyHelios
2026-10-19 08:47:53 +08:00
parent c79f4e8bf7
commit ebf1d63269
2 changed files with 97 additions and 1 deletions
+13 -1
View File
@@ -327,7 +327,19 @@ skip_if。这些字段按受限的 Python 表达式求值,变量直接写路
- edges 里每条边 {source, target, sourceHandle?}
- branch 节点的出边必须带 sourceHandle,取值是 cases 里的 key,另外要有一条 sourceHandle="default" 兜底
- loop 节点:sourceHandle="body" 连循环体,循环体最后一个节点再连回 loop 节点;sourceHandle="done" 连循环结束后的去向
- human 节点(approve 模式):sourceHandle 用 "approved" 和 "rejected"
- human 节点(approve 模式):批准走 sourceHandle "approved",驳回走下面的「驳回」出口
- 「驳回」出口:会被人驳回的节点可以多接一条 sourceHandle="rejected" 的边,驳回(包括超时驳回)之后沿它往下走。
能接的只有:human(审批、补充输入、编辑稿件三种模式)、tool(工具调用审批、写外部系统前的动作确认)、
code(代码执行审批)、dual_check(双盲复算的裁决);别的节点接了画布校验会报错。
· 什么时候接:驳回之后还要做事——通知申请人(接一个通知或写说明的节点,再到成果节点)、退回修改(连回要改的上游
那一步,改完再到这个节点问一次)、改走另一条路(比如出一份「已退回」的成果)。驳回时这一步照常结束,下游用
{{ nodes.关卡id.note }} 取驳回备注、{{ nodes.关卡id.actor }} 取谁驳回的;动作确认被驳回时写入不会发出
· 不接时:补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决被驳回,运行就此结束、记为被驳回(是人的决定,不算失败);
审批模式不接时这一路到审批为止、不再往下走(运行照常结束,没有成果)。审批写了 stop_on_reject(驳回即终止运行)就不要再接驳回出口:
驳回时运行直接结束,那条边走不到
· 驳回那条路必须走到成果节点,或者在一个不再往下连的节点结束(退回修改绕回上游、改完再问一次也算),否则驳回之后
只能一直绕下去,发布检查会报错
· 驳回不是故障:「失败重试」「出错时继续」不管驳回,不要为了驳回之后接着走去配它们
- 一个节点连出多条普通边 = 并行执行,多条边汇入同一节点 = 等待汇聚
"""
@@ -0,0 +1,84 @@
"""助手的节点手册要教「驳回」出口(docs/design/orchestration-round/00-总方案.md §3)。
手册里没写的出口,助手就不会接:驳回之后要通知申请人、退回修改、改走另一条路的,画出来的图只会在驳回时整轮结束。
这里不只查关键词,还拿校验认的那一份(会被驳回的节点类型、出口的 sourceHandle、发布检查的「走不到结束」)来对,
再照手册的写法搭一张图,校验、发布检查都过。
"""
from __future__ import annotations
import re
from app.api.copilot import NODE_REFERENCE
from app.engine.schema import GATE_TYPES, REJECT_HANDLE, GraphSpec, validate_graph
def _reject_entry() -> str:
start = NODE_REFERENCE.index("「驳回」出口")
return NODE_REFERENCE[start:NODE_REFERENCE.index("\n- 一个节点连出多条普通边", start)]
def test_the_guide_names_every_node_that_can_be_rejected_and_the_handle():
entry = _reject_entry()
assert f'sourceHandle="{REJECT_HANDLE}"' in entry
# 能接驳回出口的节点类型(schema.GATE_TYPES)一个不漏,按 type 字段写
assert all(f"{t}" in entry for t in GATE_TYPES), [t for t in GATE_TYPES if str(t) not in entry]
# 六种关卡都点到:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决
for kind in ("审批", "补充输入", "编辑稿件", "动作确认", "代码执行审批", "工具调用审批", "裁决"):
assert kind in entry, kind
def test_the_guide_says_when_to_connect_it_and_what_happens_without_it():
entry = _reject_entry()
# 什么时候接:驳回后要通知、退回修改、走另一条路
assert "通知" in entry and "退回修改" in entry and "另一条路" in entry
# 不接时:运行结束、记被驳回(不算失败);审批模式没设驳回即终止的老规矩照实说
assert "记为被驳回" in entry and "不算失败" in entry
assert "stop_on_reject" in entry
# 驳回那条路必须走到成果节点或结束(发布检查 reject_exit.no_end),绕回去改完再问一次也可以
assert "成果节点" in entry and "不再往下连" in entry and "发布检查" in entry
# 驳回不是故障:失败重试、出错时继续不管它
assert "失败重试" in entry
# 驳回之后下游取得到谁、备注
assert "{{ nodes.关卡id.note }}" in entry
def test_the_guide_does_not_leak_internal_names():
entry = _reject_entry()
for internal in ("reject_exit", "NodeRejected", "end_reason", "cancelled", "__decision__", "GATE_TYPES"):
assert internal not in entry, internal
def test_the_old_one_line_rule_is_replaced_not_duplicated():
# 以前只有一句「human 节点(approve 模式):sourceHandle 用 approved 和 rejected」:驳回出口说一处,不留两种说法
assert len(re.findall(r'sourceHandle="rejected"', NODE_REFERENCE)) == 1
def _node(nid: str, ntype: str, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def test_a_graph_drawn_the_way_the_guide_says_passes_the_checks():
"""照手册写:审批批准走 approved,驳回接 sourceHandle="rejected" 到「通知申请人」再到一个成果节点;工具的确认被驳回
退回上游修改(连回起草,改完再问一次)。画布校验、各级发布检查都不报驳回出口的问题。"""
from app.engine.governance import lint_for_publish
graph = {
"nodes": [_node("start", "input"), _node("draft", "llm", prompt="起草"),
_node("gate", "human", mode="approve", title="可以吗"),
_node("send", "tool", tool="calculator", args={"expression": "1+1"}, approval="always"),
_node("tell", "llm", prompt="通知申请人:{{ nodes.gate.note }}"),
_node("done", "output", fields=[{"name": "结果", "value": "{{ nodes.send.text }}"}]),
_node("back", "output", fields=[{"name": "结果", "value": "{{ nodes.tell.text }}"}])],
"edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"},
{"source": "gate", "target": "send", "sourceHandle": "approved"},
{"source": "gate", "target": "tell", "sourceHandle": "rejected"},
{"source": "tell", "target": "back"},
{"source": "send", "target": "done"},
{"source": "send", "target": "draft", "sourceHandle": "rejected"}],
}
spec = GraphSpec.model_validate(graph)
codes = {i.code for i in validate_graph(spec).issues if i.level == "error"}
assert not {c for c in codes if c and "reject" in c}, codes
for level in ("draft", "published", "governed"):
issues = lint_for_publish(spec, level=level).issues
assert not [i for i in issues if (i.code or "").startswith("reject_exit")], level