Files
dify/api/services/dify_builder/agent/build.py
T
Charles YaoandClaude Opus 5 69ccf2ad15 fix(dify-builder): wire resource readiness into recommender/gap prompts
Three minor whole-branch-review fixups:

- ResourceOption.readiness ("ready"/"missing_config") was computed but never
  shown to the model: discover_resources's recommender prompt listed ids and
  labels only, so it could recommend an unauthorized tool; _GAP_SYSTEM showed
  readiness in the listing (assess_capability_gap) but never defined what
  missing_config means. Both prompts now show readiness and state that
  missing_config is installed-but-not-authorized, i.e. not usable -- pinned
  with tests asserting the literal "missing_config" in the constructed
  prompts.
- strings.py: dropped three orphaned catalogue entries ("Here's the initial
  plan.", "Ask each time", "Prefer audited") -- the conflict-policy concept
  they backed was removed end to end; nothing in the handlers emits them.
- core/workflow/generator/tool_catalogue.py: provider.need_credentials was
  read OUTSIDE the per-provider try/except that already guards
  get_tools()/tool parsing. This module is shared with the cmd+K /create and
  /refine flows, so a provider whose property raised would break those too,
  not just the Builder. Moved the read inside the try/except so a bad
  provider is skipped like any other.

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

470 lines
21 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},
}
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. 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."
)
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