Skip to content

Commit 702e35c

Browse files
committed
fix: #28524
1 parent a1b735a commit 702e35c

File tree

5 files changed

+105
-174
lines changed

5 files changed

+105
-174
lines changed

api/app_factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def initialize_extensions(app: DifyApp):
5050
ext_commands,
5151
ext_compress,
5252
ext_database,
53+
ext_forward_refs,
5354
ext_hosting_provider,
5455
ext_import_modules,
5556
ext_logging,
@@ -74,6 +75,7 @@ def initialize_extensions(app: DifyApp):
7475
ext_warnings,
7576
ext_import_modules,
7677
ext_orjson,
78+
ext_forward_refs,
7779
ext_set_secretkey,
7880
ext_compress,
7981
ext_code_based_extension,

api/core/app/entities/app_invoke_entities.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class AppGenerateEntity(BaseModel):
130130
# extra parameters, like: auto_generate_conversation_name
131131
extras: dict[str, Any] = Field(default_factory=dict)
132132

133-
# tracing instance
133+
# tracing instance; use forward ref to avoid circular import at import time
134134
trace_manager: Optional["TraceQueueManager"] = None
135135

136136

@@ -275,16 +275,28 @@ class RagPipelineGenerateEntity(WorkflowAppGenerateEntity):
275275
start_node_id: str | None = None
276276

277277

278-
# Import TraceQueueManager at runtime to resolve forward references
279-
from core.ops.ops_trace_manager import TraceQueueManager
278+
# NOTE: Avoid importing heavy tracing modules at import time to prevent circular imports.
279+
# Forward reference to TraceQueueManager is kept as a string; we rebuild with a stub now to
280+
# avoid Pydantic forward-ref errors in test contexts, and with the real class at app startup.
280281

281-
# Rebuild models that use forward references
282-
AppGenerateEntity.model_rebuild()
283-
EasyUIBasedAppGenerateEntity.model_rebuild()
284-
ConversationAppGenerateEntity.model_rebuild()
285-
ChatAppGenerateEntity.model_rebuild()
286-
CompletionAppGenerateEntity.model_rebuild()
287-
AgentChatAppGenerateEntity.model_rebuild()
288-
AdvancedChatAppGenerateEntity.model_rebuild()
289-
WorkflowAppGenerateEntity.model_rebuild()
290-
RagPipelineGenerateEntity.model_rebuild()
282+
283+
# Minimal stub to satisfy Pydantic model_rebuild in environments where the real type is not importable yet.
284+
class _TraceQueueManagerStub:
285+
pass
286+
287+
288+
# Rebuild models with stub type to avoid PydanticUserError during import-time validation in tests.
289+
try:
290+
_ns = {"TraceQueueManager": _TraceQueueManagerStub}
291+
AppGenerateEntity.model_rebuild(_types_namespace=_ns)
292+
EasyUIBasedAppGenerateEntity.model_rebuild(_types_namespace=_ns)
293+
ConversationAppGenerateEntity.model_rebuild(_types_namespace=_ns)
294+
ChatAppGenerateEntity.model_rebuild(_types_namespace=_ns)
295+
CompletionAppGenerateEntity.model_rebuild(_types_namespace=_ns)
296+
AgentChatAppGenerateEntity.model_rebuild(_types_namespace=_ns)
297+
AdvancedChatAppGenerateEntity.model_rebuild(_types_namespace=_ns)
298+
WorkflowAppGenerateEntity.model_rebuild(_types_namespace=_ns)
299+
RagPipelineGenerateEntity.model_rebuild(_types_namespace=_ns)
300+
except Exception:
301+
# Best-effort; tests or import-time contexts should not fail if rebuild is unnecessary
302+
pass

api/core/workflow/nodes/base/node.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import operator
23
from abc import abstractmethod
34
from collections.abc import Generator, Mapping, Sequence
45
from functools import singledispatchmethod
@@ -269,6 +270,75 @@ def version(cls) -> str:
269270
# in `api/core/workflow/nodes/__init__.py`.
270271
raise NotImplementedError("subclasses of BaseNode must implement `version` method.")
271272

