Files
dify/api/core/dify_builder/node_defaults.py
T
Charles YaoandClaude Opus 5 71cb5688ac fix(dify-builder): a default may fill a blank, never invent a purpose
The question-classifier default shipped two `classes` with blank names. That
turns a loud preflight refusal into a node that is written, reported as a
success, and silently classifies into two meaningless categories -- and the
fabricated ids `["1", "2"]` pin `declared_branch_handles`, so a semantic
`connect` is dropped while the create survives. That is the failure shape the
registry exists to eliminate.

Draw the line explicitly and apply it to every entry: a default may supply a
field whose empty value is semantically NEUTRAL -- a container, a mode, a config
or auth block -- but never one that carries the node's PURPOSE, where an empty
value produces a node that RUNS and yields nothing. Dropped, with the engine
behaviour each empty value would have produced:

  end.outputs                  End runs OK, so run_finished_without_output is
                               False -> "All checks passed" on an empty result
  answer.answer                streams nothing
  template-transform.template  renders ""
  variable-aggregator.variables  SUCCEEDED, outputs={} -- triage cause (d)
  question-classifier.classes  two blank categories + fabricated handle ids
  question-classifier.query_variable_selector  classifies `query or ""`
  knowledge-retrieval.dataset_ids  retrieves from no dataset, succeeds empty

The whole `if-else` entry goes too, though nothing is gained or lost by it: its
one case has an empty `conditions` list that graphon combines with `all([])`, so
it always matches -- but `iter_cases()` synthesizes that identical case when
`cases` is absent, so dropping the entry does not cure it. It only stops us
writing a fabricated always-true condition that makes the node look configured.

An empty value that fails LOUDLY is not in that category: there is no silence to
protect against, and dropping it would pay the full batch-refusal cost for
nothing. Verified loud, so these stay -- `llm.model` (never launches, "model not
configured"), `llm.prompt_template` (empty messages are filtered out, leaving
zero, so graphon raises `NoPromptFoundError`), `code.code` (the sandbox runner
calls `main(**inputs_obj)` with no `main` defined), `code.outputs` (graphon
raises `CodeNodeError("Not all output parameters are validated.")` for any key
the code returns), and `http-request.url` (`Executor._init_url` raises
`InvalidURLError("url is required")` before any request goes out).
`template-transform.variables` stays too -- it is structural, and it is the
exact field the live F4 failure was missing.

The placeholder agent genuinely means "no dataset, no output", so it now says
so itself instead of borrowing a default that would say it for every caller.

The engine sweep is re-pinned in both directions: the fields the engine reports
missing from a node built from a default ALONE must be exactly the purpose
fields the registry refuses to fabricate -- no more (a structural regression)
and no fewer (a silent-success regression).

Also: name the `filter_applicable` test after what it actually asserts, since
that function does not return its working graph; and reword the comment in
`graph_normalizers` that quoted a bare `type` marker, one reflow away from
being read as a type comment and silently killing the type check again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:28:05 -07:00

293 lines
14 KiB
Python

