mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(workflow): fail-closed branch-handle repair and declared-handle check
The planner names a branch's outgoing source_handle values in one LLM call; the node builder chooses the case / class / action ids in another. ESQ1-303's edges left its if-else on "score_equals_60" and "else" against a case named "true", so the engine skipped both arms and the run succeeded with nothing. declared_branch_handles lists what a node routes on; repair_branch_edge_ handles re-homes an edge only when it is forced (exact / alias / name / stem match, one arm fanning out under a single invented name, default- source edges onto unused handles in order) and returns everything ambiguous untouched; undeclared_branch_handles reports what is left so a validator can reject it. A wrong guess would silently swap IF and ELSE. Aliases are trusted as names -- the documented limit -- which is why the node-builder handle contract, not this repair, is the primary fix. A fail-branch node's success path stays on "source" (what graphon's NodeRunResult.edge_source_handle actually emits by default), not an invented "success" handle nothing ever routes on; a fail-branch branch-type node (if-else / question-classifier / human-input) just gains the extra "fail-branch" arm. human-input also always declares its implicit "__timeout" arm, since every human-input node has a timeout config and the FE always renders that handle; that arm is excluded from the "unused declared handles" pool the fan-out/default-order repair rules count against, since it is routinely left unwired by design and must not block or absorb repair of the arms the author did configure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
967e3b3347
commit
36409bd14e
2 files changed
+502
No files matched your search
@@ -316,3 +316,237 @@ def http_request_body_errors(nodes: list[Any]) -> list[tuple[str, str]]:
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch edge handles (if-else / question-classifier / human-input / fail-branch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Names the planner uses for the arms of a two-arm if-else before the node
|
||||
# builder has chosen a case id. Trusted as NAMES: an ELSE alias always means
|
||||
# the implicit ``false`` arm, an IF alias the single declared case.
|
||||
_ELSE_ALIASES = frozenset(
|
||||
{"else", "otherwise", "default", "no", "not", "elsebranch", "else_branch", "false_branch", "falsebranch"}
|
||||
)
|
||||
_IF_ALIASES = frozenset(
|
||||
{"if", "then", "yes", "true_branch", "truebranch", "if_branch", "ifbranch", "case", "case1", "case_1"}
|
||||
)
|
||||
_DEFAULT_HANDLES = frozenset({"", "source"})
|
||||
# A handle shorter than this never claims a declared id by prefix ("no" must
|
||||
# not swallow "nothing").
|
||||
_MIN_STEM = 3
|
||||
|
||||
# Node types that route on named handles the node itself declares (if-else's
|
||||
# case ids + the implicit "false" arm, question-classifier's class ids,
|
||||
# human-input's action ids + the implicit "__timeout" arm) rather than the
|
||||
# single default "source" handle every other node type uses.
|
||||
_NAMED_BRANCH_TYPES = frozenset({"if-else", "question-classifier", "human-input"})
|
||||
|
||||
# human-input's implicit timeout arm. This is
|
||||
# ``core.workflow.nodes.human_input.constants.TIMEOUT_HANDLE`` ("__timeout"),
|
||||
# not imported: that package's __init__ pulls in graphon/pydantic entities,
|
||||
# which this module's own contract (see the module docstring) keeps out --
|
||||
# "no engine imports beyond the graph dict shape".
|
||||
_HUMAN_INPUT_TIMEOUT_HANDLE = "__timeout"
|
||||
|
||||
# Declared handles that exist on every node of their type regardless of what
|
||||
# the author configured (unlike a case id, a class id or an action id, which
|
||||
# only exist because the author added them). An author routinely leaves them
|
||||
# unwired, so they must not count as "the one remaining slot" when repair is
|
||||
# deciding whether an unnamed/misnamed edge unambiguously belongs to the last
|
||||
# free arm -- otherwise that arm would never resolve. An edge already ON one
|
||||
# of them still matches it exactly (the pass above, not this set).
|
||||
_UNCOUNTED_UNUSED_HANDLES = frozenset({_HUMAN_INPUT_TIMEOUT_HANDLE})
|
||||
|
||||
|
||||
def _canon(handle: Any) -> str:
|
||||
return "".join(ch for ch in str(handle or "").lower() if ch.isalnum() or ch == "_")
|
||||
|
||||
|
||||
def declared_branch_handles(node: Mapping[str, Any]) -> list[str]:
|
||||
"""The source handles ``node`` exposes, in declaration order; ``[]`` for a
|
||||
node that has a single ``source`` handle.
|
||||
|
||||
A node with ``error_strategy: "fail-branch"`` gains a ``"fail-branch"``
|
||||
arm. Its success path stays on whatever handle the node already routes
|
||||
on: graphon's ``NodeRunResult.edge_source_handle`` defaults to
|
||||
``"source"``, so a plain node (http-request, code, ...) declares
|
||||
``["source", "fail-branch"]`` -- not an invented ``"success"`` handle
|
||||
nothing ever emits, which would strand its real ``"source"`` edge as
|
||||
undeclared and skip it at run time. A branch-type node (if-else,
|
||||
question-classifier, human-input) keeps routing on its own handles and
|
||||
simply gains the extra ``"fail-branch"`` arm.
|
||||
"""
|
||||
data = node.get("data")
|
||||
if not isinstance(data, Mapping):
|
||||
return []
|
||||
node_type = data.get("type")
|
||||
handles: list[str] = []
|
||||
if node_type == "if-else":
|
||||
handles = [str(c["case_id"]) for c in data.get("cases") or [] if isinstance(c, Mapping) and c.get("case_id")]
|
||||
handles.append("false") # the implicit ELSE arm
|
||||
elif node_type == "question-classifier":
|
||||
handles = [str(k["id"]) for k in data.get("classes") or [] if isinstance(k, Mapping) and k.get("id")]
|
||||
elif node_type == "human-input":
|
||||
handles = [str(a["id"]) for a in data.get("user_actions") or [] if isinstance(a, Mapping) and a.get("id")]
|
||||
handles.append(_HUMAN_INPUT_TIMEOUT_HANDLE) # the implicit timeout arm
|
||||
if data.get("error_strategy") == "fail-branch":
|
||||
if node_type in _NAMED_BRANCH_TYPES:
|
||||
handles.append("fail-branch")
|
||||
else:
|
||||
handles = ["source", "fail-branch"]
|
||||
return handles
|
||||
|
||||
|
||||
def _name_aliases(node: Mapping[str, Any], handles: list[str]) -> dict[str, str]:
|
||||
"""canonical name -> declared handle, for the names a planner might use."""
|
||||
data = node.get("data") or {}
|
||||
names: dict[str, str] = {}
|
||||
if data.get("type") == "if-else":
|
||||
for alias in _ELSE_ALIASES:
|
||||
names[alias] = "false"
|
||||
if len(handles) == 2: # one case + ELSE: an IF alias is unambiguous
|
||||
for alias in _IF_ALIASES:
|
||||
names[alias] = handles[0]
|
||||
elif data.get("type") == "question-classifier":
|
||||
for klass in data.get("classes") or []:
|
||||
if isinstance(klass, Mapping) and klass.get("id") and klass.get("name"):
|
||||
names[_canon(klass["name"])] = str(klass["id"])
|
||||
elif data.get("type") == "human-input":
|
||||
for action in data.get("user_actions") or []:
|
||||
if isinstance(action, Mapping) and action.get("id"):
|
||||
for key in ("title", "label", "name"):
|
||||
if action.get(key):
|
||||
names[_canon(action[key])] = str(action["id"])
|
||||
return names
|
||||
|
||||
|
||||
def _match_handle(handle: str, handles: list[str], names: Mapping[str, str]) -> str | None:
|
||||
"""A declared handle that ``handle`` unambiguously means, else ``None``."""
|
||||
canon = _canon(handle)
|
||||
for declared in handles:
|
||||
if _canon(declared) == canon:
|
||||
return declared
|
||||
if canon in names:
|
||||
return names[canon]
|
||||
if len(canon) >= _MIN_STEM:
|
||||
stems = [
|
||||
declared
|
||||
for declared in handles
|
||||
if len(_canon(declared)) >= _MIN_STEM
|
||||
and (canon.startswith(_canon(declared)) or _canon(declared).startswith(canon))
|
||||
]
|
||||
if len(stems) == 1:
|
||||
return stems[0]
|
||||
return None
|
||||
|
||||
|
||||
def repair_branch_edge_handles(nodes: list[Any], edges: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Re-home edges leaving a branch node onto the handles the node declares.
|
||||
|
||||
Three passes per branch node, each only when it is forced:
|
||||
|
||||
1. exact / alias / name / stem match (``else`` -> ``false``, a class name
|
||||
-> its id, ``approved`` -> ``approve``);
|
||||
2. edges still unmatched, all carrying the SAME unknown handle, when
|
||||
exactly one declared handle is unused -> that handle (one arm fanning
|
||||
out under an invented name);
|
||||
3. edges on the default ``source`` handle, when there are at least as
|
||||
many unused declared handles -> unused handles in declaration order
|
||||
(the pre-existing behaviour).
|
||||
|
||||
"Unused" for (2) and (3) never counts a handle in
|
||||
``_UNCOUNTED_UNUSED_HANDLES`` (human-input's implicit ``"__timeout"``
|
||||
arm): it exists on every node of its type whether or not the author
|
||||
wired it, so it must not silently absorb an edge meant for the one real
|
||||
arm that IS left unused, and must not block that arm's resolution either.
|
||||
|
||||
Anything else -- two different unknown names, one unknown edge with two
|
||||
free arms, a three-case node with invented names -- is left exactly as it
|
||||
was and returned, so ``undeclared_branch_handles`` fails the graph closed.
|
||||
A wrong guess would silently swap the IF and ELSE arms; a visible rejection
|
||||
is better.
|
||||
"""
|
||||
unresolved: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
handles = declared_branch_handles(node)
|
||||
if not handles:
|
||||
continue
|
||||
node_id = str(node.get("id") or "")
|
||||
outgoing = [e for e in edges if isinstance(e, MutableMapping) and e.get("source") == node_id]
|
||||
if not outgoing:
|
||||
continue
|
||||
names = _name_aliases(node, handles)
|
||||
unknown: list[MutableMapping[str, Any]] = []
|
||||
for edge in outgoing:
|
||||
handle = edge.get("sourceHandle")
|
||||
if handle in handles:
|
||||
continue
|
||||
if handle is None or handle in _DEFAULT_HANDLES:
|
||||
unknown.append(edge)
|
||||
continue
|
||||
matched = _match_handle(str(handle), handles, names)
|
||||
if matched is None:
|
||||
unknown.append(edge)
|
||||
else:
|
||||
edge["sourceHandle"] = matched
|
||||
if not unknown:
|
||||
continue
|
||||
taken = {e.get("sourceHandle") for e in outgoing if e.get("sourceHandle") in handles}
|
||||
unused = [h for h in handles if h not in taken and h not in _UNCOUNTED_UNUSED_HANDLES]
|
||||
named = [
|
||||
e
|
||||
for e in unknown
|
||||
if e.get("sourceHandle") not in (None, "") and e.get("sourceHandle") not in _DEFAULT_HANDLES
|
||||
]
|
||||
defaulted = [e for e in unknown if e not in named]
|
||||
distinct_names = {str(e.get("sourceHandle")) for e in named}
|
||||
if named and not defaulted and len(distinct_names) == 1 and len(unused) == 1:
|
||||
for edge in named:
|
||||
edge["sourceHandle"] = unused[0]
|
||||
continue
|
||||
if defaulted and not named and len(defaulted) <= len(unused):
|
||||
for edge, handle in zip(defaulted, unused):
|
||||
edge["sourceHandle"] = handle
|
||||
continue
|
||||
for edge in unknown:
|
||||
unresolved.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"target": str(edge.get("target") or ""),
|
||||
"handle": edge.get("sourceHandle"),
|
||||
"declared": list(handles),
|
||||
}
|
||||
)
|
||||
return unresolved
|
||||
|
||||
|
||||
def undeclared_branch_handles(nodes: list[Any], edges: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Every edge leaving a branch node on a handle the node does not declare
|
||||
(including the default ``source``): the edge would hang off a handle that
|
||||
does not exist and its arm would silently never run."""
|
||||
bad: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, Mapping):
|
||||
continue
|
||||
handles = declared_branch_handles(node)
|
||||
if not handles:
|
||||
continue
|
||||
node_id = str(node.get("id") or "")
|
||||
for edge in edges:
|
||||
if not isinstance(edge, Mapping) or edge.get("source") != node_id:
|
||||
continue
|
||||
handle = edge.get("sourceHandle")
|
||||
if handle in handles:
|
||||
continue
|
||||
bad.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"target": str(edge.get("target") or ""),
|
||||
"handle": "source" if handle is None else handle,
|
||||
"declared": list(handles),
|
||||
}
|
||||
)
|
||||
return bad
|
||||
@@ -511,3 +511,271 @@ class TestHttpRequestBodyErrors:
|
||||
result = normalize_http_request_bodies(nodes)
|
||||
assert result == []
|
||||
assert nodes == before
|
||||
|
||||
|
||||
def _esq1_303_graph() -> dict:
|
||||
return json.loads((_FIXTURES / "esq1_303_draft_graph.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _if_else(node_id: str, case_ids: tuple[str, ...] = ("true",)) -> dict:
|
||||
return {
|
||||
"id": node_id,
|
||||
"data": {
|
||||
"type": "if-else",
|
||||
"cases": [{"case_id": c, "logical_operator": "and", "conditions": []} for c in case_ids],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _edges(source: str, pairs: list[tuple]) -> list[dict]:
|
||||
return [{"source": source, "target": target, "sourceHandle": handle} for handle, target in pairs]
|
||||
|
||||
|
||||
def _handles(edges: list[dict]) -> list[tuple]:
|
||||
return [(e["target"], e.get("sourceHandle")) for e in edges]
|
||||
|
||||
|
||||
class TestDeclaredBranchHandles:
|
||||
def test_if_else_declares_its_case_ids_plus_the_implicit_else(self):
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
assert declared_branch_handles(_if_else("n", ("true",))) == ["true", "false"]
|
||||
assert declared_branch_handles(_if_else("n", ("c1", "c2"))) == ["c1", "c2", "false"]
|
||||
|
||||
def test_question_classifier_and_human_input_declare_their_ids(self):
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
qc = {"id": "q", "data": {"type": "question-classifier", "classes": [{"id": "1", "name": "A"}, {"id": "2"}]}}
|
||||
hi = {"id": "h", "data": {"type": "human-input", "user_actions": [{"id": "approve"}, {"id": "deny"}]}}
|
||||
assert declared_branch_handles(qc) == ["1", "2"]
|
||||
# human-input always routes an implicit timeout arm too (R1(b)).
|
||||
assert declared_branch_handles(hi) == ["approve", "deny", "__timeout"]
|
||||
|
||||
def test_human_input_declares_its_action_ids_plus_the_implicit_timeout(self):
|
||||
# (i) R1(b): human-input routes an implicit "__timeout" arm
|
||||
# (TIMEOUT_HANDLE, core/workflow/nodes/human_input/constants.py) in
|
||||
# addition to its declared user actions.
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
hi = {"id": "h", "data": {"type": "human-input", "user_actions": [{"id": "approve"}, {"id": "deny"}]}}
|
||||
assert declared_branch_handles(hi) == ["approve", "deny", "__timeout"]
|
||||
|
||||
def test_fail_branch_nodes_declare_source_and_fail_branch(self):
|
||||
# R1(a): a fail-branch node's SUCCESS path stays on the handle
|
||||
# graphon actually emits (NodeRunResult.edge_source_handle defaults
|
||||
# to "source"), not an invented "success" handle nothing emits.
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
node = {"id": "h", "data": {"type": "http-request", "error_strategy": "fail-branch"}}
|
||||
assert declared_branch_handles(node) == ["source", "fail-branch"]
|
||||
|
||||
def test_if_else_with_fail_branch_declares_cases_false_and_fail_branch(self):
|
||||
# (iv) a branch-type node with error_strategy fail-branch appends
|
||||
# "fail-branch" to its own declared handles rather than replacing them.
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
node = _if_else("n", ("c1", "c2"))
|
||||
node["data"]["error_strategy"] = "fail-branch"
|
||||
assert declared_branch_handles(node) == ["c1", "c2", "false", "fail-branch"]
|
||||
|
||||
def test_plain_nodes_declare_nothing(self):
|
||||
from core.workflow.graph_normalizers import declared_branch_handles
|
||||
|
||||
assert declared_branch_handles({"id": "l", "data": {"type": "llm"}}) == []
|
||||
|
||||
|
||||
class TestRepairBranchEdgeHandles:
|
||||
"""The planner names handles before the node builder picks case ids
|
||||
(ESQ1-303: ``score_equals_60`` / ``else`` against a case named ``true``).
|
||||
Repair only what is forced; leave anything ambiguous for the validator."""
|
||||
|
||||
def test_heals_the_esq1_303_dev_edges_else_alias_pins_the_other_arm(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
graph = _esq1_303_graph()
|
||||
|
||||
unresolved = repair_branch_edge_handles(graph["nodes"], graph["edges"])
|
||||
|
||||
assert unresolved == []
|
||||
by_target = {e["target"]: e["sourceHandle"] for e in graph["edges"] if e["source"] == "node2"}
|
||||
assert by_target == {"node3": "true", "node4": "false"}
|
||||
|
||||
def test_legacy_default_handle_edges_still_take_unused_handles_in_order(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("source", "a"), (None, "b")])
|
||||
assert repair_branch_edge_handles([_if_else("n")], edges) == []
|
||||
assert _handles(edges) == [("a", "true"), ("b", "false")]
|
||||
|
||||
def test_three_default_edges_on_two_handles_stay_untouched(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("source", "a"), ("source", "b"), ("source", "c")])
|
||||
unresolved = repair_branch_edge_handles([_if_else("n")], edges)
|
||||
assert len(unresolved) == 3
|
||||
assert all(h == "source" for _, h in _handles(edges))
|
||||
|
||||
def test_both_edges_unknown_with_no_alias_is_ambiguous_and_untouched(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("pass", "a"), ("fail", "b")])
|
||||
unresolved = repair_branch_edge_handles([_if_else("n")], edges)
|
||||
assert [u["handle"] for u in unresolved] == ["pass", "fail"]
|
||||
assert _handles(edges) == [("a", "pass"), ("b", "fail")]
|
||||
|
||||
def test_a_single_unknown_edge_with_two_free_arms_is_ambiguous(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("maybe", "a")])
|
||||
assert len(repair_branch_edge_handles([_if_else("n")], edges)) == 1
|
||||
assert _handles(edges) == [("a", "maybe")]
|
||||
|
||||
def test_three_case_if_else_with_invented_names_is_ambiguous(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("low", "a"), ("mid", "b"), ("high", "c")])
|
||||
assert len(repair_branch_edge_handles([_if_else("n", ("case1", "case2"))], edges)) == 3
|
||||
assert _handles(edges) == [("a", "low"), ("b", "mid"), ("c", "high")]
|
||||
|
||||
def test_one_exact_plus_else_alias_forces_the_last_unknown_onto_the_last_free_case(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("case1", "a"), ("whatever", "b"), ("else", "c")])
|
||||
assert repair_branch_edge_handles([_if_else("n", ("case1", "case2"))], edges) == []
|
||||
assert _handles(edges) == [("a", "case1"), ("b", "case2"), ("c", "false")]
|
||||
|
||||
def test_an_if_alias_while_true_is_taken_is_a_fan_out_not_the_else_arm(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("true", "a"), ("if", "b")])
|
||||
repair_branch_edge_handles([_if_else("n")], edges)
|
||||
assert _handles(edges) == [("a", "true"), ("b", "true")]
|
||||
|
||||
def test_the_same_unknown_handle_twice_is_one_arm_fanning_out(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = _edges("n", [("false", "a"), ("matched", "b"), ("matched", "c")])
|
||||
assert repair_branch_edge_handles([_if_else("n")], edges) == []
|
||||
assert _handles(edges) == [("a", "false"), ("b", "true"), ("c", "true")]
|
||||
|
||||
def test_question_classifier_matches_class_names_case_and_space_insensitively(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"id": "q",
|
||||
"data": {
|
||||
"type": "question-classifier",
|
||||
"classes": [{"id": "1", "name": "Billing"}, {"id": "2", "name": "Tech Support"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
edges = _edges("q", [("billing", "a"), ("tech_support", "b")])
|
||||
assert repair_branch_edge_handles(nodes, edges) == []
|
||||
assert _handles(edges) == [("a", "1"), ("b", "2")]
|
||||
|
||||
def test_human_input_matches_action_stems_then_eliminates(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"id": "h",
|
||||
"data": {
|
||||
"type": "human-input",
|
||||
"user_actions": [{"id": "approve", "title": "Approve"}, {"id": "deny", "title": "Deny"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
edges = _edges("h", [("approved", "a"), ("rejected", "b")])
|
||||
assert repair_branch_edge_handles(nodes, edges) == []
|
||||
assert _handles(edges) == [("a", "approve"), ("b", "deny")]
|
||||
|
||||
def test_a_short_stem_never_matches_by_prefix(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
# "no" is too short a stem to claim "nothing"; both arms are free, so refuse.
|
||||
nodes = [{"id": "h", "data": {"type": "human-input", "user_actions": [{"id": "no"}, {"id": "later"}]}}]
|
||||
edges = _edges("h", [("nothing", "a")])
|
||||
assert len(repair_branch_edge_handles(nodes, edges)) == 1
|
||||
|
||||
def test_alias_trust_documented_limit_a_mislabelled_else_alias_swaps_the_arms(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
# The planner put an ELSE alias on the IF-arm edge and "true" on the
|
||||
# other. Names are trusted as names: this maps "default" -> false. No
|
||||
# handle repair can detect a planner/builder semantic inversion; the
|
||||
# node-builder handle contract (prompt) is what prevents it.
|
||||
edges = _edges("n", [("default", "a"), ("true", "b")])
|
||||
repair_branch_edge_handles([_if_else("n")], edges)
|
||||
assert _handles(edges) == [("a", "false"), ("b", "true")]
|
||||
|
||||
def test_non_branch_nodes_are_untouched(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
edges = [{"source": "llm1", "target": "end", "sourceHandle": "source"}]
|
||||
assert repair_branch_edge_handles([{"id": "llm1", "data": {"type": "llm"}}], edges) == []
|
||||
assert edges[0]["sourceHandle"] == "source"
|
||||
|
||||
def test_fail_branch_node_success_on_source_and_failure_on_fail_branch_is_untouched(self):
|
||||
# (ii) R1(a): the success edge is already on "source" (what graphon
|
||||
# actually emits) and the failure edge on "fail-branch"; both are
|
||||
# exact matches, so nothing is re-homed.
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
nodes = [{"id": "h", "data": {"type": "http-request", "error_strategy": "fail-branch"}}]
|
||||
edges = _edges("h", [("source", "ok"), ("fail-branch", "err")])
|
||||
assert repair_branch_edge_handles(nodes, edges) == []
|
||||
assert _handles(edges) == [("ok", "source"), ("err", "fail-branch")]
|
||||
|
||||
def test_human_input_timeout_edge_is_untouched(self):
|
||||
# (iii) the implicit "__timeout" arm is a declared handle; an edge
|
||||
# already on it is an exact match and is left alone.
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles
|
||||
|
||||
nodes = [{"id": "h", "data": {"type": "human-input", "user_actions": [{"id": "approve"}, {"id": "deny"}]}}]
|
||||
edges = _edges("h", [("approve", "a"), ("deny", "b"), ("__timeout", "c")])
|
||||
assert repair_branch_edge_handles(nodes, edges) == []
|
||||
assert _handles(edges) == [("a", "approve"), ("b", "deny"), ("c", "__timeout")]
|
||||
|
||||
|
||||
class TestUndeclaredBranchHandles:
|
||||
def test_reports_every_edge_whose_handle_the_node_does_not_declare(self):
|
||||
from core.workflow.graph_normalizers import undeclared_branch_handles
|
||||
|
||||
graph = _esq1_303_graph() # before repair: score_equals_60 / else
|
||||
bad = undeclared_branch_handles(graph["nodes"], graph["edges"])
|
||||
|
||||
assert [(b["node_id"], b["handle"], b["target"]) for b in bad] == [
|
||||
("node2", "score_equals_60", "node3"),
|
||||
("node2", "else", "node4"),
|
||||
]
|
||||
assert bad[0]["declared"] == ["true", "false"]
|
||||
|
||||
def test_default_handles_on_branch_nodes_are_undeclared_too(self):
|
||||
from core.workflow.graph_normalizers import undeclared_branch_handles
|
||||
|
||||
edges = [{"source": "n", "target": "a"}, {"source": "n", "target": "b", "sourceHandle": "source"}]
|
||||
assert [b["handle"] for b in undeclared_branch_handles([_if_else("n")], edges)] == ["source", "source"]
|
||||
|
||||
def test_a_clean_graph_reports_nothing(self):
|
||||
from core.workflow.graph_normalizers import repair_branch_edge_handles, undeclared_branch_handles
|
||||
|
||||
graph = _esq1_303_graph()
|
||||
repair_branch_edge_handles(graph["nodes"], graph["edges"])
|
||||
assert undeclared_branch_handles(graph["nodes"], graph["edges"]) == []
|
||||
|
||||
def test_fail_branch_node_clean_edges_report_nothing(self):
|
||||
# (ii) source + fail-branch is exactly the declared set; nothing to report.
|
||||
from core.workflow.graph_normalizers import undeclared_branch_handles
|
||||
|
||||
nodes = [{"id": "h", "data": {"type": "http-request", "error_strategy": "fail-branch"}}]
|
||||
edges = _edges("h", [("source", "ok"), ("fail-branch", "err")])
|
||||
assert undeclared_branch_handles(nodes, edges) == []
|
||||
|
||||
def test_human_input_timeout_edge_is_not_reported(self):
|
||||
# (iii) "__timeout" is declared, so it is not flagged as undeclared.
|
||||
from core.workflow.graph_normalizers import undeclared_branch_handles
|
||||
|
||||
nodes = [{"id": "h", "data": {"type": "human-input", "user_actions": [{"id": "approve"}, {"id": "deny"}]}}]
|
||||
edges = _edges("h", [("approve", "a"), ("deny", "b"), ("__timeout", "c")])
|
||||
assert undeclared_branch_handles(nodes, edges) == []
|
||||
Reference in new issue
Block a user