@shulex/cxclawctl 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +25 -22
  2. package/capabilities.lock.json +535 -131
  3. package/dist/cli.js +2203 -127
  4. package/dist/cli.js.map +1 -1
  5. package/package.json +20 -6
  6. package/requirements-cli.txt +0 -2
  7. package/skills/__lib__/__init__.py +0 -0
  8. package/skills/__lib__/skill-runtime/bootstrap.py +0 -134
  9. package/skills/__lib__/solvea-api/__init__.py +0 -0
  10. package/skills/__lib__/solvea-api/client.py +0 -1944
  11. package/skills/__lib__/solvea-api/dashboard.py +0 -100
  12. package/skills/__lib__/solvea-api/solvea_api.py +0 -1341
  13. package/skills/agent-analytics/SKILL.md +0 -74
  14. package/skills/agent-analytics/references/chat-report-fields.md +0 -36
  15. package/skills/agent-analytics/references/report-fields.md +0 -130
  16. package/skills/agent-analytics/references/transcript.md +0 -110
  17. package/skills/agent-builder/SKILL.md +0 -42
  18. package/skills/agent-builder/interfaces/fallback-chatflow.md +0 -47
  19. package/skills/agent-builder/references/chatflow/graph-spec.md +0 -259
  20. package/skills/agent-builder/references/chatflow/node-selection.md +0 -89
  21. package/skills/agent-builder/references/chatflow/nodes/agent.md +0 -350
  22. package/skills/agent-builder/references/chatflow/nodes/business-track.md +0 -43
  23. package/skills/agent-builder/references/chatflow/nodes/code.md +0 -157
  24. package/skills/agent-builder/references/chatflow/nodes/collect-order.md +0 -47
  25. package/skills/agent-builder/references/chatflow/nodes/finish.md +0 -34
  26. package/skills/agent-builder/references/chatflow/nodes/human-review.md +0 -60
  27. package/skills/agent-builder/references/chatflow/nodes/if-else.md +0 -100
  28. package/skills/agent-builder/references/chatflow/nodes/intent.md +0 -79
  29. package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +0 -43
  30. package/skills/agent-builder/references/chatflow/nodes/llm.md +0 -117
  31. package/skills/agent-builder/references/chatflow/nodes/note.md +0 -38
  32. package/skills/agent-builder/references/chatflow/nodes/question.md +0 -97
  33. package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +0 -68
  34. package/skills/agent-builder/references/chatflow/nodes/reply.md +0 -97
  35. package/skills/agent-builder/references/chatflow/nodes/start.md +0 -35
  36. package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +0 -48
  37. package/skills/agent-builder/references/chatflow/nodes/tool.md +0 -138
  38. package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +0 -63
  39. package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +0 -52
  40. package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +0 -50
  41. package/skills/agent-builder/references/chatflow/nodes/variable-process.md +0 -70
  42. package/skills/agent-builder/references/chatflow/overview.md +0 -53
  43. package/skills/agent-builder/references/chatflow/visual-translation.md +0 -64
  44. package/skills/agent-builder/references/chatflow/workflow.md +0 -134
  45. package/skills/agent-builder/references/multi-agent/create-update.md +0 -185
  46. package/skills/agent-builder/references/multi-agent/graph-spec.md +0 -246
  47. package/skills/agent-builder/references/multi-agent/graph-structure.md +0 -12
  48. package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +0 -13
  49. package/skills/agent-builder/references/multi-agent/migration/large.md +0 -554
  50. package/skills/agent-builder/references/multi-agent/migration/principles.md +0 -446
  51. package/skills/agent-builder/references/multi-agent/migration/small.md +0 -225
  52. package/skills/agent-builder/references/multi-agent/orchestration.md +0 -11
  53. package/skills/agent-builder/references/multi-agent/overview.md +0 -48
  54. package/skills/agent-builder/references/multi-agent/update.md +0 -128
  55. package/skills/agent-builder/references/multi-agent/workflow.md +0 -87
  56. package/skills/agent-builder/references/single-agent/overview.md +0 -48
  57. package/skills/agent-builder/references/single-agent/prompt.md +0 -16
  58. package/skills/agent-builder/references/single-agent/tools.md +0 -36
  59. package/skills/agent-builder/references/single-agent/voice.md +0 -46
  60. package/skills/agent-builder/references/tools/http-tool.md +0 -315
  61. package/skills/agent-builder/references/tools/mock-tool.md +0 -279
  62. package/skills/agent-builder/references/tools/overview.md +0 -46
  63. package/skills/agent-builder/references/tools/tool-config.md +0 -43
  64. package/skills/agent-builder/references/tools/x-token.md +0 -34
  65. package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +0 -3458
  66. package/skills/agent-builder/scripts/chatflow/validate_graph.py +0 -1697
  67. package/skills/agent-builder/scripts/copy_marketplace_agent.py +0 -61
  68. package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +0 -5974
  69. package/skills/agent-builder/scripts/multi-agent/replay_compare.py +0 -679
  70. package/skills/agent-builder/scripts/patch_agent_tools.py +0 -135
  71. package/skills/agent-builder/scripts/tools/validate_tool_config.py +0 -384
  72. package/skills/agent-tester/SKILL.md +0 -35
  73. package/skills/agent-tester/batch-test/overview.md +0 -86
  74. package/skills/agent-tester/scripts/batch_test_sheet.py +0 -213
  75. package/skills/agent-tester/single-test/badcase-retest.md +0 -85
  76. package/skills/agent-tester/single-test/direct-test.md +0 -24
  77. package/skills/agent-tester/single-test/overview.md +0 -142
  78. package/skills/delivery/SKILL.md +0 -112
  79. package/skills/delivery/interfaces/classify.md +0 -38
  80. package/skills/delivery/interfaces/create-agents.md +0 -89
  81. package/skills/delivery/interfaces/delivery-test.md +0 -67
  82. package/skills/delivery/references/flow.md +0 -40
  83. package/skills/delivery/references/validation.md +0 -15
  84. package/skills/delivery/scripts/batch_test.py +0 -704
  85. package/skills/delivery/scripts/classify.py +0 -22
  86. package/skills/delivery/scripts/create_agents.py +0 -695
  87. package/skills/delivery/scripts/gen_delivery_report.py +0 -335
  88. package/skills/delivery/scripts/gen_excel.py +0 -234
  89. package/skills/delivery/scripts/gen_report.py +0 -208
  90. package/skills/delivery/scripts/plan.py +0 -163
  91. package/skills/delivery/scripts/render.py +0 -713
  92. package/skills/delivery/scripts/render_summary.py +0 -38
  93. package/skills/delivery/scripts/sample_history_cases.py +0 -109
  94. package/skills/kb-builder/SKILL.md +0 -80
  95. package/skills/kb-builder/interfaces/qa-upload.md +0 -55
  96. package/skills/kb-builder/interfaces/web-kb.md +0 -84
  97. package/skills/kb-builder/references/directory.md +0 -373
  98. package/skills/kb-builder/references/export.md +0 -282
  99. package/skills/kb-builder/references/isolation-keywords.md +0 -113
  100. package/skills/kb-builder/references/knowledge.md +0 -153
  101. package/skills/kb-builder/references/product.md +0 -52
  102. package/skills/kb-builder/references/qa-from-help-center.md +0 -58
  103. package/skills/kb-builder/references/qa-from-pdf.md +0 -94
  104. package/skills/kb-builder/references/qa-from-tickets.md +0 -168
  105. package/skills/kb-builder/references/qa-from-web.md +0 -98
  106. package/skills/kb-builder/references/qa-quality.md +0 -73
  107. package/skills/kb-builder/references/reply-template.md +0 -237
  108. package/skills/kb-builder/references/tag-isolation-migration.md +0 -61
  109. package/skills/kb-builder/references/tags.md +0 -45
  110. package/skills/kb-builder/references/upload.md +0 -130
  111. package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +0 -333
  112. package/skills/kb-builder/scripts/build_directory_plan.py +0 -144
  113. package/skills/kb-builder/scripts/crawl_site.py +0 -433
  114. package/skills/kb-builder/scripts/create_dirs.py +0 -146
  115. package/skills/kb-builder/scripts/export_knowledge.py +0 -454
  116. package/skills/kb-builder/scripts/extract_ticket_qa.py +0 -441
  117. package/skills/kb-builder/scripts/extract_web_qa.py +0 -118
  118. package/skills/kb-builder/scripts/import_qa.py +0 -219
  119. package/skills/kb-builder/scripts/import_source_documents.py +0 -143
  120. package/skills/kb-builder/scripts/import_templates.py +0 -409
  121. package/skills/poc/SKILL.md +0 -125
  122. package/skills/poc/assets/default_manual.css +0 -279
  123. package/skills/poc/assets/notice_escalate.png +0 -0
  124. package/skills/poc/assets/notice_escalate2.png +0 -0
  125. package/skills/poc/assets/notice_new_chat.png +0 -0
  126. package/skills/poc/assets/poc_test_template.html +0 -645
  127. package/skills/poc/assets/poc_test_template.md +0 -104
  128. package/skills/poc/assets/step3_preview.png +0 -0
  129. package/skills/poc/assets/step4_order_note.png +0 -0
  130. package/skills/poc/interfaces/classify.md +0 -38
  131. package/skills/poc/interfaces/create-agents.md +0 -108
  132. package/skills/poc/interfaces/poc-test.md +0 -94
  133. package/skills/poc/references/flow.md +0 -47
  134. package/skills/poc/references/kb-directory.md +0 -22
  135. package/skills/poc/references/validation.md +0 -17
  136. package/skills/poc/scripts/classify.py +0 -22
  137. package/skills/poc/scripts/create_agents.py +0 -818
  138. package/skills/poc/scripts/gen_excel.py +0 -234
  139. package/skills/poc/scripts/gen_poc_manual.py +0 -249
  140. package/skills/poc/scripts/gen_report.py +0 -208
  141. package/skills/poc/scripts/inspect_chatflow.py +0 -209
  142. package/skills/poc/scripts/plan.py +0 -178
  143. package/skills/poc/scripts/poc_test.py +0 -495
  144. package/skills/poc/scripts/render.py +0 -713
  145. package/skills/poc/scripts/render_summary.py +0 -41
  146. package/skills/runtime-inspector/SKILL.md +0 -51
  147. package/skills/runtime-inspector/references/frontend-rendering.md +0 -126
  148. package/skills/runtime-inspector/references/model.md +0 -59
  149. package/skills/runtime-inspector/references/sandbox.md +0 -131
  150. package/skills/runtime-inspector/scripts/model_inspect.py +0 -68
  151. package/skills/runtime-inspector/scripts/prepare.py +0 -98
  152. package/skills/runtime-inspector/scripts/process.py +0 -84
  153. package/skills/runtime-inspector/scripts/read_state.py +0 -32
  154. package/skills/runtime-inspector/scripts/sandbox_inspect.py +0 -463
  155. package/skills/runtime-inspector/scripts/test_claude_proxy.py +0 -49
  156. package/skills/runtime-inspector/scripts/test_openai_proxy.py +0 -41
  157. package/skills/runtime-inspector/scripts/verify_persistence.py +0 -101
  158. package/skills/runtime-inspector/scripts/write_test.py +0 -41
  159. package/skills/setup/SKILL.md +0 -17
  160. package/skills/shulex-support/SKILL.md +0 -39
  161. package/skills/shulex-support/references/badcase-analysis/overview.md +0 -34
  162. package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +0 -105
  163. package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +0 -89
  164. package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +0 -36
  165. package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +0 -53
  166. package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +0 -259
  167. package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +0 -148
  168. package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +0 -57
  169. package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +0 -120
  170. package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +0 -126
  171. package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +0 -121
  172. package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +0 -208
  173. package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +0 -115
  174. package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +0 -72
  175. package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +0 -92
  176. package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +0 -147
  177. package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +0 -283
  178. package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +0 -77
  179. package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +0 -19
  180. package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +0 -23
  181. package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +0 -31
  182. package/skills/shulex-support/references/product-docs.md +0 -70
  183. package/skills/shulex-support/references/tickets.md +0 -184
  184. package/skills/shulex-support/scripts/support.py +0 -427
  185. package/skills/solvea-desk/SKILL.md +0 -39
  186. package/skills/solvea-desk/references/report/fields.md +0 -82
  187. package/skills/solvea-desk/references/report/report-design.md +0 -200
  188. package/skills/solvea-desk/references/report/report-template.html +0 -44
  189. package/skills/solvea-desk/references/report/report.css +0 -517
  190. package/skills/solvea-desk/references/report/workflow.md +0 -464
  191. package/skills/solvea-desk/references/tickets/query.md +0 -103
  192. package/skills/solvea-desk/references/tickets/tags.md +0 -83
  193. package/skills/solvea-desk/scripts/report/fetch_sources.py +0 -580
  194. package/skills/solvea-desk/scripts/report/render_report.py +0 -763
  195. package/skills/solvea-desk/scripts/tickets/desk_api.py +0 -73
  196. package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +0 -403
  197. package/skills/solvea-desk/scripts/tickets/ticket_tags.py +0 -305
  198. package/skills/solvea-kit/SKILL.md +0 -84
  199. package/skills/solvea-kit/references/agent.md +0 -145
  200. package/skills/solvea-kit/references/chat.md +0 -244
  201. package/skills/solvea-kit/references/intent.md +0 -69
  202. package/skills/solvea-kit/references/plugin.md +0 -53
  203. package/skills/solvea-kit/references/quality-inspection.md +0 -75
  204. package/skills/solvea-kit/references/strategy.md +0 -62
  205. package/skills/solvea-kit/references/tagging.md +0 -231
  206. package/skills/solvea-kit/references/variables.md +0 -53
  207. package/skills/solvea-kit/scripts/generate_label_import_file.py +0 -151
  208. package/skills/solvea-kit/scripts/intent_classify.py +0 -843
