Files
dify/api/services/workflow_generation/runner.py
T

3709 lines
174 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Workflow generator runner.
Planner→parallel-node-builder application pipeline. The model instance is
injected by ``WorkflowGeneratorService``; this owner coordinates generation,
source qualification, and output-contract normalization.
Pipeline:
0. ROUTER — only for large tool catalogues, extract capability queries.
1. PLANNER — short LLM call producing a high-level node list.
2. BUILDERS — bounded concurrent LLM calls producing compact node configs.
3. POSTPROC — fill safe defaults, lay nodes out left-to-right, dedupe
edge ids, and run a final structural sanity check.
Intentionally NOT here (deferred to a future iteration):
- Mermaid rendering
- Broad semantic auto-repair when multiple valid graph interpretations exist
- Multi-step validation engine with classification of fixable vs. user-required errors
- Model catalogue filtering
If quality regresses below product threshold we add those back; for now the
planner and bounded parallel node builders shipped behind cmd+k `/create` are
enough.
"""
import json
import logging
import re
import time
from collections.abc import Callable, Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from copy import deepcopy
from typing import Any, ClassVar, TypedDict, cast
from jinja2 import TemplateSyntaxError
from jinja2.sandbox import SandboxedEnvironment
from configs import dify_config
from core.workflow.generator.prompts.node_builder_prompts import (
NODE_BUILDER_USER_PROMPT,
format_mode_section,
format_node_outputs_section,
format_parallel_plan,
format_start_inputs_section,
get_node_builder_system_prompt,
)
from core.workflow.generator.prompts.node_builder_prompts import (
format_tool_catalogue_section as format_node_tool_catalogue_section,
)
from core.workflow.generator.prompts.planner_prompts import (
PLANNER_SYSTEM_PROMPT,
PLANNER_USER_PROMPT,
format_existing_graph_section,
format_ideal_output_section,
format_tool_catalogue_section,
)
from core.workflow.generator.prompts.tool_router_prompts import TOOL_ROUTER_SYSTEM_PROMPT, TOOL_ROUTER_USER_PROMPT
from core.workflow.generator.tool_catalogue import (
DIRECT_TOOL_INJECTION_LIMIT,
LegacyToolFallbackSelection,
ToolCapabilityQuery,
ToolCatalogueEntry,
find_tool_entry,
format_tool_builder_context,
format_tool_catalogue,
resolve_tool_entry,
select_legacy_fallback_selection,
select_tool_candidates,
)
from core.workflow.generator.types import (
GraphDict,
GraphViewportDict,
PlannerOutputContractDict,
PlannerResultDict,
WorkflowGenerateErrorCode,
WorkflowGenerateErrorDict,
WorkflowGenerateResultDict,
WorkflowGenerationMode,
WorkflowGenerationModeRequest,
)
from graphon.enums import BuiltinNodeTypes
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
from graphon.model_runtime.errors.invoke import (
InvokeConnectionError,
InvokeRateLimitError,
InvokeServerUnavailableError,
)
from services.dify_builder.api_tool_output_context import (
format_api_tool_code_system_rules,
format_api_tool_output_context,
format_plugin_tool_output_context,
)
from services.dify_builder.application.cognition_policy import CognitionPolicyDeniedError
from services.dify_builder.cognition_model_gateway import PolicyModelInstance
from services.dify_builder.native_agent_output_contract import declares_native_agent_output, native_agent_config_errors
from services.dify_builder.native_api_output_contract import (
collect_native_api_selector_refs,
declares_native_api_output,
is_native_api_tool,
native_api_output_error_hint,
)
from services.dify_builder.native_api_string_input_binding import normalize_native_api_string_bindings
from services.dify_builder.native_constant_output_contract import (
ConstantPlanContext,
format_native_constant_output_context,
native_constant_output_system_rules,
snapshot_native_constant_plan_context,
)
from services.dify_builder.native_http_output_contract import (
HttpPlanContext,
aliased_http_output,
declares_http_output,
format_http_output_context,
native_http_condition_problems,
native_http_variable_types,
snapshot_http_plan_context,
)
from services.workflow_generation import graph_normalizers
from services.workflow_generation.output_contracts import format_output_contracts_section, normalize_output_contracts
from services.workflow_generator_response import parse_workflow_generator_response
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
# Start-node input variable types that carry file uploads. Mirrors
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
# Backstop defaults for a file / file-list start variable when the builder
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
# (Studio rejects the draft with "supported file types is required" when it's
# empty — see ``config-var/config-modal/utils.ts``); we default to every
# standard type so no valid upload is rejected. ``custom`` is intentionally
# excluded because it would in turn require a non-empty
# ``allowed_file_extensions``. The real fix is the builder now documenting and
# emitting these fields; this is the safety net that guarantees a loadable draft.
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
# Token ceiling for the planner call when the caller didn't pin one. The plan
# includes model reasoning as well as its JSON answer for some providers.
# Leave headroom for both while bounding a runaway response. The injected
# default is limited by the model's public schema; explicit caller budgets
# and the independent Builder budget are preserved.
class _OwnerDefaultsKwargs(TypedDict, total=False):
owner_defaults: dict[str, Any]
_PLANNER_DEFAULT_MAX_TOKENS = 16384
_TOOL_ROUTER_MAX_TOKENS = 256
_MAX_OMITTED_TOOL_PREVIEW = 20
# Per-node calls trade a larger request count for a shorter critical path.
# The cap comes from ``WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS`` (default 6,
# enough to run the planner's recommended 3–6-node plans as a single wave);
# provider rate-limit bursts are absorbed by ``_invoke_with_retry``'s bounded
# backoff, and operators can dial the env var back down if their provider is
# stricter. Read at call time so tests (and live config reloads) can adjust it
# without re-importing.
def _node_builder_max_workers() -> int:
return dify_config.WORKFLOW_GENERATOR_NODE_BUILDER_MAX_WORKERS
# What an ``llm`` node publishes no matter how it is configured -- graphon's
# ``LLMNode._build_run_outputs``. ``files`` is only set when the node saved
# files, but a reference to it is legitimate either way, so it belongs here.
# ``structured_output`` is the one conditional output and is handled
# separately: it exists only with structured output enabled AND a schema.
_LLM_ENGINE_OUTPUTS = frozenset({"text", "reasoning_content", "usage", "finish_reason", "files"})
# The one llm output that is an OBJECT: its fields are addressed one segment
# deeper, as ``{{#<id>.structured_output.<field>#}}`` or the equivalent
# 3-element selector.
_STRUCTURED_OUTPUT = "structured_output"
# Start-node input types a reference may address one segment deeper. A ``file``
# resolves the engine's ``FileAttribute`` set (url / name / size / mime_type /
# transfer_method / extension / type / related_id) through
# ``VariablePool._get_file_attribute_segment``, and a ``json_object`` resolves
# nested keys through ``_get_nested_segment``.
#
# ``file-list`` is deliberately NOT here: ``ArrayFileSegment`` subclasses
# ``ArraySegment``, not ``FileSegment`` (graphon variables/segments.py:141,190),
# so the pool's ``case FileSegment()`` never matches it and
# ``_get_nested_attribute`` — which needs a mapping — returns None. A
# ``{{#start.docs.url#}}`` therefore renders as its own literal text at run
# time, which is the silently-broken graph we exist to catch. Every remaining
# input type (text-input, paragraph, number, select, checkbox) is a scalar, so
# a dotted reference to it is not a path into anything either.
_ADDRESSABLE_START_VARIABLE_TYPES = frozenset({"file", "json_object"})
_MODEL_NODE_TYPES = frozenset(
{
BuiltinNodeTypes.LLM,
BuiltinNodeTypes.QUESTION_CLASSIFIER,
BuiltinNodeTypes.PARAMETER_EXTRACTOR,
}
)
# The only node types whose output NAMES are chosen by the node's own config:
# a code node's ``outputs`` keys, a parameter-extractor's ``parameters[].name``,
# a human-input's ``inputs[].output_variable_name``, and an llm's
# ``structured_output`` schema fields, plus a native Agent caller's declared
# outputs, and a loop's ``loop_variables[].label``. Plan names remain advisory;
# actual Agent identity and output schema are validated independently against
# the built node config.
_DECLARABLE_OUTPUT_NODE_TYPES = frozenset(
{
BuiltinNodeTypes.CODE,
BuiltinNodeTypes.PARAMETER_EXTRACTOR,
BuiltinNodeTypes.HUMAN_INPUT,
BuiltinNodeTypes.LLM,
BuiltinNodeTypes.AGENT,
BuiltinNodeTypes.LOOP,
}
)
# A declared output name has to be REFERENCEABLE or declaring it only makes a
# producer expose something nothing can read. This is the run time's own
# per-segment grammar (graphon ``variable_template_parser.SELECTOR_PATTERN`` /
# the walker's ``_VAR_REF_RE``): a hyphen, a space, a dot or a leading digit
# makes the name invisible to both, and the 30-character bound doubles as the
# sanity cap on a runaway LLM payload.
_DECLARED_OUTPUT_NAME_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]{0,29}")
# Included in a trailing user message on the SECOND (and only) attempt when
# the first response failed JSON or node-config validation. Keep this
# terse — the model already has its full instructions in the original system
# message; this is a corrective nudge, not a re-statement of the spec.
_JSON_RETRY_HINT = (
"Correct the rejected response using the stage and detail below. Return ONLY a single JSON object "
"matching the required stage schema. "
"Do not include any prose, markdown code fences, comments, or trailing commas."
)
_PLANNER_SCHEMA_RETRY_HINT = (
"Your plan did not match the required topology schema. Return the complete plan again with "
"a unique non-empty id on every node and a non-empty edges array whose source and target "
"reference those ids. Return ONLY the JSON object."
)
# Provider hiccups we retry: a dropped connection, a 5xx, or a rate-limit are
# all transient — the same request usually succeeds moments later. We do NOT
# retry InvokeAuthorizationError / InvokeBadRequestError: those are permanent
# misconfigurations where a retry only adds latency and burns quota.
_TRANSIENT_INVOKE_ERRORS = (
InvokeConnectionError,
InvokeServerUnavailableError,
InvokeRateLimitError,
)
# Total invoke attempts per LLM call (1 original + retries) before we give up
# and let the error surface as the stage's MODEL_ERROR envelope.
_INVOKE_MAX_ATTEMPTS = 3
# Backoff before each retry, indexed by retry number (the wait before attempt
# 2, then before attempt 3). Short and bounded — generation is a foreground
# request, so we trade a couple of seconds for resilience, not minutes.
_INVOKE_BACKOFF_SECONDS = (0.5, 1.5)
class _StageJSONError(ValueError):
"""Raised when ``json_repair`` cannot parse a stage's response.
``stage`` is the human-readable stage name ("Planner" / "Builder") so
the outer error envelope's ``detail`` can name which step blew up.
"""
def __init__(self, stage: str, detail: str) -> None:
super().__init__(f"{stage} JSON invalid: {detail}")
self.stage = stage
class _StageSchemaError(ValueError):
"""Raised when a stage's parsed JSON violates the expected schema."""
def __init__(self, stage: str, detail: str) -> None:
super().__init__(f"{stage} schema invalid: {detail}")
self.stage = stage
def _err(code: str, detail: str, node_id: str = "") -> WorkflowGenerateErrorDict:
"""Build a structured error dict; ``node_id`` is included only when set."""
out: WorkflowGenerateErrorDict = {"code": code, "detail": detail}
if node_id:
out["node_id"] = node_id
return out
def _errors_to_str(errors: list[WorkflowGenerateErrorDict]) -> str:
"""Concatenate structured errors into the legacy single-string envelope."""
return "; ".join(e["detail"] for e in errors)
def _empty_result() -> WorkflowGenerateResultDict:
"""Fresh skeleton with no graph and no metadata — used for early returns."""
return {
"graph": {"nodes": [], "edges": [], "viewport": _DEFAULT_VIEWPORT},
"message": "",
"app_name": "",
"icon": "",
"error": "",
"errors": [],
}
def _result_with_errors(
base: WorkflowGenerateResultDict,
errors: list[WorkflowGenerateErrorDict],
) -> WorkflowGenerateResultDict:
"""Attach a structured error list to ``base``, populating the legacy ``error`` string too."""
base["errors"] = errors
base["error"] = _errors_to_str(errors)
return base
def _with_mode(result: WorkflowGenerateResultDict, mode: WorkflowGenerationMode) -> WorkflowGenerateResultDict:
"""Stamp the resolved concrete ``mode`` onto a result envelope.
``mode="auto"`` requests are resolved to a concrete mode from the planner
output; echoing it back lets the frontend pick the right app type to
create. It's present for explicit modes too so the response shape stays
uniform.
"""
result["mode"] = mode
return result
def _fallback_mode(mode: WorkflowGenerationModeRequest) -> WorkflowGenerationMode:
"""Concrete mode for envelopes emitted before the planner resolved one.
``auto`` maps to the conversational default — the same never-fail fallback
the old standalone classifier used — so ``result.mode`` never leaks the
``auto`` sentinel to the frontend.
"""
return "advanced-chat" if mode == "auto" else mode
def _planner_prompt_mode(mode: WorkflowGenerationModeRequest) -> str:
"""Mode string interpolated into the planner user prompt.
For ``auto`` the value is self-describing so the planner knows the choice
is delegated to it (system-prompt rule 15).
"""
return "auto (choose workflow or advanced-chat)" if mode == "auto" else mode
def _resolve_generation_mode(
requested: WorkflowGenerationModeRequest, plan: PlannerResultDict
) -> WorkflowGenerationMode:
"""Resolve the request mode into the concrete generation mode.
An explicit request always wins — a contradictory planner ``mode`` field is
ignored. For ``auto``: trust the planner's echoed ``mode``, else infer from
the plan's terminal node type (the structural source of truth the graph is
validated against), else fall back to the conversational default. Lenient
on purpose — a bad ``mode`` value must never fail the plan.
"""
if requested != "auto":
return requested
planner_mode = str(plan.get("mode") or "").strip().lower()
if planner_mode in ("workflow", "advanced-chat"):
return cast(WorkflowGenerationMode, planner_mode)
node_types = {str(node.get("node_type") or "") for node in plan.get("nodes") or [] if isinstance(node, dict)}
if BuiltinNodeTypes.ANSWER in node_types:
return "advanced-chat"
if BuiltinNodeTypes.END in node_types:
return "workflow"
return "advanced-chat"
def _build_plan_event(
*,
plan: PlannerResultDict,
plan_nodes: list[dict[str, Any]],
start_inputs: list[dict[str, Any]],
mode: WorkflowGenerationMode,
) -> dict[str, Any]:
"""Shape the ``plan`` event emitted before the (slower) builder runs.
Node fields are pulled defensively: the planner schema only guarantees
``node_type`` is present, so ``label`` / ``purpose`` may be missing on a
terse plan and default to empty strings.
"""
return {
"title": str(plan.get("title") or ""),
"description": str(plan.get("description") or ""),
"app_name": str(plan.get("app_name") or "").strip(),
"icon": str(plan.get("icon") or "").strip(),
"mode": mode,
"nodes": [
{
"label": str(node.get("label") or ""),
"node_type": str(node.get("node_type") or ""),
"purpose": str(node.get("purpose") or ""),
}
for node in plan_nodes
],
"start_inputs": start_inputs,
}
def _parse_node_outputs(raw: Any, plan_nodes: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Normalize the planner's optional ``node_outputs`` declaration.
The value comes straight out of an LLM, so nothing about its shape is
guaranteed: anything that is not ``{"<node id>": ["<name>", …]}`` whose
names satisfy ``_DECLARED_OUTPUT_NAME_RE`` is dropped rather than
trusted, and the whole key is optional — a plan without it degrades to
the behaviour that predates it (every isolated node builder guessing the
producers' output names).
Declarations are also restricted to ``_DECLARABLE_OUTPUT_NODE_TYPES``.
Agent plans carry only node_type, so their names are advisory promises,
never proof of native identity or actual outputs. The built Agent config
must pass the native discriminator and public output contract separately.
Other node types publish fixed engine outputs, so honouring a
declaration on one would actively point consumers at a name the engine
never exposes (an http-request node "declaring" ``response``, say).
"""
if not isinstance(raw, dict):
if raw is not None:
logger.debug("Workflow generator: ignoring node_outputs of type %s", type(raw).__name__)
return {}
declarable_ids = {
str(node.get("id") or "").strip()
for node in plan_nodes
if isinstance(node, dict) and str(node.get("node_type") or "").strip() in _DECLARABLE_OUTPUT_NODE_TYPES
}
declarable_ids.discard("")
parsed: dict[str, list[str]] = {}
for node_id, names in raw.items():
key = node_id.strip() if isinstance(node_id, str) else ""
if key not in declarable_ids:
logger.debug("Workflow generator: ignoring declared outputs for %r (not a planned producer)", node_id)
continue
if not isinstance(names, list):
logger.debug("Workflow generator: ignoring declared outputs for %r (not a list)", key)
continue
usable = [
name.strip() for name in names if isinstance(name, str) and _DECLARED_OUTPUT_NAME_RE.fullmatch(name.strip())
]
if len(usable) != len(names):
logger.debug(
"Workflow generator: dropped %d unreferenceable declared output name(s) on node %r",
len(names) - len(usable),
key,
)
# ``dict.fromkeys`` de-duplicates while preserving the planner's order:
# a repeated name would otherwise be repeated in every builder prompt.
cleaned = list(dict.fromkeys(usable))
if len(cleaned) != len(usable):
logger.debug(
"Workflow generator: collapsed %d repeated declared output name(s) on node %r",
len(usable) - len(cleaned),
key,
)
if cleaned:
parsed[key] = cleaned
return parsed
def _find_planned_tool_entry(node: dict[str, Any], entries: list[ToolCatalogueEntry]) -> ToolCatalogueEntry | None:
"""Resolve the installed tool selected by one planner node.
New planner responses carry a structured ``tool`` selector. The purpose
fallback keeps older/smaller models useful when they follow the instruction
to name ``provider/tool`` but omit the optional object.
"""
return resolve_tool_entry(entries, node.get("tool"), purpose=str(node.get("purpose") or ""))
def _planned_tool_selector_errors(
plan_nodes: list[dict[str, Any]], entries: list[ToolCatalogueEntry]
) -> list[WorkflowGenerateErrorDict]:
"""Refuse unresolved authoritative Tool identities before generation or hydration.
A supplied provider type and an installed multi-type pair require exact
resolution. Generic/no-selector plans and unique legacy prose fallbacks
remain choices for the builder. Returns ordinary UNKNOWN_TOOL diagnostics;
this check mutates neither the plan nor the graph and performs no I/O.
"""
errors: list[WorkflowGenerateErrorDict] = []
for node in plan_nodes:
selector = node.get("tool")
if node.get("node_type") == BuiltinNodeTypes.TOOL and isinstance(selector, dict):
provider = str(selector.get("provider_id") or selector.get("provider_name") or "").strip()
name = str(selector.get("tool_name") or "").strip()
native_types = {
entry["provider_type"]
for entry in entries
if entry["provider_name"] == provider and entry["tool_name"] == name
}
authoritative = selector.get("provider_type") is not None or len(native_types) > 1
if authoritative and _find_planned_tool_entry(node, entries) is None:
errors.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_TOOL,
"Planned Tool identity is unavailable or ambiguous; select an exact installed provider type.",
node_id=str(node.get("id") or ""),
)
)
return errors
def _format_omitted_tool_preview(entries: list[ToolCatalogueEntry]) -> str:
"""Build a bounded identity preview for fallback diagnostics."""
identifiers = [f"{entry['provider_name']}/{entry['tool_name']}" for entry in entries]
preview = ",".join(identifiers[:_MAX_OMITTED_TOOL_PREVIEW])
if len(identifiers) > _MAX_OMITTED_TOOL_PREVIEW:
preview = f"{preview},...(+{len(identifiers) - _MAX_OMITTED_TOOL_PREVIEW})"
return preview
def _log_tool_catalogue_fallback(
*,
reason: str,
total: int,
query_count: int,
selection: LegacyToolFallbackSelection,
started_at: float,
) -> None:
"""Record selected and omitted identities for legacy catalogue fallback."""
logger.warning(
"Workflow generator: tool catalogue selection mode=fallback reason=%s total=%s "
"query_count=%s candidates=%s omitted=%s pinned=%s limit=%s overflow=%s "
"omitted_tools=%s elapsed_ms=%.1f",
reason,
total,
query_count,
len(selection.entries),
len(selection.omitted_entries),
selection.pinned_count,
selection.limit,
selection.overflow_count,
_format_omitted_tool_preview(selection.omitted_entries),
(time.monotonic() - started_at) * 1000,
)
def _stage_error_to_envelope_code(exc: Exception) -> str:
"""Map a stage-typed exception to the result envelope's error code."""
if isinstance(exc, _StageJSONError):
return WorkflowGenerateErrorCode.INVALID_JSON
if isinstance(exc, _StageSchemaError):
return WorkflowGenerateErrorCode.INVALID_SCHEMA
return WorkflowGenerateErrorCode.MODEL_ERROR
def _reply_language_instruction(reply_language: str | None) -> str:
"""Apply an explicit session locale without changing standalone generation."""
if not reply_language:
return ""
return (
f"\n\nWrite human-facing plan title, description, app_name, node labels, purposes, "
f"input labels, and generated node title/desc in the session reply language (BCP-47: {reply_language}). "
"Keep JSON keys, enums, identifiers, variable names, selectors, and code unchanged. "
"Preserve user-supplied literal values, names, URLs, credentials, and business data. "
"This presentation language does not override a requested language for workflow output."
)
class WorkflowGenerator:
"""
Generates a Dify workflow graph from a natural-language instruction.
Domain layer — receives an already-constructed model instance. Use
``services.workflow_generator_service.WorkflowGeneratorService`` to
call this from controllers.
"""
@classmethod
def generate_workflow_graph(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationModeRequest,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
tool_catalogue_entries: list[ToolCatalogueEntry] | None = None,
installed_tools: set[tuple[str, str]] | None = None,
current_graph: dict[str, Any] | None = None,
reply_language: str | None = None,
) -> WorkflowGenerateResultDict:
"""
Run planner → node builders → postprocess and return a graph payload.
``mode`` accepts the ``"auto"`` sentinel — the planner then chooses the
concrete mode itself (echoed in its ``mode`` output field) so no extra
classification call is needed; the resolution is stamped onto the
result envelope.
``current_graph`` switches the pipeline from create mode to REFINE
mode: the existing draft graph is summarized for the planner. Node
builders receive only the config of the node they update, while configs
marked ``keep`` are reused without an LLM call. ``None`` (the default)
is plain create-from-scratch behaviour.
``tool_catalogue_text`` is an optional preformatted compatibility
override. Production calls leave it empty so the runner can dynamically
select relevant entries from ``tool_catalogue_entries`` after seeing
the instruction. Tests and internal callers may still supply text
directly, which bypasses routing.
``tool_catalogue_entries`` carries the complete catalogue as structured
server-owned metadata. Small catalogues are injected directly; large
ones are routed to a relevant subset for the planner. Tool builders,
hydration, and validation continue to use the complete inventory.
``installed_tools`` is the structural sibling — a set of
``(provider_name, tool_name)`` pairs the validator consults to reject
tool nodes the planner / builder may have hallucinated. ``None``
disables tool validation (used by unit tests and when the catalogue
build itself failed; the service-layer fallback is already empty
prompt text in that case).
Returns a dict with ``graph``, ``message``, ``error`` and ``errors``.
On any failure ``graph`` is an empty skeleton (single start node),
``error`` is the concatenated human-readable diagnostic, and
``errors`` carries machine-readable codes the frontend maps to
localised copy. Callers should toast the first localised entry from
``errors`` and keep the previous version visible.
"""
# Consume the shared event generator and keep only the final result
# envelope — ``generate_workflow_graph_stream`` shares the exact same
# pipeline so the two stay behaviourally identical. The plan event is
# ignored here.
result: WorkflowGenerateResultDict | None = None
for event_name, payload in cls._iter_generation_events(
model_instance=model_instance,
reply_language=reply_language,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
tool_catalogue_entries=tool_catalogue_entries,
installed_tools=installed_tools,
current_graph=current_graph,
):
if event_name == "result":
result = cast(WorkflowGenerateResultDict, payload)
# The event generator always emits exactly one result envelope; this
# fallback only guards against a future refactor that forgets to.
if result is None:
result = _with_mode(_empty_result(), _fallback_mode(mode))
return result
@classmethod
def generate_workflow_graph_stream(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationModeRequest,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
tool_catalogue_entries: list[ToolCatalogueEntry] | None = None,
installed_tools: set[tuple[str, str]] | None = None,
current_graph: dict[str, Any] | None = None,
reply_language: str | None = None,
) -> Iterator[tuple[str, dict[str, Any]]]:
"""
Streaming sibling of ``generate_workflow_graph``.
Yields a ``plan`` event (title / description / app_name / icon / mode /
high-level nodes / start_inputs) as soon as the planner returns, then a
final ``result`` event carrying the SAME envelope dict the non-streaming
method returns (graph / message / app_name / icon / error / errors /
mode, plus structural errors when any). On a planner / empty-plan /
builder failure only the ``result`` event is emitted — no ``plan``.
"""
yield from cls._iter_generation_events(
model_instance=model_instance,
reply_language=reply_language,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
tool_catalogue_entries=tool_catalogue_entries,
installed_tools=installed_tools,
current_graph=current_graph,
)
@classmethod
def _iter_generation_events(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationModeRequest,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
tool_catalogue_entries: list[ToolCatalogueEntry] | None = None,
installed_tools: set[tuple[str, str]] | None = None,
current_graph: dict[str, Any] | None = None,
reply_language: str | None = None,
) -> Iterator[tuple[str, dict[str, Any]]]:
"""
Drive planner → node builders → postprocess and yield generation events.
Shared core for both ``generate_workflow_graph`` (keeps only the final
``result``) and ``generate_workflow_graph_stream`` (streams every
event). Emits at most one ``plan`` event — only once the planner
produced a non-empty plan — followed by exactly one ``result`` event.
On a planner / empty-plan / builder failure it emits only the
``result`` event carrying the error envelope. Every result envelope is
stamped with the resolved concrete ``mode``.
"""
full_tool_catalogue_entries = tool_catalogue_entries or []
planner_tool_catalogue_text = cls._resolve_prompt_tool_catalogue(
model_instance=model_instance,
model_parameters=model_parameters,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
tool_catalogue_entries=full_tool_catalogue_entries,
current_graph=current_graph,
)
# ── 1. PLANNER ────────────────────────────────────────────────────
plan, plan_err = cls._run_stage(
stage="Planner",
failure_fallback_message="Failed to plan workflow",
run=lambda: cls._run_planner(
model_instance=model_instance,
reply_language=reply_language,
model_parameters=model_parameters,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=planner_tool_catalogue_text,
current_graph=current_graph,
),
)
if plan_err is not None:
failed = _with_mode(_result_with_errors(_empty_result(), [plan_err]), _fallback_mode(mode))
yield "result", cast(dict[str, Any], failed)
return
# The lambda return is non-None when no error fired — narrow it for type-checkers.
plan = cast(PlannerResultDict, plan)
# ``auto`` requests resolve here — the planner echoed its mode choice
# (or we infer it from the plan's terminal node). Explicit modes pass
# through unchanged. Everything downstream uses the concrete mode.
resolved_mode = _resolve_generation_mode(mode, plan)
plan_nodes: list[dict[str, Any]] = cast(list[dict[str, Any]], plan.get("nodes", []))
if not plan_nodes:
empty_plan = _with_mode(
_result_with_errors(
_empty_result(),
[_err(WorkflowGenerateErrorCode.EMPTY_PLAN, "Planner returned no nodes")],
),
resolved_mode,
)
yield "result", cast(dict[str, Any], empty_plan)
return
selection_errors = _planned_tool_selector_errors(plan_nodes, full_tool_catalogue_entries)
if selection_errors:
yield (
"result",
cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), selection_errors), resolved_mode)),
)
return
plan_edges = [cast(dict[str, Any], edge) for edge in (plan.get("edges") or []) if isinstance(edge, dict)]
# Planner-supplied user-input declarations. The builder uses these to
# populate ``start.data.variables`` so downstream ``{#start.<var>#}``
# references resolve at run time. Optional field — older prompts may
# omit it, in which case the postprocess walker auto-fixes references.
start_inputs: list[dict[str, Any]] = [
cast(dict[str, Any], item)
for item in (plan.get("start_inputs") or [])
if isinstance(item, dict) and (item.get("variable") or "").strip()
]
# Planner-declared producer outputs, so the isolated node builders
# agree on the names a code / parameter-extractor / human-input /
# structured-output llm node will expose. Optional and defensively
# parsed — an omitted or unusable value yields ``{}`` and every
# builder prompt is exactly the one it was before this key existed.
node_outputs = _parse_node_outputs(plan.get("node_outputs"), plan_nodes)
node_output_contracts = normalize_output_contracts(plan.get("node_output_contracts"), node_outputs, plan_nodes)
# First event the stream sees: the high-level plan, before the slower
# builder call. Non-streaming callers ignore it.
yield "plan", _build_plan_event(plan=plan, plan_nodes=plan_nodes, start_inputs=start_inputs, mode=resolved_mode)
# ── 2. BUILDER ────────────────────────────────────────────────────
builder_started_at = time.monotonic()
def build_graph() -> GraphDict:
return cls._run_parallel_node_builders(
model_instance=model_instance,
reply_language=reply_language,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=resolved_mode,
instruction=instruction,
ideal_output=ideal_output,
plan_nodes=plan_nodes,
plan_edges=plan_edges,
tool_catalogue_text=planner_tool_catalogue_text,
tool_catalogue_entries=full_tool_catalogue_entries,
start_inputs=start_inputs,
node_outputs=node_outputs,
node_output_contracts=node_output_contracts,
current_graph=current_graph,
)
graph, build_err = cls._run_stage(
stage="Builder",
failure_fallback_message="Failed to build workflow graph",
run=build_graph,
)
logger.info(
"Workflow generator: node builders completed nodes=%s elapsed_ms=%.1f",
len(plan_nodes),
(time.monotonic() - builder_started_at) * 1000,
)
if build_err is not None:
yield (
"result",
cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), [build_err]), resolved_mode)),
)
return
graph = cast(GraphDict, graph)
# ── 3. POSTPROC + VALIDATE ────────────────────────────────────────
cls._hydrate_tool_nodes(
graph=graph,
plan_nodes=plan_nodes,
tool_catalogue_entries=full_tool_catalogue_entries,
)
graph = cls._postprocess_graph(graph=graph, mode=resolved_mode, reply_language=reply_language)
# ``app_name`` / ``icon`` are planner display metadata; both default
# to "" when the LLM omits them — the FE owns the fallback.
result: WorkflowGenerateResultDict = {
"graph": graph,
"message": plan.get("description", ""),
"app_name": str(plan.get("app_name") or "").strip(),
"icon": str(plan.get("icon") or "").strip(),
"error": "",
"errors": [],
}
_with_mode(result, resolved_mode)
# Final structural sanity check — fail closed if start/end shape is
# wrong, container topology is broken, a tool was hallucinated, or a
# variable reference points at a node that won't expose it. We still
# return the partial graph so the caller can debug or salvage it.
structural_errors = cls._validate_structure(graph=graph, mode=resolved_mode, installed_tools=installed_tools)
if structural_errors:
logger.warning("Workflow generator: structural validation failed: %s", structural_errors)
yield "result", cast(dict[str, Any], _result_with_errors(result, structural_errors))
return
yield "result", cast(dict[str, Any], result)
@classmethod
def _run_stage(
cls,
*,
stage: str,
failure_fallback_message: str,
run,
) -> tuple[Any, WorkflowGenerateErrorDict | None]:
"""
Execute one pipeline stage and translate exceptions into a typed envelope entry.
Returns ``(result, None)`` on success and ``(None, error)`` on failure.
Stage-specific JSON / schema errors map to their dedicated codes; any
other exception is logged with the stack trace and mapped to
``MODEL_ERROR`` (the LLM call itself blew up — provider auth, quota,
network — caller usually wants this as a retry hint).
"""
try:
return run(), None
except (_StageJSONError, _StageSchemaError) as e:
logger.warning("Workflow generator: %s", e)
return None, _err(_stage_error_to_envelope_code(e), str(e))
except CognitionPolicyDeniedError:
raise
except Exception as e:
logger.exception("Workflow generator: %s step failed", stage.lower())
return None, _err(WorkflowGenerateErrorCode.MODEL_ERROR, f"{failure_fallback_message}: {e}")
# ------------------------------------------------------------------
# Tool routing
# ------------------------------------------------------------------
@classmethod
def _resolve_prompt_tool_catalogue(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
instruction: str,
ideal_output: str,
tool_catalogue_text: str,
tool_catalogue_entries: list[ToolCatalogueEntry],
current_graph: dict[str, Any] | None,
) -> str:
"""Return the relevant installed-tool text for the planner.
A non-empty preformatted string is an explicit compatibility override.
Production service calls pass the complete structured catalogue and let
this method dynamically route large inventories. Router failures are
intentionally non-fatal and fall back to the legacy 80-tool prompt.
"""
if tool_catalogue_text.strip():
return tool_catalogue_text
tool_count = len(tool_catalogue_entries)
if not tool_catalogue_entries:
return ""
if tool_count <= DIRECT_TOOL_INJECTION_LIMIT:
logger.info(
"Workflow generator: tool catalogue selection mode=direct total=%s query_count=0 "
"candidates=%s elapsed_ms=0.0",
tool_count,
tool_count,
)
return format_tool_catalogue(tool_catalogue_entries, max_tools=None)
started_at = time.monotonic()
explicit_text = f"{instruction}\n{ideal_output}"
query_count = 0
candidate_count = 0
try:
needs_tools, queries = cls._run_tool_router(
model_instance=model_instance,
model_parameters=model_parameters,
instruction=instruction,
ideal_output=ideal_output,
)
query_count = len(queries)
selection = select_tool_candidates(
tool_catalogue_entries,
queries if needs_tools else [],
explicit_text=explicit_text,
current_graph=current_graph,
)
candidate_count = len(selection.entries)
if selection.unmatched_queries:
fallback_selection = select_legacy_fallback_selection(
tool_catalogue_entries,
explicit_text=explicit_text,
current_graph=current_graph,
)
_log_tool_catalogue_fallback(
reason="unmatched_query",
total=tool_count,
query_count=query_count,
selection=fallback_selection,
started_at=started_at,
)
return format_tool_catalogue(fallback_selection.entries, max_tools=None)
logger.info(
"Workflow generator: tool catalogue selection mode=routed total=%s needs_tools=%s "
"query_count=%s candidates=%s pinned=%s elapsed_ms=%.1f",
tool_count,
needs_tools,
query_count,
candidate_count,
selection.pinned_count,
(time.monotonic() - started_at) * 1000,
)
return format_tool_catalogue(selection.entries, max_tools=None)
except CognitionPolicyDeniedError:
raise
except Exception as e:
fallback_selection = select_legacy_fallback_selection(
tool_catalogue_entries,
explicit_text=explicit_text,
current_graph=current_graph,
)
_log_tool_catalogue_fallback(
reason=type(e).__name__,
total=tool_count,
query_count=query_count,
selection=fallback_selection,
started_at=started_at,
)
return format_tool_catalogue(fallback_selection.entries, max_tools=None)
@classmethod
def _run_tool_router(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
instruction: str,
ideal_output: str,
) -> tuple[bool, list[ToolCapabilityQuery]]:
user_prompt = TOOL_ROUTER_USER_PROMPT.format(
instruction=instruction.strip(),
ideal_output_section=format_ideal_output_section(ideal_output),
)
owner_defaults = _clamp_for_tool_router({}) if isinstance(model_instance, PolicyModelInstance) else None
response = cls._invoke_with_retry(
model_instance=model_instance,
prompt_messages=[
SystemPromptMessage(content=TOOL_ROUTER_SYSTEM_PROMPT),
UserPromptMessage(content=user_prompt),
],
model_parameters=owner_defaults if owner_defaults is not None else _clamp_for_tool_router(model_parameters),
stage="Tool Router",
**({"owner_defaults": owner_defaults} if owner_defaults is not None else {}),
)
response_text = response.message.get_text_content() or ""
try:
parsed = parse_workflow_generator_response(response_text)
except CognitionPolicyDeniedError:
raise
except Exception as e:
raise _StageJSONError("Tool Router", str(e)) from e
if not isinstance(parsed, dict):
raise _StageJSONError("Tool Router", f"Non-object JSON: {type(parsed).__name__}")
needs_tools = parsed.get("needs_tools")
raw_queries = parsed.get("queries")
if not isinstance(needs_tools, bool):
raise _StageSchemaError("Tool Router", "needs_tools must be a boolean")
if not isinstance(raw_queries, list):
raise _StageSchemaError("Tool Router", "queries must be an array")
if not needs_tools:
return False, []
queries: list[ToolCapabilityQuery] = []
for raw_query in raw_queries[:5]:
if not isinstance(raw_query, dict):
continue
raw_capability = raw_query.get("capability")
capability = raw_capability.strip() if isinstance(raw_capability, str) else ""
raw_keywords = raw_query.get("keywords")
keywords = (
[keyword.strip() for keyword in raw_keywords[:8] if isinstance(keyword, str) and keyword.strip()]
if isinstance(raw_keywords, list)
else []
)
if capability:
queries.append(ToolCapabilityQuery(capability=capability, keywords=keywords))
if not queries:
raise _StageSchemaError("Tool Router", "needs_tools=true requires at least one capability query")
return True, queries
# ------------------------------------------------------------------
# Shared LLM call + JSON parse with one-shot retry
# ------------------------------------------------------------------
@classmethod
def _invoke_with_retry(
cls,
*,
model_instance,
prompt_messages,
model_parameters: dict[str, Any],
stage: str,
owner_defaults: dict[str, Any] | None = None,
) -> LLMResult:
"""
Invoke the LLM, retrying transient provider errors with bounded backoff.
A single dropped connection, 503, or rate-limit used to fail the whole
two-call generation; one retry recovers the dominant transient case.
Permanent errors (auth, bad-request) are NOT in
``_TRANSIENT_INVOKE_ERRORS`` so they propagate on the first attempt —
retrying a misconfigured model only adds latency and burns quota.
"""
last_exc: Exception | None = None
for attempt in range(_INVOKE_MAX_ATTEMPTS):
try:
return model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters=model_parameters,
stream=False,
**(
{"owner_defaults": owner_defaults}
if isinstance(model_instance, PolicyModelInstance) and owner_defaults is not None
else {}
),
)
except _TRANSIENT_INVOKE_ERRORS as e:
last_exc = e
if attempt >= _INVOKE_MAX_ATTEMPTS - 1:
break
delay = _INVOKE_BACKOFF_SECONDS[min(attempt, len(_INVOKE_BACKOFF_SECONDS) - 1)]
logger.info(
"Workflow generator: %s transient invoke error (attempt %s/%s): %s; retrying in %ss",
stage,
attempt + 1,
_INVOKE_MAX_ATTEMPTS,
e,
delay,
)
time.sleep(delay)
# Exhausted the retry budget — re-raise the last transient error so the
# surrounding stage maps it to a MODEL_ERROR envelope.
assert last_exc is not None
raise last_exc
@classmethod
def _invoke_and_parse_json(
cls,
*,
model_instance,
messages,
model_parameters: dict[str, Any],
stage: str,
validate: Callable[[dict[str, Any]], None] | None = None,
owner_defaults: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Parse an object and optionally validate it within one correction budget.
Why one retry only: each LLM call is expensive (full system+user
prompt), so an open retry loop would burn quota for a model that's
fundamentally returning prose instead of JSON. One retry with a
corrective hint catches the dominant failure mode — a stray
markdown fence or trailing prose — without masking a model that
simply can't follow the spec.
JSON and schema rejections share two attempts. ``validate`` must raise
``_StageSchemaError`` with a safe detail containing no response values.
The last rejection determines ``INVALID_JSON`` or ``INVALID_SCHEMA``.
Provider errors propagate through the existing invoke retry boundary.
"""
last_error: _StageJSONError | _StageSchemaError | None = None
for attempt in range(2):
# The corrective nudge goes in as a trailing USER message, not a
# system one: a system message appended after the user turn is
# silently dropped or reordered by several providers (Anthropic,
# Gemini), so the retry hint would never reach the model. A user
# turn is always the latest instruction the model answers.
attempt_messages = messages
if last_error is not None:
correction = json.dumps(
{
"instruction": _JSON_RETRY_HINT,
"stage": stage,
"code": _stage_error_to_envelope_code(last_error),
"detail": str(last_error),
}
)
attempt_messages = [*messages, UserPromptMessage(content=correction)]
response = cls._invoke_with_retry(
model_instance=model_instance,
prompt_messages=attempt_messages,
model_parameters=model_parameters,
stage=stage,
**({"owner_defaults": owner_defaults} if owner_defaults is not None else {}),
)
text = response.message.get_text_content() or ""
requested_max_tokens = model_parameters.get("max_tokens")
usage = response.usage
token_counts = (
(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)
if isinstance(usage, LLMUsage)
else (None, None, None)
)
logger.info(
"Workflow generator response metadata: stage=%s attempt=%s response_characters=%s "
"requested_max_tokens=%s effective_provider_limit=unavailable prompt_tokens=%s "
"completion_tokens=%s total_tokens=%s finish_reason=unavailable",
stage,
attempt + 1,
len(text),
requested_max_tokens if type(requested_max_tokens) is int else "unavailable",
*(value if type(value) is int else "unavailable" for value in token_counts),
)
try:
parsed = parse_workflow_generator_response(text)
except CognitionPolicyDeniedError:
raise
except Exception as e:
# Parser exception messages can embed rejected response text.
last_error = _StageJSONError(stage, f"JSON parse failed: {type(e).__name__}")
logger.info("Workflow generator: %s JSON parse failed on attempt %s", stage, attempt + 1)
continue
if isinstance(parsed, dict):
if validate is not None:
try:
validate(parsed)
except _StageSchemaError as e:
last_error = e
logger.info("Workflow generator: %s schema rejected on attempt %s", stage, attempt + 1)
continue
if attempt > 0:
logger.info("Workflow generator: %s response recovered on retry", stage)
return parsed
last_error = _StageJSONError(stage, f"Non-object JSON: {type(parsed).__name__}")
logger.info("Workflow generator: %s non-object JSON on attempt %s", stage, attempt + 1)
assert last_error is not None
raise last_error
# ------------------------------------------------------------------
# Planner
# ------------------------------------------------------------------
@classmethod
def _run_planner(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
mode: WorkflowGenerationModeRequest,
instruction: str,
ideal_output: str,
tool_catalogue_text: str,
current_graph: dict[str, Any] | None = None,
reply_language: str | None = None,
) -> PlannerResultDict:
user_prompt = PLANNER_USER_PROMPT.format(
mode=_planner_prompt_mode(mode),
instruction=instruction.strip(),
existing_graph_section=format_existing_graph_section(current_graph),
ideal_output_section=format_ideal_output_section(ideal_output),
tool_catalogue_section=format_tool_catalogue_section(tool_catalogue_text),
)
messages = [
SystemPromptMessage(
content=PLANNER_SYSTEM_PROMPT
+ "\n\n"
+ native_constant_output_system_rules()
+ _reply_language_instruction(reply_language)
),
UserPromptMessage(content=user_prompt),
]
owner_defaults = (
_clamp_for_planner({}, model_instance=model_instance)
if isinstance(model_instance, PolicyModelInstance)
else None
)
clamped_parameters = (
owner_defaults
if owner_defaults is not None
else _clamp_for_planner(
model_parameters,
model_instance=model_instance,
)
)
owner_kwargs: _OwnerDefaultsKwargs = {"owner_defaults": owner_defaults} if owner_defaults is not None else {}
parsed = cls._invoke_and_parse_json(
model_instance=model_instance,
messages=messages,
model_parameters=clamped_parameters,
stage="Planner",
**owner_kwargs,
)
try:
return cls._validate_planner_schema(parsed)
except _StageSchemaError:
logger.info("Workflow generator: planner schema invalid; retrying once")
parsed = cls._invoke_and_parse_json(
model_instance=model_instance,
messages=[*messages, UserPromptMessage(content=_PLANNER_SCHEMA_RETRY_HINT)],
model_parameters=clamped_parameters,
stage="Planner",
**owner_kwargs,
)
return cls._validate_planner_schema(parsed)
@staticmethod
def _validate_planner_schema(parsed: dict[str, Any]) -> PlannerResultDict:
"""Require the single planner contract consumed by node builders."""
nodes = parsed.get("nodes")
if not isinstance(nodes, list):
raise _StageSchemaError("Planner", "missing 'nodes' array")
if not nodes:
return cast(PlannerResultDict, parsed)
node_ids: set[str] = set()
for node in nodes:
if not isinstance(node, dict) or not node.get("node_type"):
raise _StageSchemaError("Planner", f"malformed node entry: {node!r}")
if node.get("node_type") in ("iteration-start", "loop-start"):
raise _StageSchemaError("Planner", "container start markers are owned by the generator")
node_id = node.get("id")
if not isinstance(node_id, str) or not node_id.strip():
raise _StageSchemaError("Planner", f"node missing non-empty id: {node!r}")
if node_id in node_ids:
raise _StageSchemaError("Planner", f"duplicate node id: {node_id!r}")
node_ids.add(node_id)
edges = parsed.get("edges")
if not isinstance(edges, list) or not edges:
raise _StageSchemaError("Planner", "missing non-empty 'edges' array")
for edge in edges:
if not isinstance(edge, dict):
raise _StageSchemaError("Planner", f"malformed edge entry: {edge!r}")
source = edge.get("source")
target = edge.get("target")
if not isinstance(source, str) or not isinstance(target, str):
raise _StageSchemaError("Planner", f"edge missing source or target: {edge!r}")
if source not in node_ids or target not in node_ids:
raise _StageSchemaError("Planner", f"edge references unknown node: {edge!r}")
return cast(PlannerResultDict, parsed)
# ------------------------------------------------------------------
# Builder
# ------------------------------------------------------------------
@classmethod
def _run_parallel_node_builders(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str,
plan_nodes: list[dict[str, Any]],
plan_edges: list[dict[str, Any]],
tool_catalogue_text: str,
tool_catalogue_entries: list[ToolCatalogueEntry],
start_inputs: list[dict[str, Any]],
node_outputs: dict[str, list[str]],
current_graph: dict[str, Any] | None,
reply_language: str | None = None,
node_output_contracts: dict[str, dict[str, PlannerOutputContractDict]] | None = None,
) -> GraphDict:
"""Build changed node configs concurrently and expand them into a graph.
Refine plans can mark existing nodes as ``keep``; those nodes bypass
the model entirely and retain their full data config. Every other node
gets one compact call and at most one JSON/node-config correction,
with at most ``_node_builder_max_workers()`` calls in flight. Exhausted
or permanent fragment failures abort the graph, preserving the
generator's existing fail-closed contract.
"""
http_contract_context = snapshot_http_plan_context(
plan_nodes, plan_edges, (current_graph or {}).get("nodes") or []
)
constant_contract_context = snapshot_native_constant_plan_context(
plan_nodes, plan_edges, (current_graph or {}).get("nodes") or []
)
existing_by_id = {
str(node.get("id")): node
for node in ((current_graph or {}).get("nodes") or [])
if isinstance(node, dict) and node.get("id")
}
existing_edges = [edge for edge in ((current_graph or {}).get("edges") or []) if isinstance(edge, dict)]
nodes_to_build = [
node for node in plan_nodes if not (node.get("action") == "keep" and str(node.get("id")) in existing_by_id)
]
# Shared across every builder call in this request — compute once.
plan_json = format_parallel_plan(plan_nodes, plan_edges, start_inputs)
mode_section = format_mode_section(mode)
# An llm producer's declared names are structured-output schema fields,
# which consumers must reference through ``structured_output`` — the
# rendered section needs the types to say so. Ids are stripped exactly
# as ``_parse_node_outputs`` strips its keys, so both sides of the
# lookup agree.
node_types = {
str(node.get("id") or "").strip(): str(node.get("node_type") or "").strip() for node in plan_nodes
}
configs_by_id: dict[str, dict[str, Any]] = {}
if nodes_to_build:
max_workers = min(_node_builder_max_workers(), len(nodes_to_build))
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="workflow-node-builder") as executor:
futures = {
executor.submit(
cls._run_node_builder,
model_instance=model_instance,
reply_language=reply_language,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode_section=mode_section,
instruction=instruction,
ideal_output=ideal_output,
target_node=node,
plan_json=plan_json,
tool_catalogue_text=tool_catalogue_text,
tool_catalogue_entries=tool_catalogue_entries,
start_inputs=start_inputs,
node_outputs=node_outputs,
node_output_contracts=node_output_contracts,
node_types=node_types,
existing_node=existing_by_id.get(str(node.get("id"))),
http_contract_context=http_contract_context,
constant_contract_context=constant_contract_context,
): str(node.get("id"))
for node in nodes_to_build
}
try:
for future in as_completed(futures):
node_id = futures[future]
configs_by_id[node_id] = future.result()
except BaseException:
# Fail fast: one failed fragment aborts the whole graph, so
# queued builder calls would only burn quota and delay the
# error envelope. In-flight calls cannot be interrupted;
# they finish while the pool shuts down.
for pending in futures:
pending.cancel()
raise
return cls._assemble_parallel_graph(
plan_nodes=plan_nodes,
plan_edges=plan_edges,
configs_by_id=configs_by_id,
existing_by_id=existing_by_id,
existing_edges=existing_edges,
)
@classmethod
def _run_node_builder(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode_section: str,
instruction: str,
ideal_output: str,
target_node: dict[str, Any],
plan_json: str,
tool_catalogue_text: str,
tool_catalogue_entries: list[ToolCatalogueEntry],
start_inputs: list[dict[str, Any]],
node_outputs: dict[str, list[str]],
node_types: dict[str, str],
existing_node: dict[str, Any] | None,
http_contract_context: HttpPlanContext | None = None,
constant_contract_context: ConstantPlanContext | None = None,
reply_language: str | None = None,
node_output_contracts: dict[str, dict[str, PlannerOutputContractDict]] | None = None,
) -> dict[str, Any]:
"""Generate semantic fields directly inside config for one normalized node.
The caller supplies the planned node and owns its canvas wrapper. Reject
a generated config.data wrapper within the existing one-correction budget,
before assembly or default merging can hide its misplaced fields. A second
rejection raises a schema error without returning a usable config.
"""
node_id = str(target_node.get("id") or "")
node_type = str(target_node.get("node_type") or "")
model_section = ""
if node_type in _MODEL_NODE_TYPES:
model_section = (
f"# Selected model (copy verbatim)\n\nprovider={provider}, name={model_name}, mode={model_mode}\n\n"
)
existing_config_section = ""
if existing_node:
existing_data = existing_node.get("data") if isinstance(existing_node.get("data"), dict) else {}
existing_config_section = (
"# Existing config to preserve unless the instruction changes it\n\n"
f"{json.dumps(existing_data, ensure_ascii=False, separators=(',', ':'))}\n\n"
)
tool_catalogue_section = ""
if node_type == BuiltinNodeTypes.TOOL:
selected_tool = _find_planned_tool_entry(target_node, tool_catalogue_entries)
tool_catalogue_section = (
format_tool_builder_context(selected_tool)
if selected_tool is not None
else format_node_tool_catalogue_section(tool_catalogue_text)
)
api_context = format_api_tool_output_context(
plan_json=plan_json,
target_node_id=node_id,
entries=tool_catalogue_entries,
)
tool_catalogue_section += api_context
tool_catalogue_section += format_plugin_tool_output_context(
plan_json=plan_json, target_node_id=node_id, entries=tool_catalogue_entries
)
http_system_rules, http_user_context = format_http_output_context(http_contract_context, node_id)
tool_catalogue_section += http_user_context
constant_system_rules, constant_user_context = format_native_constant_output_context(
constant_contract_context, node_id
)
tool_catalogue_section += constant_user_context
system_prompt = get_node_builder_system_prompt(node_type) + _reply_language_instruction(reply_language)
if constant_system_rules:
system_prompt += "\n\n" + constant_system_rules
if http_system_rules:
system_prompt += "\n\n" + http_system_rules
api_code_rules = format_api_tool_code_system_rules(node_type=node_type, api_context=api_context)
if api_code_rules:
system_prompt += "\n\n" + api_code_rules
user_prompt = NODE_BUILDER_USER_PROMPT.format(
node_id=node_id,
node_type=node_type,
label=str(target_node.get("label") or ""),
purpose=str(target_node.get("purpose") or ""),
instruction=instruction.strip(),
ideal_output_section=format_ideal_output_section(ideal_output),
mode_section=mode_section,
model_section=model_section,
tool_catalogue_section=tool_catalogue_section,
start_inputs_section=(
format_start_inputs_section(start_inputs) if node_type == BuiltinNodeTypes.START else ""
),
existing_config_section=existing_config_section,
# Stripped for the same reason ``node_types`` is: the declaration's
# keys are stripped, so a padded plan id must still find its own
# entry instead of silently losing its producer instruction.
node_outputs_section=(
format_node_outputs_section(node_outputs, node_id.strip(), node_types)
+ format_output_contracts_section(node_output_contracts, node_types)
),
plan_json=plan_json,
)
stage = f"Builder {node_id}"
def validate_config(parsed: dict[str, Any]) -> None:
if "config" not in parsed:
raise _StageSchemaError(stage, "missing 'config' object")
if not isinstance(parsed["config"], dict):
raise _StageSchemaError(stage, f"'config' must be an object, got {type(parsed['config']).__name__}")
if "data" in parsed["config"]:
raise _StageSchemaError(
stage,
"'config.data' is a canvas wrapper; put the node's configuration fields directly in 'config'.",
)
if node_type == BuiltinNodeTypes.TEMPLATE_TRANSFORM:
template = parsed["config"].get("template")
if not isinstance(template, str):
raise _StageSchemaError(stage, "Template 'template' must be a string")
try:
# Parse only AI-produced configs, before assembly; kept nodes
# bypass builders. Native Jinja grammar permits advanced syntax.
SandboxedEnvironment().parse(template)
except TemplateSyntaxError:
raise _StageSchemaError(
stage,
"Template must use valid Jinja syntax: reference declared variables[].variable "
"as {{ alias }}, not Dify {{#node.variable#}} placeholders.",
) from None
if node_type == BuiltinNodeTypes.AGENT:
errors = native_agent_config_errors(parsed["config"])
if errors:
raise _StageSchemaError(stage, "native Agent config errors: " + json.dumps(errors))
parsed = cls._invoke_and_parse_json(
model_instance=model_instance,
messages=[
SystemPromptMessage(content=system_prompt),
UserPromptMessage(content=user_prompt),
],
model_parameters=model_parameters,
stage=stage,
validate=validate_config,
)
config = parsed["config"]
assert isinstance(config, dict)
return config
@staticmethod
def _hydrate_tool_nodes(
*,
graph: GraphDict,
plan_nodes: list[dict[str, Any]],
tool_catalogue_entries: list[ToolCatalogueEntry],
) -> None:
"""Stamp generated tool nodes with trusted installed-tool metadata.
The model still owns variable bindings, but provider identity, plugin
metadata, parameter declarations, output schema, and form defaults are
deterministic catalogue data. Hydrating them here prevents a selected
installed plugin from becoming an unusable guessed node configuration.
Unresolved authoritative selections raise _StageSchemaError before any
graph mutation. Generation reports the same check as UNKNOWN_TOOL before
builders run, so an alternate installed Tool cannot survive as success.
"""
selection_errors = _planned_tool_selector_errors(plan_nodes, tool_catalogue_entries)
if selection_errors:
raise _StageSchemaError("Tool selection", selection_errors[0]["detail"])
if not tool_catalogue_entries:
return
planned_by_id = {
str(node.get("id") or ""): node
for node in plan_nodes
if node.get("node_type") == BuiltinNodeTypes.TOOL and node.get("id")
}
for node in graph.get("nodes") or []:
planned = planned_by_id.get(node.get("id") or "")
if planned is None:
continue
data = node.get("data")
if not isinstance(data, dict):
continue
entry = _find_planned_tool_entry(planned, tool_catalogue_entries)
if entry is None and not isinstance(planned.get("tool"), dict):
entry = find_tool_entry(
tool_catalogue_entries,
str(data.get("provider_id") or data.get("provider_name") or ""),
str(data.get("tool_name") or ""),
provider_type=str(data["provider_type"]) if data.get("provider_type") else None,
)
if entry is None:
continue
data.update(
{
"provider_id": entry["provider_name"],
"provider_name": entry["provider_name"],
"provider_type": entry["provider_type"],
"tool_name": entry["tool_name"],
"tool_label": entry["tool_label"] or entry["tool_name"],
"tool_description": entry.get("description", ""),
"tool_node_version": "2",
}
)
for field in ("plugin_id", "plugin_unique_identifier"):
value = entry.get(field, "")
if value:
data[field] = value
else:
data.pop(field, None)
parameters = deepcopy(entry.get("parameters") or [])
data["paramSchemas"] = parameters
parameter_names = [str(parameter.get("name")) for parameter in parameters if parameter.get("name")]
parameter_name_set = set(parameter_names)
llm_parameter_names = {
str(parameter.get("name"))
for parameter in parameters
if parameter.get("name") and parameter.get("form") != "form"
}
form_parameter_names = parameter_name_set - llm_parameter_names
data["params"] = dict.fromkeys(parameter_names, "")
raw_tool_parameters = data.get("tool_parameters")
data["tool_parameters"] = (
{name: value for name, value in raw_tool_parameters.items() if name in llm_parameter_names}
if isinstance(raw_tool_parameters, dict)
else {}
)
data["tool_parameters"] = normalize_native_api_string_bindings(
tool_data=data,
nodes=graph.get("nodes") or [],
parameters=parameters,
placeholder_pattern=WorkflowGenerator._VAR_REF_RE,
)
raw_configurations = data.get("tool_configurations")
configurations = (
{name: value for name, value in raw_configurations.items() if name in form_parameter_names}
if isinstance(raw_configurations, dict)
else {}
)
data["tool_configurations"] = configurations
for parameter in parameters:
if parameter.get("form") != "form" or parameter.get("default") is None:
continue
name = str(parameter.get("name") or "")
if name:
configurations.setdefault(
name,
{"type": "constant", "value": deepcopy(parameter["default"])},
)
output_schema = entry.get("output_schema") or {}
data["output_schema"] = deepcopy(output_schema)
@classmethod
def _assemble_parallel_graph(
cls,
*,
plan_nodes: list[dict[str, Any]],
plan_edges: list[dict[str, Any]],
configs_by_id: dict[str, dict[str, Any]],
existing_by_id: dict[str, dict[str, Any]],
existing_edges: list[dict[str, Any]] | None = None,
) -> GraphDict:
"""Expand compact node configs and planner topology into graph JSON.
``existing_edges`` (refine only) preserves wiring the planner cannot
express: the synthetic ``<container>start`` entry edge keeps its
existing target instead of being re-pointed at whichever child the
planner happened to list first.
"""
label_to_id = {
str(node.get("label")): str(node.get("id")) for node in plan_nodes if node.get("label") and node.get("id")
}
type_by_id = {str(node.get("id")): str(node.get("node_type") or "") for node in plan_nodes}
children_by_parent: dict[str, list[str]] = {}
nodes: list[dict[str, Any]] = []
for planned in plan_nodes:
node_id = str(planned.get("id") or "")
node_type = str(planned.get("node_type") or "")
existing = existing_by_id.get(node_id)
node: dict[str, Any]
if planned.get("action") == "keep" and existing is not None:
node = deepcopy(existing)
else:
config = dict(configs_by_id.get(node_id) or {})
for shared_key in ("type", "title", "desc", "selected"):
config.pop(shared_key, None)
if node_type == BuiltinNodeTypes.LLM and existing is None:
# New generated apps pass final text downstream by default.
# Existing nodes retain the native backward-compatible behavior.
config.setdefault("reasoning_format", "separated")
data: dict[str, Any] = {
"type": node_type,
"title": str(planned.get("label") or node_id),
"desc": str(planned.get("purpose") or ""),
**config,
}
node = deepcopy(existing) if existing is not None else {"id": node_id}
node["id"] = node_id
node["data"] = data
if node_type == "custom-note":
# A planned annotation names a canvas component, not an
# executable node. Preserve the native note representation.
node["type"] = "custom-note"
data["type"] = ""
parent_ref = str(planned.get("parent") or "")
parent_id = label_to_id.get(parent_ref, parent_ref)
if not parent_id and str(node.get("parentId") or "") in type_by_id:
# Kept nodes rarely re-state containment — recover the parent
# from the deepcopied wrapper so entry-edge synthesis still
# counts this child.
parent_id = str(node["parentId"])
if parent_id:
child_index = len(children_by_parent.get(parent_id, []))
node["parentId"] = parent_id
node.setdefault("position", {"x": 240 + 260 * child_index, "y": 60})
node.setdefault("data", {})
parent_type = type_by_id.get(parent_id)
if parent_type == BuiltinNodeTypes.ITERATION:
node["data"].setdefault("isInIteration", True)
node["data"].setdefault("iteration_id", parent_id)
elif parent_type == BuiltinNodeTypes.LOOP:
node["data"].setdefault("isInLoop", True)
node["data"].setdefault("loop_id", parent_id)
children_by_parent.setdefault(parent_id, []).append(node_id)
elif node.get("parentId"):
# The container was dropped from the plan: strip the stale
# containment markers so the kept node rejoins the top level
# (and its auto-layout) instead of pointing at a deleted parent.
for wrapper_key in ("parentId", "extent", "zIndex", "position", "positionAbsolute"):
node.pop(wrapper_key, None)
if isinstance(node.get("data"), dict):
for marker_key in ("isInIteration", "iteration_id", "isInLoop", "loop_id"):
node["data"].pop(marker_key, None)
nodes.append(node)
if node_type in cls._CONTAINER_TYPES:
start_id = f"{node_id}start"
node.setdefault("data", {})["start_node_id"] = start_id
node.setdefault("width", 808)
node.setdefault("height", 204)
node.setdefault("zIndex", 1)
is_iteration = node_type == BuiltinNodeTypes.ITERATION
nodes.append(
{
"id": start_id,
"type": "custom-iteration-start" if is_iteration else "custom-loop-start",
"parentId": node_id,
"extent": "parent",
"draggable": False,
"selectable": False,
"zIndex": 1002,
"position": {"x": 60, "y": 78},
"data": {
"type": "iteration-start" if is_iteration else "loop-start",
"title": "",
"desc": "",
"selected": False,
"isInIteration" if is_iteration else "isInLoop": True,
},
}
)
edges: list[dict[str, Any]] = []
for planned_edge in plan_edges:
edge: dict[str, Any] = {
"source": str(planned_edge.get("source") or ""),
"target": str(planned_edge.get("target") or ""),
}
source_handle = planned_edge.get("source_handle") or planned_edge.get("sourceHandle")
target_handle = planned_edge.get("target_handle") or planned_edge.get("targetHandle")
if source_handle:
edge["sourceHandle"] = str(source_handle)
if target_handle:
edge["targetHandle"] = str(target_handle)
edges.append(edge)
# Synthesize each container's entry edge. Refine keeps the existing
# entry target when it is still a child — the planner's node listing
# order says nothing about execution order inside a kept container.
planned_sources = {str(edge.get("source") or "") for edge in edges}
existing_entry_targets = {
str(edge.get("source") or ""): str(edge.get("target") or "") for edge in (existing_edges or [])
}
for parent_id, child_ids in children_by_parent.items():
start_id = f"{parent_id}start"
if not child_ids or start_id in planned_sources:
continue
preferred = existing_entry_targets.get(start_id, "")
entry_target = preferred if preferred in child_ids else child_ids[0]
edges.append({"source": start_id, "target": entry_target})
return cast(GraphDict, {"nodes": nodes, "edges": edges, "viewport": _DEFAULT_VIEWPORT})
# ------------------------------------------------------------------
# Postprocessing
# ------------------------------------------------------------------
@classmethod
def _postprocess_graph(
cls, *, graph: GraphDict, mode: WorkflowGenerationMode, reply_language: str | None = None
) -> GraphDict:
"""Fill safe defaults and apply only deterministic graph repairs."""
# Internally treat nodes/edges as untyped dicts — TypedDicts forbid the
# arbitrary-key setdefault writes we need here, but the caller only sees
# the final structurally-valid ``GraphDict`` shape.
nodes: list[dict[str, Any]] = list(cast(list[dict[str, Any]], graph.get("nodes", [])))
edges: list[dict[str, Any]] = list(cast(list[dict[str, Any]], graph.get("edges", [])))
# 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, 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._sanitize_node_ids(nodes=nodes, edges=edges)
# An LLM context accepts one selector. If the builder wires multiple
# retrieval nodes straight into an LLM but selects only one result,
# insert a template-transform fan-in that renders every result into
# one string and point the context at that output.
cls._insert_multi_retrieval_context_templates(nodes=nodes, edges=edges, reply_language=reply_language)
# 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 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("zIndex", 1002)
node.setdefault("extent", "parent")
# 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)
node.setdefault("sourcePosition", "right")
node.setdefault("targetPosition", "left")
# ``parentId`` → set of inner-node ids, so edges between siblings can be
# marked ``isInIteration`` / ``isInLoop`` with the right container id.
inner_node_to_parent: dict[str, str] = {
n["id"]: n["parentId"] for n in nodes if n.get("parentId") and n.get("id")
}
# Map parent id → its container node-type so we can pick the right flag.
parent_type: dict[str, str] = {}
for n in nodes:
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 / human-input, and any
# node with error_strategy "fail-branch") route only on the handles
# they declare. An edge on any other handle -- the default "source"
# on an if-else, or a named-but-wrong one like the planner's "else"
# against case "true" -- dangles off a handle that doesn't exist and
# its arm never runs. Re-home only what is forced (fail-closed:
# ambiguous edges are left as they are), 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 = []
for edge in edges:
cls._fill_edge_defaults(edge)
key = (
edge.get("source", ""),
edge.get("sourceHandle", "source"),
edge.get("target", ""),
edge.get("targetHandle", "target"),
)
if key in seen:
continue
seen.add(key)
edge["id"] = f"{key[0]}-{key[1]}-{key[2]}-{key[3]}"
deduped_edges.append(edge)
# Build source/target → node_type lookup so we can fill edge.data.{sourceType,targetType}
# which Dify's edge renderer needs.
type_by_id = {node.get("id", ""): node.get("data", {}).get("type", "") for node in nodes}
for edge in deduped_edges:
edge.setdefault("data", {})
edge["data"].setdefault("sourceType", type_by_id.get(edge.get("source", ""), ""))
edge["data"].setdefault("targetType", type_by_id.get(edge.get("target", ""), ""))
# An edge is "inside" a container iff both endpoints share the same
# parent. Set isInIteration / isInLoop + iteration_id / loop_id +
# zIndex so the canvas renders it inside the subgraph rather than
# at the top level. Edges connecting a container to the outside
# world keep the defaults (isInIteration=False, isInLoop=False).
src_parent = inner_node_to_parent.get(edge.get("source", ""))
tgt_parent = inner_node_to_parent.get(edge.get("target", ""))
in_iter = bool(src_parent and src_parent == tgt_parent and parent_type.get(src_parent) == "iteration")
in_loop = bool(src_parent and src_parent == tgt_parent and parent_type.get(src_parent) == "loop")
edge["data"].setdefault("isInIteration", in_iter)
edge["data"].setdefault("isInLoop", in_loop)
if in_iter:
edge["data"].setdefault("iteration_id", src_parent)
edge.setdefault("zIndex", 1002)
if in_loop:
edge["data"].setdefault("loop_id", src_parent)
edge.setdefault("zIndex", 1002)
viewport = graph.get("viewport") or _DEFAULT_VIEWPORT
# Coerce to floats in case the LLM emitted strings.
viewport = {
"x": float(viewport.get("x", 0.0)),
"y": float(viewport.get("y", 0.0)),
"zoom": float(viewport.get("zoom", 0.7)),
}
# Variable-reference walker: every ``{#node-id.var#}`` and every
# ``["node-id", "var"]`` selector must point at a variable the source
# node actually exposes — otherwise the workflow's variable resolver
# fails at run time with "variable not found". The dominant failure
# mode is a prompt that references ``{#start.url#}`` when the start
# node has ``variables: []``, so we auto-inject missing start-node
# variables. We also repair a mistaken output name when its source
# exposes one supported repair output. Native Code and audited Tool
# contracts keep illegal names unresolved for structural validation.
cls._normalize_sys_query_references(nodes=nodes, mode=mode)
cls._reconcile_variable_references(nodes=nodes, mode=mode)
# Schema backstop: a "file" / "file-list" start variable MUST carry a
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
# ("supported file types is required"). The builder is now told to set
# it, but we fill safe defaults for any variable that still lacks it so
# the generated workflow always loads and runs.
cls._normalize_start_file_variables(nodes=nodes)
# A `select` start variable with no options (e.g. a bool requirement the
# LLM emitted as an options-less select) renders as an empty, unfillable
# dropdown in the test form -> repair it to a text-input.
cls._normalize_start_select_variables(nodes=nodes)
# The deterministic heal set every pre-preflight caller shares
# (``services.workflow_generation.graph_normalizers.heal_nodes_for_preflight``), called
# as the ONE list rather than re-spelled member by member: an operator
# written the ASCII way (``>=`` -> ``≥``) and a condition value written
# as a JSON number (ESQ1-303's ``"value": 60``), the ``type`` an
# http-request body item cannot default (ESQ1-302), an ``authorization``
# with no ``type``, and a parameter-extractor ``query`` written as an
# array of selector arrays (Blocker A). A normalizer added there reaches
# this path with it, so the generator and the Builder's apply_repair
# chokepoint cannot drift.
#
# ``derive_if_else_var_types`` is called separately because the shared
# set deliberately EXCLUDES it: ``varType`` is a frontend-only operator
# hint the engine ignores, so it is not something a preflight can turn
# on -- but a generated draft is handed straight to the canvas, which
# wants it.
for changed_id in graph_normalizers.heal_nodes_for_preflight(nodes):
logger.info("Workflow generator: applied the shared pre-preflight heals to node %s", changed_id)
graph_normalizers.derive_if_else_var_types(nodes)
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
# ------------------------------------------------------------------
# Variable-reference reconciliation
# ------------------------------------------------------------------
@classmethod
def _normalize_sys_query_references(
cls,
*,
nodes: list[dict[str, Any]],
mode: WorkflowGenerationMode,
) -> None:
"""Normalize malformed ``sys.query`` references without changing their intent.
Chatflows expose ``sys.query`` directly. Plain workflows do not, so
their query references become a Start-node input and the existing
reconciliation pass declares that input immediately afterwards.
"""
if mode == "advanced-chat":
target_node_id = "sys"
else:
start_node = next(
(node for node in nodes if node.get("data", {}).get("type") == BuiltinNodeTypes.START),
None,
)
start_node_id = start_node.get("id") if start_node else None
if not isinstance(start_node_id, str) or not start_node_id:
return
target_node_id = start_node_id
for node in nodes:
data = node.get("data")
if isinstance(data, dict):
cls._normalize_sys_query_reference_in_data(data, target_node_id=target_node_id)
@classmethod
def _normalize_sys_query_reference_in_data(
cls,
value: Any,
*,
target_node_id: str,
allow_selector: bool = True,
) -> Any:
"""Rewrite query placeholders and selectors at any node-data depth.
Some node schemas genuinely store a selector inside another list --
a variable aggregator's ``variables`` (graphon: ``list[list[str]]``).
A parameter extractor's ``query`` is NOT one of these: graphon wants
``query: list[str]``, one selector, same as a question-classifier's
``query_variable_selector``. This walker still recurses into any
list, so a nested ``[["sys", "query"]]`` it is handed comes out
still nested (``[[target_node_id, "query"]]``); unwrapping that is
``services.workflow_generation.graph_normalizers.normalize_parameter_extractor_queries``'s
job, run later in ``_postprocess_graph``. Literal string-list fields
opt out so an option list such as ``["sys", "query"]`` is preserved.
"""
target_placeholder = f"{{{{#{target_node_id}.query#}}}}"
if isinstance(value, str):
return value.replace("{{#sys.query#}}", target_placeholder).replace("{{#sys,query#}}", target_placeholder)
if isinstance(value, dict):
for key, item in list(value.items()):
item_allows_selector = allow_selector and key not in cls._NON_SELECTOR_LIST_KEYS
if item_allows_selector and cls._is_sys_query_selector(item):
value[key] = [target_node_id, "query"]
continue
if (
item_allows_selector
and cls._is_selector_field(key)
and isinstance(item, str)
and cls._is_sys_query_token(item)
):
value[key] = [target_node_id, "query"]
continue
value[key] = cls._normalize_sys_query_reference_in_data(
item,
target_node_id=target_node_id,
allow_selector=item_allows_selector,
)
return value
if isinstance(value, list):
if allow_selector and cls._is_sys_query_selector(value):
return [target_node_id, "query"]
for index, item in enumerate(value):
value[index] = cls._normalize_sys_query_reference_in_data(
item,
target_node_id=target_node_id,
allow_selector=allow_selector,
)
return value
@staticmethod
def _is_sys_query_selector(value: Any) -> bool:
"""Recognize the valid selector and common one-item LLM variants."""
if value == ["sys", "query"]:
return True
if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], str):
return False
return WorkflowGenerator._is_sys_query_token(value[0])
@staticmethod
def _is_sys_query_token(value: str) -> bool:
"""Return whether a string is a compact malformed query selector."""
return value.replace(" ", "") in {"sys.query", "sys,query"}
@staticmethod
def _is_selector_field(key: str) -> bool:
"""Return whether a data field explicitly stores one selector."""
return key == "selector" or key.endswith("_selector")
# Detects ``{{#node_id.var#}}`` placeholders. We match the EXACT regex
# Dify's workflow runtime uses (see
# ``graphon.runtime.variable_pool.VARIABLE_PATTERN``):
#
# \{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}
#
# Two consequences for the generator:
# 1. Node ids MUST be ``[a-zA-Z0-9_]`` — letters, digits, underscores.
# A hyphenated id like ``node-1`` does NOT match at run time, so the
# whole ``{{#node-1.var#}}`` survives into the LLM prompt literally
# and the LLM at run time echoes it back as the answer. The
# postprocess remap below defensively rewrites any hyphen the
# builder LLM still produces.
# 2. The walker must match the same regex so we don't auto-fix
# references the runtime would never resolve anyway.
_VAR_REF_RE: ClassVar = re.compile(
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 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"\{\{#([^#.{}]+)\.([^#]+)#\}\}")
# 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 sanitize LLM-emitted ids.
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
# ``data`` keys whose SUBTREE holds no ``[node_id, var]`` value-selector —
# so the reference walker must not read a 2-element string list inside one
# as a selector. The gate is inherited by everything below the key.
#
# Node config: ``default`` holds an input's default value; ``options``
# holds select choices; the ``allowed_file_*`` keys hold a file variable's
# upload config (types / extensions / methods).
#
# JSON Schema: ``structured_output`` is an llm node's whole schema wrapper,
# and ``required`` / ``enum`` / ``type`` are the schema keywords that hold
# string arrays. A schema is DATA, never a selector, but the walker is
# generic and read ``required: ["content", "speaker_notes"]`` as a
# reference to a node called ``content`` — which aborted the entire
# generation with UNKNOWN_NODE_REFERENCE. Arity 2 was the whole trigger.
# The subtree and the keywords are both listed on purpose: the subtree
# covers everything a structured-output schema will ever grow, and the
# keywords cover the next schema-bearing node type, whose schema will not
# live under ``structured_output`` (a tool's ``output_schema``, say).
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
{
"default",
"options",
"allowed_file_types",
"allowed_file_extensions",
"allowed_file_upload_methods",
"structured_output",
"required",
"enum",
"type",
}
)
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
Apply deterministic repairs to unresolved variable references.
Missing start-node inputs are added as ``paragraph`` variables. A
DOTTED reference to a start input that cannot be addressed one segment
deeper is repaired to its root. For non-start nodes, a mistaken output
name is rewritten only for supported aliases or sole-output repairs.
Native Code and the audited code/simple_code tool keep illegal names
unresolved: a different declared output does not establish intent.
Sources with zero or multiple outputs remain untouched so
validation fails closed instead of guessing which value the workflow
should consume.
For Advanced-Chat mode, ``sys.query`` and ``sys.files`` are always
treated as resolved without any declaration. Tool nodes' parameter
references aren't validated here because we don't know each tool's
schema — the run time validates those.
"""
http_producer_ids = frozenset(selector[0] for selector, _ in native_http_variable_types(nodes))
nodes_by_id: dict[str, dict[str, Any]] = {n.get("id", ""): n for n in nodes if n.get("id")}
start_node = next(
(n for n in nodes if n.get("data", {}).get("type") == BuiltinNodeTypes.START),
None,
)
ordered_refs = cls._ordered_refs(nodes)
# PASS 1 — every missing start INPUT is declared first, before anything
# reads the start node's declarations.
#
# Pass 2 keys the dotted-reference repair on whether the root names a
# declared input, and this pass is what declares it. Doing both in one
# loop made the outcome depend on which reference the loop happened to
# reach first: a graph carrying both ``{{#start.docs#}}`` and
# ``{{#start.docs.url#}}`` with ``docs`` undeclared repaired cleanly
# when the flat form came first and aborted on UNRESOLVED_REFERENCE
# when the dotted one did. The harvest is a set, so "first" was
# ``PYTHONHASHSEED`` — the cmd+K abort this repair exists to remove,
# arriving at random. Nothing here may read what a later iteration of
# the same loop writes.
if start_node is not None:
for node_id, var in ordered_refs:
if mode == "advanced-chat" and node_id == "sys":
continue
if nodes_by_id.get(node_id) is not start_node or "." in var:
continue
if cls._declares_variable(start_node, var):
continue
cls._inject_start_variable(start_node, var)
logger.info("Workflow generator: auto-injected missing start variable %r", var)
# PASS 2 — repair what is still unresolved against the now-complete
# declarations.
for node_id, var in ordered_refs:
# Advanced-Chat system variables are always resolved.
if mode == "advanced-chat" and node_id == "sys":
continue
target = nodes_by_id.get(node_id)
if target is None:
# An edge / data dangling reference — we can't fix it; the
# structural validator picks this up if it's a topology issue.
continue
if cls._declares_variable(target, var):
continue
if start_node is not None and target is start_node:
root, _, tail = var.partition(".")
if not tail:
# Pass 1 owns the tail-less form; still undeclared here
# means the injection was declined, and there is nothing
# left to repair it to.
continue
# A DOTTED name on the start node. It is never injected: the
# run time's grammar has no dot inside a segment, so a form
# input literally called ``docs.url`` could never resolve.
#
# When the ROOT names a declared input we are here only because
# that input's type has nothing to address one segment deeper
# (``file-list`` and every scalar — see
# ``_ADDRESSABLE_START_VARIABLE_TYPES``). The dotted form never
# resolves and the root form always does, so repair it to the
# root and let the change log carry it, exactly like every
# other reference repair. Reporting it instead aborted the
# whole generation, and cmd+K ``/create`` / ``/refine`` do not
# retry.
#
# A root that names NOTHING is a genuinely invented reference
# with no value to fall back to: that one stays reported.
if not cls._declares_variable(start_node, root):
continue
replacement: str | None = root
else:
# Prefer a known output-name alias (e.g. HTTP-request `response` -> `body`);
# otherwise fall back to a rewrite only when the source has a sole output.
replacement = cls._aliased_output(
target, var, http_producer_is_unique=node_id in http_producer_ids
) or cls._sole_declared_variable(target)
if replacement is None:
continue
target_is_llm = (target.get("data") or {}).get("type") == BuiltinNodeTypes.LLM
for node in nodes:
data = node.get("data")
if isinstance(data, dict):
cls._rewrite_variable_reference_in_data(
data,
node_id=node_id,
old_variable=var,
new_variable=replacement,
target_is_llm=target_is_llm,
)
logger.info(
"Workflow generator: rewrote unresolved reference %s.%s to declared output %s.%s",
node_id,
var,
node_id,
replacement,
)
@classmethod
def _ordered_refs(cls, nodes: list[dict[str, Any]]) -> list[tuple[str, str]]:
"""Every ``(node_id, var)`` reference in the graph, in a DETERMINISTIC
order.
The harvest itself is a set — the same reference reached through two
nodes is one reference — but nothing downstream may inherit a set's
iteration order. ``_reconcile_variable_references`` mutates the start
node's declarations while it walks, and ``_collect_unresolved_refs``
builds a user-visible error list, so in both cases the order decides
the output. A set of string tuples orders by ``PYTHONHASHSEED``, which
is how a repair became a coin flip between a clean graph and an
UNRESOLVED_REFERENCE abort. Sorting costs nothing and makes both
reproducible.
"""
refs: set[tuple[str, str]] = set()
for node in nodes:
cls._collect_refs_in_data(node.get("data") or {}, refs)
return sorted(refs)
@classmethod
def _rewrite_variable_reference_in_data(
cls,
value: Any,
*,
node_id: str,
old_variable: str,
new_variable: str,
allow_selector: bool = True,
target_is_llm: bool = False,
) -> Any:
"""Rewrite one exact placeholder or selector at any data depth.
``target_is_llm`` mirrors the walker's gate on the 3-element selector:
only an ``llm`` node publishes a ``structured_output`` object, so only
there is a 3-string list a selector rather than plain data.
"""
if isinstance(value, str):
return cls._VAR_REF_RE.sub(
lambda match: (
f"{{{{#{node_id}.{new_variable}#}}}}"
if match.group(1) == node_id and match.group(2) == old_variable
else match.group(0)
),
value,
)
if isinstance(value, dict):
for key, item in list(value.items()):
value[key] = cls._rewrite_variable_reference_in_data(
item,
node_id=node_id,
old_variable=old_variable,
new_variable=new_variable,
allow_selector=allow_selector and key not in cls._NON_SELECTOR_LIST_KEYS,
target_is_llm=target_is_llm,
)
return value
if isinstance(value, list):
if allow_selector and all(isinstance(item, str) for item in value):
# Stripped on both sides: the walker strips every segment when
# it harvests a selector, so comparing raw here would find a
# padded selector unresolved and then never repair it.
segments = [item.strip() for item in value]
if len(segments) == 2 and segments == [node_id, old_variable]:
return [node_id, new_variable]
if (
target_is_llm
and len(segments) == 3
and segments[1] == _STRUCTURED_OUTPUT
and [segments[0], f"{segments[1]}.{segments[2]}"] == [node_id, old_variable]
):
# The 3-segment structured-output selector the walker
# harvests as ``structured_output.<field>``. A replacement
# is always a plain output name, so the repaired selector
# is 2 segments — exactly what the equivalent placeholder
# becomes.
#
# Gated EXACTLY like the harvest (``llm`` target, middle
# segment ``structured_output``) — an ungated rewrite
# collapsed a plain 3-string list such as an end node's
# ``["n2", "structured_output", "x"]`` data tag to 2
# elements, silently editing data the walker never
# considered a reference.
return [node_id, new_variable]
for index, item in enumerate(value):
value[index] = cls._rewrite_variable_reference_in_data(
item,
node_id=node_id,
old_variable=old_variable,
new_variable=new_variable,
allow_selector=allow_selector,
target_is_llm=target_is_llm,
)
return value
@classmethod
def _collect_refs_in_data(
cls,
value: Any,
out: set[tuple[str, str]],
*,
allow_selector: bool = True,
) -> None:
"""Recursively harvest placeholders and selectors at any data depth."""
if isinstance(value, str):
for match in cls._VAR_REF_RE.finditer(value):
node_id, var = match.group(1).strip(), match.group(2).strip()
if node_id and var:
out.add((node_id, var))
return
if isinstance(value, dict):
for k, v in value.items():
cls._collect_refs_in_data(
v,
out,
allow_selector=allow_selector and k not in cls._NON_SELECTOR_LIST_KEYS,
)
return
if isinstance(value, list):
if allow_selector and all(isinstance(item, str) for item in value):
if len(value) == 2:
node_id, var = value[0].strip(), value[1].strip()
if node_id and var:
out.add((node_id, var))
elif len(value) == 3 and value[1].strip() == _STRUCTURED_OUTPUT:
# The engine addresses an llm's structured output with a
# 3-segment selector, and the builder head now teaches that
# form — so it must be validated, not skipped. Harvested as
# the same dotted name the equivalent placeholder produces
# (``structured_output.<field>``) so both forms travel one
# path through ``_declares_variable`` and the repairer.
#
# Harvested WHATEVER the target's type: only an ``llm`` node
# publishes a ``structured_output`` object, so this selector
# pointed anywhere else cannot resolve and must be reported
# rather than skipped — ``["start", "structured_output",
# "x"]`` is exactly the invented reference this walk exists
# to catch. The type check lives on the REWRITE instead
# (``_rewrite_variable_reference_in_data``'s
# ``target_is_llm``), which is where reading a plain
# 3-string list as a selector did damage: an end node's
# ``["n2", "structured_output", "x"]`` data tag was
# collapsed to ``["n2", "a"]`` against a code node.
# Reporting is loud and recoverable; that edit was silent.
# Every OTHER 3-string list stays plain data, as before.
node_id, field = value[0].strip(), value[2].strip()
if node_id and field:
out.add((node_id, f"{_STRUCTURED_OUTPUT}.{field}"))
for item in value:
cls._collect_refs_in_data(item, out, allow_selector=allow_selector)
@staticmethod
def _llm_schema_properties(data: dict[str, Any]) -> dict[str, Any]:
"""
An ``llm`` node's structured-output schema properties, or ``{}``
when there is none. Structured-output keys land under
``structured_output.schema.properties`` (graphon's
``LLMNodeData``) when structured output is enabled. Read once here
and shared by every caller that needs to know the node's declared
schema properties or whether it has a real (non-empty) one.
"""
return ((data.get("structured_output") or {}).get("schema") or {}).get("properties") or {}
@staticmethod
def _is_builtin_simple_code(data: dict[str, Any]) -> bool:
"""The audited code/simple_code builtin emits TEXT messages only.
Node output_schema is model-controlled in the shared cmd+K path; it
cannot expand this producer's known contract. Unknown tool identities
still need the generic dynamic-output compatibility below.
"""
return (
data.get("type") == BuiltinNodeTypes.TOOL
and data.get("provider_type") == "builtin"
and (data.get("provider_id") or data.get("provider_name")) == "code"
and data.get("tool_name") == "simple_code"
)
@classmethod
def _declares_variable(cls, node: dict[str, Any], var: str) -> bool:
"""
Does ``node`` expose a variable named ``var``? Each node type
publishes outputs differently — start exposes ``data.variables``,
llm exposes the engine's fixed set (see ``_LLM_ENGINE_OUTPUTS``),
code exposes ``data.outputs`` keys, etc. Tool parameters are
validated at run time, not here.
``var`` may be DOTTED, because the walker hands us the whole tail of a
multi-segment reference (``{{#node1.doc.url#}}``,
``["node2", "structured_output", "title"]``). The engine resolves such
a reference against the segment the first two elements name
(``VariablePool.get``), so we validate the ROOT and leave the depth to
the run time. Start and llm are stricter about object outputs, and the
audited code/simple_code tool rejects traversal through scalar text.
Native API envelope declarations are owned by the output-contract policy.
"""
data = node.get("data") or {}
node_type = data.get("type")
root, _, tail = var.partition(".")
if node_type == BuiltinNodeTypes.START:
declared = next(
(v for v in (data.get("variables") or []) if isinstance(v, dict) and v.get("variable") == root),
None,
)
if declared is None:
return False
# Only a file / file-list / json_object input holds something to
# address one segment deeper; every other type is a scalar, so a
# dotted reference to it resolves to nothing at run time.
return not tail or str(declared.get("type") or "") in _ADDRESSABLE_START_VARIABLE_TYPES
if node_type == BuiltinNodeTypes.LLM:
# graphon's ``LLMNode._build_run_outputs`` (nodes/llm/node.py
# ~721-740) always publishes text / reasoning_content / usage /
# finish_reason, adds ``files`` when the node saved any, and adds
# ``structured_output`` -- ONE object, addressed as
# ``{{#<id>.structured_output.<field>#}}`` -- only when structured
# output is enabled with a schema. A schema property is NEVER a
# top-level output, so ``<llm>.<property>`` is rejected: the engine
# can't resolve it, and accepting it let a broken graph reach the
# canvas silently.
if var in _LLM_ENGINE_OUTPUTS:
return True
# ``structured_output`` is the node's ONLY object output, so it is
# the only root a dotted reference may address here; how deep the
# schema goes is the run time's business.
return root == _STRUCTURED_OUTPUT and not cls._llm_is_schema_less(data)
if node_type == BuiltinNodeTypes.AGENT:
return declares_native_agent_output(data, var)
if node_type == BuiltinNodeTypes.CODE:
return root in (data.get("outputs") or {})
if node_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
return root == "result"
if node_type == BuiltinNodeTypes.PARAMETER_EXTRACTOR:
return any(isinstance(p, dict) and p.get("name") == root for p in (data.get("parameters") or []))
if node_type == BuiltinNodeTypes.HTTP_REQUEST:
return declares_http_output(var)
if node_type == BuiltinNodeTypes.TEMPLATE_TRANSFORM:
return root == "output"
if node_type == BuiltinNodeTypes.TOOL:
if is_native_api_tool(data):
return declares_native_api_output(var)
if cls._is_builtin_simple_code(data):
# simple_code.py yields create_text_message(result), never
# named VARIABLE messages. Printed JSON is still scalar text;
# even invented node schema properties cannot declare fields.
return root in {"text", "files", "json"} and not (root == "text" and tail)
# Other providers may emit declared or dynamic VARIABLE outputs.
# Preserve the standard envelope and schema-less compatibility:
# absent/unreadable properties do not prove a custom root invalid.
if root in {"text", "files", "json"}:
return True
output_schema = data.get("output_schema")
properties = output_schema.get("properties") if isinstance(output_schema, dict) else None
if not isinstance(properties, dict) or not properties:
return True
return root in properties
if node_type == BuiltinNodeTypes.ITERATION:
# graphon's ``IterationNode`` publishes THREE names, not one:
# - ``output``: the node's own result
# (``outputs={"output": flattened_outputs}``,
# nodes/iteration/iteration_node.py:551/565/631) -- what a
# consumer OUTSIDE the container reads.
# - ``item`` / ``index``: added to a deep COPY of the pool that
# only the child engine sees (``_create_graph_engine``, :827-828),
# so they resolve for a node INSIDE the sub-pipeline.
#
# Accepting ``output`` alone meant a CORRECT ``{{#node4.item#}}``
# was read as unresolved and silently rewritten to
# ``{{#node4.output#}}`` -- this item's value replaced by the whole
# array. Scope is not enforced here because the walker carries only
# the TARGET of a reference, never the node that made it; the
# alternative to accepting all three is that silent rewrite, which
# is strictly worse than an out-of-scope ``item`` failing loudly at
# run time.
return root in {"output", "item", "index"}
if node_type == BuiltinNodeTypes.LOOP:
# A loop is NOT the same shape as an iteration: it never publishes
# ``item`` / ``index``. It adds each declared
# ``loop_variables[].label`` to the MAIN pool
# (nodes/loop/loop_node.py:279-286, so visible inside AND outside),
# and its result is those labels plus ``loop_round``
# (:489-492), alongside whatever ``data.outputs`` already declares.
#
# ``output`` stays accepted although the engine does not publish it
# under that name: dropping it would turn graphs that generate
# today into failures, which is a tightening, not this fix. This
# fix only stops a CORRECT reference being rewritten.
if root in {"output", "loop_round"}:
return True
if root in (data.get("outputs") or {}):
return True
return any(isinstance(v, dict) and v.get("label") == root for v in (data.get("loop_variables") or []))
if node_type == BuiltinNodeTypes.QUESTION_CLASSIFIER:
return root in {"class_id", "class_name"}
if node_type == BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
# Single ``text`` output: a string, or an array of strings when
# ``is_array_file`` is set.
return root == "text"
if node_type in (BuiltinNodeTypes.VARIABLE_AGGREGATOR, BuiltinNodeTypes.LEGACY_VARIABLE_AGGREGATOR):
return root == "output"
if node_type == BuiltinNodeTypes.LIST_OPERATOR:
return root in {"result", "first_record", "last_record"}
if node_type == BuiltinNodeTypes.HUMAN_INPUT:
return any(
isinstance(item, dict) and item.get("output_variable_name") == root
for item in (data.get("inputs") or [])
)
# Other node types (if-else, iteration-start, loop-start, ...) don't
# produce outputs of their own.
return False
# Well-known output-name mistakes the builder LLM makes, keyed by
# (node type, wrong name) -> correct output. Used to repair references the
# sole-output rewrite can't (a node with several outputs), but only for
# unambiguous aliases. HTTP-request nodes expose {body, status_code, headers,
# files} and never `response`; the response body a workflow wants is `body`.
_OUTPUT_ALIASES: dict[tuple[str, str], str] = {
# Preserve the legacy generic-tool alias for unknown providers. Known
# API and code/simple_code contracts are excluded in _aliased_output.
(BuiltinNodeTypes.TOOL, "result"): "text",
# The structured-output family a builder invents when it planned
# structured output for this LLM node but the node ended up
# schema-less (never enabled, or enabled with an empty schema -- see
# ``_llm_is_schema_less``). Gated in ``_aliased_output``: a node
# that genuinely has structured output enabled keeps these names
# unresolved rather than guessing at a schema property.
(BuiltinNodeTypes.LLM, "__structured_output__"): "text",
(BuiltinNodeTypes.LLM, "__structured_output"): "text",
(BuiltinNodeTypes.LLM, "structured_output"): "text",
(BuiltinNodeTypes.LLM, "output"): "text",
(BuiltinNodeTypes.LLM, "result"): "text",
}
@classmethod
def _llm_is_schema_less(cls, data: dict[str, Any]) -> bool:
"""
Whether an ``llm`` node's ``data`` describes a node with no usable
structured output. True when the enable flag,
``structured_output_enabled`` (the wire/JSON key graphon's
``LLMNodeData`` serialises -- its internal attribute name,
``structured_output_switch_on``, is never the dict key a raw graph
carries), is falsy, OR the ``structured_output`` schema carries no
properties. Either condition alone makes the structured-output
family not a real output here (e.g. a schema block left behind by
an isolated node-builder call that never turned the flag on), so an
invented reference to it should fall back to the node's actual
(default) output instead of staying unresolved.
"""
if not data.get("structured_output_enabled"):
return True
return not cls._llm_schema_properties(data)
@classmethod
def _aliased_output(cls, node: dict[str, Any], var: str, *, http_producer_is_unique: bool = True) -> str | None:
"""Map a known-wrong output name to the correct one for this node type,
but only when that correct output is actually declared (safety)."""
data = node.get("data") or {}
node_type = data.get("type")
if is_native_api_tool(data) or cls._is_builtin_simple_code(data) or not isinstance(node_type, str):
return None
if node_type == BuiltinNodeTypes.HTTP_REQUEST:
return aliased_http_output(data, var, producer_is_unique=http_producer_is_unique)
replacement = cls._OUTPUT_ALIASES.get((node_type, var))
if replacement is None:
return None
if node_type == BuiltinNodeTypes.LLM and not cls._llm_is_schema_less(data):
# A real structured-output LLM node may genuinely mean one of
# these names as a schema property; only a schema-less node
# gets the invented name rewritten.
return None
if cls._declares_variable(node, replacement):
return replacement
return None
@classmethod
def _sole_declared_variable(cls, node: dict[str, Any]) -> str | None:
"""Return a supported sole-output repair target, or ``None`` when unsafe."""
data = node.get("data") or {}
node_type = data.get("type")
if node_type == BuiltinNodeTypes.LLM:
# Schema-less means the enable FLAG is off or the schema is empty
# (``_llm_is_schema_less``), not merely "carries no properties": a
# node left with a leftover schema by an isolated builder call
# still produces nothing but ``text``, and reading schema presence
# alone left its consumers' references unresolved -- now a
# generation failure rather than a silent run-time break.
return "text" if cls._llm_is_schema_less(data) else None
if node_type == BuiltinNodeTypes.CODE:
# Output names carry the code's contract, even with a sole output.
# Guessing a replacement can turn a boolean/array reference into
# an unrelated value and hide the error from structural validation.
return None
if node_type == BuiltinNodeTypes.PARAMETER_EXTRACTOR:
parameters = [
parameter.get("name")
for parameter in (data.get("parameters") or [])
if isinstance(parameter, dict) and isinstance(parameter.get("name"), str)
]
return parameters[0] if len(parameters) == 1 else None
if node_type == BuiltinNodeTypes.HUMAN_INPUT:
human_outputs: list[str] = []
for item in data.get("inputs") or []:
if not isinstance(item, dict):
continue
output_name = item.get("output_variable_name")
if isinstance(output_name, str):
human_outputs.append(output_name)
return human_outputs[0] if len(human_outputs) == 1 else None
if not isinstance(node_type, str):
return None
# ``iteration`` and ``loop`` are deliberately absent: an iteration
# publishes ``output`` / ``item`` / ``index`` and a loop publishes
# ``loop_round`` plus each declared loop variable, so neither has a
# SOLE output to fall back on. Guessing ``output`` for them is exactly
# the "which value did the workflow mean?" this helper refuses to
# answer for every other multi-output type -- and it is how a correct
# ``{{#node4.item#}}`` got silently rewritten to the whole array.
single_output_by_type: dict[str, str] = {
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: "result",
BuiltinNodeTypes.TEMPLATE_TRANSFORM: "output",
BuiltinNodeTypes.DOCUMENT_EXTRACTOR: "text",
BuiltinNodeTypes.VARIABLE_AGGREGATOR: "output",
BuiltinNodeTypes.LEGACY_VARIABLE_AGGREGATOR: "output",
}
return single_output_by_type.get(node_type)
@classmethod
def _sanitize_node_ids(cls, *, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None:
"""
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 (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 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.
"""
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 cls._INVALID_ID_CHARS_RE.search(old):
continue
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:
return
# Rewrite edges' source / target.
for edge in edges:
for key in ("source", "target"):
v = edge.get(key)
if isinstance(v, str) and v in id_map:
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. 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 sorted(id_map.items(), key=lambda kv: -len(kv[0])):
eid = eid.replace(old, new)
edge["id"] = eid
# Rewrite every reference inside any node's data (recursively) plus
# the wrapper-level ``parentId`` — ReactFlow stores parentId on the
# node wrapper, but the LLM occasionally emits it inside ``data``
# too. We cover both spots so the strip is symmetric.
for node in nodes:
wrapper_parent = node.get("parentId")
if isinstance(wrapper_parent, str) and wrapper_parent in id_map:
node["parentId"] = id_map[wrapper_parent]
data = node.get("data")
if isinstance(data, dict):
cls._rewrite_refs_in_data(data, id_map)
@classmethod
def _rewrite_refs_in_data(cls, value: Any, id_map: dict[str, str]) -> None:
"""Recursive sibling of ``_collect_refs_in_data`` that does rewrites."""
match value:
case dict():
for k, v in list(value.items()):
if k in cls._ID_FIELDS and isinstance(v, str):
# Direct id field — apply the longest matching prefix
# (handles ``"nodeKstart"`` where ``nodeK`` is the
# container's old id).
for old, new in sorted(id_map.items(), key=lambda kv: -len(kv[0])):
if old in v:
value[k] = v.replace(old, new)
v = value[k]
match v:
case str():
rewritten = cls._LENIENT_VAR_REF_RE.sub(lambda m: cls._rewrite_var_ref(m, id_map), v)
if rewritten != v:
value[k] = rewritten
case [str(v0), str(v1)] if v0 in id_map:
# 2-element ``["node-id", "var"]`` selector list.
value[k] = [id_map[v0], v1]
case _:
cls._rewrite_refs_in_data(v, id_map)
case list():
for item in value:
cls._rewrite_refs_in_data(item, id_map)
@classmethod
def _rewrite_var_ref(cls, m: re.Match[str], id_map: dict[str, str]) -> str:
node_id = m.group(1)
rest = m.group(2)
new_id = id_map.get(node_id, node_id)
return f"{{{{#{new_id}.{rest}#}}}}"
@classmethod
def _insert_multi_retrieval_context_templates(
cls,
*,
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
reply_language: str | None = None,
) -> None:
"""Fan multiple retrieval inputs into an LLM through one template.
The repair is intentionally narrow: it applies only when at least two
knowledge-retrieval nodes have direct edges into the same LLM and that
LLM currently uses one of those results as its enabled context. This
is enough to repair the builder's lossy single-context output without
guessing about unrelated retrievals or mutually exclusive branches.
"""
nodes_by_id: dict[str, dict[str, Any]] = {
node_id: node for node in nodes if isinstance(node_id := node.get("id"), str)
}
used_ids = set(nodes_by_id)
llm_nodes = [node for node in nodes if node.get("data", {}).get("type") == BuiltinNodeTypes.LLM]
for llm_node in llm_nodes:
llm_id = llm_node.get("id")
if not isinstance(llm_id, str):
continue
incoming_retrieval_edges: list[dict[str, Any]] = []
for edge in edges:
source_id = edge.get("source")
if edge.get("target") != llm_id or not isinstance(source_id, str):
continue
source_node = nodes_by_id.get(source_id)
if source_node and source_node.get("data", {}).get("type") == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
incoming_retrieval_edges.append(edge)
retrieval_ids = list(
dict.fromkeys(
edge["source"] for edge in incoming_retrieval_edges if isinstance(edge.get("source"), str)
)
)
if len(retrieval_ids) < 2:
continue
llm_data = llm_node.get("data")
if not isinstance(llm_data, dict):
continue
context = llm_data.get("context")
if not isinstance(context, dict) or not context.get("enabled"):
continue
selector = context.get("variable_selector")
if selector not in [[retrieval_id, "result"] for retrieval_id in retrieval_ids]:
continue
template_id = cls._next_generated_node_id(prefix="retrieval_context", used_ids=used_ids)
variables = [
{"variable": f"knowledge_{index}", "value_selector": [retrieval_id, "result"]}
for index, retrieval_id in enumerate(retrieval_ids, start=1)
]
sections = [
(
f"## Knowledge source {index}\n"
f"{{% for item in knowledge_{index} %}}{{{{ item.content }}}}\n{{% endfor %}}"
)
for index in range(1, len(retrieval_ids) + 1)
]
nodes.append(
{
"id": template_id,
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": BuiltinNodeTypes.TEMPLATE_TRANSFORM,
"title": (
" + ".join(
str(nodes_by_id[node_id]["data"].get("title") or node_id) for node_id in retrieval_ids
)
if reply_language
else "Combine Knowledge"
),
"variables": variables,
"template": "\n\n".join(sections),
},
}
)
incoming_edge_objects = {id(edge) for edge in incoming_retrieval_edges}
edges[:] = [edge for edge in edges if id(edge) not in incoming_edge_objects]
edges.extend(
{"source": retrieval_id, "target": template_id, "type": "custom"} for retrieval_id in retrieval_ids
)
edges.append({"source": template_id, "target": llm_id, "type": "custom"})
context["variable_selector"] = [template_id, "output"]
cls._ensure_llm_context_placeholder(llm_data)
logger.info(
"Workflow generator: inserted template %s to combine retrieval inputs for LLM %s",
template_id,
llm_id,
)
@staticmethod
def _next_generated_node_id(*, prefix: str, used_ids: set[str]) -> str:
"""Return a short runtime-safe node id and reserve it in ``used_ids``."""
suffix = 1
candidate = prefix
while candidate in used_ids:
suffix += 1
candidate = f"{prefix}_{suffix}"
used_ids.add(candidate)
return candidate
@staticmethod
def _ensure_llm_context_placeholder(llm_data: dict[str, Any]) -> None:
"""Ensure an enabled LLM context is actually present in its prompt."""
prompt_template = llm_data.get("prompt_template")
if isinstance(prompt_template, list):
messages = [message for message in prompt_template if isinstance(message, dict)]
if any("{{#context#}}" in str(message.get("text") or "") for message in messages):
return
target = next((message for message in reversed(messages) if message.get("role") == "user"), None)
if target is None:
prompt_template.append({"role": "user", "text": "{{#context#}}"})
return
target["text"] = f"{target.get('text') or ''}\n\n{{{{#context#}}}}"
return
if isinstance(prompt_template, dict):
text = str(prompt_template.get("text") or "")
if "{{#context#}}" not in text:
prompt_template["text"] = f"{text}\n\n{{{{#context#}}}}"
@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 a handle it does not declare.
if-else exposes one source handle per ``case_id`` plus the implicit
"false" (ELSE) handle; question-classifier one per class id; human-input
one per action id plus the implicit "__timeout" arm; a node with
``error_strategy: "fail-branch"`` adds a "fail-branch" arm (a plain
node's success arm stays on "source"). The builder prompt documents
this, but the planner names the handles BEFORE the node builder picks
the ids (ESQ1-303: ``score_equals_60`` / ``else`` against a case named
``true``) and LLMs still emit the default handle -- either renders as
an edge hanging off a handle that doesn't exist, and the arm silently
never runs.
Delegates to ``services.workflow_generation.graph_normalizers.repair_branch_edge_handles``
(shared with the Builder). Repairs only what is forced -- exact / alias /
name matches, one arm fanning out under an invented name, default-handle
edges onto unused handles in declaration order. Anything ambiguous is
left exactly as it was (the shared function returns it; this method
logs each one as a warning), so a validator can still see it and fail
closed -- the INVALID_BRANCH_HANDLE check, when enabled. A wrong guess
that swaps the IF and ELSE arms is worse than an edge left for that
check.
"""
# The shared repair reports only what it could NOT re-home; snapshot
# the handles (by edge object) so every edge it did move is logged too.
before = [(edge, edge.get("sourceHandle")) for edge in edges if isinstance(edge, dict)]
unresolved = graph_normalizers.repair_branch_edge_handles(nodes, edges)
for edge, old_handle in before:
if edge.get("sourceHandle") != old_handle:
logger.info(
"Workflow generator: re-homed edge %s -> %s from handle %r onto branch handle %r",
edge.get("source"),
edge.get("target"),
old_handle,
edge.get("sourceHandle"),
)
for item in unresolved:
logger.warning(
"Workflow generator: cannot re-home edge %s -> %s (handle %r; node declares %s)",
item["node_id"],
item["target"],
item["handle"],
item["declared"],
)
@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."""
data = start_node.setdefault("data", {})
existing = data.setdefault("variables", [])
if any(isinstance(v, dict) and v.get("variable") == var for v in existing):
return
existing.append(
{
"variable": var,
"label": _label_from_variable(var),
"type": "paragraph",
"required": True,
"max_length": 4096,
"options": [],
}
)
@classmethod
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
"""
Fill the required upload config on every file / file-list start variable.
A start variable of type ``file`` / ``file-list`` is invalid without a
non-empty ``allowed_file_types`` — Studio rejects the draft with
"supported file types is required" (see the front-end validator in
``config-var/config-modal/utils.ts``) and the workflow never runs. The
builder prompt now documents these fields, but LLMs still drop them, so
we backfill safe defaults here:
* a start variable a ``document-extractor`` consumes but that wasn't
declared as a file type → promoted to ``file`` (or ``file-list``
when the extractor's ``is_array_file`` is set), defaulting its
allowed types to ``["document"]`` (what extraction needs);
* empty / missing ``allowed_file_types`` → every standard file type;
* ``custom`` present without ``allowed_file_extensions`` → drop
``custom`` (it would otherwise require a non-empty extension list);
* empty / missing ``allowed_file_upload_methods`` → local + remote;
* ensure ``allowed_file_extensions`` is at least an empty list.
Idempotent: a variable that already declares valid file config is left
untouched.
"""
start_node = next(
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
None,
)
if start_node is None:
return
variables = (start_node.get("data") or {}).get("variables")
if not isinstance(variables, list):
return
# Start variables a document-extractor reads → whether it wants an
# array (file-list). These MUST be file inputs even if the builder
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
# run time. ``["document"]`` is the right default for text extraction.
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
for var in variables:
if not isinstance(var, dict):
continue
name = var.get("variable")
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
var["type"] = "file-list" if extractor_file_vars[name] else "file"
var.setdefault("allowed_file_types", ["document"])
if var.get("type") not in _FILE_VARIABLE_TYPES:
continue
allowed_types = var.get("allowed_file_types")
if not isinstance(allowed_types, list) or not allowed_types:
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
var["allowed_file_types"] = allowed_types
# ``custom`` demands a non-empty extension list; without one, drop it
# so the variable doesn't trip the "file extensions required" check.
extensions = var.get("allowed_file_extensions")
has_extensions = isinstance(extensions, list) and bool(extensions)
if "custom" in allowed_types and not has_extensions:
pruned = [t for t in allowed_types if t != "custom"]
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
methods = var.get("allowed_file_upload_methods")
if not isinstance(methods, list) or not methods:
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
if not isinstance(var.get("allowed_file_extensions"), list):
var["allowed_file_extensions"] = []
@classmethod
def _normalize_start_select_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
"""
Repair ``select`` start variables that have no usable options.
Dify has no native boolean start-variable type, so a ``bool`` requirement
the builder proposes gets emitted as a ``select`` — and the LLM often
leaves its ``options`` empty. An options-less ``select`` renders as an
empty, unfillable dropdown in the test-input form, so the user can never
submit test data. Convert any ``select`` with no usable (non-empty string)
options into a ``text-input`` the user can type into; a ``select`` that
declares real options is normalized to just those strings and kept.
Idempotent: a valid select is left as a select with cleaned options.
"""
start_node = next(
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
None,
)
if start_node is None:
return
variables = (start_node.get("data") or {}).get("variables")
if not isinstance(variables, list):
return
for var in variables:
if not isinstance(var, dict) or var.get("type") != "select":
continue
raw_options = var.get("options")
usable = (
[o for o in raw_options if isinstance(o, str) and o.strip()] if isinstance(raw_options, list) else []
)
if usable:
var["options"] = usable
continue
# No usable options -> the dropdown would be empty and unfillable.
# Fall back to a free-text input so the user can still provide a value.
var["type"] = "text-input"
var["options"] = []
@classmethod
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
"""
Map start-variable name → ``is_array_file`` for every start variable a
``document-extractor`` node reads via its ``variable_selector``.
When two extractors read the same variable we keep ``True`` (file-list)
if any of them wants an array, since a file-list also satisfies a
single-file read.
"""
out: dict[str, bool] = {}
if not start_id:
return out
for node in nodes:
data = node.get("data") or {}
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
continue
selector = data.get("variable_selector")
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
var_name = selector[1]
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
return out
@classmethod
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""
data = node.setdefault("data", {})
marker_flow_type = {
"iteration-start": "custom-iteration-start",
"loop-start": "custom-loop-start",
}.get(data.get("type"))
if marker_flow_type:
node["type"] = marker_flow_type
else:
node.setdefault("type", "custom")
data.setdefault("title", node.get("id", "Node"))
data.setdefault("desc", "")
data.setdefault("selected", False)
# `data.type` is the actual node-type string — we never override it.
@classmethod
def _fill_edge_defaults(cls, edge: dict[str, Any]) -> None:
edge.setdefault("type", "custom")
edge.setdefault("sourceHandle", "source")
edge.setdefault("targetHandle", "target")
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
_CONTAINER_TYPES: ClassVar = frozenset({BuiltinNodeTypes.ITERATION, BuiltinNodeTypes.LOOP})
@classmethod
def _validate_structure(
cls,
*,
graph: GraphDict,
mode: WorkflowGenerationMode,
installed_tools: set[tuple[str, str]] | None = None,
) -> list[WorkflowGenerateErrorDict]:
"""
Return a list of structured errors for every violation found.
Catches:
* exactly-one ``start`` node + mode-aware terminal (``end`` for
workflow, ``answer`` for advanced-chat);
* edges whose endpoints don't exist;
* dangling ``parentId`` / ``start_node_id`` / ``iteration_id`` /
``loop_id`` references inside node ``data``;
* container nodes (iteration / loop) without children, children
whose ``parentId`` points at a non-container, and cycles in the
parent chain;
* variable references (``{{#node.var#}}`` / value selectors) that
point at a node which does NOT declare the variable;
* direct native HTTP v1 numeric status comparisons using the known
incompatible string/boolean ``is`` / ``is not`` operators;
* tool nodes naming a ``(provider, tool)`` pair the tenant hasn't
installed.
Per-node config validation (model spec, prompt template shape, etc.)
is deferred to ``WorkflowService.sync_draft_workflow``; we only fail
on structural issues the user must know about so they don't get a
broken-at-runtime draft.
"""
errors: list[WorkflowGenerateErrorDict] = []
nodes_raw = graph.get("nodes", [])
nodes: list[dict[str, Any]] = list(cast(list[dict[str, Any]], nodes_raw))
if not nodes:
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:
errors.append(
_err(
WorkflowGenerateErrorCode.MISSING_START,
f"Workflow must have exactly one 'start' node (found {len(starts)})",
)
)
if mode == "advanced-chat":
terminal_count = sum(1 for t in types if t == BuiltinNodeTypes.ANSWER)
terminal_name = "answer"
else:
terminal_count = sum(1 for t in types if t == BuiltinNodeTypes.END)
terminal_name = "end"
if terminal_count < 1:
errors.append(
_err(
WorkflowGenerateErrorCode.MISSING_TERMINAL,
f"Workflow must end with at least one '{terminal_name}' node",
)
)
# Edges must reference real node ids.
known_ids: set[str] = {node.get("id", "") for node in nodes if node.get("id")}
for edge in graph.get("edges", []):
src = edge.get("source")
tgt = edge.get("target")
if src not in known_ids:
errors.append(_err(WorkflowGenerateErrorCode.DANGLING_EDGE, f"Edge source node not found: {src!r}"))
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))
# Container topology.
errors.extend(cls._collect_container_errors(nodes=nodes))
# Tool catalogue check — only run if the caller wired in a catalogue.
if installed_tools is not None:
errors.extend(cls._collect_unknown_tools(nodes=nodes, installed_tools=installed_tools))
# Branch edges must leave on a handle the node declares. Postprocess
# re-homed every edge it could do so unambiguously; whatever is left
# would hang off a handle that does not exist and its arm would never
# run, while the run still reports "succeeded" (ESQ1-303).
edges_raw = [dict(edge) for edge in graph.get("edges", [])]
for bad in graph_normalizers.undeclared_branch_handles(nodes, edges_raw):
errors.append(
_err(
WorkflowGenerateErrorCode.INVALID_BRANCH_HANDLE,
f"Edge {bad['node_id']} -> {bad['target']} uses handle {bad['handle']!r}, "
f"which the branch node does not declare; it declares {bad['declared']}",
node_id=bad["node_id"],
)
)
# A json / raw-text / binary http-request body is sent as ONE rendered
# template; the executor rejects any other item count at run time
# (ESQ1-302). There is no correct automatic collapse of several
# key/value items into one JSON template, so this is a rejection.
for node_id, detail in graph_normalizers.http_request_body_errors(nodes):
errors.append(_err(WorkflowGenerateErrorCode.INVALID_HTTP_BODY, detail, node_id=node_id))
# Variable-reference resolution — walks ``{{#node.var#}}`` placeholders
# and value selectors and flags anything pointing at a node that
# doesn't declare the variable. Start-node refs are auto-fixed
# earlier in postprocess, so anything that survives to here is
# genuinely unresolvable.
errors.extend(cls._collect_unresolved_refs(nodes=nodes, mode=mode))
errors.extend(
_err(WorkflowGenerateErrorCode.INVALID_SCHEMA, problem.detail, node_id=problem.node_id)
for problem in native_http_condition_problems(nodes)
)
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]
) -> list[WorkflowGenerateErrorDict]:
"""Flag ``parentId`` / ``start_node_id`` / ``iteration_id`` / ``loop_id`` pointing nowhere.
``parentId`` is checked at BOTH the node wrapper and inside ``data``
because Dify's schema puts it on the wrapper (ReactFlow convention)
but the LLM occasionally drops it into ``data`` too. Either spot is
a real signal that we should validate.
"""
out: list[WorkflowGenerateErrorDict] = []
for node in nodes:
node_id = node.get("id", "")
data = node.get("data") or {}
for field in cls._ID_FIELDS:
ref = data.get(field)
if isinstance(ref, str) and ref and ref not in known_ids:
out.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_NODE_REFERENCE,
f"Node {node_id!r} field {field!r} references unknown node: {ref!r}",
node_id=node_id,
)
)
# Wrapper-level parentId (the ReactFlow canonical location).
wrapper_parent = node.get("parentId")
if isinstance(wrapper_parent, str) and wrapper_parent and wrapper_parent not in known_ids:
out.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_NODE_REFERENCE,
f"Node {node_id!r} parentId references unknown node: {wrapper_parent!r}",
node_id=node_id,
)
)
return out
@classmethod
def _collect_container_errors(cls, *, nodes: list[dict[str, Any]]) -> list[WorkflowGenerateErrorDict]:
"""
Validate iteration / loop topology:
* every container has at least one executable child whose
``parentId`` points at it;
* every non-container node with a ``parentId`` points at a real
container, not at a non-container node;
* no cycles in the parent chain (a node cannot be its own
ancestor).
"""
out: list[WorkflowGenerateErrorDict] = []
by_id: dict[str, dict[str, Any]] = {n.get("id", ""): n for n in nodes if n.get("id")}
# Containers and the set of node-ids that have a parentId pointing at them.
container_ids = {n.get("id", "") for n in nodes if n.get("data", {}).get("type") in cls._CONTAINER_TYPES}
children_by_parent: dict[str, list[str]] = {cid: [] for cid in container_ids}
for n in nodes:
parent = (n.get("data") or {}).get("parentId") or n.get("parentId")
if not isinstance(parent, str) or not parent:
continue
if parent in container_ids:
node_type = (n.get("data") or {}).get("type")
if node_type not in {"iteration-start", "loop-start"}:
children_by_parent.setdefault(parent, []).append(n.get("id", ""))
elif parent in by_id:
# Parent exists but isn't a container — that's a topology bug.
out.append(
_err(
WorkflowGenerateErrorCode.INVALID_CONTAINER,
f"Node {n.get('id')!r} parentId {parent!r} is not an iteration or loop node",
node_id=n.get("id", ""),
)
)
# parent missing from by_id is already flagged by _collect_dangling_id_refs.
for cid in container_ids:
if not children_by_parent.get(cid):
out.append(
_err(
WorkflowGenerateErrorCode.INVALID_CONTAINER,
f"Container node {cid!r} has no child nodes",
node_id=cid,
)
)
# Cycle detection — for each node, walk up parentId chain and
# bail if we ever revisit a node we've already seen on the chain.
for n in nodes:
seen: set[str] = set()
cur_id = n.get("id", "")
cur: dict[str, Any] | None = n
depth = 0
while cur is not None and depth < 64: # generous safety cap
parent = (cur.get("data") or {}).get("parentId") or cur.get("parentId")
if not isinstance(parent, str) or not parent:
break
if parent == cur_id or parent in seen:
out.append(
_err(
WorkflowGenerateErrorCode.INVALID_CONTAINER,
f"Cycle detected in parentId chain at node {cur_id!r}",
node_id=cur_id,
)
)
break
seen.add(parent)
cur = by_id.get(parent)
depth += 1
return out
@classmethod
def _collect_unknown_tools(
cls,
*,
nodes: list[dict[str, Any]],
installed_tools: set[tuple[str, str]],
) -> list[WorkflowGenerateErrorDict]:
"""Flag tool nodes naming a provider / tool pair not in the catalogue."""
out: list[WorkflowGenerateErrorDict] = []
for n in nodes:
data = n.get("data") or {}
if data.get("type") != BuiltinNodeTypes.TOOL:
continue
# The builder is told to put the catalogue's ``provider_name``
# into BOTH ``provider_id`` and ``provider_name``. Accept either
# for the lookup so we don't false-fail on the rare case where
# the LLM only populated one of the two fields.
provider = str(data.get("provider_id") or data.get("provider_name") or "").strip()
tool = str(data.get("tool_name") or "").strip()
if not provider or not tool:
out.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_TOOL,
f"Tool node {n.get('id')!r} missing provider / tool name",
node_id=n.get("id", ""),
)
)
continue
if (provider, tool) not in installed_tools:
out.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_TOOL,
f"Tool {provider}/{tool} is not installed for this tenant",
node_id=n.get("id", ""),
)
)
return out
@classmethod
def _collect_unresolved_refs(
cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode
) -> list[WorkflowGenerateErrorDict]:
"""
Walk every variable reference and flag anything pointing at a node
that doesn't declare it. The postprocess step has already
auto-injected missing start-node variables and repaired references to
sole outputs, so by the time this runs only ambiguous or impossible
references should fail.
"""
out: list[WorkflowGenerateErrorDict] = []
by_id: dict[str, dict[str, Any]] = {n.get("id", ""): n for n in nodes if n.get("id")}
# Ordered, so the same graph always produces the same error list —
# a set's order would shuffle the user-visible errors per process.
# Native API policy distinguishes real bindings from literal data.
# Its refs are authoritative only for known native API identities;
# unknown providers retain the existing generic reference protocol.
native_api_ids = {node_id for node_id, node in by_id.items() if is_native_api_tool(node.get("data") or {})}
references = {(node_id, var) for node_id, var in cls._ordered_refs(nodes) if node_id not in native_api_ids}
references.update(collect_native_api_selector_refs(nodes, placeholder_pattern=cls._VAR_REF_RE))
for node_id, var in sorted(references):
if mode == "advanced-chat" and node_id == "sys":
continue
target = by_id.get(node_id)
if target is None:
out.append(
_err(
WorkflowGenerateErrorCode.UNKNOWN_NODE_REFERENCE,
f"Reference {{#{node_id}.{var}#}} points at unknown node {node_id!r}",
node_id=node_id,
)
)
continue
if cls._declares_variable(target, var):
continue
detail = f"Reference {{#{node_id}.{var}#}} not declared on node {node_id!r}"
if is_native_api_tool(target.get("data") or {}):
detail += native_api_output_error_hint()
elif cls._is_builtin_simple_code(target.get("data") or {}):
detail += (
"; builtin/code/simple_code exposes only the standard roots text, files, json. "
"Printed JSON remains scalar text and does not declare named fields. "
"Use a native Code node with declared typed outputs (for example boolean valid and array items), "
"or explicitly parse the text into declared typed outputs before consuming those fields."
)
out.append(_err(WorkflowGenerateErrorCode.UNRESOLVED_REFERENCE, detail, node_id=node_id))
return out
def _clamp_for_planner(params: dict[str, Any], *, model_instance: Any | None = None) -> dict[str, Any]:
"""
The planner needs only a tight, deterministic plan — clamp temperature
and cap max_tokens so we don't burn budget. Returns a copy.
Temperature above 0.5 is pinned back to 0.2 (the planner's output is a
small node list; rambling only hurts). ``max_tokens`` gets a bounded default
ONLY when the caller didn't pin one — an explicit value is always honoured.
The public model schema bounds that injected default when available. An
unavailable or unusable schema keeps the bounded fallback; it never blocks
generation or changes explicit reasoning settings.
"""
out = dict(params)
out.setdefault("temperature", 0.2)
if "temperature" in out and isinstance(out["temperature"], (int, float)) and out["temperature"] > 0.5:
out["temperature"] = 0.2
if "max_tokens" not in out:
budget = _PLANNER_DEFAULT_MAX_TOKENS
if model_instance is not None:
try:
schema = model_instance.get_model_schema()
for rule in schema.parameter_rules:
if rule.name != "max_tokens":
continue
maximum = rule.max
if type(maximum) in (int, float) and 1 <= maximum < budget:
budget = int(maximum)
break
except CognitionPolicyDeniedError:
raise
except Exception:
# Schema inspection is advisory; never expose provider details
# or turn metadata unavailability into a failed generation.
logger.debug("Workflow generator: planner model token limit unavailable; using bounded default")
out["max_tokens"] = budget
return out
def _clamp_for_tool_router(params: dict[str, Any]) -> dict[str, Any]:
"""Use a small deterministic budget for capability-query extraction."""
out = dict(params)
out["temperature"] = 0.1
out["max_tokens"] = _TOOL_ROUTER_MAX_TOKENS
return out
def _label_from_variable(var: str) -> str:
"""Turn ``snake_case`` / ``camelCase`` into a Title-Cased UI label."""
if not var:
return ""
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", var).lower()
return " ".join(part.capitalize() for part in snake.split("_") if part)
# Re-export json for callers / tests; keeps ruff happy when only the module is imported.
_ = json