273+
@classmethod
274+
def get_node_type_classes_mapping(cls) -> Mapping["NodeType", Mapping[str, type["Node"]]]:
275+
"""Build mapping of NodeType -> {version -> Node subclass} at runtime.
276+
277+
- Ensures all node modules are imported (via importing core.workflow.nodes) so subclasses are loaded.
278+
- Walks the subclass tree to find concrete Node implementations that define node_type and version().
279+
- Chooses "latest" per node type by preferring the numerically highest version if possible; otherwise
280+
falls back to lexicographical order.
281+
"""
282+
# Best-effort import to trigger package __init__ side effects (which import concrete node modules)
283+
try:
284+
import importlib
285+
import pkgutil
286+
287+
import core.workflow.nodes as _nodes_pkg
288+
289+
# Recursively import all modules under core.workflow.nodes to load subclasses
290+
for _finder, _modname, _ispkg in pkgutil.walk_packages(_nodes_pkg.__path__, _nodes_pkg.__name__ + "."):
291+
try:
292+
importlib.import_module(_modname)
293+
except Exception:
294+
# Best-effort: ignore modules that fail to import during tests
295+
pass
296+
except Exception:
297+
pass
298+
299+
# Gather all subclasses (recursive)
300+
def _iter_subclasses(base: type["Node"]) -> set[type["Node"]]:
301+
seen: set[type[Node]] = set()
302+
stack = list(base.__subclasses__())
303+
while stack:
304+
sub = stack.pop()
305+
if sub in seen:
306+
continue
307+
seen.add(sub)
308+
stack.extend(list(sub.__subclasses__()))
309+
return seen
310+
311+
mapping: dict[NodeType, dict[str, type[Node]]] = {}
312+
for sub in _iter_subclasses(cls):
313+
node_type = getattr(sub, "node_type", None)
314+
version_fn = getattr(sub, "version", None)
315+
if node_type is None or not callable(version_fn):
316+
continue
317+
try:
318+
version = sub.version()
319+
except Exception:
320+
continue
321+
if node_type not in mapping:
322+
mapping[node_type] = {}
323+
mapping[node_type][version] = sub # type: ignore[assignment]
324+
325+
# Compute "latest" per node type
326+
for node_type, versions in mapping.items():
327+
# Prefer numeric sort when possible
328+
numeric_pairs: list[tuple[str, float]] = []
329+
for v in versions:
330+
try:
331+
numeric_pairs.append((v, float(v)))
332+
except Exception:
333+
pass
334+
if numeric_pairs:
335+
latest_key = max(numeric_pairs, key=operator.itemgetter(1))[0]
336+
else:
337+
latest_key = max(versions.keys())
338+
versions["latest"] = versions[latest_key]
339+
340+
return mapping
341+
272342
@property
273343
def retry(self) -> bool:
274344
return False
Lines changed: 4 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,11 @@
11
from collections.abc import Mapping
22

