mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat: harden /create and /refine workflow generation for edge cases (#37336)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9911ab3ef
commit
09bb87d089
@@ -44,6 +44,13 @@ class InstructionTemplatePayload(BaseModel):
|
||||
type: str = Field(..., description="Instruction template type")
|
||||
|
||||
|
||||
# Upper bound for the generator's free-text inputs. Generous for prose (a
|
||||
# detailed instruction rarely passes 2k chars) while keeping the
|
||||
# planner+builder prompts well inside every mainstream context window.
|
||||
# Mirrored by the ``maxLength`` on the frontend generator textarea.
|
||||
_MAX_INSTRUCTION_LENGTH = 10_000
|
||||
|
||||
|
||||
class WorkflowGeneratePayload(BaseModel):
|
||||
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
@@ -320,6 +327,22 @@ class WorkflowGenerateApi(Resource):
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
# Bound the prompt at the boundary too: an arbitrarily long
|
||||
# instruction (or pasted document) blows the planner/builder context
|
||||
# window and fails with an opaque provider error after two slow LLM
|
||||
# calls. The cap matches the frontend textarea's maxLength.
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": "INSTRUCTION_TOO_LONG",
|
||||
"detail": f"Instruction and ideal output must each be at most "
|
||||
f"{_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -526,13 +526,77 @@ Now emit the complete workflow graph JSON.
|
||||
"""
|
||||
|
||||
|
||||
# Node wrapper fields that carry no meaning the builder needs: pure canvas /
|
||||
# selection state, plus geometry the runner's postprocess recomputes anyway.
|
||||
# Stripping them out of the refine prompt cuts its size roughly in half on
|
||||
# hand-edited graphs — fewer tokens in, and (because the builder echoes
|
||||
# untouched nodes verbatim) far fewer tokens out, which is where the latency
|
||||
# lives.
|
||||
_PRUNED_NODE_KEYS = frozenset(
|
||||
{
|
||||
"positionAbsolute",
|
||||
"sourcePosition",
|
||||
"targetPosition",
|
||||
"selected",
|
||||
"dragging",
|
||||
"measured",
|
||||
}
|
||||
)
|
||||
|
||||
# Additionally pruned from TOP-LEVEL nodes only: the layered auto-layout
|
||||
# recomputes their position and size defaults, so the builder never needs to
|
||||
# reproduce them. Container children keep ``position`` (relative to the
|
||||
# parent, which we cannot recompute) and containers keep ``width`` /
|
||||
# ``height`` (their canvas size is real config, not a default).
|
||||
_PRUNED_TOP_LEVEL_NODE_KEYS = _PRUNED_NODE_KEYS | {"position", "width", "height"}
|
||||
|
||||
_CONTAINER_DATA_TYPES = frozenset({"iteration", "loop"})
|
||||
|
||||
# Edge fields the builder must echo; everything else (ids, zIndex,
|
||||
# sourceType / targetType, isInIteration / isInLoop markers) is recomputed
|
||||
# by the runner's postprocess from the node topology.
|
||||
_KEPT_EDGE_KEYS = ("source", "target", "sourceHandle", "targetHandle")
|
||||
|
||||
|
||||
def compact_graph_for_builder(current_graph: dict) -> dict:
|
||||
"""
|
||||
Strip canvas noise out of a draft graph before prompt injection.
|
||||
|
||||
Keeps everything semantically meaningful — ids, wrapper ``type``,
|
||||
``parentId``, the full ``data`` config, child positions, container
|
||||
sizes — and drops geometry / selection state the postprocess pass
|
||||
recomputes. The builder echoes untouched nodes verbatim, so every byte
|
||||
removed here is removed twice (prompt AND completion).
|
||||
"""
|
||||
nodes_out: list[dict] = []
|
||||
for node in current_graph.get("nodes") or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
is_child = bool(node.get("parentId"))
|
||||
is_container = isinstance(node.get("data"), dict) and node["data"].get("type") in _CONTAINER_DATA_TYPES
|
||||
pruned = _PRUNED_NODE_KEYS if (is_child or is_container) else _PRUNED_TOP_LEVEL_NODE_KEYS
|
||||
compact = {k: v for k, v in node.items() if k not in pruned}
|
||||
if is_container:
|
||||
# Container position is still recomputed by the layout pass.
|
||||
compact.pop("position", None)
|
||||
nodes_out.append(compact)
|
||||
edges_out = [
|
||||
{k: edge[k] for k in _KEPT_EDGE_KEYS if k in edge}
|
||||
for edge in (current_graph.get("edges") or [])
|
||||
if isinstance(edge, dict)
|
||||
]
|
||||
return {"nodes": nodes_out, "edges": edges_out}
|
||||
|
||||
|
||||
def format_builder_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
Refine mode: give the builder the FULL existing graph JSON so it can keep
|
||||
Refine mode: give the builder the existing graph JSON so it can keep
|
||||
every node and edge the user's change does not touch byte-for-byte — same
|
||||
ids, same config, same prompt templates. Without the full config the
|
||||
builder would regenerate untouched nodes from scratch and silently drop
|
||||
the user's hand-tuned settings.
|
||||
the user's hand-tuned settings. Canvas-only fields are stripped first
|
||||
(see ``compact_graph_for_builder``) — they're recomputed in postprocess,
|
||||
so carrying them only slows the call down.
|
||||
|
||||
Returns an empty string in create mode (no ``current_graph``); the builder
|
||||
then behaves exactly as before, constructing the graph purely from the
|
||||
@@ -540,7 +604,7 @@ def format_builder_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
if not current_graph:
|
||||
return ""
|
||||
graph_json = json.dumps(current_graph, ensure_ascii=False, separators=(",", ":"))
|
||||
graph_json = json.dumps(compact_graph_for_builder(current_graph), ensure_ascii=False, separators=(",", ":"))
|
||||
return (
|
||||
"# Existing graph to refine (JSON)\n\n"
|
||||
"You are REFINING this existing graph, NOT building from scratch. Apply "
|
||||
|
||||
@@ -70,6 +70,10 @@ logger = logging.getLogger(__name__)
|
||||
_NODE_X_OFFSET = 80
|
||||
_NODE_X_STEP = 320
|
||||
_NODE_Y = 280
|
||||
# Vertical gap between lanes when two branches share the same topological
|
||||
# depth (e.g. the two arms of an if-else). Default node height is 100, so
|
||||
# 160 leaves clear air between stacked nodes.
|
||||
_NODE_Y_STEP = 160
|
||||
_DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
|
||||
_DEFAULT_NODE_WIDTH = 244
|
||||
_DEFAULT_NODE_HEIGHT = 100
|
||||
@@ -575,34 +579,32 @@ class WorkflowGenerator:
|
||||
|
||||
# Defensive ID remap: Dify's run-time placeholder regex only accepts
|
||||
# ``[a-zA-Z0-9_]`` in the node-id slot, so anything the LLM emits with
|
||||
# hyphens (``node-1``, ``node-Kstart``, etc.) would break every
|
||||
# placeholder pointing at it. Strip hyphens out of every id + every
|
||||
# hyphens, dots, or spaces (``node-1``, ``node.2``, etc.) would break
|
||||
# every placeholder pointing at it. Sanitize every id + every
|
||||
# cross-reference (edges' ``source`` / ``target``, ``parentId``,
|
||||
# ``start_node_id`` / ``iteration_id`` / ``loop_id`` on data, and the
|
||||
# ``{{#…#}}`` and ``["node-id", "var"]`` references) BEFORE the rest
|
||||
# of the postprocess pass touches them.
|
||||
cls._strip_hyphens_from_node_ids(nodes=nodes, edges=edges)
|
||||
cls._sanitize_node_ids(nodes=nodes, edges=edges)
|
||||
|
||||
# Container-child nodes carry their own relative positions inside the
|
||||
# parent and have a special ``type`` (custom-iteration-start /
|
||||
# custom-loop-start). We must not override their positions or wrapper
|
||||
# ``type``; only top-level (parentId-less) nodes get the left-to-right
|
||||
# auto layout.
|
||||
top_level_index = 0
|
||||
# ``type``; only top-level (parentId-less) nodes get the layered
|
||||
# auto layout (x = topological depth, y = lane within the layer).
|
||||
cls._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
for node in nodes:
|
||||
cls._fill_node_defaults(node)
|
||||
if node.get("parentId"):
|
||||
# Inner node — keep whatever the LLM emitted; only fill the
|
||||
# absolutely-required defaults so the canvas can render it.
|
||||
node.setdefault("position", {"x": 0.0, "y": 0.0})
|
||||
node.setdefault("zIndex", 1002)
|
||||
node.setdefault("extent", "parent")
|
||||
else:
|
||||
node["position"] = {
|
||||
"x": float(_NODE_X_OFFSET + _NODE_X_STEP * top_level_index),
|
||||
"y": float(_NODE_Y),
|
||||
}
|
||||
top_level_index += 1
|
||||
# Inner nodes keep their LLM-emitted relative position; top-level
|
||||
# nodes were positioned by the layered layout. The setdefault only
|
||||
# fires for inner nodes without a position and for a (broken)
|
||||
# id-less node the layout pass couldn't see.
|
||||
node.setdefault("position", {"x": 0.0, "y": 0.0})
|
||||
node.setdefault("positionAbsolute", dict(node["position"]))
|
||||
node.setdefault("width", _DEFAULT_NODE_WIDTH)
|
||||
node.setdefault("height", _DEFAULT_NODE_HEIGHT)
|
||||
@@ -620,6 +622,12 @@ class WorkflowGenerator:
|
||||
if n.get("id") in inner_node_to_parent.values():
|
||||
parent_type[n["id"]] = n.get("data", {}).get("type", "")
|
||||
|
||||
# Branch nodes (if-else / question-classifier) emit one handle per
|
||||
# case; an edge leaving them on the default "source" handle dangles
|
||||
# off a handle that doesn't exist on the canvas. Repair the
|
||||
# unambiguous cases before edge ids are computed from the handles.
|
||||
cls._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
# Dedupe edges (LLMs sometimes emit the same edge twice).
|
||||
seen: set[tuple[str, str, str, str]] = set()
|
||||
deduped_edges = []
|
||||
@@ -712,14 +720,19 @@ class WorkflowGenerator:
|
||||
r"\{\{#([a-zA-Z0-9_]{1,50})\.([a-zA-Z_][a-zA-Z0-9_]{0,29}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){0,9})#\}\}"
|
||||
)
|
||||
|
||||
# Lenient sibling used only by the defensive hyphen-strip pass — it
|
||||
# allows hyphens in the node-id slot so we can rewrite the LLM's
|
||||
# ``{{#node-1.var#}}`` outputs BEFORE the strict walker sees them.
|
||||
# Lenient sibling used only by the defensive id-sanitize pass — it
|
||||
# accepts ANY character in the node-id slot (except the ``.`` separator
|
||||
# and ``#`` terminator) so we can rewrite the LLM's ``{{#node-1.var#}}``
|
||||
# / ``{{#node 2.var#}}`` outputs BEFORE the strict walker sees them.
|
||||
# Never use this for validation, only for rewriting.
|
||||
_LENIENT_VAR_REF_RE: ClassVar = re.compile(r"\{\{#([A-Za-z0-9_-]+)\.([^#]+)#\}\}")
|
||||
_LENIENT_VAR_REF_RE: ClassVar = re.compile(r"\{\{#([^#.{}]+)\.([^#]+)#\}\}")
|
||||
|
||||
# Characters the run-time placeholder regex rejects in the node-id slot.
|
||||
# Anything matching this in a node id must be sanitized away.
|
||||
_INVALID_ID_CHARS_RE: ClassVar = re.compile(r"[^a-zA-Z0-9_]")
|
||||
|
||||
# Strings inside ``data`` that look like node-id slugs and need
|
||||
# remapping when we defensively strip hyphens out of LLM-emitted ids.
|
||||
# remapping when we defensively sanitize LLM-emitted ids.
|
||||
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
|
||||
|
||||
# ``data`` keys whose value is a plain string list, never a
|
||||
@@ -860,34 +873,51 @@ class WorkflowGenerator:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _strip_hyphens_from_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
|
||||
def _sanitize_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Strip ``-`` out of every node id and rewrite every cross-reference.
|
||||
Rewrite every node id to ``[a-zA-Z0-9_]`` and fix every cross-reference.
|
||||
|
||||
Dify's run-time ``VARIABLE_PATTERN`` accepts only ``[a-zA-Z0-9_]`` in
|
||||
the node-id slot of ``{{#…#}}`` placeholders. The builder LLM often
|
||||
emits ``node-1`` style ids; left unfixed those make every placeholder
|
||||
silently fail at run time, the literal ``{{#node-1.var#}}`` survives
|
||||
into the prompt, and the LLM at run time echoes it back as the user's
|
||||
output — the bug we are here to kill.
|
||||
emits ``node-1`` style ids (and occasionally dots or spaces); left
|
||||
unfixed those make every placeholder silently fail at run time, the
|
||||
literal ``{{#node-1.var#}}`` survives into the prompt, and the LLM at
|
||||
run time echoes it back as the user's output — the bug we are here
|
||||
to kill.
|
||||
|
||||
Approach: build a one-to-one ``old → new`` map by removing hyphens,
|
||||
then rewrite (a) every node ``id``, (b) every edge ``source`` /
|
||||
``target``, (c) every ``parentId`` / ``start_node_id`` /
|
||||
``iteration_id`` / ``loop_id`` inside ``data``, (d) every
|
||||
``{{#…#}}`` reference in any string, (e) every ``["node-id", "var"]``
|
||||
value-selector list. We do NOT rename variable names — only ids.
|
||||
Approach: build a one-to-one ``old → new`` map by dropping the invalid
|
||||
characters — collision-safe: when the sanitized id is already taken
|
||||
(e.g. the builder emitted BOTH ``node-1`` and ``node1``) a numeric
|
||||
suffix keeps the two distinct instead of silently merging every
|
||||
reference onto one node. Then rewrite (a) every node ``id``, (b) every
|
||||
edge ``source`` / ``target``, (c) every ``parentId`` /
|
||||
``start_node_id`` / ``iteration_id`` / ``loop_id`` inside ``data``,
|
||||
(d) every ``{{#…#}}`` reference in any string, (e) every
|
||||
``["node-id", "var"]`` value-selector list. We do NOT rename variable
|
||||
names — only ids.
|
||||
"""
|
||||
# Build id rewrite map. Collision-safe because we just strip a single
|
||||
# character class — two different hyphenated ids ``node-1`` and
|
||||
# ``node1`` would collide, but the builder LLM has been instructed
|
||||
# to pick one style so in practice it's one or the other.
|
||||
id_map: dict[str, str] = {}
|
||||
# Ids that are already valid are reserved up front so a sanitized id
|
||||
# can never collide with an untouched sibling.
|
||||
used: set[str] = {
|
||||
n["id"] for n in nodes if isinstance(n.get("id"), str) and not cls._INVALID_ID_CHARS_RE.search(n["id"])
|
||||
}
|
||||
fallback_seq = 0
|
||||
for node in nodes:
|
||||
old = node.get("id")
|
||||
if not isinstance(old, str) or "-" not in old:
|
||||
if not isinstance(old, str) or not cls._INVALID_ID_CHARS_RE.search(old):
|
||||
continue
|
||||
new = old.replace("-", "")
|
||||
base = cls._INVALID_ID_CHARS_RE.sub("", old)
|
||||
if not base:
|
||||
# Id was nothing but invalid characters (e.g. "节点", "--").
|
||||
fallback_seq += 1
|
||||
base = f"node_{fallback_seq}"
|
||||
new = base
|
||||
suffix = 2
|
||||
while new in used:
|
||||
new = f"{base}_{suffix}"
|
||||
suffix += 1
|
||||
used.add(new)
|
||||
id_map[old] = new
|
||||
node["id"] = new
|
||||
if not id_map:
|
||||
@@ -901,10 +931,12 @@ class WorkflowGenerator:
|
||||
edge[key] = id_map[v]
|
||||
# Also rewrite the edge id if the builder emitted one referencing
|
||||
# the old ids; the dedupe pass later recomputes it anyway, but
|
||||
# rewriting here keeps logs sane.
|
||||
# rewriting here keeps logs sane. Longest-first so an id that is
|
||||
# a substring of another (``node-1`` in ``node-12``) can't corrupt
|
||||
# the longer match.
|
||||
eid = edge.get("id")
|
||||
if isinstance(eid, str):
|
||||
for old, new in id_map.items():
|
||||
for old, new in sorted(id_map.items(), key=lambda kv: -len(kv[0])):
|
||||
eid = eid.replace(old, new)
|
||||
edge["id"] = eid
|
||||
|
||||
@@ -955,6 +987,116 @@ class WorkflowGenerator:
|
||||
new_id = id_map.get(node_id, node_id)
|
||||
return f"{{{{#{new_id}.{rest}#}}}}"
|
||||
|
||||
@classmethod
|
||||
def _repair_branch_edge_handles(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Re-home edges that leave a branch node on the default "source" handle.
|
||||
|
||||
if-else exposes one source handle per ``case_id`` plus the implicit
|
||||
"false" (ELSE) handle; question-classifier exposes one per class id.
|
||||
The builder prompt documents this, but LLMs still emit the default
|
||||
handle, which renders as an edge hanging off a handle that doesn't
|
||||
exist and the branch silently never runs.
|
||||
|
||||
Repair only when unambiguous: default-handle edges are assigned to the
|
||||
node's UNUSED branch handles in declaration order, and only when there
|
||||
are at least as many unused handles as edges to fix. Anything
|
||||
ambiguous is left alone — a wrong guess that swaps the IF and ELSE
|
||||
arms is worse than a visible dangling edge.
|
||||
"""
|
||||
for node in nodes:
|
||||
data = node.get("data") or {}
|
||||
node_type = data.get("type")
|
||||
if node_type == BuiltinNodeTypes.IF_ELSE:
|
||||
branch_handles = [
|
||||
str(case["case_id"])
|
||||
for case in (data.get("cases") or [])
|
||||
if isinstance(case, dict) and case.get("case_id")
|
||||
]
|
||||
# ELSE is implicit — it has a handle even though no case
|
||||
# declares it.
|
||||
branch_handles.append("false")
|
||||
elif node_type == BuiltinNodeTypes.QUESTION_CLASSIFIER:
|
||||
branch_handles = [
|
||||
str(klass["id"])
|
||||
for klass in (data.get("classes") or [])
|
||||
if isinstance(klass, dict) and klass.get("id")
|
||||
]
|
||||
else:
|
||||
continue
|
||||
|
||||
node_id = node.get("id")
|
||||
outgoing = [e for e in edges if e.get("source") == node_id]
|
||||
taken = {e.get("sourceHandle") for e in outgoing if e.get("sourceHandle") in branch_handles}
|
||||
unused = [h for h in branch_handles if h not in taken]
|
||||
defaulted = [e for e in outgoing if e.get("sourceHandle") in (None, "", "source")]
|
||||
if not defaulted or len(defaulted) > len(unused):
|
||||
continue
|
||||
for edge, handle in zip(defaulted, unused):
|
||||
edge["sourceHandle"] = handle
|
||||
logger.info(
|
||||
"Workflow generator: re-homed default-handle edge %s -> %s onto branch handle %r",
|
||||
node_id,
|
||||
edge.get("target"),
|
||||
handle,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _layout_top_level_nodes(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Lay out top-level nodes by graph topology instead of array order.
|
||||
|
||||
x = longest-path depth from the entry layer, y = lane within the
|
||||
layer — so an if-else's two arms render as two parallel rows instead
|
||||
of overlapping on one line, and a builder that emits nodes out of
|
||||
execution order still gets a left-to-right canvas. Longest-path (not
|
||||
BFS) layering keeps a join node (variable-aggregator, end) to the
|
||||
right of its deepest branch.
|
||||
|
||||
Cycle-safe: Kahn's algorithm simply never reaches nodes on a cycle,
|
||||
and those get parked one layer past the deepest laid-out node in
|
||||
declaration order — the cycle itself is flagged by the structural
|
||||
validator afterwards.
|
||||
"""
|
||||
top_level = [n for n in nodes if not n.get("parentId") and isinstance(n.get("id"), str) and n.get("id")]
|
||||
id_set = {n["id"] for n in top_level}
|
||||
|
||||
succs: dict[str, list[str]] = {node_id: [] for node_id in id_set}
|
||||
indegree: dict[str, int] = dict.fromkeys(id_set, 0)
|
||||
seen_pairs: set[tuple[str, str]] = set()
|
||||
for edge in edges:
|
||||
src, tgt = edge.get("source"), edge.get("target")
|
||||
if not isinstance(src, str) or not isinstance(tgt, str):
|
||||
continue
|
||||
if src not in id_set or tgt not in id_set or src == tgt or (src, tgt) in seen_pairs:
|
||||
continue
|
||||
seen_pairs.add((src, tgt))
|
||||
succs[src].append(tgt)
|
||||
indegree[tgt] += 1
|
||||
|
||||
depth: dict[str, int] = {}
|
||||
queue = [n["id"] for n in top_level if indegree[n["id"]] == 0]
|
||||
for node_id in queue:
|
||||
depth[node_id] = 0
|
||||
while queue:
|
||||
cur = queue.pop(0)
|
||||
for nxt in succs[cur]:
|
||||
depth[nxt] = max(depth.get(nxt, 0), depth[cur] + 1)
|
||||
indegree[nxt] -= 1
|
||||
if indegree[nxt] == 0:
|
||||
queue.append(nxt)
|
||||
|
||||
overflow_depth = (max(depth.values()) + 1) if depth else 0
|
||||
lanes: dict[int, int] = {}
|
||||
for node in top_level:
|
||||
d = depth.get(node["id"], overflow_depth)
|
||||
lane = lanes.get(d, 0)
|
||||
lanes[d] = lane + 1
|
||||
node["position"] = {
|
||||
"x": float(_NODE_X_OFFSET + _NODE_X_STEP * d),
|
||||
"y": float(_NODE_Y + _NODE_Y_STEP * lane),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _inject_start_variable(cls, start_node: dict[str, Any], var: str) -> None:
|
||||
"""Add a default ``paragraph`` input so ``{{#start.<var>#}}`` resolves."""
|
||||
@@ -1122,6 +1264,24 @@ class WorkflowGenerator:
|
||||
errors.append(_err(WorkflowGenerateErrorCode.INVALID_SCHEMA, "Generated graph has no nodes"))
|
||||
return errors
|
||||
|
||||
# Duplicate ids make every cross-reference ambiguous (edges, variable
|
||||
# placeholders, parentId all resolve to "whichever node wins"), so a
|
||||
# graph with them is unusable no matter how the canvas renders it.
|
||||
id_counts: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
node_id = node.get("id", "")
|
||||
if node_id:
|
||||
id_counts[node_id] = id_counts.get(node_id, 0) + 1
|
||||
for node_id, count in id_counts.items():
|
||||
if count > 1:
|
||||
errors.append(
|
||||
_err(
|
||||
WorkflowGenerateErrorCode.DUPLICATE_NODE_ID,
|
||||
f"Duplicate node id {node_id!r} ({count} nodes share it)",
|
||||
node_id=node_id,
|
||||
)
|
||||
)
|
||||
|
||||
types = [node.get("data", {}).get("type", "") for node in nodes]
|
||||
starts = [t for t in types if t == BuiltinNodeTypes.START]
|
||||
if len(starts) != 1:
|
||||
@@ -1156,6 +1316,11 @@ class WorkflowGenerator:
|
||||
if tgt not in known_ids:
|
||||
errors.append(_err(WorkflowGenerateErrorCode.DANGLING_EDGE, f"Edge target node not found: {tgt!r}"))
|
||||
|
||||
# Workflow graphs must be DAGs — a directed cycle hangs or errors the
|
||||
# run, and nothing downstream of the cycle ever executes. (A "loop"
|
||||
# container is the sanctioned way to iterate; its edges are internal.)
|
||||
errors.extend(cls._collect_edge_cycle_errors(graph=graph, known_ids=known_ids))
|
||||
|
||||
# Dangling node-id references in node ``data`` (parentId, start_node_id, iteration_id, loop_id).
|
||||
errors.extend(cls._collect_dangling_id_refs(nodes=nodes, known_ids=known_ids))
|
||||
|
||||
@@ -1175,6 +1340,54 @@ class WorkflowGenerator:
|
||||
|
||||
return errors
|
||||
|
||||
@classmethod
|
||||
def _collect_edge_cycle_errors(cls, *, graph: GraphDict, known_ids: set[str]) -> list[WorkflowGenerateErrorDict]:
|
||||
"""
|
||||
Flag directed cycles among the graph's edges (Kahn's algorithm).
|
||||
|
||||
Self-loops are reported per node; a longer cycle is reported once,
|
||||
naming every node Kahn's peeling never reaches (cycle members plus
|
||||
anything downstream of them). Edges into unknown ids are ignored
|
||||
here — the dangling-edge check already covers those.
|
||||
"""
|
||||
out: list[WorkflowGenerateErrorDict] = []
|
||||
succs: dict[str, list[str]] = {node_id: [] for node_id in known_ids}
|
||||
indegree: dict[str, int] = dict.fromkeys(known_ids, 0)
|
||||
for edge in graph.get("edges", []):
|
||||
src, tgt = edge.get("source"), edge.get("target")
|
||||
if src not in known_ids or tgt not in known_ids:
|
||||
continue
|
||||
if src == tgt:
|
||||
out.append(
|
||||
_err(
|
||||
WorkflowGenerateErrorCode.GRAPH_CYCLE,
|
||||
f"Node {src!r} has an edge pointing at itself",
|
||||
node_id=src,
|
||||
)
|
||||
)
|
||||
continue
|
||||
succs[src].append(tgt)
|
||||
indegree[tgt] += 1
|
||||
|
||||
queue = [node_id for node_id, deg in indegree.items() if deg == 0]
|
||||
visited = 0
|
||||
while queue:
|
||||
cur = queue.pop()
|
||||
visited += 1
|
||||
for nxt in succs[cur]:
|
||||
indegree[nxt] -= 1
|
||||
if indegree[nxt] == 0:
|
||||
queue.append(nxt)
|
||||
if visited < len(known_ids):
|
||||
trapped = sorted(node_id for node_id, deg in indegree.items() if deg > 0)
|
||||
out.append(
|
||||
_err(
|
||||
WorkflowGenerateErrorCode.GRAPH_CYCLE,
|
||||
f"Workflow graph contains a cycle; affected nodes: {', '.join(trapped)}",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _collect_dangling_id_refs(
|
||||
cls, *, nodes: list[dict[str, Any]], known_ids: set[str]
|
||||
|
||||
@@ -19,6 +19,9 @@ class WorkflowGenerateErrorCode:
|
||||
INVALID_JSON: Final = "INVALID_JSON"
|
||||
INVALID_SCHEMA: Final = "INVALID_SCHEMA"
|
||||
EMPTY_INSTRUCTION: Final = "EMPTY_INSTRUCTION"
|
||||
INSTRUCTION_TOO_LONG: Final = "INSTRUCTION_TOO_LONG"
|
||||
DUPLICATE_NODE_ID: Final = "DUPLICATE_NODE_ID"
|
||||
GRAPH_CYCLE: Final = "GRAPH_CYCLE"
|
||||
EMPTY_PLAN: Final = "EMPTY_PLAN"
|
||||
UNKNOWN_NODE_REFERENCE: Final = "UNKNOWN_NODE_REFERENCE"
|
||||
INVALID_CONTAINER: Final = "INVALID_CONTAINER"
|
||||
|
||||
@@ -10,6 +10,8 @@ when data is present, and (3) round-trip the raw catalogue text unchanged.
|
||||
from core.workflow.generator.prompts.builder_prompts import (
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT,
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW,
|
||||
compact_graph_for_builder,
|
||||
format_builder_existing_graph_section,
|
||||
format_builder_tool_catalogue_section,
|
||||
format_plan_block,
|
||||
get_builder_system_prompt,
|
||||
@@ -182,3 +184,102 @@ class TestFormatPlanBlockParentHints:
|
||||
]
|
||||
)
|
||||
assert "parent='Ghost Container'" in out
|
||||
|
||||
|
||||
class TestCompactGraphForBuilder:
|
||||
"""
|
||||
The refine-mode existing-graph JSON is the single biggest token sink in
|
||||
the pipeline — and the builder echoes untouched nodes back, doubling the
|
||||
cost. The compactor must drop canvas noise (recomputed in postprocess)
|
||||
while keeping everything the builder genuinely has to preserve.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _graph() -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "custom",
|
||||
"position": {"x": 80, "y": 282},
|
||||
"positionAbsolute": {"x": 80, "y": 282},
|
||||
"width": 244,
|
||||
"height": 100,
|
||||
"sourcePosition": "right",
|
||||
"targetPosition": "left",
|
||||
"selected": True,
|
||||
"data": {"type": "start", "title": "Start", "variables": []},
|
||||
},
|
||||
{
|
||||
"id": "iter1",
|
||||
"type": "custom",
|
||||
"position": {"x": 400, "y": 282},
|
||||
"width": 808,
|
||||
"height": 204,
|
||||
"data": {"type": "iteration", "title": "Per Item", "start_node_id": "iter1start"},
|
||||
},
|
||||
{
|
||||
"id": "iter1start",
|
||||
"type": "custom-iteration-start",
|
||||
"parentId": "iter1",
|
||||
"position": {"x": 60, "y": 78},
|
||||
"positionAbsolute": {"x": 460, "y": 360},
|
||||
"data": {"type": "iteration-start", "title": ""},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "node1-source-iter1-target",
|
||||
"source": "node1",
|
||||
"target": "iter1",
|
||||
"sourceHandle": "source",
|
||||
"targetHandle": "target",
|
||||
"type": "custom",
|
||||
"zIndex": 0,
|
||||
"data": {"sourceType": "start", "targetType": "iteration", "isInIteration": False},
|
||||
}
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
|
||||
def test_drops_canvas_noise_from_top_level_nodes(self):
|
||||
compact = compact_graph_for_builder(self._graph())
|
||||
start = next(n for n in compact["nodes"] if n["id"] == "node1")
|
||||
for key in ("position", "positionAbsolute", "width", "height", "sourcePosition", "targetPosition", "selected"):
|
||||
assert key not in start
|
||||
# Semantics survive.
|
||||
assert start["data"]["type"] == "start"
|
||||
assert start["type"] == "custom"
|
||||
|
||||
def test_keeps_container_size_but_not_position(self):
|
||||
compact = compact_graph_for_builder(self._graph())
|
||||
container = next(n for n in compact["nodes"] if n["id"] == "iter1")
|
||||
assert container["width"] == 808
|
||||
assert container["height"] == 204
|
||||
assert "position" not in container
|
||||
|
||||
def test_keeps_child_relative_position(self):
|
||||
compact = compact_graph_for_builder(self._graph())
|
||||
child = next(n for n in compact["nodes"] if n["id"] == "iter1start")
|
||||
assert child["position"] == {"x": 60, "y": 78}
|
||||
assert child["parentId"] == "iter1"
|
||||
assert child["type"] == "custom-iteration-start"
|
||||
assert "positionAbsolute" not in child
|
||||
|
||||
def test_edges_keep_only_topology_fields(self):
|
||||
compact = compact_graph_for_builder(self._graph())
|
||||
assert compact["edges"] == [
|
||||
{"source": "node1", "target": "iter1", "sourceHandle": "source", "targetHandle": "target"}
|
||||
]
|
||||
|
||||
def test_viewport_is_dropped(self):
|
||||
assert "viewport" not in compact_graph_for_builder(self._graph())
|
||||
|
||||
def test_existing_graph_section_embeds_the_compact_graph(self):
|
||||
section = format_builder_existing_graph_section(self._graph())
|
||||
assert "Existing graph to refine" in section
|
||||
assert "positionAbsolute" not in section
|
||||
assert '"start_node_id":"iter1start"' in section
|
||||
|
||||
def test_existing_graph_section_empty_for_create_mode(self):
|
||||
assert format_builder_existing_graph_section(None) == ""
|
||||
|
||||
@@ -8,11 +8,13 @@ readable error envelope.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.generator.runner import WorkflowGenerator
|
||||
from core.workflow.generator.types import GraphDict
|
||||
|
||||
|
||||
def _llm_result(text: str) -> MagicMock:
|
||||
@@ -2495,3 +2497,427 @@ class TestWorkflowGeneratorFileVariables:
|
||||
]
|
||||
WorkflowGenerator._normalize_start_file_variables(nodes=nodes)
|
||||
assert "allowed_file_types" not in nodes[0]["data"]["variables"][0]
|
||||
|
||||
|
||||
class TestWorkflowGeneratorIdSanitization:
|
||||
"""
|
||||
Beyond hyphens: the sanitize pass must handle ANY character the run-time
|
||||
placeholder regex rejects (dots, spaces, unicode) and must stay
|
||||
collision-safe when stripping makes two ids identical — silently merging
|
||||
``node-1`` and ``node1`` would point every reference at one node.
|
||||
"""
|
||||
|
||||
def test_collision_between_stripped_and_existing_id_gets_a_suffix(self):
|
||||
nodes: list[dict[str, Any]] = [
|
||||
{"id": "node1", "data": {"type": "start", "variables": []}},
|
||||
{
|
||||
"id": "node-1",
|
||||
"data": {
|
||||
"type": "llm",
|
||||
"prompt_template": [{"role": "user", "text": "{{#node-1.text#}} and {{#node1.x#}}"}],
|
||||
},
|
||||
},
|
||||
]
|
||||
edges = [{"id": "e", "source": "node1", "target": "node-1"}]
|
||||
|
||||
WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges)
|
||||
|
||||
ids = [n["id"] for n in nodes]
|
||||
assert len(set(ids)) == 2
|
||||
assert ids[0] == "node1"
|
||||
renamed = ids[1]
|
||||
assert renamed != "node1"
|
||||
# Edge target follows the rename; references to the untouched sibling
|
||||
# stay untouched.
|
||||
assert edges[0]["target"] == renamed
|
||||
text = nodes[1]["data"]["prompt_template"][0]["text"]
|
||||
assert f"{{{{#{renamed}.text#}}}}" in text
|
||||
assert "{{#node1.x#}}" in text
|
||||
|
||||
def test_sanitizes_dots_and_spaces(self):
|
||||
nodes: list[dict[str, Any]] = [
|
||||
{"id": "step.one", "data": {"type": "start", "variables": []}},
|
||||
{
|
||||
"id": "step two",
|
||||
"data": {"type": "llm", "prompt_template": [{"role": "user", "text": "{{#step two.text#}}"}]},
|
||||
},
|
||||
]
|
||||
edges = [{"id": "e", "source": "step.one", "target": "step two"}]
|
||||
|
||||
WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges)
|
||||
|
||||
assert [n["id"] for n in nodes] == ["stepone", "steptwo"]
|
||||
assert (edges[0]["source"], edges[0]["target"]) == ("stepone", "steptwo")
|
||||
assert "{{#steptwo.text#}}" in nodes[1]["data"]["prompt_template"][0]["text"]
|
||||
|
||||
def test_id_with_no_valid_characters_gets_a_fallback(self):
|
||||
nodes = [
|
||||
{"id": "节点", "data": {"type": "start", "variables": []}},
|
||||
{"id": "node2", "data": {"type": "end", "outputs": []}},
|
||||
]
|
||||
edges = [{"id": "e", "source": "节点", "target": "node2"}]
|
||||
|
||||
WorkflowGenerator._sanitize_node_ids(nodes=nodes, edges=edges)
|
||||
|
||||
new_id = nodes[0]["id"]
|
||||
assert new_id
|
||||
assert new_id != "节点"
|
||||
assert edges[0]["source"] == new_id
|
||||
|
||||
|
||||
class TestWorkflowGeneratorLayeredLayout:
|
||||
"""
|
||||
Top-level layout is computed from topology (longest-path layering), not
|
||||
array order: branches that run in parallel share a column and stack in
|
||||
lanes, and a join lands to the right of its deepest input.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _node(node_id: str, node_type: str) -> dict:
|
||||
return {"id": node_id, "type": "custom", "data": {"type": node_type, "title": node_id}}
|
||||
|
||||
def test_diamond_branches_share_a_column_in_separate_lanes(self):
|
||||
nodes = [
|
||||
self._node("start", "start"),
|
||||
self._node("branch", "if-else"),
|
||||
self._node("a", "llm"),
|
||||
self._node("b", "llm"),
|
||||
self._node("join", "variable-aggregator"),
|
||||
]
|
||||
edges = [
|
||||
{"source": "start", "target": "branch"},
|
||||
{"source": "branch", "target": "a"},
|
||||
{"source": "branch", "target": "b"},
|
||||
{"source": "a", "target": "join"},
|
||||
{"source": "b", "target": "join"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
|
||||
pos = {n["id"]: n["position"] for n in nodes}
|
||||
assert pos["start"]["x"] < pos["branch"]["x"] < pos["a"]["x"] < pos["join"]["x"]
|
||||
# The two arms share the column but not the lane.
|
||||
assert pos["a"]["x"] == pos["b"]["x"]
|
||||
assert pos["a"]["y"] != pos["b"]["y"]
|
||||
|
||||
def test_out_of_order_node_array_still_flows_left_to_right(self):
|
||||
# Builder emitted the array end-first; topology must win.
|
||||
nodes = [
|
||||
self._node("end", "end"),
|
||||
self._node("middle", "llm"),
|
||||
self._node("start", "start"),
|
||||
]
|
||||
edges = [
|
||||
{"source": "start", "target": "middle"},
|
||||
{"source": "middle", "target": "end"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
|
||||
pos = {n["id"]: n["position"] for n in nodes}
|
||||
assert pos["start"]["x"] < pos["middle"]["x"] < pos["end"]["x"]
|
||||
|
||||
def test_join_lands_right_of_its_deepest_branch(self):
|
||||
# start → a → b → join, start → join: BFS depth would put join at 1;
|
||||
# longest-path layering must put it at 3.
|
||||
nodes = [
|
||||
self._node("start", "start"),
|
||||
self._node("a", "llm"),
|
||||
self._node("b", "llm"),
|
||||
self._node("join", "end"),
|
||||
]
|
||||
edges = [
|
||||
{"source": "start", "target": "a"},
|
||||
{"source": "a", "target": "b"},
|
||||
{"source": "b", "target": "join"},
|
||||
{"source": "start", "target": "join"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
|
||||
pos = {n["id"]: n["position"] for n in nodes}
|
||||
assert pos["join"]["x"] > pos["b"]["x"] > pos["a"]["x"] > pos["start"]["x"]
|
||||
|
||||
def test_container_children_are_not_repositioned(self):
|
||||
nodes = [
|
||||
self._node("start", "start"),
|
||||
self._node("iter", "iteration"),
|
||||
{
|
||||
"id": "inner",
|
||||
"type": "custom",
|
||||
"parentId": "iter",
|
||||
"position": {"x": 60.0, "y": 78.0},
|
||||
"data": {"type": "llm", "title": "inner"},
|
||||
},
|
||||
self._node("end", "end"),
|
||||
]
|
||||
edges = [
|
||||
{"source": "start", "target": "iter"},
|
||||
{"source": "iter", "target": "end"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
|
||||
inner = next(n for n in nodes if n["id"] == "inner")
|
||||
assert inner["position"] == {"x": 60.0, "y": 78.0}
|
||||
|
||||
def test_cycle_members_are_parked_instead_of_hanging(self):
|
||||
# A cycle must not hang the layout pass; its members get parked one
|
||||
# layer past the laid-out nodes (validation flags the cycle itself).
|
||||
nodes = [
|
||||
self._node("start", "start"),
|
||||
self._node("a", "llm"),
|
||||
self._node("b", "llm"),
|
||||
]
|
||||
edges = [
|
||||
{"source": "start", "target": "a"},
|
||||
{"source": "a", "target": "b"},
|
||||
{"source": "b", "target": "a"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._layout_top_level_nodes(nodes=nodes, edges=edges)
|
||||
|
||||
for node in nodes:
|
||||
assert "position" in node
|
||||
|
||||
|
||||
class TestWorkflowGeneratorBranchHandleRepair:
|
||||
"""
|
||||
Edges leaving if-else / question-classifier on the default "source"
|
||||
handle dangle off a handle that doesn't exist on the canvas. The repair
|
||||
pass re-homes them onto unused branch handles when (and only when) the
|
||||
assignment is unambiguous.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _if_else_node() -> dict:
|
||||
return {
|
||||
"id": "branch",
|
||||
"data": {
|
||||
"type": "if-else",
|
||||
"cases": [{"case_id": "true", "conditions": []}],
|
||||
},
|
||||
}
|
||||
|
||||
def test_assigns_true_then_false_to_default_handle_edges(self):
|
||||
nodes = [self._if_else_node()]
|
||||
edges = [
|
||||
{"source": "branch", "target": "a", "sourceHandle": "source"},
|
||||
{"source": "branch", "target": "b"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
assert edges[0]["sourceHandle"] == "true"
|
||||
assert edges[1]["sourceHandle"] == "false"
|
||||
|
||||
def test_respects_an_already_correct_handle(self):
|
||||
nodes = [self._if_else_node()]
|
||||
edges = [
|
||||
{"source": "branch", "target": "a", "sourceHandle": "true"},
|
||||
{"source": "branch", "target": "b", "sourceHandle": "source"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
assert edges[0]["sourceHandle"] == "true"
|
||||
assert edges[1]["sourceHandle"] == "false"
|
||||
|
||||
def test_leaves_ambiguous_assignments_alone(self):
|
||||
# Three default edges, only two free handles — guessing could swap
|
||||
# the IF and ELSE arms, so the repair must not touch anything.
|
||||
nodes = [self._if_else_node()]
|
||||
edges = [
|
||||
{"source": "branch", "target": "a", "sourceHandle": "source"},
|
||||
{"source": "branch", "target": "b", "sourceHandle": "source"},
|
||||
{"source": "branch", "target": "c", "sourceHandle": "source"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
assert all(e["sourceHandle"] == "source" for e in edges)
|
||||
|
||||
def test_question_classifier_uses_class_ids(self):
|
||||
nodes = [
|
||||
{
|
||||
"id": "qc",
|
||||
"data": {
|
||||
"type": "question-classifier",
|
||||
"classes": [{"id": "1", "name": "A"}, {"id": "2", "name": "B"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
edges = [
|
||||
{"source": "qc", "target": "a"},
|
||||
{"source": "qc", "target": "b"},
|
||||
]
|
||||
|
||||
WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
assert edges[0]["sourceHandle"] == "1"
|
||||
assert edges[1]["sourceHandle"] == "2"
|
||||
|
||||
def test_non_branch_nodes_are_untouched(self):
|
||||
nodes = [{"id": "llm1", "data": {"type": "llm"}}]
|
||||
edges = [{"source": "llm1", "target": "end", "sourceHandle": "source"}]
|
||||
|
||||
WorkflowGenerator._repair_branch_edge_handles(nodes=nodes, edges=edges)
|
||||
|
||||
assert edges[0]["sourceHandle"] == "source"
|
||||
|
||||
|
||||
class TestWorkflowGeneratorGraphCycleValidation:
|
||||
"""A workflow graph must be a DAG; cycles hang or error the run."""
|
||||
|
||||
def test_self_loop_is_flagged_with_the_node_id(self):
|
||||
graph = {
|
||||
"nodes": [],
|
||||
"edges": [{"source": "a", "target": "a"}],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
errors = WorkflowGenerator._collect_edge_cycle_errors(graph=cast(GraphDict, graph), known_ids={"a"})
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["code"] == "GRAPH_CYCLE"
|
||||
assert errors[0]["node_id"] == "a"
|
||||
|
||||
def test_two_node_cycle_is_flagged_once(self):
|
||||
graph = {
|
||||
"nodes": [],
|
||||
"edges": [
|
||||
{"source": "start", "target": "a"},
|
||||
{"source": "a", "target": "b"},
|
||||
{"source": "b", "target": "a"},
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
errors = WorkflowGenerator._collect_edge_cycle_errors(
|
||||
graph=cast(GraphDict, graph), known_ids={"start", "a", "b"}
|
||||
)
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["code"] == "GRAPH_CYCLE"
|
||||
assert "a" in errors[0]["detail"]
|
||||
assert "b" in errors[0]["detail"]
|
||||
|
||||
def test_acyclic_graph_produces_no_errors(self):
|
||||
graph = {
|
||||
"nodes": [],
|
||||
"edges": [
|
||||
{"source": "start", "target": "a"},
|
||||
{"source": "start", "target": "b"},
|
||||
{"source": "a", "target": "end"},
|
||||
{"source": "b", "target": "end"},
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
errors = WorkflowGenerator._collect_edge_cycle_errors(
|
||||
graph=cast(GraphDict, graph), known_ids={"start", "a", "b", "end"}
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
def test_cyclic_builder_output_surfaces_graph_cycle_code(self):
|
||||
planner = json.dumps(
|
||||
{
|
||||
"title": "t",
|
||||
"description": "d",
|
||||
"nodes": [
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "LLM", "node_type": "llm", "purpose": "x"},
|
||||
{"label": "End", "node_type": "end", "purpose": "x"},
|
||||
],
|
||||
}
|
||||
)
|
||||
builder = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "start", "title": "Start", "variables": []},
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "llm", "title": "LLM"},
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "end", "title": "End", "outputs": []},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "node1", "target": "node2"},
|
||||
{"source": "node2", "target": "node2"},
|
||||
{"source": "node2", "target": "node3"},
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
)
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.side_effect = [_llm_result(planner), _llm_result(builder)]
|
||||
|
||||
result = WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters={},
|
||||
provider="openai",
|
||||
model_name="gpt-4o",
|
||||
model_mode="chat",
|
||||
mode="workflow",
|
||||
instruction="x",
|
||||
)
|
||||
|
||||
assert any(e["code"] == "GRAPH_CYCLE" for e in result["errors"])
|
||||
|
||||
|
||||
class TestWorkflowGeneratorDuplicateNodeIds:
|
||||
"""Duplicate ids make every cross-reference ambiguous — fail loudly."""
|
||||
|
||||
def test_duplicate_ids_surface_dedicated_code(self):
|
||||
planner = json.dumps(
|
||||
{
|
||||
"title": "t",
|
||||
"description": "d",
|
||||
"nodes": [
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "End", "node_type": "end", "purpose": "x"},
|
||||
],
|
||||
}
|
||||
)
|
||||
builder = json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "start", "title": "Start", "variables": []},
|
||||
},
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "end", "title": "End", "outputs": []},
|
||||
},
|
||||
],
|
||||
"edges": [{"source": "node1", "target": "node1"}],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
)
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.side_effect = [_llm_result(planner), _llm_result(builder)]
|
||||
|
||||
result = WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters={},
|
||||
provider="openai",
|
||||
model_name="gpt-4o",
|
||||
model_mode="chat",
|
||||
mode="workflow",
|
||||
instruction="x",
|
||||
)
|
||||
|
||||
codes = {e["code"] for e in result["errors"]}
|
||||
assert "DUPLICATE_NODE_ID" in codes
|
||||
|
||||
Reference in New Issue
Block a user