"""Per-node-type default ``data`` configs for every node the Builder's agents
may create.
There is no importable Python source for Dify's per-node-type default config --
the canonical values live in the frontend's
``web/app/components/workflow/nodes/<type>/default.ts`` (mirrored, for LLM
prompt-authoring, by ``core.workflow.generator.prompts.builder_prompts
._NODE_SNIPPETS``). This module hand-maintains a literal Python copy of the
SAFE part of those defaults; each entry cites its ``default.ts`` lines and the
engine fields it covers. Keep it pure: it is imported by
``services.dify_builder.graph_ops`` and must not reach back into ``services``,
``configs``, Flask or the model runtime.
Why it matters: graphon requires a handful of fields per node type with NO
default (``TemplateTransformNodeData.variables``, ``CodeNodeData.outputs``,
``VariableAggregatorNodeData.output_type``, ...). An Edit/Fix LLM that emits
``create_node{template-transform, {title, template}}`` produces a node the draft
preflight refuses -- and the refusal kills the WHOLE batch, parking the session
at ``edit.plan_approval`` (triage ``edit-branch-failure-2026-09-22`` cause (b)).
``graph_ops._build_node`` merges these defaults UNDER the caller's config so a
missing structural field is filled and a caller-supplied one always wins.
THE RULE FOR ADDING A FIELD HERE
--------------------------------
A default may supply a field whose empty value is semantically NEUTRAL -- a
container, a mode, a config or auth block (``variables: []``, ``headers: ""``,
``authorization: {"type": "no-auth"}``, ``output_type``, ``code_language``). It
must NOT supply a field that carries the node's PURPOSE, where an empty value
produces a node that RUNS and yields nothing. Fabricating one of those turns a
loud preflight refusal into a node that is written, reported as a success, and
silently does the wrong thing -- the exact failure shape this module exists to
eliminate.
Leaving it out costs a batch, and that cost is real: TODAY the omission is not
caught early. ``graph_ops.filter_applicable`` dry-runs the ``apply_*`` functions
only -- it performs no graphon validation -- so a node missing a required field
is "applicable", and the one corrective re-prompt in
``services/dify_builder/agent/edit.py`` never sees it. The omission surfaces at
``apply_repair``'s preflight instead, where all three handlers catch
``DraftWouldNotStartError``, write NOTHING and park the session
(``handlers_edit.py``, ``handlers_fix.py``, ``handlers_build.py``). That is F4
cause (b) itself. Running ``preflight_errors`` inside ``filter_applicable`` so
the re-prompt can name the field is Task 5's job, specified there -- not this
module's. Until then, a refused batch is still the right trade against a written
node that silently answers wrong; it is just more expensive than it should be.
An empty value that makes the node FAIL LOUDLY is not in the purpose category at
all -- there is no silence to protect against, and dropping it would pay the
full batch-refusal cost for nothing. Verified loud, so all of these stay:
* ``llm.model`` with ``provider: ""`` -- never launches; the Builder surfaces
"model not configured".
* ``llm.prompt_template`` whose only message is empty -- every empty message is
filtered out, leaving zero, and graphon raises ``NoPromptFoundError``
(``nodes/llm/node.py:1397-1402``).
* ``code.code`` as ``""`` -- the sandbox runner calls ``main(**inputs_obj)``
with no ``main`` defined and raises
(``core/helper/code_executor/python3/python3_transformer.py:20``).
* ``code.outputs`` as ``{}`` -- any key the code actually returns is unvalidated,
so graphon raises ``CodeNodeError("Not all output parameters are
validated.")`` (``nodes/code/code_node.py:690-693``).
* ``http-request.url`` as ``""`` -- ``Executor._init_url`` raises
``InvalidURLError("url is required")`` before any request goes out
(``nodes/http_request/executor.py:172-174``).
Purpose fields deliberately NOT defaulted, and what each empty value would do:
* ``end.outputs`` -- the End node runs OK, so ``run_finished_without_output``
(``core/dify_builder/handlers_fix.py``) returns False and the Builder reports
"All checks passed" on an empty result.
* ``answer.answer`` -- streams nothing.
* ``template-transform.template`` -- renders "".
* ``variable-aggregator.variables`` -- aggregates nothing and returns
``SUCCEEDED, outputs={}``; this is triage cause (d) verbatim.
* ``question-classifier.classes`` -- two blank categories, and the fabricated
ids would also pin ``declared_branch_handles`` to ``["1", "2"]`` so a
semantic ``connect`` is silently dropped while the create survives.
* ``question-classifier.query_variable_selector`` -- ``query or ""``
(``nodes/question_classifier/question_classifier_node.py:260-280``), i.e. the
classifier runs on an empty string.
* ``knowledge-retrieval.dataset_ids`` -- retrieves from no dataset and succeeds
with an empty result list.
Editor-only keys are also not copied. ``_targetBranches`` (if-else,
question-classifier) is frontend editor state that the canvas regenerates from
``cases`` / ``classes`` (``nodes/question-classifier/use-config.ts:139,173``,
``nodes/if-else/use-config.helpers.ts:53-75``); the backend reads branch handles
from ``cases``/``classes`` too (``declared_branch_handles``). A caller's
``cases``/``classes`` win the merge while a default ``_targetBranches`` would
not, so shipping it would attach a STALE branch list to every created branch
node.
Scope: defaults fill MISSING structural fields. Fields that are present but
WRONG are the deterministic heal set's job
(``core.workflow.graph_normalizers.heal_nodes_for_preflight``) -- do not
duplicate its work here.
``default_config``/``default_config_or_empty`` return a deep copy every call:
callers merge canned values (a real provider/model name, a real dataset id, ...)
into the returned dict, and mutating a return value must never leak into a later
call.
"""
import copy
from typing import Any
from graphon.enums import BuiltinNodeTypes
_DEFAULTS: dict[str, dict[str, Any]] = {
# web/app/components/workflow/nodes/start/default.ts:17-19
# graphon nodes/start/entities.py:14 -- ``variables`` has a default_factory.
# A start form with no fields is a normal, intentional design, so the empty
# list carries no meaning of its own.
BuiltinNodeTypes.START: {
"variables": [],
},
# web/app/components/workflow/nodes/end/default.ts:15-17, minus ``outputs``
# (purpose field -- see the module docstring). graphon
# nodes/end/entities.py:11 requires it, so an End the LLM forgot to wire up
# is refused by name instead of silently returning nothing.
BuiltinNodeTypes.END: {},
# web/app/components/workflow/nodes/answer/default.ts:14-17, minus
# ``answer`` (purpose field). ``variables`` is a frontend-only container.
BuiltinNodeTypes.ANSWER: {
"variables": [],
},
# web/app/components/workflow/nodes/llm/default.ts:44-66
# graphon nodes/llm/entities.py:69-75 -- ``model``, ``prompt_template`` and
# ``context`` are all required. An empty provider or an empty-only prompt
# both fail LOUDLY at run time (see the module docstring), so these are
# scaffolding, not fabricated meaning.
BuiltinNodeTypes.LLM: {
"model": {
"provider": "",
"name": "",
"mode": "chat",
"completion_params": {"temperature": 0.7},
},
"prompt_template": [{"role": "system", "text": ""}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
},
# web/app/components/workflow/nodes/knowledge-retrieval/default.ts:18-28,
# minus ``dataset_ids`` (purpose field;
# core/workflow/nodes/knowledge_retrieval/entities.py:42 requires it). The
# two query selectors stay: they are optional in the entity and ``[]`` is
# indistinguishable from absent, so they fabricate nothing.
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: {
"query_variable_selector": [],
"query_attachment_selector": [],
"retrieval_mode": "multiple",
"multiple_retrieval_config": {
"top_k": 4,
"score_threshold": None,
"reranking_enable": False,
},
},
# web/app/components/workflow/nodes/code/default.ts:18-23 (complete).
# graphon nodes/code/entities.py:57-60 requires ``variables``,
# ``code_language``, ``code`` and ``outputs``. Neither empty value is
# silent: ``code: ""`` leaves the sandbox runner with no ``main`` to call,
# and ``outputs: {}`` makes graphon raise ``CodeNodeError("Not all output
# parameters are validated.")`` for any key the code returns -- both are
# named run-time errors, so there is no silent success to protect against
# and no reason to pay a batch refusal.
BuiltinNodeTypes.CODE: {
"code": "",
"code_language": "python3",
"variables": [],
"outputs": {},
},
# web/app/components/workflow/nodes/template-transform/default.ts:18-21,
# minus ``template`` (purpose field). ``variables`` is STRUCTURAL and stays
# -- graphon nodes/template_transform/entities.py:10 requires it and this is
# the exact field the Edit LLM omitted in the live F4 failure.
BuiltinNodeTypes.TEMPLATE_TRANSFORM: {
"variables": [],
},
# web/app/components/workflow/nodes/question-classifier/default.ts:18-53,
# minus ``classes``, ``query_variable_selector`` (purpose fields) and the
# editor-only ``_targetBranches``. graphon
# nodes/question_classifier/entities.py:34-36 requires all three of
# ``query_variable_selector``, ``model`` and ``classes``.
BuiltinNodeTypes.QUESTION_CLASSIFIER: {
"model": {
"provider": "",
"name": "",
"mode": "chat",
"completion_params": {"temperature": 0.7},
},
"vision": {"enabled": False},
},
# web/app/components/workflow/nodes/http/default.ts:16-41 (complete; there
# is no ``nodes/http-request/`` directory).
# graphon nodes/http_request/entities.py:117-130 -- ``method``, ``url``,
# ``authorization``, ``headers`` and ``params`` are all required.
# ``method``/``authorization``/``headers``/``params`` are mode/config/auth
# with neutral empty values, and ``url: ""`` is not silent either:
# ``Executor._init_url`` raises ``InvalidURLError("url is required")``
# before any request goes out (``nodes/http_request/executor.py:172-174``).
# The frontend's
# ``timeout`` uses the ``max_*`` keys the editor reads; graphon's
# ``HttpRequestNodeTimeout`` ignores them and
# ``HttpRequestNode._get_request_timeout:247-255`` falls back to the
# configured defaults, which is what the block config itself ships
# (``nodes/http_request/node.py:166-170`` carries both shapes).
BuiltinNodeTypes.HTTP_REQUEST: {
"variables": [],
"method": "get",
"url": "",
"authorization": {
"type": "no-auth",
"config": None,
},
"headers": "",
"params": "",
"body": {
"type": "none",
"data": [],
},
"ssl_verify": True,
"timeout": {
"max_connect_timeout": 0,
"max_read_timeout": 0,
"max_write_timeout": 0,
},
"retry_config": {
"retry_enabled": True,
"max_retries": 3,
"retry_interval": 100,
},
},
# web/app/components/workflow/nodes/variable-assigner/default.ts:23-26 (the
# variable-AGGREGATOR node's frontend directory is ``variable-assigner``),
# minus ``variables`` (purpose field). graphon
# nodes/variable_aggregator/entities.py:27-28 requires both, and an
# aggregator with nothing to aggregate is triage cause (d) itself.
BuiltinNodeTypes.VARIABLE_AGGREGATOR: {
"output_type": "any",
},
# web/app/components/workflow/nodes/tool/default.ts:22-26
# graphon nodes/tool/entities.py:48-53,133 -- ``tool_configurations`` and
# ``tool_parameters`` are required containers. The provider/tool identity
# fields (``provider_id``, ``provider_type``, ``provider_name``,
# ``tool_name``, ``tool_label``) are the purpose fields here and are not
# knowable as a default; only the caller can supply them.
BuiltinNodeTypes.TOOL: {
"tool_parameters": {},
"tool_configurations": {},
"tool_node_version": "2",
},
# NOTE: ``if-else`` has no entry, and omitting it changes NOTHING either way
# -- nothing gained, nothing lost. Its frontend default is one case with an
# empty ``conditions`` list, which graphon's ``ConditionProcessor`` combines
# with ``all([])``, so that case always matches and the node routes
# everything down the IF arm. Dropping the entry does NOT cure that:
# ``IfElseNodeData.cases`` is optional and ``iter_cases()`` synthesizes the
# identical empty "true" case when it is absent, so an unconfigured if-else
# behaves the same with or without us writing it down. ``_if_else_case_ids``
# likewise already reports ``["true"]`` for a node without ``cases``, so
# declared handles are unchanged. The entry is simply noise: it would write
# a fabricated always-true condition into the draft, making the node look
# configured in the canvas and in ``diff_graphs`` when it is not.
}
def default_config(node_type: str) -> dict[str, Any]:
"""Return a fresh deep copy of ``node_type``'s default ``data`` config.
Raises ``ValueError`` for a node type with no entry. Use this where an
unregistered type is a programming error the caller wants to hear about
(the placeholder agent builds its whole graph from these). Node-building on
behalf of an LLM must use ``default_config_or_empty`` instead.
"""
template = _DEFAULTS.get(node_type)
if template is None:
raise ValueError(f"no default config registered for node type: {node_type!r}")
return copy.deepcopy(template)
def default_config_or_empty(node_type: str) -> dict[str, Any]:
"""Return a fresh deep copy of ``node_type``'s default ``data`` config, or
``{}`` when the type has no entry.
This is the lookup on the node-creation chokepoint. A node type this module
does not cover yet must contribute no defaults -- never abort the edit that
is creating it.
"""
template = _DEFAULTS.get(node_type)
if template is None:
return {}
return copy.deepcopy(template)