3+
# Import the package once to trigger metaclass auto-registration via __init__ side effects
4+
import core.workflow.nodes as _ # noqa: F401
35
from core.workflow.enums import NodeType
4-
from core.workflow.nodes.agent.agent_node import AgentNode
5-
from core.workflow.nodes.answer.answer_node import AnswerNode
66
from core.workflow.nodes.base.node import Node
7-
from core.workflow.nodes.code import CodeNode
8-
from core.workflow.nodes.datasource.datasource_node import DatasourceNode
9-
from core.workflow.nodes.document_extractor import DocumentExtractorNode
10-
from core.workflow.nodes.end.end_node import EndNode
11-
from core.workflow.nodes.http_request import HttpRequestNode
12-
from core.workflow.nodes.human_input import HumanInputNode
13-
from core.workflow.nodes.if_else import IfElseNode
14-
from core.workflow.nodes.iteration import IterationNode, IterationStartNode
15-
from core.workflow.nodes.knowledge_index import KnowledgeIndexNode
16-
from core.workflow.nodes.knowledge_retrieval import KnowledgeRetrievalNode
17-
from core.workflow.nodes.list_operator import ListOperatorNode
18-
from core.workflow.nodes.llm import LLMNode
19-
from core.workflow.nodes.loop import LoopEndNode, LoopNode, LoopStartNode
20-
from core.workflow.nodes.parameter_extractor import ParameterExtractorNode
21-
from core.workflow.nodes.question_classifier import QuestionClassifierNode
22-
from core.workflow.nodes.start import StartNode
23-
from core.workflow.nodes.template_transform import TemplateTransformNode
24-
from core.workflow.nodes.tool import ToolNode
25-
from core.workflow.nodes.trigger_plugin import TriggerEventNode
26-
from core.workflow.nodes.trigger_schedule import TriggerScheduleNode
27-
from core.workflow.nodes.trigger_webhook import TriggerWebhookNode
28-
from core.workflow.nodes.variable_aggregator import VariableAggregatorNode
29-
from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode as VariableAssignerNodeV1
30-
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2
317

328
LATEST_VERSION = "latest"
339

