Files
dify/api/services/dify_builder/agent/build.py
T
Charles YaoandClaude Opus 5 fe3f8154b5 feat(dify-builder): carry node usage rules on the Builder-only directive
Meeting item 9 asked for per-node usage detail in the generator prompts.
Those prompts are shared with cmd+K, so the detail rides the channel the
Builder already owns instead: _WORKFLOW_TOPOLOGY_DIRECTIVE, prepended to
every generator instruction at build.py:377 and invisible to cmd+K. That
mechanism is proven -- it is why workflow mode never gets 'answer' nodes.

The rules added are the ones that broke real builds: tool nodes expose
text/files/json and not 'result' (ESQ1-296, ESQ1-289), document-extractor
needs a file input, container children belong inside the container
(ESQ1-288), if-else operators must match the variable's type
(ESQ1-285/290), and http-request inputs belong on the start node
(ESQ1-286).

Deliberately does NOT restate the node whitelist: the generator's shared
system prompt already carries it, and duplicating it would burn planner
output budget that ESQ1-300 already implicates.

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

528 lines
24 KiB
Python

"""Real LLM cognition for Dify Builder's Build mode (light methods).
Pure functions called by ``LlmBuilderAgent`` for Build cognition.
Each degrades to an honest result on model-None / provider-error / parse-fail
rather than crashing the advance. build_nodes lives in the same module
(added in Task A6)."""
import json
import logging
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from typing import Any
from core.app.app_config.entities import ModelConfig
from core.dify_builder.contract import ResourceOption
from core.dify_builder.models import BuildNodesResult, MutationIntent
from graphon.enums import BUILT_IN_NODE_TYPES
from services.dify_builder import graph_ops
from services.dify_builder.agent import form_schema, graph_translate, llm, resources
from services.dify_builder.agent.model_resolver import resolve_model_instance
from services.workflow_generator_service import WorkflowGeneratorService
logger = logging.getLogger(__name__)
# A reply longer than this is prose, not a title. Well above
# ``naming.MAX_NAME_LENGTH`` so a good-but-long title is trimmed downstream
# rather than rejected here.
_MAX_PROPOSED_NAME_CHARS = 80
def analyze_goal(
model,
goal_text: str,
on_reasoning: Callable[[str], None] | None = None,
) -> dict[str, Any]:
if model is None:
return _degraded_form(goal_text)
system = (
"You are a Dify workflow requirements analyst. Given a build goal, propose 3-6 "
"clarifying requirement fields SHAPED BY THE GOAL, and a sensible default value per "
f"field. {form_schema.FORM_FIELD_TYPE_GUIDANCE}"
'Reply with ONLY JSON: {"fields": [{"key": "...", "label": "...", "type": "...", '
'"options": ["..."]}], "values": {"<key>": <default>}}.'
) + llm.json_language_instruction("field labels and values")
try:
data = llm.invoke_json(model, system=system, user=f"GOAL:\n{goal_text}", on_reasoning=on_reasoning)
except Exception:
return _degraded_form(goal_text)
fields = data.get("fields")
values = data.get("values")
if not isinstance(fields, list) or not isinstance(values, dict):
return _degraded_form(goal_text)
return {"fields": form_schema.reconcile_form_fields(fields, values), "values": values}
def propose_app_name(
model,
goal_text: str,
requirements: dict[str, Any],
on_reasoning: Callable[[str], None] | None = None,
) -> str:
"""Name the app the way a person would title it (spec N1).
Returns "" whenever it cannot beat the name already cut from the prompt --
no model, a failed call, or a sentence instead of a title. The caller then
keeps the derived name, so this never has to be right, only safe.
"""
if model is None:
return ""
system = (
"You name Dify apps. Given the goal the user typed, reply with a short title for "
"the app -- a noun phrase naming what it does, at most 5 words, no trailing "
"punctuation, no quotes, not a sentence and not an instruction. "
"Write it in the same language the user wrote the goal in. "
"Reply with ONLY the title."
)
user = f"GOAL:\n{goal_text}"
if requirements:
user += f"\n\nREQUIREMENTS:\n{json.dumps(requirements, ensure_ascii=False)[:1000]}"
try:
raw = llm.invoke_text(model, system=system, user=user, on_reasoning=on_reasoning).strip()
except Exception:
logger.warning("dify_builder: app-name proposal failed; keeping the derived name", exc_info=True)
return ""
# A sentence ("Sure! Here is a good name for...") is worse than the
# mechanical cut, so accept only a title-shaped reply.
first_line = raw.splitlines()[0].strip() if raw else ""
if not first_line or len(first_line) > _MAX_PROPOSED_NAME_CHARS:
return ""
return first_line
def _degraded_form(goal_text: str) -> dict[str, Any]:
return {
"fields": [{"key": "goal", "label": "Goal", "type": "textarea", "options": []}],
"values": {"goal": goal_text},
}
# The node types a plan step may map to. This exists because the Builder's own
# planner previously had NO node vocabulary (ESQ1-279): it planned in business
# language ("Send a Feishu notification"), and the generator -- whose system
# prompt DOES carry a node whitelist -- was then handed steps it could not map
# onto any real node, failing only after the user had approved the plan.
#
# Deliberately Builder-only. The generator's own whitelist lives in the SHARED
# core/workflow/generator/prompts/planner_prompts.py, which cmd+K also uses and
# which this package must not touch. Keep the two conceptually aligned, but do
# not try to import across that boundary.
_DIFY_NODE_VOCABULARY = """\
- "start" — workflow entry. Always present. Holds the input form variables.
- "end" — workflow exit. Returns the result.
- "llm" — call an LLM with a prompt.
- "knowledge-retrieval" — query a Dify knowledge base.
- "code" — run a Python/JavaScript snippet.
- "template-transform" — Jinja2 string templating.
- "http-request" — call an external HTTP API.
- "tool" — call a Dify built-in or installed plugin tool (web search, Slack, JSON, …).
- "if-else" — branch on a condition.
- "iteration" — run a sub-pipeline over every item of a list.
- "loop" — repeat a sub-pipeline until an exit condition holds.
- "question-classifier" — route to a labelled branch by free-text intent.
- "parameter-extractor" — pull structured fields out of free text with an LLM.
- "document-extractor" — extract text from an uploaded file. Needs a file input.
- "variable-aggregator" — rejoin mutually-exclusive branches into one variable.
- "list-operator" — filter / sort / slice an array variable.
- "assigner" — update an existing conversation or loop variable.
- "human-input" — pause for a person to review, approve, or enter data."""
def propose_plan_v1(
model,
requirements: dict[str, Any],
on_reasoning: Callable[[str], None] | None = None,
) -> list[str]:
if model is None:
return _degraded_plan()
system = (
"You are a Dify workflow planner. Given requirements, propose an ordered list of "
"concise build steps.\n\n"
"Every step MUST be implementable by exactly one of these Dify node types, and you "
"may plan ONLY in these terms:\n\n"
f"{_DIFY_NODE_VOCABULARY}\n\n"
"Write each step as the work that node does, naming the node type. Good: "
'"llm node drafts a reply from the ticket text". Bad: "Notify the team on Feishu" '
"-- that names a product, not a node.\n\n"
"Dify has no scheduler, database, email or messaging node. A step that needs one of "
"those is either a `tool` node (when an installed plugin provides it), an "
"`http-request` node, or outside the workflow entirely -- say so in the step rather "
"than inventing a node.\n\n"
'Reply with ONLY JSON: {"plan": ["step", ...]}.'
) + llm.json_language_instruction("plan steps")
try:
data = llm.invoke_json(
model,
system=system,
user=f"REQUIREMENTS:\n{requirements}",
on_reasoning=on_reasoning,
)
except Exception:
return _degraded_plan()
plan = data.get("plan")
return [str(p) for p in plan] if isinstance(plan, list) and plan else _degraded_plan()
def _degraded_plan() -> list[str]:
return ["Ingest the input", "Process with an LLM", "Emit the result"]
def discover_resources(
model,
tenant_id: str,
plan_items: list[str],
on_reasoning: Callable[[str], None] | None = None,
) -> list[ResourceOption]:
inv = resources.list_tenant_resources(tenant_id)
catalog = {r.id: ("knowledge", r) for r in inv.datasets}
catalog.update({r.id: ("plugin", r) for r in inv.tools})
catalog.update({r.id: ("model", r) for r in inv.models})
if not catalog or model is None:
return []
system = (
"You are a Dify workflow resource recommender. From the AVAILABLE resources, pick the "
"ids relevant to the plan. Each is listed with its readiness: 'ready' or 'missing_config' "
"(installed but not authorized for this workspace, so it cannot actually be used) -- never "
"recommend a missing_config resource. Use ONLY listed ids. Reply with ONLY JSON: "
'{"resource_ids": ["<id>", ...]}.'
)
listing = "\n".join(f"- {rid} ({kind}, {ref.readiness}): {ref.label}" for rid, (kind, ref) in catalog.items())
user = f"PLAN:\n{chr(10).join(plan_items)}\n\nAVAILABLE:\n{listing}"
try:
data = llm.invoke_json(model, system=system, user=user, on_reasoning=on_reasoning)
chosen_ids = [rid for rid in (data.get("resource_ids") or []) if rid in catalog]
except Exception:
logger.warning("dify_builder: resource recommendation failed; recommending none", exc_info=True)
chosen_ids = []
# Recommending nothing is the honest answer when there is no signal -- no
# model, a failed call, or a model that picked none. The old code fell back
# to the whole tenant inventory, and because the client pre-checks every
# recommendation and submits them by default, that silently bound dozens of
# unrelated resources and appended their names to the plan fed to the
# generator. The caller states the situation instead.
return [
ResourceOption(
id=rid,
label=catalog[rid][1].label,
meta=catalog[rid][1].meta,
kind=catalog[rid][0],
readiness=catalog[rid][1].readiness,
)
for rid in chosen_ids
]
_GAP_SYSTEM = (
"You check whether a workflow plan can be built from the resources listed. Each resource is "
"shown with its readiness: 'ready' or 'missing_config' -- installed but not authorized for "
"this workspace, so it is not usable; treat a missing_config resource as unavailable, the "
"same as if it were not listed at all. "
"Name in ONE plain sentence any step no listed resource can perform, and say what "
"capability is missing -- never which product to install. Reply with ONLY JSON: "
'{"gap": "<sentence, or empty string when the plan is covered>"}.'
)
def assess_capability_gap(model, plan_items: list[str], options: list[ResourceOption]) -> str:
"""One sentence naming what the workspace cannot do, or "".
Grounded only in what is installed -- there is no marketplace lookup, so
this says a capability is missing, never which plugin would supply it. A
``missing_config`` tool (installed but unauthorized) cannot actually cover
a step, so it counts against the plan the same as if it weren't there at
all -- the readiness the model is shown reflects that. A model we cannot
reach reports no gap: inventing one would block a build that might be
fine.
"""
if model is None or not plan_items:
return ""
listing = "\n".join(f"- {o.label} ({o.kind}, {o.readiness})" for o in options) or "- (none)"
try:
data = llm.invoke_json(
model,
system=_GAP_SYSTEM + llm.json_language_instruction("the gap sentence"),
user=f"PLAN:\n{chr(10).join(plan_items)}\n\nAVAILABLE:\n{listing}",
)
except Exception:
logger.warning("dify_builder: capability-gap assessment failed", exc_info=True)
return ""
gap = data.get("gap")
return str(gap).strip() if isinstance(gap, str) else ""
def bind_resources(model, tenant_id: str, plan_items: list[str], resource_ids: list[str]) -> list[str]:
inv = resources.list_tenant_resources(tenant_id)
by_id = {r.id: r for r in (*inv.datasets, *inv.tools, *inv.models)}
labels = [by_id[rid].label for rid in resource_ids if rid in by_id]
if not labels:
return list(plan_items)
suffix = f" (using {', '.join(labels)})"
# Deterministic, clean binding: name the resources on the retrieval/process step.
bound = list(plan_items)
if bound:
bound[-1] = bound[-1] + suffix if suffix not in bound[-1] else bound[-1]
else:
bound = [f"Use {', '.join(labels)}"]
return bound
def learn_from_build(
model,
goal_text: str,
requirements: dict[str, Any],
plan_items: list[str],
built_node_ids: list[str],
on_reasoning: Callable[[str], None] | None = None,
) -> str:
fallback = f"Reusable skill: a {len(built_node_ids)}-node workflow for: {goal_text[:80]}"
if model is None:
return fallback
system = "Summarize this built workflow as a one-line reusable skill descriptor. Reply with plain text."
try:
return (
llm.invoke_text(
model,
system=system,
user=f"GOAL: {goal_text}\nPLAN: {plan_items}",
on_reasoning=on_reasoning,
)
or fallback
)
except Exception:
return fallback
_ALLOWED_NODE_TYPES: set[str] = set(BUILT_IN_NODE_TYPES)
def _generator_model_config(tenant_id: str, model_config: dict[str, Any]) -> ModelConfig:
if model_config:
return ModelConfig.model_validate(
{
"provider": model_config.get("provider", ""),
"name": model_config.get("name", ""),
"mode": model_config.get("mode", "chat"),
"completion_params": model_config.get("completion_params", {}),
}
)
inst = resolve_model_instance(tenant_id, None) # tenant default; read real provider/name off it
return ModelConfig.model_validate(
{
"provider": inst.provider,
"name": inst.model_name,
"mode": "chat",
"completion_params": {},
}
)
def _selected_workflow_model(tenant_id: str, resource_ids: Sequence[str]) -> ModelConfig | None:
"""The user-selected MODEL resource (from the resource-confirmation step) as a
ModelConfig to ground the built workflow's node model blocks, or None when the
user selected no model.
A model resource id is ``{provider}/{name}`` (see ``resources._list_models``),
so split on the last '/'. Only ids that are actually model resources qualify --
a selected dataset/tool must never ground a node's model. This is deliberately
separate from the Builder's session/cognition model: the workflow runs on the
model the user picked, not on whatever model drives the Builder itself.
"""
if not resource_ids:
return None
model_ids = {m.id for m in resources.list_tenant_resources(tenant_id).models}
selected = next((rid for rid in resource_ids if rid in model_ids), None)
if selected is None:
return None
provider, _, name = selected.rpartition("/")
if not provider or not name:
return None
return ModelConfig.model_validate({"provider": provider, "name": name, "mode": "chat", "completion_params": {}})
# Prepended to the generator instruction so the LLM emits a WORKFLOW-shaped graph
# (start + end node) even when the plan reads like a chatbot. Without this the model
# often produces a chatflow graph (answer node / no end node) that the generator
# rejects as MISSING_TERMINAL -- and the generator does NOT retry that class of
# failure -- so the build silently yields no nodes.
_WORKFLOW_TOPOLOGY_DIRECTIVE = (
"Build a Dify WORKFLOW graph (this is NOT a chat app): it MUST begin with exactly one "
"'start' node and terminate in at least one 'end' node that returns the result. Do NOT use "
"'answer' nodes -- those exist only in chat / advanced-chat apps and are invalid in a workflow."
"\n\n"
# Node-usage guidance (meeting item 9). Builder-only: this string is
# prepended to the instruction at build.py:377 and cmd+K never sees it.
# Only rules that earned their place by breaking a real build belong here --
# every line costs planner output budget, which ESQ1-300 implicates.
"Node usage rules:\n"
"- A node may only reference variables its upstream node actually declares. A 'tool' node "
"exposes 'text', 'files' and 'json' (plus whatever its provider declares) -- it does NOT "
"expose 'result'. A 'code' node exposes exactly the keys in its own outputs map.\n"
"- 'document-extractor' requires a 'file' or 'file-list' input variable on the start node; "
"feed its 'text' output into the node that consumes it.\n"
"- Children of an 'iteration' or 'loop' belong INSIDE the container via their parent "
"reference. Do not wire a container's body as sibling top-level nodes.\n"
"- Compare like with like in 'if-else': a numeric variable needs a numeric operator, a "
"string variable a string operator.\n"
"- Use a 'tool' node for a third-party service only when the plan names an installed tool; "
"otherwise use 'http-request' and expose its changing inputs as start-node variables rather "
"than hardcoding them."
)
def _terminal_retry_instruction(base_instruction: str, error: str) -> str:
"""Corrective instruction for the single retry after a topology-validation
failure -- feed the specific generator error back with an explicit fix."""
return (
f"Your previous attempt was rejected: {error}. Regenerate the COMPLETE workflow graph "
"with exactly one 'start' node and at least one 'end' node wired from the final step. "
f"Do NOT use 'answer' nodes.\n\n{base_instruction}"
)
def _generation_error_text(result: dict[str, Any]) -> str:
"""Best human-readable failure reason from a generator result -- the top-level
``error`` (e.g. "UNRESOLVED_REFERENCE: Reference {#node4.x#} not declared"), else
the joined ``errors`` details, else a generic fallback. Surfaced to the user so
a failed build shows WHY, not a hardcoded 'couldn't build' message."""
err = result.get("error")
if isinstance(err, str) and err.strip():
return err.strip()
errors = result.get("errors")
if isinstance(errors, list) and errors:
parts = [
str(e.get("detail") or e.get("message") or e.get("code"))
for e in errors
if isinstance(e, dict) and (e.get("detail") or e.get("message") or e.get("code"))
]
if parts:
return "; ".join(parts)
return "the generator returned no usable graph"
def _diagnostic(**fields: Any) -> dict[str, Any]:
"""One debug-log breadcrumb, stamped with the server's UTC time.
``at`` is the correlation key: pod logs are lost on restart, but the exported
debug log keeps this timestamp so the surviving log window can be searched
around it (and matched to the same failure the server logged).
"""
return {"at": datetime.now(UTC).isoformat(), **fields}
def _generation_diagnostic(result: dict[str, Any], *, attempt: int) -> dict[str, Any]:
"""Capture one failed generation attempt: the generator's structured errors
(``code`` / ``detail`` / ``node_id`` -- e.g. UNRESOLVED_REFERENCE on node2),
which is strictly more than the server-side log line carries."""
errors = result.get("errors")
errors = [e for e in errors if isinstance(e, dict)] if isinstance(errors, list) else []
return _diagnostic(
source="workflow-generator",
attempt=attempt,
message=_generation_error_text(result),
codes=[str(e.get("code")) for e in errors if e.get("code")],
errors=errors,
)
def build_nodes(
tenant_id: str,
model_config: dict[str, Any],
plan_items: list[str],
resource_ids: Sequence[str] = (),
) -> BuildNodesResult:
try:
mc = _generator_model_config(tenant_id, model_config)
base_instruction = f"{_WORKFLOW_TOPOLOGY_DIRECTIVE}\n\n" + "\n".join(plan_items)
def _generate(instruction: str) -> dict[str, Any]:
return WorkflowGeneratorService.generate_workflow_graph(
tenant_id=tenant_id,
mode="workflow",
instruction=instruction,
model_config=mc,
current_graph=None,
)
diagnostics: list[dict[str, Any]] = []
result = _generate(base_instruction)
graph = result.get("graph") or {}
if result.get("error") or not graph.get("nodes"):
diagnostics.append(_generation_diagnostic(result, attempt=1))
# The generator's own retry only covers invalid-JSON / bad-schema, NOT a
# structurally-valid graph that fails topology validation (e.g. no 'end'
# node). Retry ONCE with the specific error fed back as a corrective nudge.
retry_error = result.get("error") or "the generated graph had no nodes"
result = _generate(_terminal_retry_instruction(base_instruction, retry_error))
graph = result.get("graph") or {}
if result.get("error") or not graph.get("nodes"):
diagnostics.append(_generation_diagnostic(result, attempt=len(diagnostics) + 1))
error = _generation_error_text(result)
logger.warning(
"Dify Builder: build_nodes produced no graph for tenant %s (%d plan items): error=%s",
tenant_id,
len(plan_items),
error,
)
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
intents = graph_translate.to_intents(graph)
# Ground node model blocks to the user-SELECTED model (resource confirmation),
# falling back to the generation/session model when none was selected. The
# generator above still runs on ``mc`` (cognition); only the built workflow's
# runtime model follows the user's choice.
grounding_mc = _selected_workflow_model(tenant_id, resource_ids) or mc
_ground(intents, grounding_mc, tenant_id, plan_items)
applicable, rejected = graph_ops.filter_applicable({"nodes": [], "edges": []}, intents, _ALLOWED_NODE_TYPES)
if not applicable:
reason = rejected[0][1] if rejected else "no applicable node intents"
error = f"the generated nodes were rejected by validation: {reason}"
logger.warning("Dify Builder: build_nodes rejected all intents for tenant %s: %s", tenant_id, error)
diagnostics.append(
_diagnostic(
source="build_nodes",
message=error,
rejected=[{"reason": str(r), "intent": str(i.op)} for i, r in rejected],
)
)
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
# Retries that eventually succeeded still leave their breadcrumbs behind.
return BuildNodesResult(intents=applicable, diagnostics=diagnostics)
except Exception as exc: # any generation/translation failure -> honest empty build
logger.exception(
"Dify Builder: build_nodes generation failed for tenant %s (%d plan items); returning empty build",
tenant_id,
len(plan_items),
)
# Surface the exception text (e.g. a provider error like credit_balance_exhausted)
# so the user sees WHY, not a hardcoded 'couldn't build' message.
message = str(exc).strip() or type(exc).__name__
return BuildNodesResult(
intents=[],
error=message,
diagnostics=[_diagnostic(source="build_nodes", message=message, exception=type(exc).__name__)],
)
def _ground(intents: list[MutationIntent], mc: ModelConfig, tenant_id: str, plan_items: list[str]) -> None:
mode = mc.mode.value if hasattr(mc.mode, "value") else str(mc.mode)
datasets = resources.list_tenant_resources(tenant_id).datasets
matched = [d.id for d in datasets if any(d.label in item for item in plan_items)]
for intent in intents:
if intent.op != "create_node":
continue
config = intent.args.get("config") or {}
if isinstance(config.get("model"), dict):
# Ground ONLY provider+name (the real, configured model); preserve whatever
# mode/completion_params the generator produced -- never fabricate params.
# Applies to ANY node type that carries a model block (llm,
# question-classifier, parameter-extractor, ...), not just "llm" --
# otherwise a drifted/hallucinated model on those node types flows
# through ungrounded.
model = dict(config["model"])
model["provider"] = mc.provider
model["name"] = mc.name
model.setdefault("mode", mode)
config["model"] = model
if intent.args.get("node_type") == "knowledge-retrieval":
config["dataset_ids"] = list(matched) # independent copy per node -- never share one list
intent.args["config"] = config