@@ -1,1697 +0,0 @@
1
- #!/usr/bin/env python3
2
- """chatFlowGraph 结构校验工具。
3
-
4
- 提供 validate_graph(graph) 函数与 CLI(用于在提交 chatflow 前自检):
5
-
6
- python3 /skills/agent-builder/scripts/chatflow/validate_graph.py --data-file graph.json
7
- """
8
-
9
- import argparse
10
- import ast
11
- import builtins
12
- import json
13
- import keyword
14
- import os
15
- import re
16
- import sys
17
-
18
- _solvea_lib_dir = os.environ.get("SOLVEA_LIB_DIR")
19
- if not _solvea_lib_dir:
20
- cur = os.path.abspath(os.path.dirname(__file__))
21
- while True:
22
- candidate = os.path.join(cur, "__lib__")
23
- if os.path.isdir(candidate):
24
- _solvea_lib_dir = candidate
25
- break
26
- parent = os.path.dirname(cur)
27
- if parent == cur:
28
- raise RuntimeError("Cannot locate skills __lib__")
29
- cur = parent
30
- sys.path.insert(0, os.path.join(_solvea_lib_dir, "skill-runtime"))
31
- from bootstrap import * # noqa: E402,F401,F403
32
-
33
- PARAM_NAME_REG = re.compile(r"^(?!\d)[A-Za-z0-9_-]{1,64}$")
34
- NEXT_STEP_TITLE_REG = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
35
- CODE_FIELD_NAME_REG = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
36
- CODE_RESERVED_NAMES = set(keyword.kwlist) | set(dir(builtins))
37
- CODE_ALLOWED_IMPORTS = {"math", "json", "datetime", "re"}
38
- CODE_DANGEROUS_CALLS = {
39
- "eval", "exec", "compile", "__import__", "input", "globals", "locals",
40
- "vars", "dir", "getattr", "setattr", "delattr", "open", "breakpoint", "type",
41
- }
42
- CODE_DANGEROUS_ATTRS = {"sys", "_sys", "codecs", "builtins", "bltns", "_os"}
43
- CODE_DANGEROUS_STRING_PATTERNS = {
44
- "__class__", "__mro__", "__bases__", "__subclasses__", "__globals__",
45
- "__dict__", "__builtins__", "__import__",
46
- }
47
- LIBRARY_BLOCK_REG = re.compile(r"\{#LibraryBlock\s+(.+?)\s*#\}")
48
- LIBRARY_BLOCK_ATTR_REG = re.compile(r"(?:^|\s+)([A-Za-z_]\w*)=")
49
-
50
-
51
- def _parse_library_block_fields(raw_fields):
52
- matches = list(LIBRARY_BLOCK_ATTR_REG.finditer(raw_fields))
53
- if not matches or raw_fields[:matches[0].start()].strip():
54
- raise ValueError("invalid attributes; expected key=value")
55
- fields = {}
56
- for index, match in enumerate(matches):
57
- key = match.group(1)
58
- if not key or key in fields:
59
- raise ValueError(f"invalid or duplicate attribute '{key}'")
60
- value_end = matches[index + 1].start() if index + 1 < len(matches) else len(raw_fields)
61
- value = raw_fields[match.end():value_end].strip()
62
- if value.startswith('"') or value.endswith('"'):
63
- if len(value) < 2 or not (value.startswith('"') and value.endswith('"')):
64
- raise ValueError(f"invalid quoted attribute '{key}'")
65
- value = value[1:-1]
66
- fields[key] = value
67
- return fields
68
-
69
-
70
- def _parse_library_blocks(text):
71
- if not text:
72
- return []
73
- if not isinstance(text, str):
74
- raise ValueError("prompt must be a string")
75
- matches = list(LIBRARY_BLOCK_REG.finditer(text))
76
- if text.count("{#LibraryBlock") != len(matches):
77
- raise ValueError("incomplete LibraryBlock")
78
- blocks = []
79
- for match in matches:
80
- fields = _parse_library_block_fields(match.group(1))
81
- block_type = fields.get("type")
82
- if block_type == "variable":
83
- if not {"id", "type", "name"}.issubset(fields):
84
- raise ValueError("variable block requires id/type/name")
85
- if not fields["id"].isdigit() or int(fields["id"]) <= 0 or not fields["name"]:
86
- raise ValueError("variable block requires a positive id and non-empty name")
87
- elif block_type == "tool":
88
- if not {"id", "type", "name", "tool"}.issubset(fields):
89
- raise ValueError("tool block requires id/type/name/tool")
90
- if not fields["id"].isdigit() or int(fields["id"]) <= 0:
91
- raise ValueError("tool block requires a positive id")
92
- if not fields["name"] or not fields["tool"]:
93
- raise ValueError("tool block requires non-empty name and tool")
94
- elif block_type == "transferHuman":
95
- if not {"tool_id", "type", "name"}.issubset(fields):
96
- raise ValueError("transferHuman block requires tool_id/type/name")
97
- if not fields["tool_id"].isdigit() or int(fields["tool_id"]) <= 0:
98
- raise ValueError("transferHuman block requires a positive numeric tool_id")
99
- if not fields["name"]:
100
- raise ValueError("transferHuman block requires a non-empty name")
101
- if fields.get("action") and fields["action"] not in {
102
- "NOT_REPLY", "CUSTOM_REPLY", "TEMPLATE_REPLY",
103
- }:
104
- raise ValueError("transferHuman block has an unsupported action")
105
- if fields.get("templateId") and (
106
- not fields["templateId"].isdigit() or int(fields["templateId"]) <= 0
107
- ):
108
- raise ValueError("transferHuman block templateId must be a positive integer")
109
- else:
110
- raise ValueError(f"unsupported block type '{block_type}'")
111
- blocks.append({"type": block_type, "fields": fields, "raw": match.group(0)})
112
- return blocks
113
-
114
-
115
- def _format_library_blocks(text, blocks):
116
- by_raw = {block["raw"]: block for block in blocks}
117
-
118
- def replace(match):
119
- block = by_raw[match.group(0)]
120
- fields = block["fields"]
121
- if block["type"] == "variable":
122
- return f" {fields['name']} "
123
- if block["type"] == "tool":
124
- if fields["tool"] == "template_reply":
125
- return f" template_reply tool (template id: {fields['id']})"
126
- return f"{fields['name']} tool "
127
- return f" Transfer_To_Agent (reason: {fields['name']}) "
128
-
129
- return LIBRARY_BLOCK_REG.sub(replace, text)
130
-
131
-
132
- def _collapse_prompt_whitespace(value):
133
- if not isinstance(value, str):
134
- return value
135
- return re.sub(r"\s+", " ", value).strip()
136
-
137
-
138
- def _normalize_template_items(value):
139
- if isinstance(value, list):
140
- return [item for item in value if isinstance(item, dict)]
141
- if isinstance(value, dict):
142
- for key in ("list", "records", "items", "data"):
143
- child = value.get(key)
144
- if isinstance(child, (list, dict)):
145
- return _normalize_template_items(child)
146
- return []
147
-
148
-
149
- def _is_note_node(node):
150
- data = node.get("data", {}) or {}
151
- return node.get("type") == "CUSTOM_NOTE" or data.get("category") == "NOTE"
152
-
153
-
154
- def _detect_cycle(nodes, edges):
155
- """DFS 三色法检测有向图中的环。返回找到的环路径(节点 id 列表),无环返回 None。"""
156
- adj = {}
157
- for e in edges:
158
- src = e.get("source")
159
- tgt = e.get("target")
160
- if src and tgt:
161
- adj.setdefault(src, []).append(tgt)
162
-
163
- WHITE, GRAY, BLACK = 0, 1, 2
164
- color = {n["id"]: WHITE for n in nodes}
165
- parent = {}
166
- found = []
167
-
168
- def dfs(u):
169
- color[u] = GRAY
170
- for v in adj.get(u, []):
171
- if v not in color:
172
- continue # 边指向不存在节点的情况由其他规则报错
173
- if color[v] == GRAY:
174
- # 反向边 → 环。从 u 沿 parent 回溯到 v 还原路径。
175
- path = [v]
176
- cur = u
177
- while cur != v:
178
- path.append(cur)
179
- if cur not in parent:
180
- break
181
- cur = parent[cur]
182
- path.append(v)
183
- path.reverse()
184
- found.append(path)
185
- return True
186
- if color[v] == WHITE:
187
- parent[v] = u
188
- if dfs(v):
189
- return True
190
- color[u] = BLACK
191
- return False
192
-
193
- for nid in list(color):
194
- if color[nid] == WHITE:
195
- if dfs(nid):
196
- return found[0]
197
- return None
198
-
199
-
200
- def _code_all_paths_return(stmts):
201
- for node in stmts:
202
- if isinstance(node, ast.Return):
203
- return True
204
- if isinstance(node, ast.If):
205
- if node.orelse and _code_all_paths_return(node.body) and _code_all_paths_return(node.orelse):
206
- return True
207
- elif isinstance(node, ast.Try) or (hasattr(ast, "TryStar") and isinstance(node, ast.TryStar)):
208
- if node.finalbody and _code_all_paths_return(node.finalbody):
209
- return True
210
- if node.handlers and _code_all_paths_return(node.body) and all(_code_all_paths_return(h.body) for h in node.handlers):
211
- return True
212
- elif isinstance(node, ast.With):
213
- if _code_all_paths_return(node.body):
214
- return True
215
- elif hasattr(ast, "Match") and isinstance(node, ast.Match):
216
- has_wildcard = any(isinstance(c.pattern, ast.MatchAs) and c.pattern.pattern is None for c in node.cases)
217
- if has_wildcard and all(_code_all_paths_return(c.body) for c in node.cases):
218
- return True
219
- return False
220
-
221
-
222
- def _validate_code_source(code, title):
223
- errors = []
224
- try:
225
- tree = ast.parse(code)
226
- except SyntaxError as e:
227
- return [f"CODE '{title}' syntax error at line {e.lineno}: {e.msg}"]
228
-
229
- if not _code_all_paths_return(tree.body):
230
- errors.append(f"CODE '{title}' must return a value on all execution paths")
231
-
232
- for node in ast.walk(tree):
233
- if isinstance(node, ast.Import):
234
- for alias in node.names:
235
- module_root = alias.name.split(".")[0]
236
- if module_root not in CODE_ALLOWED_IMPORTS:
237
- errors.append(f"CODE '{title}' import of '{alias.name}' is not allowed")
238
- elif isinstance(node, ast.ImportFrom):
239
- if not node.module:
240
- errors.append(f"CODE '{title}' relative imports are not allowed")
241
- else:
242
- module_root = node.module.split(".")[0]
243
- if module_root not in CODE_ALLOWED_IMPORTS:
244
- errors.append(f"CODE '{title}' import from '{node.module}' is not allowed")
245
- elif isinstance(node, ast.Attribute):
246
- if node.attr.startswith("__") and node.attr.endswith("__"):
247
- errors.append(f"CODE '{title}' access to dunder attribute '{node.attr}' is not allowed")
248
- elif node.attr in CODE_DANGEROUS_ATTRS:
249
- errors.append(f"CODE '{title}' access to attribute '{node.attr}' is not allowed")
250
- elif isinstance(node, ast.Name):
251
- if node.id.startswith("__") and node.id.endswith("__"):
252
- errors.append(f"CODE '{title}' use of dunder name '{node.id}' is not allowed")
253
- elif node.id == "type":
254
- errors.append(f"CODE '{title}' use of 'type' is not allowed")
255
- elif isinstance(node, ast.Constant) and isinstance(node.value, str):
256
- for pattern in CODE_DANGEROUS_STRING_PATTERNS:
257
- if pattern in node.value:
258
- errors.append(f"CODE '{title}' string pattern '{pattern}' is not allowed")
259
- elif isinstance(node, ast.Call):
260
- if isinstance(node.func, ast.Name) and node.func.id in CODE_DANGEROUS_CALLS:
261
- errors.append(f"CODE '{title}' call to '{node.func.id}()' is not allowed")
262
- elif isinstance(node.func, ast.Attribute) and node.func.attr in {"eval", "exec"}:
263
- errors.append(f"CODE '{title}' call to method '{node.func.attr}()' is not allowed")
264
- elif isinstance(node, (ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith)):
265
- errors.append(f"CODE '{title}' async constructs are not allowed")
266
- elif isinstance(node, (ast.Yield, ast.YieldFrom)):
267
- errors.append(f"CODE '{title}' yield/generator constructs are not allowed")
268
- elif isinstance(node, ast.ClassDef):
269
- errors.append(f"CODE '{title}' class definitions are not allowed")
270
- return errors
271
-
272
-
273
- def validate_graph(graph, ai_fields=None, plugins=None, agent_ai_fields=None, templates=None):
274
- """Validate a chatFlowGraph structure before submitting.
275
-
276
- Returns: {"valid": bool, "errors": [...], "warnings": [...], "stats": {...}}
277
- """
278
- errors = []
279
- warnings = []
280
-
281
- valid_system_selectors = None
282
- valid_ai_field_ids = None
283
- templates_by_id = None
284
- if templates is not None:
285
- templates_by_id = {
286
- str(item.get("id") or item.get("templateId")): item
287
- for item in _normalize_template_items(templates)
288
- if item.get("id") is not None or item.get("templateId") is not None
289
- }
290
- if isinstance(ai_fields, list):
291
- valid_system_selectors = set()
292
- valid_ai_field_ids = set()
293
-
294
- def add_ai_field(field, category, parent_path=None):
295
- if not isinstance(field, dict) or field.get("id") is None:
296
- return
297
- path = [*(parent_path or []), field["id"]]
298
- valid_system_selectors.add(tuple(["SYSTEM", category, *path]))
299
- valid_ai_field_ids.add(field["id"])
300
- for child in field.get("children") or []:
301
- add_ai_field(child, category, path)
302
-
303
- for field in ai_fields:
304
- if isinstance(field, dict):
305
- add_ai_field(field, field.get("bizType", "Custom"))
306
-
307
- valid_agent_ai_field_ids = valid_ai_field_ids
308
- if isinstance(agent_ai_fields, list):
309
- valid_agent_ai_field_ids = set()
310
-
311
- def add_agent_ai_field(field):
312
- if not isinstance(field, dict) or field.get("id") is None:
313
- return
314
- valid_agent_ai_field_ids.add(field["id"])
315
- for child in field.get("children") or []:
316
- add_agent_ai_field(child)
317
-
318
- for field in agent_ai_fields:
319
- add_agent_ai_field(field)
320
- nodes = graph.get("nodes", [])
321
- edges = graph.get("edges", [])
322
-
323
- edge_ids = set()
324
- required_edge_fields = ("id", "source", "sourceHandle", "target", "targetHandle", "type")
325
- for edge_idx, edge_item in enumerate(edges):
326
- if not isinstance(edge_item, dict):
327
- errors.append(f"edge[{edge_idx}] must be an object")
328
- continue
329
- for field in required_edge_fields:
330
- field_value = edge_item.get(field)
331
- if field_value is None or (isinstance(field_value, str) and not field_value.strip()):
332
- errors.append(f"edge[{edge_idx}] missing {field}")
333
- edge_id = edge_item.get("id")
334
- if edge_id is not None and (not isinstance(edge_id, str) or edge_id.strip()):
335
- if edge_id in edge_ids:
336
- errors.append(f"duplicate edge id '{edge_id}'")
337
- edge_ids.add(edge_id)
338
- edge_type = edge_item.get("type")
339
- if edge_type is not None and (not isinstance(edge_type, str) or edge_type.strip()) and edge_type != "default":
340
- errors.append(f"edge[{edge_idx}] unsupported type '{edge_type}'; expected 'default'")
341
-
342
- node_ids = {n["id"] for n in nodes}
343
- note_node_ids = {n["id"] for n in nodes if _is_note_node(n)}
344
- runtime_nodes = [n for n in nodes if n["id"] not in note_node_ids]
345
- runtime_edges = [
346
- e for e in edges
347
- if isinstance(e, dict)
348
- and e.get("source")
349
- and e.get("target")
350
- and e.get("source") not in note_node_ids
351
- and e.get("target") not in note_node_ids
352
- ]
353
- runtime_node_ids = {n["id"] for n in runtime_nodes}
354
- runtime_node_map = {n["id"]: n for n in runtime_nodes}
355
-
356
- plugin_metadata_available = plugins is not None
357
- plugins_by_id = {}
358
- for plugin in plugins or []:
359
- if isinstance(plugin, dict) and plugin.get("id") is not None:
360
- plugins_by_id[str(plugin["id"])] = plugin
361
-
362
- tool_output_paths_by_node = {}
363
- for node in runtime_nodes:
364
- if node.get("data", {}).get("category") != "TOOL":
365
- continue
366
- tool = node.get("data", {}).get("config", {}).get("tool") or {}
367
- plugin = plugins_by_id.get(str(tool.get("id")))
368
- if not plugin:
369
- continue
370
- response_items = []
371
- for plugin_config in (plugin.get("meta") or {}).values():
372
- if isinstance(plugin_config, dict):
373
- items = (plugin_config.get("responseParam") or {}).get("items")
374
- if isinstance(items, list):
375
- response_items = items
376
- break
377
-
378
- valid_paths = set()
379
-
380
- def add_tool_paths(items, parent_path=()):
381
- for item in items or []:
382
- if not isinstance(item, dict) or not item.get("name"):
383
- continue
384
- path = (*parent_path, item["name"])
385
- valid_paths.add(path)
386
- # Keep arrays as leaves to match aiFlowV2 processToolParamItems.
387
- if item.get("type") == "object":
388
- add_tool_paths(item.get("objectItems") or [], path)
389
-
390
- add_tool_paths(response_items)
391
- tool_output_paths_by_node[node["id"]] = valid_paths
392
-
393
- def is_blank(value):
394
- return value is None or (isinstance(value, str) and not value.strip())
395
-
396
- # 1. Must have nodes
397
- if not nodes:
398
- errors.append("Graph has no nodes")
399
- return {"valid": False, "errors": errors, "warnings": warnings}
400
-
401
- # 2. Must have exactly one START node
402
- start_nodes = [n for n in runtime_nodes if n.get("data", {}).get("category") == "START"]
403
- if len(start_nodes) == 0:
404
- errors.append("Missing START node")
405
- elif len(start_nodes) > 1:
406
- errors.append(f"Multiple START nodes found: {[n['id'] for n in start_nodes]}")
407
-
408
- # 3. All edge source/target must reference existing nodes
409
- for edge_idx, e in enumerate(edges):
410
- if not isinstance(e, dict):
411
- continue
412
- edge_label = e.get("id") or f"edge[{edge_idx}]"
413
- if e.get("source") not in node_ids:
414
- errors.append(f"Edge {edge_label}: source '{e.get('source')}' not in nodes")
415
- if e.get("target") not in node_ids:
416
- errors.append(f"Edge {edge_label}: target '{e.get('target')}' not in nodes")
417
-
418
- # 4. START must have outgoing edge
419
- if start_nodes:
420
- start_id = start_nodes[0]["id"]
421
- start_out = [e for e in runtime_edges if e.get("source") == start_id]
422
- if not start_out:
423
- errors.append(f"START node '{start_id}' has no outgoing edge")
424
-
425
- # 5. Check for orphan nodes (not reachable from START)
426
- if start_nodes and runtime_edges:
427
- reachable = set()
428
- queue = [start_nodes[0]["id"]]
429
- while queue:
430
- nid = queue.pop(0)
431
- if nid in reachable:
432
- continue
433
- reachable.add(nid)
434
- for e in runtime_edges:
435
- if e.get("source") == nid and e.get("target") not in reachable:
436
- queue.append(e["target"])
437
- orphans = runtime_node_ids - reachable
438
- if orphans:
439
- orphan_info = [f"{oid}({runtime_node_map[oid].get('data', {}).get('category', '')})"
440
- for oid in orphans]
441
- errors.append(f"Orphan nodes not reachable from START: {orphan_info}")
442
-
443
- # 6. Non-START/FINISH nodes should have incoming edges
444
- for n in runtime_nodes:
445
- cat = n.get("data", {}).get("category", "")
446
- if cat == "START":
447
- continue
448
- incoming = [e for e in runtime_edges if e.get("target") == n["id"]]
449
- if not incoming:
450
- warnings.append(f"Node '{n['id']}' ({cat}) has no incoming edge")
451
-
452
- # 7. AGENT nodes must keep both UI displayPrompt and runtime prompt.
453
- for n in runtime_nodes:
454
- cat = n.get("data", {}).get("category", "")
455
- if cat == "AGENT":
456
- config = n.get("data", {}).get("config", {})
457
- agent_data = config.get("agentData", {})
458
- if not agent_data.get("prompt"):
459
- errors.append(f"AGENT node '{n['id']}' has empty prompt")
460
- if not agent_data.get("displayPrompt"):
461
- errors.append(f"AGENT node '{n['id']}' has empty displayPrompt")
462
-
463
- # 8. Node titles must not be empty (except START)
464
- for n in runtime_nodes:
465
- cat = n.get("data", {}).get("category", "")
466
- if cat in ("START",):
467
- continue
468
- title = n.get("data", {}).get("title", "")
469
- if is_blank(title):
470
- errors.append(f"Node '{n['id']}' ({cat}) has empty title")
471
-
472
- # 9. Check duplicate titles
473
- titles = [n.get("data", {}).get("title", "") for n in runtime_nodes
474
- if n.get("data", {}).get("category") not in ("START",) and n.get("data", {}).get("title")]
475
- seen = set()
476
- for t in titles:
477
- if t in seen:
478
- errors.append(f"Duplicate node title: '{t}'")
479
- seen.add(t)
480
-
481
- # 10. Frontend connection-completeness rules (align with aiFlowV2 useCheckFlow).
482
- # Some handles are implicit UI handles and are not present in node config as normal branches.
483
- # If they are not connected, the frontend shows "当前节点未连接完全" and runtime fallback routes dead-end.
484
- def title_of(node):
485
- return node.get("data", {}).get("title") or node["id"]
486
-
487
- def validate_template_reference(template_id, owner):
488
- if not template_id or templates_by_id is None:
489
- return
490
- template = templates_by_id.get(str(template_id))
491
- if template is None:
492
- errors.append(f"{owner} references unknown template id {template_id}")
493
- return
494
- status = str(template.get("status") or "").upper()
495
- if status != "ENABLED":
496
- errors.append(
497
- f"{owner} references template id {template_id} with status "
498
- f"{status or 'UNKNOWN'}; expected ENABLED"
499
- )
500
-
501
- incoming_by_node = {}
502
- outgoing_by_node = {}
503
- handles_by_node = {}
504
- for e in runtime_edges:
505
- incoming_by_node.setdefault(e.get("target"), []).append(e)
506
- outgoing_by_node.setdefault(e.get("source"), []).append(e)
507
- if e.get("source"):
508
- handles_by_node.setdefault(e.get("source"), set()).add(e.get("sourceHandle"))
509
-
510
- def has_incoming(node):
511
- return bool(incoming_by_node.get(node["id"]))
512
-
513
- def has_outgoing(node):
514
- return bool(outgoing_by_node.get(node["id"]))
515
-
516
- def require_incoming(node):
517
- if not has_incoming(node):
518
- errors.append(f"{node.get('data', {}).get('category')} '{title_of(node)}' has no incoming edge")
519
-
520
- def require_outgoing(node):
521
- if not has_outgoing(node):
522
- errors.append(f"{node.get('data', {}).get('category')} '{title_of(node)}' has no outgoing edge")
523
-
524
- def require_handles(node, handles):
525
- actual = handles_by_node.get(node["id"], set())
526
- cat = node.get("data", {}).get("category")
527
- title = title_of(node)
528
- for handle in handles:
529
- if handle and handle not in actual:
530
- errors.append(f"{cat} '{title}' missing {handle} edge")
531
-
532
- extract_ids_by_node = {}
533
- code_outputs_by_node = {}
534
- agent_params_by_node = {}
535
- agent_params_by_step_by_node = {}
536
- human_review_fields_by_node = {}
537
- for n in runtime_nodes:
538
- cat = n.get("data", {}).get("category")
539
- cfg = n.get("data", {}).get("config", {}) or {}
540
- if cat == "VARIABLE_PROCESS":
541
- extracts = (cfg.get("variableProcess") or {}).get("extracts") or []
542
- extract_ids_by_node[n["id"]] = {item.get("id") for item in extracts if item.get("id")}
543
- elif cat == "VARIABLE_EXTRACT":
544
- extracts = (cfg.get("variableExtract") or {}).get("extracts") or []
545
- extract_ids_by_node[n["id"]] = {item.get("id") for item in extracts if item.get("id")}
546
- elif cat == "CODE":
547
- outputs = (cfg.get("code") or {}).get("outputs") or []
548
- code_outputs_by_node[n["id"]] = {item.get("fieldName") for item in outputs if item.get("fieldName")}
549
- elif cat == "AGENT":
550
- params = set()
551
- params_by_step = {}
552
- for step in cfg.get("nextStep") or []:
553
- step_params = set()
554
- for param in step.get("params") or []:
555
- if param.get("name"):
556
- params.add(param["name"])
557
- step_params.add(param["name"])
558
- if step.get("id"):
559
- params_by_step[step["id"]] = step_params
560
- agent_params_by_node[n["id"]] = params
561
- agent_params_by_step_by_node[n["id"]] = params_by_step
562
- elif cat == "HUMAN_REVIEW":
563
- human_review = cfg.get("humanReview") or cfg.get("humanInLoop") or {}
564
- fields = human_review.get("fields") or []
565
- human_review_fields_by_node[n["id"]] = {item.get("id") for item in fields if item.get("id")}
566
-
567
- llm_structured_output_by_node = {}
568
- for n in runtime_nodes:
569
- if n.get("data", {}).get("category") != "LLM":
570
- continue
571
- llm_cfg = (n.get("data", {}).get("config", {}) or {}).get("llm") or {}
572
- structured_output = llm_cfg.get("structuredOutput") or {}
573
- llm_structured_output_by_node[n["id"]] = structured_output
574
-
575
- def is_predecessor(source_id, current_id):
576
- if not source_id or not current_id or source_id == current_id:
577
- return False
578
- queue = [source_id]
579
- seen = set()
580
- while queue:
581
- nid = queue.pop(0)
582
- if nid == current_id:
583
- return True
584
- if nid in seen:
585
- continue
586
- seen.add(nid)
587
- for edge_item in outgoing_by_node.get(nid, []):
588
- target = edge_item.get("target")
589
- if target and target not in seen:
590
- queue.append(target)
591
- return False
592
-
593
- def connected_agent_step_ids(agent_node_id, current_node_id):
594
- connected = set()
595
- visited = set()
596
-
597
- def dfs(node_id):
598
- if node_id in visited:
599
- return
600
- visited.add(node_id)
601
- for edge_item in incoming_by_node.get(node_id, []):
602
- source = edge_item.get("source")
603
- if source == agent_node_id and edge_item.get("sourceHandle"):
604
- connected.add(edge_item.get("sourceHandle"))
605
- elif source:
606
- dfs(source)
607
-
608
- if current_node_id:
609
- dfs(current_node_id)
610
- return connected
611
-
612
- def validate_selector(selector, owner, field, current_node_id=None,
613
- allow_self=False, self_extract_ids=None,
614
- allow_node=False, system_only=False,
615
- allow_tool_status=False):
616
- if not selector:
617
- return []
618
- if not isinstance(selector, list):
619
- return [f"{owner} {field} must be a variable selector array"]
620
- first = selector[0] if selector else None
621
- if system_only and first != "SYSTEM":
622
- return [f"{owner} {field} must target SYSTEM variable"]
623
-
624
- def check_source_node(node_id, expected_categories, label):
625
- ref = runtime_node_map.get(node_id)
626
- if not ref:
627
- return [f"{owner} {field} references missing {label} node '{node_id}'"]
628
- ref_cat = ref.get("data", {}).get("category")
629
- if expected_categories is not None:
630
- expected = {expected_categories} if isinstance(expected_categories, str) else set(expected_categories)
631
- else:
632
- expected = None
633
- if expected is not None and ref_cat not in expected:
634
- return [f"{owner} {field} references {label} node '{node_id}' with category '{ref_cat}'"]
635
- if current_node_id and not is_predecessor(node_id, current_node_id):
636
- return [f"{owner} {field} references non-predecessor {label} node '{node_id}'"]
637
- return []
638
-
639
- if first == "SYSTEM":
640
- if len(selector) < 3:
641
- return [f"{owner} {field} SYSTEM selector must include category and field path"]
642
- if valid_system_selectors is not None and tuple(selector) not in valid_system_selectors:
643
- return [f"{owner} {field} references invalid SYSTEM field path {selector}"]
644
- return []
645
-
646
- if first == "TOOL":
647
- if len(selector) < 3:
648
- return [f"{owner} {field} TOOL selector must include node id and output path"]
649
- if "ACTION_STATUS_NAME" in selector and not allow_tool_status:
650
- return [f"{owner} {field} does not allow TOOL action status selector"]
651
- source_errors = check_source_node(selector[1], "TOOL", "TOOL")
652
- if source_errors:
653
- return source_errors
654
- if selector[2:] == ["ACTION_STATUS_NAME"]:
655
- return []
656
- if any(isinstance(segment, str) and "." in segment for segment in selector[2:]):
657
- return [
658
- f"{owner} {field} references invalid TOOL output path {selector[2:]}; "
659
- "nested fields must use separate path segments"
660
- ]
661
- valid_paths = tool_output_paths_by_node.get(selector[1])
662
- if valid_paths is not None and tuple(selector[2:]) not in valid_paths:
663
- return [f"{owner} {field} references invalid TOOL output path {selector[2:]}"]
664
- return []
665
-
666
- if first == "NODE":
667
- if not allow_node:
668
- return [f"{owner} {field} does not allow NODE output selector"]
669
- if len(selector) != 2:
670
- return [f"{owner} {field} NODE selector must have 2 elements"]
671
- return check_source_node(selector[1], None, "NODE")
672
-
673
- if first == "EXTRACT":
674
- if len(selector) != 3:
675
- return [f"{owner} {field} EXTRACT selector must have 3 elements"]
676
- node_id = selector[1]
677
- selector_errors = check_source_node(node_id, {"VARIABLE_EXTRACT", "VARIABLE_PROCESS"}, "EXTRACT")
678
- if selector_errors:
679
- return selector_errors
680
- extract_id = selector[2]
681
- if extract_id not in extract_ids_by_node.get(node_id, set()):
682
- return [f"{owner} {field} references unknown EXTRACT id '{extract_id}' on node '{node_id}'"]
683
- return []
684
-
685
- if first == "CODE":
686
- if len(selector) not in (2, 3):
687
- return [f"{owner} {field} CODE selector must have 2 or 3 elements"]
688
- node_id = selector[1]
689
- selector_errors = check_source_node(node_id, "CODE", "CODE")
690
- if selector_errors:
691
- return selector_errors
692
- if len(selector) == 3 and selector[2] not in code_outputs_by_node.get(node_id, set()):
693
- return [f"{owner} {field} references unknown CODE output '{selector[2]}' on node '{node_id}'"]
694
- return []
695
-
696
- if first == "AGENT":
697
- if len(selector) != 3:
698
- return [f"{owner} {field} AGENT selector must have 3 elements"]
699
- node_id = selector[1]
700
- selector_errors = check_source_node(node_id, "AGENT", "AGENT")
701
- if selector_errors:
702
- return selector_errors
703
- if selector[2] not in agent_params_by_node.get(node_id, set()):
704
- return [f"{owner} {field} references unknown AGENT param '{selector[2]}' on node '{node_id}'"]
705
- if current_node_id and agent_params_by_step_by_node.get(node_id):
706
- connected_steps = connected_agent_step_ids(node_id, current_node_id)
707
- params_on_connected_steps = set()
708
- for step_id in connected_steps:
709
- params_on_connected_steps.update(agent_params_by_step_by_node.get(node_id, {}).get(step_id, set()))
710
- if selector[2] not in params_on_connected_steps:
711
- return [
712
- f"{owner} {field} references AGENT param '{selector[2]}' that is not available on the connected nextStep path"
713
- ]
714
- return []
715
-
716
- if first == "LLM":
717
- if len(selector) < 3:
718
- return [f"{owner} {field} LLM selector must have at least 3 elements"]
719
- node_id = selector[1]
720
- selector_errors = check_source_node(node_id, "LLM", "LLM")
721
- if selector_errors:
722
- return selector_errors
723
- output_field = selector[2]
724
- if output_field == "text":
725
- if len(selector) != 3:
726
- return [f"{owner} {field} LLM text selector must have exactly 3 elements"]
727
- return []
728
- if output_field != "structured_output":
729
- return [
730
- f"{owner} {field} LLM selector must use 'text' or 'structured_output', got {output_field!r}"
731
- ]
732
- structured_output = llm_structured_output_by_node.get(node_id) or {}
733
- if not structured_output.get("enabled"):
734
- return [
735
- f"{owner} {field} references LLM structured_output on node '{node_id}' but structuredOutput is disabled"
736
- ]
737
- schema = structured_output.get("schema") or {}
738
- nested_path = selector[3:]
739
- if nested_path:
740
- if not isinstance(nested_path[0], str):
741
- return [f"{owner} {field} LLM structured_output root path must be a string"]
742
- if any(isinstance(segment, bool) or not isinstance(segment, (str, int)) for segment in nested_path):
743
- return [f"{owner} {field} LLM structured_output path segments must be strings or integers"]
744
- properties = schema.get("properties") if isinstance(schema, dict) else None
745
- if isinstance(properties, dict) and nested_path[0] not in properties:
746
- return [
747
- f"{owner} {field} references unknown LLM structured_output field '{nested_path[0]}' on node '{node_id}'"
748
- ]
749
- return []
750
-
751
- if first == "HUMAN_REVIEW":
752
- if len(selector) not in (2, 3):
753
- return [f"{owner} {field} HUMAN_REVIEW selector must have 2 or 3 elements"]
754
- node_id = selector[1]
755
- selector_errors = check_source_node(node_id, "HUMAN_REVIEW", "HUMAN_REVIEW")
756
- if selector_errors:
757
- return selector_errors
758
- if len(selector) == 3 and selector[2] not in human_review_fields_by_node.get(node_id, set()):
759
- return [f"{owner} {field} references unknown HUMAN_REVIEW field '{selector[2]}' on node '{node_id}'"]
760
- return []
761
-
762
- if first == "SELF":
763
- if not allow_self:
764
- return [f"{owner} {field} does not allow SELF selector"]
765
- if len(selector) != 2:
766
- return [f"{owner} {field} SELF selector must have 2 elements"]
767
- if selector[1] not in (self_extract_ids or set()):
768
- return [f"{owner} {field} references unknown SELF extract id '{selector[1]}'"]
769
- return []
770
-
771
- return [f"{owner} {field} has unknown selector type '{first}'"]
772
-
773
- def validate_selector_templates(text, owner, field, current_node_id=None,
774
- allow_tool_status=False, allow_node=False):
775
- if not text:
776
- return []
777
- if not isinstance(text, str):
778
- return [f"{owner} {field} must be a string"]
779
-
780
- selector_errors = []
781
- prefix = "[[var:"
782
- suffix = "]]"
783
- decoder = json.JSONDecoder()
784
- cursor = 0
785
- while True:
786
- start = text.find(prefix, cursor)
787
- if start < 0:
788
- break
789
- json_start = start + len(prefix)
790
- try:
791
- selector, json_end = decoder.raw_decode(text, json_start)
792
- except json.JSONDecodeError:
793
- selector_errors.append(f"{owner} {field} has invalid variable template JSON")
794
- break
795
- if not text.startswith(suffix, json_end):
796
- selector_errors.append(f"{owner} {field} has a variable template without closing ']]'")
797
- break
798
- selector_errors.extend(
799
- validate_selector(
800
- selector,
801
- owner,
802
- field,
803
- current_node_id=current_node_id,
804
- allow_tool_status=allow_tool_status,
805
- allow_node=allow_node,
806
- )
807
- )
808
- cursor = json_end + len(suffix)
809
- return selector_errors
810
-
811
- def reject_selector_templates(text, owner, field):
812
- if not text or not re.search(r"\[\[var:\[[\s\S]*?\]\]\]", text):
813
- return []
814
- return [
815
- f"{owner} {field} does not support [[var:...]] templates; "
816
- "use aiFieldIds for variable-system prompt fields"
817
- ]
818
-
819
- def validate_tool_input_params(input_params, owner, current_node_id):
820
- selector_errors = []
821
- for idx, param in enumerate(input_params or []):
822
- field_name = f"inputParams[{idx}].variableSelector"
823
- if param.get("variableSelector"):
824
- selector_errors.extend(
825
- validate_selector(param.get("variableSelector"), owner, field_name, current_node_id=current_node_id)
826
- )
827
- selector_errors.extend(validate_tool_input_params(param.get("children") or [], owner, current_node_id))
828
- return selector_errors
829
-
830
- def find_missing_tool_params(request_items, input_params, parent_path=""):
831
- missing = []
832
- for request_item in request_items or []:
833
- if request_item.get("required") is False:
834
- continue
835
- name = request_item.get("name")
836
- if not name:
837
- continue
838
- path = f"{parent_path}.{name}" if parent_path else name
839
- matched = next(
840
- (param for param in input_params or [] if param.get("name") == name),
841
- None,
842
- )
843
- if not matched:
844
- missing.append(path)
845
- continue
846
- is_object = (
847
- matched.get("varType") == "object"
848
- or request_item.get("type") == "object"
849
- or request_item.get("typeEnum") == "ObjectType"
850
- )
851
- if is_object:
852
- request_children = request_item.get("objectItems") or []
853
- input_children = matched.get("children") or matched.get("objectItems") or []
854
- missing.extend(find_missing_tool_params(request_children, input_children, path))
855
- elif not (matched.get("text") or matched.get("variableSelector")):
856
- missing.append(path)
857
- return missing
858
-
859
- for n in runtime_nodes:
860
- cat = n.get("data", {}).get("category", "")
861
- cfg = n.get("data", {}).get("config", {})
862
-
863
- if cat == "FINISH":
864
- require_incoming(n)
865
-
866
- if cat in {"REPLY", "TRANSFER_TO_HUMAN", "CODE", "SUB_GRAPH"}:
867
- require_incoming(n)
868
-
869
- if cat in {
870
- "BUSINESS_TRACK", "COLLECT_ORDER", "TOOL", "VARIABLE_ASSIGNMENT",
871
- "VARIABLE_EXTRACT", "VARIABLE_PROCESS", "LLM",
872
- }:
873
- require_incoming(n)
874
- require_outgoing(n)
875
-
876
- if cat == "REPLY":
877
- reply_content = cfg.get("replyContent") or {}
878
- reply_type = reply_content.get("type") or "PROMPT"
879
- if reply_type == "TEMPLATE":
880
- if not reply_content.get("templateId"):
881
- errors.append(f"REPLY '{title_of(n)}' missing templateId")
882
- else:
883
- validate_template_reference(
884
- reply_content.get("templateId"), f"REPLY '{title_of(n)}'"
885
- )
886
- if reply_content.get("aiFieldIds"):
887
- errors.append(f"REPLY '{title_of(n)}' TEMPLATE does not use aiFieldIds")
888
- elif reply_type == "PROMPT":
889
- if is_blank(reply_content.get("prompt")):
890
- errors.append(f"REPLY '{title_of(n)}' missing prompt")
891
- for idx, field_id in enumerate(reply_content.get("aiFieldIds") or []):
892
- if isinstance(field_id, bool) or not isinstance(field_id, int):
893
- errors.append(
894
- f"REPLY '{title_of(n)}' aiFieldIds[{idx}] must be an integer field id"
895
- )
896
- errors.extend(
897
- reject_selector_templates(
898
- reply_content.get("prompt"),
899
- f"REPLY '{title_of(n)}'",
900
- "replyContent.prompt",
901
- )
902
- )
903
- elif reply_type == "DYNAMIC_TEMPLATE":
904
- template_content = reply_content.get("templateContent")
905
- if is_blank(template_content):
906
- errors.append(f"REPLY '{title_of(n)}' missing templateContent")
907
- else:
908
- errors.extend(
909
- validate_selector_templates(
910
- template_content,
911
- f"REPLY '{title_of(n)}'",
912
- "replyContent.templateContent",
913
- current_node_id=n["id"],
914
- allow_tool_status=True,
915
- allow_node=True,
916
- )
917
- )
918
- if reply_content.get("aiFieldIds"):
919
- errors.append(
920
- f"REPLY '{title_of(n)}' DYNAMIC_TEMPLATE does not use aiFieldIds"
921
- )
922
- else:
923
- errors.append(f"REPLY '{title_of(n)}' has unsupported type '{reply_type}'")
924
-
925
- if cat == "TRANSFER_TO_HUMAN":
926
- transfer_to_human = cfg.get("transferToHuman") or {}
927
- transfer_type = transfer_to_human.get("type") or "PROMPT"
928
- if transfer_type == "TEMPLATE":
929
- if not transfer_to_human.get("templateId"):
930
- errors.append(f"TRANSFER_TO_HUMAN '{title_of(n)}' missing templateId")
931
- else:
932
- validate_template_reference(
933
- transfer_to_human.get("templateId"),
934
- f"TRANSFER_TO_HUMAN '{title_of(n)}'",
935
- )
936
- elif transfer_type == "PROMPT":
937
- if is_blank(transfer_to_human.get("prompt")):
938
- errors.append(f"TRANSFER_TO_HUMAN '{title_of(n)}' missing prompt")
939
- for idx, field_id in enumerate(transfer_to_human.get("aiFieldIds") or []):
940
- if isinstance(field_id, bool) or not isinstance(field_id, int):
941
- errors.append(
942
- f"TRANSFER_TO_HUMAN '{title_of(n)}' aiFieldIds[{idx}] must be an integer field id"
943
- )
944
- elif valid_ai_field_ids is not None and field_id not in valid_ai_field_ids:
945
- errors.append(
946
- f"TRANSFER_TO_HUMAN '{title_of(n)}' aiFieldIds[{idx}] references unknown field id {field_id}"
947
- )
948
- elif transfer_type != "NO_REPLY":
949
- errors.append(f"TRANSFER_TO_HUMAN '{title_of(n)}' has unsupported type '{transfer_type}'")
950
- if transfer_type != "PROMPT" and transfer_to_human.get("aiFieldIds"):
951
- errors.append(
952
- f"TRANSFER_TO_HUMAN '{title_of(n)}' type {transfer_type} does not use aiFieldIds"
953
- )
954
-
955
- if cat == "COLLECT_ORDER":
956
- for idx, item in enumerate(cfg.get("collectOrderException") or []):
957
- item_type = item.get("type") or "REPLY"
958
- if item_type == "TEMPLATE":
959
- if not item.get("templateId"):
960
- errors.append(f"COLLECT_ORDER '{title_of(n)}' exception[{idx}] missing templateId")
961
- else:
962
- validate_template_reference(
963
- item.get("templateId"),
964
- f"COLLECT_ORDER '{title_of(n)}' exception[{idx}]",
965
- )
966
- elif item_type != "REPLY":
967
- errors.append(
968
- f"COLLECT_ORDER '{title_of(n)}' exception[{idx}] has unsupported type '{item_type}'"
969
- )
970
- elif is_blank(item.get("reply")):
971
- errors.append(f"COLLECT_ORDER '{title_of(n)}' exception[{idx}] missing reply")
972
-
973
- if cat == "TOOL":
974
- tool = cfg.get("tool") or {}
975
- if tool.get("id") is None:
976
- errors.append(f"TOOL '{title_of(n)}' missing tool id")
977
- # Tool.type defaults to EXTERNAL_HTTP_CALL in the ChatFlow schema.
978
- # Existing graphs therefore legitimately omit it; reject only an
979
- # explicitly supplied invalid value.
980
- if "type" in tool and (
981
- not isinstance(tool.get("type"), str) or not tool.get("type").strip()
982
- ):
983
- errors.append(f"TOOL '{title_of(n)}' missing tool type")
984
- errors.extend(validate_tool_input_params(tool.get("inputParams") or [], f"TOOL '{title_of(n)}'", n["id"]))
985
- plugin = plugins_by_id.get(str(tool.get("id")))
986
- if plugin:
987
- request_items = []
988
- for plugin_config in (plugin.get("meta") or {}).values():
989
- if isinstance(plugin_config, dict):
990
- items = (plugin_config.get("requestParam") or {}).get("items")
991
- if isinstance(items, list):
992
- request_items = items
993
- break
994
- for path in find_missing_tool_params(request_items, tool.get("inputParams") or []):
995
- errors.append(f"TOOL '{title_of(n)}' required tool param '{path}' is not configured")
996
-
997
- if cat == "QUESTION":
998
- require_incoming(n)
999
- require_outgoing(n)
1000
- collect_info = cfg.get("collectInfo", {})
1001
- question_type = collect_info.get("questionType") or "PROMPT"
1002
- collect_type = collect_info.get("type") or "VARIABLE"
1003
- if question_type not in {"PROMPT", "TEMPLATE"}:
1004
- errors.append(f"QUESTION '{title_of(n)}' has unsupported questionType '{question_type}'")
1005
- elif question_type == "TEMPLATE":
1006
- if not collect_info.get("questionTemplateId"):
1007
- errors.append(f"QUESTION '{title_of(n)}' missing questionTemplateId")
1008
- else:
1009
- validate_template_reference(
1010
- collect_info.get("questionTemplateId"),
1011
- f"QUESTION '{title_of(n)}'",
1012
- )
1013
- elif is_blank(collect_info.get("question")):
1014
- errors.append(f"QUESTION '{title_of(n)}' missing question")
1015
- variables = collect_info.get("variables") or []
1016
- replies = collect_info.get("replies") or []
1017
- if not variables and not replies:
1018
- errors.append(f"QUESTION '{title_of(n)}' missing variables or replies")
1019
- condition = collect_info.get("condition") or "AND"
1020
- if condition not in {"AND", "OR"}:
1021
- errors.append(f"QUESTION '{title_of(n)}' has unsupported condition '{condition}'")
1022
- if collect_type not in {"VARIABLE", "USER_REPLY"}:
1023
- errors.append(f"QUESTION '{title_of(n)}' has unsupported type '{collect_type}'")
1024
- elif collect_type == "VARIABLE" and not variables:
1025
- errors.append(f"QUESTION '{title_of(n)}' VARIABLE mode missing variables")
1026
- if collect_type == "USER_REPLY":
1027
- reply_names = []
1028
- for idx, reply in enumerate(replies):
1029
- if not reply.get("id"):
1030
- errors.append(f"QUESTION '{title_of(n)}' reply[{idx}] missing id")
1031
- if is_blank(reply.get("name")):
1032
- errors.append(f"QUESTION '{title_of(n)}' reply[{idx}] missing name")
1033
- elif reply.get("name") in reply_names:
1034
- errors.append(f"QUESTION '{title_of(n)}' duplicate reply name '{reply.get('name')}'")
1035
- reply_names.append(reply.get("name"))
1036
- if collect_type == "USER_REPLY":
1037
- reply_handles = [r.get("id") for r in replies]
1038
- require_handles(n, [*reply_handles, "OTHER"])
1039
-
1040
- if cat == "INTENT":
1041
- require_incoming(n)
1042
- require_outgoing(n)
1043
- intent_handles = [i.get("id") for i in cfg.get("intents", [])]
1044
- intent_names = []
1045
- for idx, intent in enumerate(cfg.get("intents", []) or []):
1046
- if not intent.get("id"):
1047
- errors.append(f"INTENT '{title_of(n)}' intent[{idx}] missing id")
1048
- if not intent.get("intentId"):
1049
- if is_blank(intent.get("name")):
1050
- errors.append(f"INTENT '{title_of(n)}' intent[{idx}] missing name")
1051
- if is_blank(intent.get("desc")):
1052
- errors.append(f"INTENT '{title_of(n)}' intent[{idx}] missing desc")
1053
- if not is_blank(intent.get("name")):
1054
- if intent.get("name") in intent_names:
1055
- errors.append(f"INTENT '{title_of(n)}' duplicate intent name '{intent.get('name')}'")
1056
- intent_names.append(intent.get("name"))
1057
- require_handles(n, [*intent_handles, "OTHER"])
1058
-
1059
- if cat == "IF_ELSE":
1060
- require_incoming(n)
1061
- require_outgoing(n)
1062
- cases = cfg.get("cases", []) or []
1063
- if not cases:
1064
- errors.append(f"IF_ELSE '{title_of(n)}' missing cases")
1065
- case_handles = [case.get("id") for case in cases]
1066
- for case_idx, case in enumerate(cases):
1067
- if not case.get("id"):
1068
- errors.append(f"IF_ELSE '{title_of(n)}' case[{case_idx}] missing id")
1069
- conditions = case.get("conditions") or []
1070
- if not conditions:
1071
- errors.append(f"IF_ELSE '{title_of(n)}' case[{case_idx}] missing conditions")
1072
- for cond_idx, cond in enumerate(conditions):
1073
- if not cond.get("variableSelector"):
1074
- errors.append(f"IF_ELSE '{title_of(n)}' case[{case_idx}].conditions[{cond_idx}] missing variableSelector")
1075
- else:
1076
- errors.extend(
1077
- validate_selector(
1078
- cond.get("variableSelector"),
1079
- f"IF_ELSE '{title_of(n)}'",
1080
- f"case[{case_idx}].conditions[{cond_idx}].variableSelector",
1081
- current_node_id=n["id"],
1082
- allow_tool_status=True,
1083
- )
1084
- )
1085
- if not cond.get("comparisonOperator"):
1086
- errors.append(f"IF_ELSE '{title_of(n)}' case[{case_idx}].conditions[{cond_idx}] missing comparisonOperator")
1087
- if cond.get("comparisonOperator") not in {"EMPTY", "NOT_EMPTY"}:
1088
- value = cond.get("value")
1089
- if value is None or value == "" or value == []:
1090
- errors.append(f"IF_ELSE '{title_of(n)}' case[{case_idx}].conditions[{cond_idx}] missing value")
1091
- elif (
1092
- str(cond.get("varType") or "").lower() == "boolean"
1093
- and (not isinstance(value, str) or value not in ("true", "false"))
1094
- ):
1095
- errors.append(
1096
- f"IF_ELSE '{title_of(n)}' case[{case_idx}].conditions[{cond_idx}] "
1097
- "boolean value must be string 'true' or 'false'"
1098
- )
1099
- require_handles(n, [*case_handles, "FALSE"])
1100
-
1101
- if cat == "KNOWLEDGE_REPLY":
1102
- require_incoming(n)
1103
- require_handles(n, ["RESOLVED", "UNRESOLVED"])
1104
-
1105
- if cat == "RAG_REPLY":
1106
- require_incoming(n)
1107
- require_handles(n, ["TRUE", "LACK_KNOWLEDGE"])
1108
- rag_reply = cfg.get("ragReply") or {}
1109
- keyword_ids = rag_reply.get("knowledgeKeywordIds")
1110
- if keyword_ids is not None and (
1111
- not isinstance(keyword_ids, list)
1112
- or any(not isinstance(item, int) or isinstance(item, bool) for item in keyword_ids)
1113
- ):
1114
- errors.append(
1115
- f"RAG_REPLY '{title_of(n)}' knowledgeKeywordIds must be an integer id array"
1116
- )
1117
- keyword_selector = rag_reply.get("knowledgeKeywordSelector")
1118
- if keyword_ids is not None and keyword_selector is not None:
1119
- errors.append(
1120
- f"RAG_REPLY '{title_of(n)}' must not set both knowledgeKeywordIds and knowledgeKeywordSelector"
1121
- )
1122
- if keyword_selector is not None:
1123
- errors.extend(
1124
- validate_selector(
1125
- keyword_selector,
1126
- f"RAG_REPLY '{title_of(n)}'",
1127
- "knowledgeKeywordSelector",
1128
- current_node_id=n["id"],
1129
- )
1130
- )
1131
- if rag_reply.get("customizeQuery"):
1132
- if not rag_reply.get("querySelector"):
1133
- errors.append(f"RAG_REPLY '{title_of(n)}' missing querySelector")
1134
- else:
1135
- errors.extend(
1136
- validate_selector(
1137
- rag_reply.get("querySelector"),
1138
- f"RAG_REPLY '{title_of(n)}'",
1139
- "querySelector",
1140
- current_node_id=n["id"],
1141
- )
1142
- )
1143
-
1144
- if cat == "HUMAN_REVIEW":
1145
- require_incoming(n)
1146
- human_review = cfg.get("humanReview") or cfg.get("humanInLoop") or {}
1147
- method = human_review.get("method") or "approval"
1148
- if method not in {"approval", "fill"}:
1149
- errors.append(f"HUMAN_REVIEW '{title_of(n)}' has unsupported method '{method}'")
1150
- midway_message_strategy = human_review.get("midwayMessageStrategy") or "ignore"
1151
- if midway_message_strategy not in {"ignore", "handle"}:
1152
- errors.append(
1153
- f"HUMAN_REVIEW '{title_of(n)}' has unsupported midwayMessageStrategy '{midway_message_strategy}'"
1154
- )
1155
- if is_blank(human_review.get("description")):
1156
- errors.append(f"HUMAN_REVIEW '{title_of(n)}' missing description")
1157
- else:
1158
- errors.extend(
1159
- validate_selector_templates(
1160
- human_review.get("description"),
1161
- f"HUMAN_REVIEW '{title_of(n)}'",
1162
- "description",
1163
- current_node_id=n["id"],
1164
- allow_tool_status=True,
1165
- allow_node=True,
1166
- )
1167
- )
1168
- if method == "fill":
1169
- for idx, field in enumerate(human_review.get("fields") or []):
1170
- if is_blank(field.get("fieldName")):
1171
- errors.append(f"HUMAN_REVIEW '{title_of(n)}' field[{idx}] missing fieldName")
1172
- if field.get("fieldType") == "options":
1173
- options = field.get("options") or []
1174
- if not options or any(is_blank(option) for option in options):
1175
- errors.append(f"HUMAN_REVIEW '{title_of(n)}' field[{idx}] has empty options")
1176
- elif len(set(options)) != len(options):
1177
- errors.append(f"HUMAN_REVIEW '{title_of(n)}' field[{idx}] has duplicate options")
1178
- if method == "fill":
1179
- require_handles(n, ["SOURCE"])
1180
- else:
1181
- require_handles(n, ["AGREE", "REJECT"])
1182
-
1183
- if cat == "BUSINESS_TRACK":
1184
- business_track = cfg.get("businessTrack") or {}
1185
- trace_events = business_track.get("traceEvents")
1186
- if not isinstance(trace_events, list) or not trace_events:
1187
- errors.append(f"BUSINESS_TRACK '{title_of(n)}' missing traceEvents")
1188
- elif any(not isinstance(event, str) or not event.strip() for event in trace_events):
1189
- errors.append(
1190
- f"BUSINESS_TRACK '{title_of(n)}' traceEvents must be non-empty strings"
1191
- )
1192
-
1193
- if cat == "CODE":
1194
- code = cfg.get("code") or {}
1195
- if not code.get("sourceCode"):
1196
- errors.append(f"CODE '{title_of(n)}' has empty sourceCode")
1197
- else:
1198
- errors.extend(_validate_code_source(code.get("sourceCode"), title_of(n)))
1199
- input_names = []
1200
- for idx, item in enumerate(code.get("inputs") or []):
1201
- field_name = item.get("fieldName")
1202
- if not field_name:
1203
- errors.append(f"CODE '{title_of(n)}' input[{idx}] missing fieldName")
1204
- else:
1205
- input_names.append(field_name)
1206
- if not item.get("variableSelector"):
1207
- errors.append(f"CODE '{title_of(n)}' input[{idx}] missing variableSelector")
1208
- else:
1209
- errors.extend(
1210
- validate_selector(
1211
- item.get("variableSelector"),
1212
- f"CODE '{title_of(n)}'",
1213
- f"input[{idx}].variableSelector",
1214
- current_node_id=n["id"],
1215
- allow_tool_status=True,
1216
- )
1217
- )
1218
- if not code.get("outputs"):
1219
- errors.append(f"CODE '{title_of(n)}' missing outputs")
1220
- output_names = []
1221
- for idx, item in enumerate(code.get("outputs") or []):
1222
- field_name = item.get("fieldName")
1223
- if not field_name:
1224
- errors.append(f"CODE '{title_of(n)}' output[{idx}] missing fieldName")
1225
- else:
1226
- output_names.append(field_name)
1227
- if not item.get("fieldType"):
1228
- errors.append(f"CODE '{title_of(n)}' output[{idx}] missing fieldType")
1229
- for group_name, names in (("input", input_names), ("output", output_names)):
1230
- duplicates = {name for name in names if names.count(name) > 1}
1231
- for name in sorted(duplicates):
1232
- errors.append(f"CODE '{title_of(n)}' duplicate {group_name} fieldName '{name}'")
1233
- for name in [*input_names, *output_names]:
1234
- if not CODE_FIELD_NAME_REG.match(name):
1235
- errors.append(f"CODE '{title_of(n)}' fieldName '{name}' is not a valid Python identifier")
1236
- if name in CODE_RESERVED_NAMES:
1237
- errors.append(f"CODE '{title_of(n)}' fieldName '{name}' is a reserved Python name")
1238
-
1239
- if cat == "SUB_GRAPH":
1240
- if has_outgoing(n):
1241
- errors.append(f"SUB_GRAPH '{title_of(n)}' must not have outgoing edges")
1242
- sub_graph = cfg.get("subGraph") or {}
1243
- if not sub_graph.get("targetAgentId"):
1244
- errors.append(f"SUB_GRAPH '{title_of(n)}' missing targetAgentId")
1245
- context_turn_limit = sub_graph.get("contextTurnLimit", 5)
1246
- if context_turn_limit < 1 or context_turn_limit > 10:
1247
- errors.append(f"SUB_GRAPH '{title_of(n)}' contextTurnLimit must be between 1 and 10")
1248
-
1249
- if cat == "VARIABLE_PROCESS":
1250
- variable_process = cfg.get("variableProcess") or {}
1251
- extracts = variable_process.get("extracts") or []
1252
- assignments = variable_process.get("assignments") or []
1253
- self_extract_ids = {item.get("id") for item in extracts if item.get("id")}
1254
- if not extracts and not assignments:
1255
- errors.append(f"VARIABLE_PROCESS '{title_of(n)}' has neither extracts nor assignments")
1256
- for data_idx, selector in enumerate(variable_process.get("additionalData") or []):
1257
- errors.extend(
1258
- validate_selector(
1259
- selector,
1260
- f"VARIABLE_PROCESS '{title_of(n)}'",
1261
- f"additionalData[{data_idx}]",
1262
- current_node_id=n["id"],
1263
- allow_node=True,
1264
- )
1265
- )
1266
- for idx, item in enumerate(extracts):
1267
- if not item.get("id") or not item.get("name") or not item.get("describe") or not item.get("type"):
1268
- errors.append(f"VARIABLE_PROCESS '{title_of(n)}' extract[{idx}] missing id/name/describe/type")
1269
- if item.get("name") and not PARAM_NAME_REG.match(item.get("name")):
1270
- errors.append(f"VARIABLE_PROCESS '{title_of(n)}' extract[{idx}] has invalid name '{item.get('name')}'")
1271
- for idx, item in enumerate(assignments):
1272
- if not item.get("id") or not item.get("operation") or not item.get("target"):
1273
- errors.append(f"VARIABLE_PROCESS '{title_of(n)}' assignment[{idx}] missing id/operation/target")
1274
- if item.get("operation") == "CONSTANT" and is_blank(item.get("source")):
1275
- errors.append(f"VARIABLE_PROCESS '{title_of(n)}' assignment[{idx}] CONSTANT missing source")
1276
- for target_idx, selector in enumerate(item.get("target") or []):
1277
- errors.extend(
1278
- validate_selector(
1279
- selector,
1280
- f"VARIABLE_PROCESS '{title_of(n)}'",
1281
- f"assignment[{idx}].target[{target_idx}]",
1282
- current_node_id=n["id"],
1283
- system_only=True,
1284
- )
1285
- )
1286
- if item.get("operation") == "OVER_WRITE" and isinstance(item.get("source"), list):
1287
- errors.extend(
1288
- validate_selector(
1289
- item.get("source"),
1290
- f"VARIABLE_PROCESS '{title_of(n)}'",
1291
- f"assignment[{idx}].source",
1292
- current_node_id=n["id"],
1293
- allow_self=True,
1294
- self_extract_ids=self_extract_ids,
1295
- )
1296
- )
1297
-
1298
- if cat == "VARIABLE_EXTRACT":
1299
- variable_extract = cfg.get("variableExtract") or {}
1300
- extracts = variable_extract.get("extracts") or []
1301
- if not extracts:
1302
- errors.append(f"VARIABLE_EXTRACT '{title_of(n)}' missing extracts")
1303
- for data_idx, selector in enumerate(variable_extract.get("additionalData") or []):
1304
- errors.extend(
1305
- validate_selector(
1306
- selector,
1307
- f"VARIABLE_EXTRACT '{title_of(n)}'",
1308
- f"additionalData[{data_idx}]",
1309
- current_node_id=n["id"],
1310
- allow_node=True,
1311
- )
1312
- )
1313
- for idx, item in enumerate(extracts):
1314
- if not item.get("id") or not item.get("name") or not item.get("describe") or not item.get("type"):
1315
- errors.append(f"VARIABLE_EXTRACT '{title_of(n)}' extract[{idx}] missing id/name/describe/type")
1316
- if item.get("name") and not PARAM_NAME_REG.match(item.get("name")):
1317
- errors.append(f"VARIABLE_EXTRACT '{title_of(n)}' extract[{idx}] has invalid name '{item.get('name')}'")
1318
-
1319
- if cat == "VARIABLE_ASSIGNMENT":
1320
- assignments = cfg.get("variableAssignment") or []
1321
- for idx, item in enumerate(assignments):
1322
- if not item.get("id") or not item.get("operation") or not item.get("target"):
1323
- errors.append(f"VARIABLE_ASSIGNMENT '{title_of(n)}' assignment[{idx}] missing id/operation/target")
1324
- if item.get("operation") == "CONSTANT" and is_blank(item.get("source")):
1325
- errors.append(f"VARIABLE_ASSIGNMENT '{title_of(n)}' assignment[{idx}] CONSTANT missing source")
1326
- for target_idx, selector in enumerate(item.get("target") or []):
1327
- errors.extend(
1328
- validate_selector(
1329
- selector,
1330
- f"VARIABLE_ASSIGNMENT '{title_of(n)}'",
1331
- f"assignment[{idx}].target[{target_idx}]",
1332
- current_node_id=n["id"],
1333
- system_only=True,
1334
- )
1335
- )
1336
- if item.get("operation") == "OVER_WRITE" and isinstance(item.get("source"), list):
1337
- errors.extend(
1338
- validate_selector(
1339
- item.get("source"),
1340
- f"VARIABLE_ASSIGNMENT '{title_of(n)}'",
1341
- f"assignment[{idx}].source",
1342
- current_node_id=n["id"],
1343
- )
1344
- )
1345
-
1346
- if cat == "AGENT":
1347
- require_incoming(n)
1348
- agent_data = cfg.get("agentData") or {}
1349
- transfer_to_humans = agent_data.get("transferToHumans")
1350
- if transfer_to_humans not in (None, []):
1351
- errors.append(
1352
- f"AGENT '{title_of(n)}' transferToHumans is not supported by the current runtime; use a TRANSFER_TO_HUMAN node"
1353
- )
1354
- max_rounds_exceeded_action = cfg.get("maxRoundsExceededAction") or {}
1355
- if not isinstance(max_rounds_exceeded_action, dict):
1356
- errors.append(
1357
- f"AGENT '{title_of(n)}' maxRoundsExceededAction must be an object"
1358
- )
1359
- else:
1360
- exceeded_action_type = max_rounds_exceeded_action.get("type") or "TRANSFER_TO_HUMAN"
1361
- if exceeded_action_type not in {"FORCE_NEXT_STEP", "TRANSFER_TO_HUMAN"}:
1362
- errors.append(
1363
- f"AGENT '{title_of(n)}' has unsupported maxRoundsExceededAction '{exceeded_action_type}'"
1364
- )
1365
- errors.extend(
1366
- reject_selector_templates(
1367
- agent_data.get("prompt"),
1368
- f"AGENT '{title_of(n)}'",
1369
- "agentData.prompt",
1370
- )
1371
- )
1372
- errors.extend(
1373
- reject_selector_templates(
1374
- agent_data.get("displayPrompt"),
1375
- f"AGENT '{title_of(n)}'",
1376
- "agentData.displayPrompt",
1377
- )
1378
- )
1379
- library_blocks = []
1380
- try:
1381
- library_blocks = _parse_library_blocks(agent_data.get("displayPrompt"))
1382
- except ValueError as exc:
1383
- errors.append(f"AGENT '{title_of(n)}' has invalid LibraryBlock: {exc}")
1384
- else:
1385
- if library_blocks:
1386
- expected_runtime_prompt = _format_library_blocks(
1387
- agent_data.get("displayPrompt"), library_blocks
1388
- )
1389
- runtime_prompt = agent_data.get("prompt")
1390
- if runtime_prompt != expected_runtime_prompt:
1391
- legacy_runtime_prompt = expected_runtime_prompt.replace(
1392
- "Transfer_To_Agent", "Transfer_to_agent"
1393
- )
1394
- if (
1395
- any(block["type"] == "transferHuman" for block in library_blocks)
1396
- and _collapse_prompt_whitespace(runtime_prompt)
1397
- == _collapse_prompt_whitespace(legacy_runtime_prompt)
1398
- ):
1399
- warnings.append(
1400
- f"AGENT '{title_of(n)}' runtime prompt uses legacy "
1401
- "Transfer_to_agent casing; resave it as Transfer_To_Agent"
1402
- )
1403
- elif (
1404
- _collapse_prompt_whitespace(runtime_prompt)
1405
- == _collapse_prompt_whitespace(expected_runtime_prompt)
1406
- ):
1407
- warnings.append(
1408
- f"AGENT '{title_of(n)}' runtime prompt differs from "
1409
- "displayPrompt only in whitespace; resave it to synchronize formatting"
1410
- )
1411
- else:
1412
- errors.append(
1413
- f"AGENT '{title_of(n)}' runtime prompt is not synchronized "
1414
- "with displayPrompt LibraryBlocks"
1415
- )
1416
- used_plugins = agent_data.get("usedPlugins") or []
1417
- seen_plugin_ids = set()
1418
- duplicate_plugin_ids = set()
1419
- for plugin_id in used_plugins:
1420
- if isinstance(plugin_id, bool) or not isinstance(plugin_id, int):
1421
- continue
1422
- if plugin_id in seen_plugin_ids:
1423
- duplicate_plugin_ids.add(plugin_id)
1424
- seen_plugin_ids.add(plugin_id)
1425
- for plugin_id in sorted(duplicate_plugin_ids, key=str):
1426
- warnings.append(
1427
- f"AGENT '{title_of(n)}' usedPlugins contains duplicate id {plugin_id}"
1428
- )
1429
- required_system_plugin_types = set()
1430
- transfer_tool_ids = []
1431
- for block in library_blocks:
1432
- fields = block["fields"]
1433
- if block["type"] == "variable":
1434
- field_id = int(fields["id"])
1435
- if field_id not in (agent_data.get("aiFieldIds") or []):
1436
- errors.append(
1437
- f"AGENT '{title_of(n)}' variable LibraryBlock id {field_id} "
1438
- "is missing from aiFieldIds"
1439
- )
1440
- elif block["type"] == "tool" and fields["tool"] == "template_reply":
1441
- template_id = int(fields["id"])
1442
- required_system_plugin_types.add("TEMPLATE_REPLY")
1443
- validate_template_reference(
1444
- template_id, f"AGENT '{title_of(n)}' template_reply LibraryBlock"
1445
- )
1446
- elif block["type"] == "tool":
1447
- plugin_id = int(fields["id"])
1448
- if plugin_id not in used_plugins:
1449
- errors.append(
1450
- f"AGENT '{title_of(n)}' tool LibraryBlock id {plugin_id} "
1451
- "is missing from usedPlugins"
1452
- )
1453
- plugin = plugins_by_id.get(str(plugin_id))
1454
- if plugin_metadata_available and plugin is None:
1455
- errors.append(
1456
- f"AGENT '{title_of(n)}' tool LibraryBlock references "
1457
- f"unknown plugin id {plugin_id}"
1458
- )
1459
- elif plugin is not None:
1460
- plugin_name = next(
1461
- (
1462
- str(plugin.get(field)).strip()
1463
- for field in ("name", "pluginName", "title", "toolName")
1464
- if str(plugin.get(field) or "").strip()
1465
- ),
1466
- "",
1467
- )
1468
- if plugin_name:
1469
- for field in ("name", "tool"):
1470
- if fields[field] != plugin_name:
1471
- errors.append(
1472
- f"AGENT '{title_of(n)}' tool LibraryBlock {field} "
1473
- f"{fields[field]!r} does not match plugin id "
1474
- f"{plugin_id} name {plugin_name!r}"
1475
- )
1476
- elif block["type"] == "transferHuman":
1477
- required_system_plugin_types.add("TRANSFER_TO_HUMAN")
1478
- transfer_tool_ids.append(block["fields"]["tool_id"])
1479
- if len(transfer_tool_ids) != len(set(transfer_tool_ids)):
1480
- warnings.append(
1481
- f"AGENT '{title_of(n)}' has duplicate transferHuman tool_id values; "
1482
- "resave those PE blocks with unique instance IDs"
1483
- )
1484
- bound_plugin_types = set()
1485
- for idx, plugin_id in enumerate(agent_data.get("usedPlugins") or []):
1486
- if isinstance(plugin_id, bool) or not isinstance(plugin_id, int):
1487
- errors.append(
1488
- f"AGENT '{title_of(n)}' usedPlugins[{idx}] must be an integer tool id"
1489
- )
1490
- continue
1491
- plugin = plugins_by_id.get(str(plugin_id))
1492
- if plugin:
1493
- plugin_type = str(
1494
- plugin.get("type") or plugin.get("pluginType") or ""
1495
- ).upper()
1496
- if plugin_type:
1497
- bound_plugin_types.add(plugin_type)
1498
- if plugin_metadata_available:
1499
- for plugin_type in sorted(required_system_plugin_types - bound_plugin_types):
1500
- errors.append(
1501
- f"AGENT '{title_of(n)}' {plugin_type} LibraryBlock requires a "
1502
- f"usedPlugins entry whose plugin type is {plugin_type}"
1503
- )
1504
- for idx, field_id in enumerate(agent_data.get("aiFieldIds") or []):
1505
- if isinstance(field_id, bool) or not isinstance(field_id, int):
1506
- errors.append(
1507
- f"AGENT '{title_of(n)}' aiFieldIds[{idx}] must be an integer field id"
1508
- )
1509
- elif valid_agent_ai_field_ids is not None and field_id not in valid_agent_ai_field_ids:
1510
- errors.append(
1511
- f"AGENT '{title_of(n)}' aiFieldIds[{idx}] references unknown field id {field_id}"
1512
- )
1513
- keyword_ids = agent_data.get("knowledgeKeywordIds")
1514
- if keyword_ids is not None and (
1515
- not isinstance(keyword_ids, list)
1516
- or any(not isinstance(item, int) or isinstance(item, bool) for item in keyword_ids)
1517
- ):
1518
- errors.append(
1519
- f"AGENT '{title_of(n)}' knowledgeKeywordIds must be an integer id array"
1520
- )
1521
- keyword_selector = agent_data.get("knowledgeKeywordSelector")
1522
- if keyword_ids is not None and keyword_selector is not None:
1523
- errors.append(
1524
- f"AGENT '{title_of(n)}' must not set both knowledgeKeywordIds and knowledgeKeywordSelector"
1525
- )
1526
- if keyword_selector is not None:
1527
- errors.extend(
1528
- validate_selector(
1529
- keyword_selector,
1530
- f"AGENT '{title_of(n)}'",
1531
- "knowledgeKeywordSelector",
1532
- current_node_id=n["id"],
1533
- )
1534
- )
1535
- next_step = cfg.get("nextStep") or []
1536
- if next_step:
1537
- param_names = []
1538
- for idx, step in enumerate(next_step):
1539
- if not step.get("id"):
1540
- errors.append(f"AGENT '{title_of(n)}' nextStep[{idx}] missing id")
1541
- if not step.get("title"):
1542
- errors.append(f"AGENT '{title_of(n)}' nextStep[{idx}] missing title")
1543
- elif not NEXT_STEP_TITLE_REG.fullmatch(str(step.get("title"))):
1544
- warnings.append(
1545
- f"AGENT '{title_of(n)}' nextStep[{idx}].title is not lowercase snake_case; "
1546
- "lowercase snake_case is recommended for generated transfer tool names"
1547
- )
1548
- if not step.get("prompt"):
1549
- errors.append(f"AGENT '{title_of(n)}' nextStep[{idx}] missing prompt")
1550
- for param_idx, param in enumerate(step.get("params") or []):
1551
- param_name = param.get("name")
1552
- if not param_name or not param.get("type") or not param.get("describe"):
1553
- errors.append(
1554
- f"AGENT '{title_of(n)}' nextStep[{idx}].params[{param_idx}] missing name/type/describe"
1555
- )
1556
- if param_name:
1557
- param_names.append(param_name)
1558
- if not PARAM_NAME_REG.match(param_name):
1559
- errors.append(
1560
- f"AGENT '{title_of(n)}' nextStep[{idx}].params[{param_idx}] has invalid name '{param_name}'"
1561
- )
1562
- duplicate_param_names = {name for name in param_names if param_names.count(name) > 1}
1563
- for name in sorted(duplicate_param_names):
1564
- errors.append(f"AGENT '{title_of(n)}' has duplicate nextStep param name '{name}'")
1565
- require_handles(n, [ns.get("id") for ns in next_step])
1566
- else:
1567
- if (
1568
- isinstance(max_rounds_exceeded_action, dict)
1569
- and max_rounds_exceeded_action.get("type") == "FORCE_NEXT_STEP"
1570
- ):
1571
- warnings.append(
1572
- f"AGENT '{title_of(n)}' uses FORCE_NEXT_STEP without nextStep; runtime will hand off instead"
1573
- )
1574
- # AGENT without nextStep: SOURCE edge required unless this is a terminal AGENT
1575
- # (terminal AGENTs are rare — warn rather than error)
1576
- handles = handles_by_node.get(n["id"], set())
1577
- if "SOURCE" not in handles:
1578
- warnings.append(f"AGENT '{title_of(n)}' has no SOURCE outbound edge — confirm it's a terminal node")
1579
-
1580
- if cat == "LLM":
1581
- require_handles(n, ["SOURCE"])
1582
- llm_cfg = cfg.get("llm") or {}
1583
- prompt = llm_cfg.get("promptTemplate")
1584
- if is_blank(prompt):
1585
- errors.append(f"LLM '{title_of(n)}' missing promptTemplate")
1586
- history_round_limit = cfg.get("historyRoundLimit", 0)
1587
- if (
1588
- not isinstance(history_round_limit, int)
1589
- or isinstance(history_round_limit, bool)
1590
- or history_round_limit < -1
1591
- ):
1592
- errors.append(
1593
- f"LLM '{title_of(n)}' historyRoundLimit must be -1, 0, or a positive integer"
1594
- )
1595
- for legacy_field in ("aiFieldIds", "modelId", "outputs", "outputVariables"):
1596
- if legacy_field in llm_cfg:
1597
- warnings.append(
1598
- f"LLM '{title_of(n)}' {legacy_field} is ignored by the current runtime; use variable templates or structuredOutput"
1599
- )
1600
-
1601
- vision = llm_cfg.get("vision") or {}
1602
- if not isinstance(vision, dict):
1603
- errors.append(f"LLM '{title_of(n)}' vision must be an object")
1604
- else:
1605
- vision_enabled = vision.get("enabled", False)
1606
- vision_detail = vision.get("detail", "auto")
1607
- media_selectors = vision.get("mediaSelectors", [])
1608
- if not isinstance(vision_enabled, bool):
1609
- errors.append(f"LLM '{title_of(n)}' vision.enabled must be boolean")
1610
- if vision_detail not in {"auto", "low", "high"}:
1611
- errors.append(f"LLM '{title_of(n)}' vision.detail must be auto, low, or high")
1612
- if not isinstance(media_selectors, list):
1613
- errors.append(f"LLM '{title_of(n)}' vision.mediaSelectors must be a list")
1614
- elif vision_enabled and not media_selectors:
1615
- errors.append(f"LLM '{title_of(n)}' vision.enabled requires mediaSelectors")
1616
- else:
1617
- for idx, selector in enumerate(media_selectors):
1618
- errors.extend(
1619
- validate_selector(
1620
- selector,
1621
- f"LLM '{title_of(n)}'",
1622
- f"vision.mediaSelectors[{idx}]",
1623
- current_node_id=n["id"],
1624
- allow_node=True,
1625
- )
1626
- )
1627
-
1628
- structured_output = llm_cfg.get("structuredOutput") or {}
1629
- if not isinstance(structured_output, dict):
1630
- errors.append(f"LLM '{title_of(n)}' structuredOutput must be an object")
1631
- else:
1632
- structured_enabled = structured_output.get("enabled", False)
1633
- schema = structured_output.get("schema", {})
1634
- if not isinstance(structured_enabled, bool):
1635
- errors.append(f"LLM '{title_of(n)}' structuredOutput.enabled must be boolean")
1636
- if not isinstance(schema, dict):
1637
- errors.append(f"LLM '{title_of(n)}' structuredOutput.schema must be an object")
1638
- elif structured_enabled:
1639
- if schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
1640
- errors.append(
1641
- f"LLM '{title_of(n)}' structuredOutput.schema must be object-rooted with properties"
1642
- )
1643
- if "required" in schema and not isinstance(schema.get("required"), list):
1644
- errors.append(
1645
- f"LLM '{title_of(n)}' structuredOutput.schema.required must be a list"
1646
- )
1647
-
1648
- errors.extend(
1649
- validate_selector_templates(
1650
- prompt,
1651
- f"LLM '{title_of(n)}'",
1652
- "llm.promptTemplate",
1653
- current_node_id=n["id"],
1654
- allow_tool_status=True,
1655
- allow_node=True,
1656
- )
1657
- )
1658
-
1659
- # 11. Cycle detection (对齐前端 useCheckFlow.detectCycle)
1660
- # 多轮追问交给节点自带的 maxRounds 字段(KNOWLEDGE_REPLY / QUESTION),
1661
- # 不要在图层面回连——QUESTION "Need_More_Help" 应该接 TRANSFER_TO_HUMAN
1662
- # 或 FINISH,不要回连上游 KB。
1663
- cycle_path = _detect_cycle(runtime_nodes, runtime_edges)
1664
- if cycle_path:
1665
- labels = []
1666
- for nid in cycle_path:
1667
- n = runtime_node_map.get(nid)
1668
- title = n.get("data", {}).get("title") if n else None
1669
- labels.append(title or nid)
1670
- errors.append(f"Graph has a cycle: {' → '.join(labels)}")
1671
-
1672
- return {
1673
- "valid": len(errors) == 0,
1674
- "errors": errors,
1675
- "warnings": warnings,
1676
- "stats": {
1677
- "nodes": len(nodes),
1678
- "edges": len(edges),
1679
- "categories": [n.get("data", {}).get("category", "") for n in nodes],
1680
- },
1681
- }
1682
-
1683
-
1684
- def main():
1685
- parser = argparse.ArgumentParser(description="chatFlowGraph 结构校验")
1686
- parser.add_argument("--data-file", required=True, help="JSON file with chatFlowGraph")
1687
- args = parser.parse_args()
1688
-
1689
- with open(args.data_file, encoding="utf-8") as f:
1690
- graph = json.load(f)
1691
- result = validate_graph(graph)
1692
- print(json.dumps(result, indent=2, ensure_ascii=False))
1693
- sys.exit(0 if result["valid"] else 1)
1694
-
1695
-
1696
- if __name__ == "__main__":
1697
- main()