34-
# NOTE(QuantumGhost): This should be in sync with subclasses of BaseNode.
35-
# Specifically, if you have introduced new node types, you should add them here.
36-
#
37-
# TODO(QuantumGhost): This could be automated with either metaclass or `__init_subclass__`
38-
# hook. Try to avoid duplication of node information.
39-
NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[Node]]] = {
40-
NodeType.START: {
41-
LATEST_VERSION: StartNode,
42-
"1": StartNode,
43-
},
44-
NodeType.END: {
45-
LATEST_VERSION: EndNode,
46-
"1": EndNode,
47-
},
48-
NodeType.ANSWER: {
49-
LATEST_VERSION: AnswerNode,
50-
"1": AnswerNode,
51-
},
52-
NodeType.LLM: {
53-
LATEST_VERSION: LLMNode,
54-
"1": LLMNode,
55-
},
56-
NodeType.KNOWLEDGE_RETRIEVAL: {
57-
LATEST_VERSION: KnowledgeRetrievalNode,
58-
"1": KnowledgeRetrievalNode,
59-
},
60-
NodeType.IF_ELSE: {
61-
LATEST_VERSION: IfElseNode,
62-
"1": IfElseNode,
63-
},
64-
NodeType.CODE: {
65-
LATEST_VERSION: CodeNode,
66-
"1": CodeNode,
67-
},
68-
NodeType.TEMPLATE_TRANSFORM: {
69-
LATEST_VERSION: TemplateTransformNode,
70-
"1": TemplateTransformNode,
71-
},
72-
NodeType.QUESTION_CLASSIFIER: {
73-
LATEST_VERSION: QuestionClassifierNode,
74-
"1": QuestionClassifierNode,
75-
},
76-
NodeType.HTTP_REQUEST: {
77-
LATEST_VERSION: HttpRequestNode,
78-
"1": HttpRequestNode,
79-
},
80-
NodeType.TOOL: {
81-
LATEST_VERSION: ToolNode,
82-
# This is an issue that caused problems before.
83-
# Logically, we shouldn't use two different versions to point to the same class here,
84-
# but in order to maintain compatibility with historical data, this approach has been retained.
85-
"2": ToolNode,
86-
"1": ToolNode,
87-
},
88-
NodeType.VARIABLE_AGGREGATOR: {
89-
LATEST_VERSION: VariableAggregatorNode,
90-
"1": VariableAggregatorNode,
91-
},
92-
NodeType.LEGACY_VARIABLE_AGGREGATOR: {
93-
LATEST_VERSION: VariableAggregatorNode,
94-
"1": VariableAggregatorNode,
95-
}, # original name of VARIABLE_AGGREGATOR
96-
NodeType.ITERATION: {
97-
LATEST_VERSION: IterationNode,
98-
"1": IterationNode,
99-
},
100-
NodeType.ITERATION_START: {
101-
LATEST_VERSION: IterationStartNode,
102-
"1": IterationStartNode,
103-
},
104-
NodeType.LOOP: {
105-
LATEST_VERSION: LoopNode,
106-
"1": LoopNode,
107-
},
108-
NodeType.LOOP_START: {
109-
LATEST_VERSION: LoopStartNode,
110-
"1": LoopStartNode,
111-
},
112-
NodeType.LOOP_END: {
113-
LATEST_VERSION: LoopEndNode,
114-
"1": LoopEndNode,
115-
},
116-
NodeType.PARAMETER_EXTRACTOR: {
117-
LATEST_VERSION: ParameterExtractorNode,
118-
"1": ParameterExtractorNode,
119-
},
120-
NodeType.VARIABLE_ASSIGNER: {
121-
LATEST_VERSION: VariableAssignerNodeV2,
122-
"1": VariableAssignerNodeV1,
123-
"2": VariableAssignerNodeV2,
124-
},
125-
NodeType.DOCUMENT_EXTRACTOR: {
126-
LATEST_VERSION: DocumentExtractorNode,
127-
"1": DocumentExtractorNode,
128-
},
129-
NodeType.LIST_OPERATOR: {
130-
LATEST_VERSION: ListOperatorNode,
131-
"1": ListOperatorNode,
132-
},
133-
NodeType.AGENT: {
134-
LATEST_VERSION: AgentNode,
135-
# This is an issue that caused problems before.
136-
# Logically, we shouldn't use two different versions to point to the same class here,
137-
# but in order to maintain compatibility with historical data, this approach has been retained.
138-
"2": AgentNode,
139-
"1": AgentNode,
140-
},
141-
NodeType.HUMAN_INPUT: {
142-
LATEST_VERSION: HumanInputNode,
143-
"1": HumanInputNode,
144-
},
145-
NodeType.DATASOURCE: {
146-
LATEST_VERSION: DatasourceNode,
147-
"1": DatasourceNode,
148-
},
149-
NodeType.KNOWLEDGE_INDEX: {
150-
LATEST_VERSION: KnowledgeIndexNode,
151-
"1": KnowledgeIndexNode,
152-
},
153-
NodeType.TRIGGER_WEBHOOK: {
154-
LATEST_VERSION: TriggerWebhookNode,
155-
"1": TriggerWebhookNode,
156-
},
157-
NodeType.TRIGGER_PLUGIN: {
158-
LATEST_VERSION: TriggerEventNode,
159-
"1": TriggerEventNode,
160-
},
161-
NodeType.TRIGGER_SCHEDULE: {
162-
LATEST_VERSION: TriggerScheduleNode,
163-
"1": TriggerScheduleNode,
164-
},
165-
}
10+
# Auto-generated mapping via metaclass registry
11+
NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[Node]]] = Node.get_node_type_classes_mapping()

api/core/workflow/nodes/tool/tool_node.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from core.tools.errors import ToolInvokeError
1313
from core.tools.tool_engine import ToolEngine
1414
from core.tools.utils.message_transformer import ToolFileMessageTransformer
15-
from core.tools.workflow_as_tool.tool import WorkflowTool
1615
from core.variables.segments import ArrayAnySegment, ArrayFileSegment
1716
from core.variables.variables import ArrayAnyVariable
1817
from core.workflow.enums import (
@@ -450,8 +449,10 @@ def _transform_message(
450449

451450
@staticmethod
452451
def _extract_tool_usage(tool_runtime: Tool) -> LLMUsage:
453-
if isinstance(tool_runtime, WorkflowTool):
454-
return tool_runtime.latest_usage
452+
# Avoid importing WorkflowTool at module import time; rely on duck typing
453+
latest = getattr(tool_runtime, "latest_usage", None)
454+
if latest is not None:
455+
return latest # type: ignore[return-value]
455456
return LLMUsage.empty_usage()
456457

457458
@classmethod

0 commit comments

Comments
 (0)