Files
dify/api/core/dify_builder/models.py
T
Charles YaoandClaude Opus 5 6877532f8e fix(dify-builder): key the repeated-repair guard on the engine, not LLM prose
_repair_is_repeating could never fire in production. _note_repair_error
counted repeats by exact string equality on diagnosis.root_cause, which is
LLM prose regenerated on every diagnosis call. Observed live across four
turns of one session: same failure, reworded every time, one turn switching
to Chinese. Equality therefore never held, repair_attempts reset forever,
and the guard was unreachable -- that session burned 9 failed verify runs
and 8 repair approvals with the guard in place.

The call site's own comment reads "ESQ1-285 burned nine approvals this way,
ESQ1-290 six". The reproduction burned nine again.

Key on the ENGINE's output instead: _failure_signature(run) returns the
first failed node's id plus its error text (whitespace-collapsed), falling
back to the launch error, and "" when the run identifies nothing -- so two
unknown failures never compare equal.

Why the existing tests passed while production was broken: every one of them
fed _note_repair_error the SAME literal string twice, and StubAgent.diagnose
always returns one fixed root_cause. They encoded the premise that is false
in production. The new handler-level test drives three turns with an agent
that rewords its root_cause each call while the engine fails identically,
and fails against the old implementation with repair_attempts == 0.

last_repair_error now holds that signature rather than prose. It was never
displayed -- its only use was this comparison.

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

347 lines
12 KiB
Python

