Files
dify/api/core/dify_builder/node_defaults.py
T
Charles YaoandClaude Opus 5 c4ac3d49bf fix(dify-builder): a proposal is engine-checked before it reaches the gate
`filter_applicable` dry-ran STRUCTURE only. In the live F4 Edit four intents
came back "applicable, 0 rejected", the batch then died at `apply_repair`'s
preflight, and the one corrective re-prompt could never fire -- it is keyed on
`rejected` being non-empty. Every re-approval re-sent a byte-identical prompt
and got a byte-identical batch back.

`filter_applicable` now returns its dry-run graph (`DryRun` NamedTuple) after
running it through the shared `heal_nodes_for_preflight` -- the same
normalizers, at the same point in the sequence, the write chokepoint runs. A
dry run that healed less would report a defect the write was going to fix; one
that healed more would pass a batch the write refuses. It also skips the
intents the port drops as already-present, via a predicate both now share, so
a re-sent duplicate `create_node` no longer burns the re-prompt.

`preflight.vet_intents` then puts that graph through `validate_node_config` and
feeds any NEW problem into the existing re-prompt, quoting the engine's own
message. `new_preflight_problems` is the one definition of that rule, shared
with `apply_repair` so the dry run and the write cannot answer differently.

It is keyed on the set of pydantic error LOCATIONS per node, which is the only
key that gets both halves right. Node identity exempts an already-refused node
from every later verdict -- and since a Fix repair targets a node that is by
definition already invalid, that would leave `propose_repair` with no node-data
coverage on its own culprit. Message text is too fine: pydantic quotes a
truncated repr of the input, so filling one of three missing fields rewrites
the other two messages and an exact-text diff refuses the repair that improved
the node.

The refusal text is rebuilt from a credential-redacted copy of the node. The
verdict still comes from the real one; only the words the model is shown are
withheld, because a model-level pydantic error echoes the node's whole `data`
and that is where a live bearer token sits.

The retry budget is unchanged: one corrective re-prompt, then Edit's existing
fallback to the first attempt's applicable intents and Fix's `_no_fix()`.

Also folds in the Task 1 review: the generator's postprocess calls the shared
heal set plus `derive_if_else_var_types` instead of re-spelling three
normalizers inline, and the valid-operator no-op sweep covers the
list-operator container so `FilterOperator` gets its own check.

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

292 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 re-prompt, not a batch. ``preflight.vet_intents`` puts
the dry-run graph through ``preflight_errors`` and hands the engine's own
message -- which names the node and the missing field -- to the one corrective
re-prompt in ``services/dify_builder/agent/edit.py`` and
``services/dify_builder/agent/fix.py``. Before that existed, a node missing a
required field was simply "applicable", the re-prompt could not fire, and the
omission surfaced 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``) -- F4 cause
(b) itself. A batch refused with the field named is still the right trade
against a written node that silently answers wrong.
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)