"""Dify Builder domain models.
Port of dify-enterprise/server/pkg/enterprise/biz/dify_builder/models.go.
Deltas from the Go source (per the P1 port plan's Global Constraints / ADR):
- ``ForwardAuth`` is dropped entirely. ``Turn`` carries an ``Actor``
(``account_id``, ``tenant_id``) instead of a forwarded console token, which
is never persisted and only ever valid for a single ``Advance`` call.
- Every field except ``PcState``/``EntryMode``-typed fields
(``Session.entry_mode``, ``Session.current_state``, ``Checkpoint.state``)
``Turn.actor``, and ``ChangedNode.node_id`` gets a default matching the Go zero value (``""``,
``0``, ``False``, empty list/dict, ``datetime.min``). This mirrors how the
Go test suite constructs these structs: almost every struct literal in
``*_test.go`` sets only a handful of fields and relies on Go's implicit
zero-initialization for the rest (e.g. ``&Session{AppID: "app", ...}``,
``&DifyBuilderContext{}``, ``&DifyBuilderContext{FailedRunID: "TR-1"}``). The excepted
fields have no meaningful "empty" value (an empty string is not a valid
``PcState``/``EntryMode`` member) and are always set explicitly in the Go
suite, so they stay required here.
- ``Diagnosis`` / ``Risk`` / ``MutationIntent`` / ``ChangeSet`` / ``NodeEvent``
/ ``ApplyResult`` (Go: ``ports.go``) live here rather than in ``ports.py``.
``DifyBuilderContext`` embeds ``Diagnosis``/``Risk``/``ChangeSet``/
``MutationIntent`` directly, and defining them in ``ports.py`` (which
imports the rest of these models) would create a models<->ports import
cycle; ``ports.py`` imports them back out of this module instead.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any
from core.dify_builder.state import PcState
class EntryMode(StrEnum):
FIX = "fix"
FIX_CHECKLIST = "fix_checklist"
BUILD = "build"
EDIT = "edit"
@dataclass(kw_only=True)
class ChecklistError:
"""One pre-publish checklist finding for a node.
Config errors (``messages``) are fixable via ``set_node_config``;
``unconnected`` and ``plugin_missing`` are structural — surfaced to the
user, not auto-fixed in v1.
"""
node_id: str = ""
node_type: str = ""
title: str = ""
messages: list[str] = field(default_factory=list)
unconnected: bool = False
plugin_missing: bool = False
@dataclass(kw_only=True)
class Session:
id: str = ""
app_id: str = ""
tenant_id: str = ""
owner_account_id: str = ""
entry_mode: EntryMode
current_state: PcState
version: int = 0
created_at: datetime = field(default_factory=lambda: datetime.min)
updated_at: datetime = field(default_factory=lambda: datetime.min)
# Graph, Inputs and StartSchema are opaque JSON blobs the engine passes
# through; only the Dify adapter and the agent interpret them.
Graph = dict[str, Any]
Inputs = dict[str, Any]
StartSchema = dict[str, Any]
@dataclass(kw_only=True)
class NodeOutput:
node_id: str = ""
title: str = ""
status: str = "" # success | failed | skip
error: str = ""
inputs: Inputs = field(default_factory=dict)
outputs: Inputs = field(default_factory=dict)
@dataclass(kw_only=True)
class Run:
id: str = ""
kind: str = "" # original-failed | verify
dify_run_id: str = ""
status: str = "" # running | succeeded | failed
per_node: list[NodeOutput] = field(default_factory=list)
culprit_node_id: str = ""
# Launch-failure text (run threw before any node executed); "" once a node ran.
error: str = ""
inputs_ref: str = ""
tokens: int = 0
elapsed_ms: int = 0
immutable: bool = False
@dataclass(kw_only=True)
class Snapshot:
id: str = ""
session_id: str = ""
hash: str = ""
graph: Graph = field(default_factory=dict)
@dataclass(kw_only=True)
class Checkpoint:
id: str = ""
session_id: str = ""
state: PcState
snapshot_id: str = ""
@dataclass(kw_only=True)
class TestInput:
# Not a pytest test class; ``__test__`` (unannotated, so not a dataclass
# field) tells pytest's Test*-name collector to skip it.
__test__ = False
id: str = ""
session_id: str = ""
source: str = "" # reuse | mock | upload
inputs: Inputs = field(default_factory=dict)
start_schema_hash: str = ""
@dataclass(kw_only=True)
class ConversationItem:
seq: int = 0
kind: str = "" # user | decision | notice | run_context | preflight_context | assistant_turn | plan
# | form | challenge | resource_select | checkpoint | change_set | test_result | error | summary
# | publish | build_learning
payload: dict[str, Any] = field(default_factory=dict)
at_version: int = 0
@dataclass(kw_only=True)
class Diagnosis:
culprit_node_id: str = ""
root_cause: str = ""
severity: str = ""
@dataclass(kw_only=True)
class Risk:
level: str = "" # low | high
reason: str = ""
has_external_side_effect: bool = False
@dataclass(kw_only=True)
class MutationIntent:
"""A single graph mutation the dify_builder applies to the draft.
``op`` selects one of five verbs (Slice 1: mutation verbs); ``args`` is
the verb's untyped wire-shaped payload -- ``services.dify_builder
.graph_ops`` unpacks it as keyword arguments (``apply_fn(graph,
**intent.args)``), so each verb's arg-dict keys below are exactly that
verb's ``apply_*`` function's parameter names. Required keys are
enforced by ``graph_ops.validate_intent_args``; ``?`` marks an optional
key.
- ``set_node_config``: ``{node_id, path, value}``
- ``create_node``: ``{node_type, config, position?, node_id?}``
- ``delete_node``: ``{node_id}``
- ``connect``: ``{from_node, to_node, source_handle?, target_handle?}``
- ``insert_between``: ``{edge, node_type, config, position?, node_id?}``
"""
op: str = ""
args: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class ChangeSet:
changed_nodes: list[str] = field(default_factory=list)
diff: str = ""
@dataclass(kw_only=True)
class NodeEvent:
"""A per-node progress event emitted while a draft run streams."""
node_id: str = ""
title: str = ""
status: str = ""
error: str = ""
# The Dify workflow run these nodes belong to. Carried on the event so a
# client can point the canvas at the run while it is still going -- the
# run id is known from the first streamed frame, long before the card
# that reports the outcome exists. "" when the adapter cannot supply one.
run_id: str = ""
@dataclass(kw_only=True)
class ChangedNode:
"""A node's identity and display name captured with a change summary."""
node_id: str
title: str = ""
@dataclass(kw_only=True)
class ApplyResult:
"""Reports what a repair changed and the draft's new execution revision."""
changed_nodes: list[str] = field(default_factory=list)
nodes: list[ChangedNode] = field(default_factory=list)
new_hash: str = ""
changes: list[str] = field(default_factory=list)
scope: str = ""
structure_fingerprint: str = ""
@dataclass(kw_only=True)
class BuildNodesResult:
"""Outcome of ``build_nodes``: the create/connect ``intents`` to apply, plus a
human-readable ``error`` explaining WHY generation failed when ``intents`` is
empty -- the generator's specific reason (e.g. UNRESOLVED_REFERENCE, non-object
JSON) or a provider error (e.g. credit_balance_exhausted). Empty on success."""
intents: list[MutationIntent] = field(default_factory=list)
error: str = ""
# Structured, timestamped breadcrumbs for the exported debug log. Server logs
# are lost when the api pod restarts, so each failed generation attempt travels
# back here (server UTC ``at``, generator ``code``/``node_id``/``detail``) and is
# stamped onto the ErrorCard -> conversation item -> exported trace.
diagnostics: list[dict[str, Any]] = field(default_factory=list)
@dataclass(kw_only=True)
class DifyBuilderContext:
"""Per-session working state persisted as the commit context.
One context type for all entry modes (Fix, checklist, Build, Edit).
Fix-specific and Build-specific fields coexist; each scenario touches
only its own, all default-valued (serialized as a JSON blob -- no
migration on rename or on adding fields).
"""
# -- Fix / checklist fields --
failed_run_id: str = ""
diagnosis: Diagnosis | None = None
staged_repair: list[MutationIntent] = field(default_factory=list)
# Repair-loop convergence. Nothing tracked attempts before, so a repair
# that changed nothing could be approved forever (ESQ1-291) and a repair
# aimed at the wrong field could thrash indefinitely (ESQ1-285/290).
repair_attempts: int = 0
# The ENGINE failure signature of the last diagnosed run (see
# handlers_build._failure_signature), NOT human-readable prose -- it is
# compared for equality to detect a repair loop and is never displayed.
last_repair_error: str = ""
risk: Risk | None = None
change_set: ChangeSet | None = None
checkpoint_id: str = ""
verify_run_id: str = ""
test_input_ref: str = ""
# Execution revision, distinct from the editor's full graph persistence hash.
last_snapshot_hash: str = ""
next_seq: int = 0
# source distinguishes diagnosis origin: "" (run, default) | "checklist".
source: str = ""
checklist_errors: list[ChecklistError] = field(default_factory=list)
# -- Build fields (Slice 2, additive) --
goal_text: str = ""
reply_language: str = "" # detected user-input language (BCP-47); drives reply/localization
requirements: dict[str, Any] = field(default_factory=dict)
plan_items: list[str] = field(default_factory=list)
plan_version_tag: str = ""
resource_selection: dict[str, Any] = field(default_factory=dict)
built_node_ids: list[str] = field(default_factory=list)
form_fields: list[dict] = field(default_factory=list)
# The app's name as last seen by the engine, so the build-complete card can
# say it (spec N4). Empty means unknown -- that card then keeps its generic
# title rather than naming the app wrongly.
app_name: str = ""
# True while the app still carries the name cut from its creating prompt, so
# Builder may refine it once (spec N1); cleared the moment it does. Plan
# approval freezes the name for good (spec N3, 批准即定名).
app_name_auto: bool = False
# -- Edit fields (Slice 3, additive) --
edit_rules: dict[str, Any] = field(default_factory=dict)
edit_target_node_ids: list[str] = field(default_factory=list)
# -- Lifecycle fields (Slice 4, additive) --
paused: bool = False
checkpoint_seq: int = 0
# -- Recovery fields (C-1, additive) --
last_structure_fingerprint: str = ""
recovery_class: str = ""
# -- Governance fields (C-2, additive) --
skill_learning_policy: str = "ask"
# -- Agent model selection (Foundation slice, additive) --
# The user's per-session model choice, in Dify's model-selector shape
# {provider, name, mode, completion_params}. Empty == use the tenant default LLM.
# Kept a plain dict so core imports no services/model-runtime type.
model_config: dict[str, Any] = field(default_factory=dict)
@dataclass(kw_only=True)
class Action:
"""A user-driven event entering the machine."""
# request_fix | approve_repair | reject_repair | run_verify | provide_testdata |
# publish | keep_draft | undo | re_fix | message
kind: str = ""
payload: dict[str, Any] = field(default_factory=dict)
base_version: int = 0
# Opaque execution revision observed in the latest server view.
# Empty means the action does not mutate or otherwise depend on the app
# draft (for example a free-text conversation turn).
base_app_revision: str = ""
@dataclass(kw_only=True)
class Actor:
"""The acting console identity for one Advance call. Never persisted."""
account_id: str
tenant_id: str
@dataclass(kw_only=True)
class Turn:
"""Per-request data carried through one Advance.
Replaces Go's ``Turn.Auth ForwardAuth`` — there is no forwarded console
token in this port; ``actor`` identifies who is driving this turn.
"""
action: Action | None = None
actor: Actor