@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,3458 +0,0 @@
1
- """
2
- CHAT_FLOW graph builder for Solvea agents.
3
-
4
- Provides node/edge construction functions with built-in constraint enforcement
5
- (ASCII titles, prompt/displayPrompt split, structural validation). Agents should
6
- normally use the CLI: write source JSON, compile it to a full chatFlowGraph,
7
- validate the graph file, then deploy/update/publish the same graph file.
8
-
9
- Direct CLI:
10
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py --help --pretty
11
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py inspect --agent-name My_Agent
12
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py compile --source-file source.json --graph-file graph.json
13
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py validate --graph-file graph.json
14
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py deploy --agent-name My_Agent --graph-file graph.json
15
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py update --agent-id 12345 --graph-file patch.json
16
- python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py publish --agent-id 12345 --graph-file patch.json
17
- """
18
-
19
- import argparse
20
- from contextlib import contextmanager
21
- import json
22
- import os
23
- from pathlib import Path
24
- import re
25
- import sys
26
- import time
27
- import uuid
28
-
29
-
30
- # ---------------------------------------------------------------------------
31
- # Helpers
32
- # ---------------------------------------------------------------------------
33
-
34
- def uid():
35
- """Generate a random UUID string (public API)."""
36
- return str(uuid.uuid4())
37
-
38
- # Keep _uid as alias for internal/backward compatibility
39
- _uid = uid
40
-
41
-
42
- # ---------------------------------------------------------------------------
43
- # variableSelector builders
44
- # ---------------------------------------------------------------------------
45
-
46
- def var_tool_output(tool_node_or_id, *field_path):
47
- """
48
- TOOL output variable selector.
49
-
50
- Format: ["TOOL", node_id, field1, field2, ...]
51
- Example: var_tool_output(n2, "orderType") -> ["TOOL", "<id>", "orderType"]
52
- var_tool_output(n2, "consigneeInfo", "country") -> ["TOOL", "<id>", "consigneeInfo", "country"]
53
-
54
- NOTE: Do NOT add an "output" layer — field path starts directly at the 3rd element.
55
- """
56
- node_id = tool_node_or_id["id"] if isinstance(tool_node_or_id, dict) else tool_node_or_id
57
- return ["TOOL", node_id, *field_path]
58
-
59
-
60
- def var_tool_status(tool_node_or_id):
61
- """
62
- TOOL action status variable selector.
63
-
64
- Format: ["TOOL", node_id, "ACTION_STATUS_NAME"]
65
- """
66
- node_id = tool_node_or_id["id"] if isinstance(tool_node_or_id, dict) else tool_node_or_id
67
- return ["TOOL", node_id, "ACTION_STATUS_NAME"]
68
-
69
-
70
- def var_system(category, field_path):
71
- """
72
- System / variable-system field selector.
73
-
74
- Format: ["SYSTEM", category, *field_path]
75
- Example: var_system("Order", [3248, 3249])
76
- """
77
- path = field_path if isinstance(field_path, list) else [field_path]
78
- return ["SYSTEM", category, *path]
79
-
80
-
81
- def var_node(node_or_id):
82
- """
83
- Node reference variable selector.
84
-
85
- Format: ["NODE", node_id]
86
- """
87
- node_id = node_or_id["id"] if isinstance(node_or_id, dict) else node_or_id
88
- return ["NODE", node_id]
89
-
90
-
91
- def var_extract(node_or_id, variable_id):
92
- """
93
- Variable extract reference selector.
94
-
95
- Format: ["EXTRACT", node_id, variable_id]
96
- """
97
- node_id = node_or_id["id"] if isinstance(node_or_id, dict) else node_or_id
98
- return ["EXTRACT", node_id, variable_id]
99
-
100
-
101
- def var_code_output(code_node_or_id, field_name):
102
- """
103
- CODE output variable selector.
104
-
105
- Format: ["CODE", node_id, field_name]
106
- """
107
- node_id = code_node_or_id["id"] if isinstance(code_node_or_id, dict) else code_node_or_id
108
- return ["CODE", node_id, field_name]
109
-
110
-
111
- def _normalize_id_list(items, field_name):
112
- normalized = []
113
- for idx, item in enumerate(items or []):
114
- if isinstance(item, bool) or not isinstance(item, int):
115
- raise ValueError(f"{field_name}[{idx}] must be an integer id, got {item!r}")
116
- normalized.append(item)
117
- return normalized
118
-
119
-
120
- def _validate_title(title):
121
- """Allow Chinese/Unicode letters, ASCII alnum, underscore, hyphen."""
122
- cleaned = re.sub(r'[^\w\u4e00-\u9fff-]', '_', title)
123
- cleaned = re.sub(r'_+', '_', cleaned).strip('_')
124
- if not cleaned:
125
- # All non-ASCII chars (e.g. pure Chinese) → generate a fallback
126
- cleaned = f"Node_{_uid()[:8]}"
127
- print(f" [auto-fix] title '{title}' -> '{cleaned}' (original had no ASCII chars)")
128
- elif cleaned != title:
129
- print(f" [auto-fix] title '{title}' -> '{cleaned}'")
130
- return cleaned
131
-
132
-
133
- def _short_id(node_id):
134
- """Return first 8 chars of a node ID (handles long UUIDs)."""
135
- return node_id[:8]
136
-
137
-
138
- # ---------------------------------------------------------------------------
139
- # Prompt builder — layered constraints
140
- # ---------------------------------------------------------------------------
141
-
142
- BASE_CONSTRAINTS = [
143
- "NEVER guide customers to seek human support, contact retailers, "
144
- "contact our support team, or transfer to a human agent.",
145
-
146
- "NEVER repeat issues the customer has already described or include "
147
- "content irrelevant to their question — this appears mechanical "
148
- "rather than empathetic.",
149
-
150
- "NEVER restate the customer's problem as a form of acknowledgment "
151
- "— it feels robotic, not comforting. Move directly to providing "
152
- "a solution or next step.",
153
-
154
- "ALWAYS reply in the same language the customer used in their message.",
155
-
156
- "NEVER disclose internal costs, supplier details, profit margins, "
157
- "or internal processes.",
158
-
159
- "NEVER make decisions on behalf of the customer without their "
160
- "explicit consent (e.g., canceling orders, changing shipping address).",
161
- ]
162
-
163
- KNOWLEDGE_BASE_CONSTRAINTS = [
164
- "If the available knowledge does not appear relevant or sufficient "
165
- "to resolve the customer's issue, escalate to a human agent "
166
- "immediately rather than providing a potentially incorrect answer.",
167
- ]
168
-
169
- AFTER_SALES_CONSTRAINTS = [
170
- "NEVER guide customers to contact Amazon, eBay, or any third-party "
171
- "platform to handle returns or exchanges.",
172
-
173
- "NEVER instruct customers to follow warranty terms or return policy "
174
- "procedures on their own.",
175
- ]
176
-
177
- COMPENSATION_CONSTRAINTS = [
178
- "NEVER promise refunds, compensation, replacements, or store credit "
179
- "unless explicitly authorized in this flow.",
180
- ]
181
-
182
- SCENARIO_MAP = {
183
- "knowledge_base": KNOWLEDGE_BASE_CONSTRAINTS,
184
- "after_sales": AFTER_SALES_CONSTRAINTS,
185
- "compensation": COMPENSATION_CONSTRAINTS,
186
- }
187
-
188
- _AFTER_SALES_KEYWORDS = {
189
- "return", "refund", "exchange", "warranty", "replacement",
190
- "damage", "defect", "broken", "missing", "lost",
191
- }
192
-
193
- _COMPENSATION_KEYWORDS = {
194
- "refund", "compensation", "discount", "coupon", "credit",
195
- "reimburse", "price adjustment",
196
- }
197
-
198
-
199
- def _infer_scenarios(title, task, key_info, kb_dirs):
200
- """Infer applicable scenario tags from node content."""
201
- scenarios = set()
202
- text = f"{title} {task} {key_info}".lower()
203
-
204
- if kb_dirs:
205
- scenarios.add("knowledge_base")
206
-
207
- if any(kw in text for kw in _AFTER_SALES_KEYWORDS):
208
- scenarios.add("after_sales")
209
-
210
- if any(kw in text for kw in _COMPENSATION_KEYWORDS):
211
- scenarios.add("compensation")
212
-
213
- return scenarios
214
-
215
-
216
- def build_prompt(role, task, key_info, guidelines, brand_name,
217
- scenarios=None, kb_dirs=None, extra_constraints=None):
218
- """
219
- Assemble a four-segment prompt with auto-generated layered constraints.
220
-
221
- All output is in English.
222
-
223
- Parameters
224
- ----------
225
- role : str
226
- Role description (e.g. "specialized in logistics and order tracking").
227
- task : str
228
- Task description for this specific node.
229
- key_info : str
230
- Key information relevant to the node's function.
231
- guidelines : str
232
- Behavioral guidelines for the AI agent.
233
- brand_name : str
234
- Brand name injected into the Role segment.
235
- scenarios : list[str] | None
236
- Manual scenario override. Replaces auto-inferred scenarios except
237
- ``knowledge_base`` which is always added when *kb_dirs* is non-empty.
238
- kb_dirs : list[str] | None
239
- Knowledge base directory IDs. Non-empty triggers ``knowledge_base``.
240
- extra_constraints : list[str] | None
241
- Additional custom constraints appended after all layers.
242
- """
243
- # --- resolve scenarios ---
244
- if scenarios is not None:
245
- active = set(scenarios)
246
- else:
247
- active = _infer_scenarios("", task, key_info, kb_dirs)
248
-
249
- # knowledge_base is always forced when kb_dirs present
250
- if kb_dirs:
251
- active.add("knowledge_base")
252
-
253
- # --- build constraints list ---
254
- constraints = list(BASE_CONSTRAINTS)
255
- for tag in sorted(active):
256
- constraints.extend(SCENARIO_MAP.get(tag, []))
257
- if extra_constraints:
258
- constraints.extend(extra_constraints)
259
-
260
- restrictions = "\n".join(f"- {c}" for c in constraints)
261
-
262
- return (
263
- f"## Role\n"
264
- f"You are a professional customer service representative "
265
- f"officially representing {brand_name}. {role}\n\n"
266
- f"## Task\n{task}\n\n"
267
- f"## Key Information\n{key_info}\n\n"
268
- f"## Guidelines\n{guidelines}\n\n"
269
- f"## Restrictions (NEVER violate)\n{restrictions}"
270
- )
271
-
272
-
273
- # ---------------------------------------------------------------------------
274
- # Node builders
275
- # ---------------------------------------------------------------------------
276
-
277
- def start_node(position=(0, 0)):
278
- """START node -- unique entry point of the graph."""
279
- return {
280
- "id": _uid(),
281
- "type": "CUSTOM",
282
- "data": {"category": "START"},
283
- "position": {"x": position[0], "y": position[1]},
284
- }
285
-
286
-
287
- def finish_node(title="Finish", position=(0, 0)):
288
- """FINISH node -- shared terminal node; must have at least one inbound edge."""
289
- title = _validate_title(title)
290
- return {
291
- "id": _uid(),
292
- "type": "CUSTOM",
293
- "data": {
294
- "category": "FINISH",
295
- "title": title,
296
- "config": {},
297
- },
298
- "position": {"x": position[0], "y": position[1]},
299
- }
300
-
301
-
302
- def reply_node(title, prompt="", reply_type="PROMPT", template_id=None,
303
- template_content=None, ai_field_ids=None, use_long_memory=False,
304
- disable_prev_node_results=False, position=(0, 0)):
305
- """REPLY node -- prompt, fixed template, or rendered variable template."""
306
- title = _validate_title(title)
307
- reply_type = (reply_type or "PROMPT").upper()
308
- if reply_type not in {"PROMPT", "TEMPLATE", "DYNAMIC_TEMPLATE"}:
309
- raise ValueError(f"unsupported REPLY type: {reply_type}")
310
- if reply_type == "TEMPLATE" and not template_id:
311
- raise ValueError("REPLY type TEMPLATE requires template_id")
312
- if reply_type == "DYNAMIC_TEMPLATE" and not isinstance(template_content, str):
313
- raise ValueError("REPLY type DYNAMIC_TEMPLATE requires template_content")
314
- if reply_type == "DYNAMIC_TEMPLATE" and not template_content.strip():
315
- raise ValueError("REPLY type DYNAMIC_TEMPLATE requires non-empty template_content")
316
- if reply_type != "PROMPT" and ai_field_ids:
317
- raise ValueError(f"REPLY type {reply_type} does not use ai_field_ids")
318
- return {
319
- "id": _uid(),
320
- "type": "CUSTOM",
321
- "data": {
322
- "category": "REPLY",
323
- "title": title,
324
- "config": {
325
- "replyContent": {
326
- "type": reply_type,
327
- "prompt": prompt if reply_type == "PROMPT" else None,
328
- "templateId": template_id if reply_type == "TEMPLATE" else None,
329
- "templateContent": template_content if reply_type == "DYNAMIC_TEMPLATE" else None,
330
- "aiFieldIds": _normalize_id_list(ai_field_ids, "ai_field_ids") if reply_type == "PROMPT" else None,
331
- },
332
- "useLongMemory": bool(use_long_memory),
333
- "disablePrevNodeResult": bool(disable_prev_node_results),
334
- },
335
- },
336
- "position": {"x": position[0], "y": position[1]},
337
- }
338
-
339
-
340
- def agent_node(title, prompt, plugins=None, ai_field_ids=None,
341
- enable_kb=False, kb_dirs=None,
342
- knowledge_keyword_ids=None, knowledge_keyword_selector=None,
343
- max_rounds=10, max_rounds_exceeded_action="TRANSFER_TO_HUMAN",
344
- next_step=None, use_long_memory=False,
345
- disable_prev_node_results=False, position=(0, 0)):
346
- """
347
- AGENT V2 node -- LLM-powered reply with displayPrompt for UI and normalized runtime prompt.
348
-
349
- next_step example::
350
-
351
- [
352
- {"id": "uuid-1", "title": "resolved", "prompt": "User issue resolved"},
353
- {"id": "uuid-2", "title": "not_resolved", "prompt": "User still has issues"},
354
- ]
355
-
356
- When next_step is provided, sourceHandle for edges = next_step[].id.
357
- When next_step is None, sourceHandle = "SOURCE".
358
-
359
- NOTE: transferToHumans is always set to [] to avoid GraphDefParseError in
360
- complex graphs. Flow-internal next_steps are not human handoff branches;
361
- deterministic handoff should use a separate TRANSFER_TO_HUMAN node.
362
- """
363
- title = _validate_title(title)
364
- if isinstance(max_rounds_exceeded_action, dict):
365
- max_rounds_exceeded_action = max_rounds_exceeded_action.get("type")
366
- max_rounds_exceeded_action = str(
367
- max_rounds_exceeded_action or "TRANSFER_TO_HUMAN"
368
- ).upper()
369
- if max_rounds_exceeded_action not in {"FORCE_NEXT_STEP", "TRANSFER_TO_HUMAN"}:
370
- raise ValueError(
371
- "AGENT max_rounds_exceeded_action must be FORCE_NEXT_STEP or TRANSFER_TO_HUMAN"
372
- )
373
- prompt_blocks = _parse_library_blocks(prompt)
374
- used_plugins = list(dict.fromkeys(_normalize_id_list(plugins, "plugins")))
375
- for block in prompt_blocks:
376
- if block["type"] != "tool" or block["fields"]["tool"] == "template_reply":
377
- continue
378
- plugin_id = int(block["fields"]["id"])
379
- if plugin_id not in used_plugins:
380
- used_plugins.append(plugin_id)
381
- config = {
382
- "maxRounds": max_rounds,
383
- "maxRoundsExceededAction": {"type": max_rounds_exceeded_action},
384
- "useLongMemory": bool(use_long_memory),
385
- "disablePrevNodeResult": bool(disable_prev_node_results),
386
- "agentData": {
387
- "prompt": _format_chatflow_agent_prompt(prompt),
388
- "displayPrompt": prompt,
389
- "usedPlugins": used_plugins,
390
- "aiFieldIds": _normalize_id_list(ai_field_ids, "ai_field_ids"),
391
- "enableKnowledgeBase": enable_kb,
392
- "knowledgeDirectoryIds": kb_dirs or [],
393
- # Keep the legacy UI-only transferToHumans collection empty. The
394
- # executable transfer tool is bound through usedPlugins from the
395
- # Chatflow plugin catalog when compiling online.
396
- "transferToHumans": [],
397
- },
398
- }
399
- if knowledge_keyword_ids is not None:
400
- config["agentData"]["knowledgeKeywordIds"] = knowledge_keyword_ids
401
- if knowledge_keyword_selector is not None:
402
- config["agentData"]["knowledgeKeywordSelector"] = knowledge_keyword_selector
403
- if next_step:
404
- config["nextStep"] = next_step
405
- return {
406
- "id": _uid(),
407
- "type": "CUSTOM",
408
- "data": {
409
- "category": "AGENT",
410
- "version": 2,
411
- "title": title,
412
- "config": config,
413
- },
414
- "position": {"x": position[0], "y": position[1]},
415
- }
416
-
417
-
418
- def llm_node(title, prompt, history_round_limit=0, use_long_memory=False,
419
- vision=None, structured_output=None, position=(0, 0)):
420
- """LLM node -- one-shot text, vision, or JSON-schema processing."""
421
- title = _validate_title(title)
422
- if not isinstance(history_round_limit, int) or isinstance(history_round_limit, bool) or history_round_limit < -1:
423
- raise ValueError("LLM history_round_limit must be -1, 0, or a positive integer")
424
- if vision is None:
425
- vision = {}
426
- if not isinstance(vision, dict):
427
- raise ValueError("LLM vision must be an object")
428
- vision_enabled = bool(vision.get("enabled", False))
429
- vision_detail = vision.get("detail", "auto")
430
- vision_media_selectors = vision.get("media_selectors", vision.get("mediaSelectors", []))
431
- if vision_detail not in {"auto", "low", "high"}:
432
- raise ValueError("LLM vision.detail must be auto, low, or high")
433
- if not isinstance(vision_media_selectors, list):
434
- raise ValueError("LLM vision.media_selectors must be a list")
435
- if vision_enabled and not vision_media_selectors:
436
- raise ValueError("LLM vision.enabled requires media_selectors")
437
-
438
- if structured_output is None:
439
- structured_output = {}
440
- if not isinstance(structured_output, dict):
441
- raise ValueError("LLM structured_output must be an object")
442
- structured_output_enabled = bool(structured_output.get("enabled", False))
443
- structured_output_schema = structured_output.get("schema", {})
444
- if not isinstance(structured_output_schema, dict):
445
- raise ValueError("LLM structured_output.schema must be an object")
446
- if structured_output_enabled:
447
- if (
448
- structured_output_schema.get("type") != "object"
449
- or not isinstance(structured_output_schema.get("properties"), dict)
450
- ):
451
- raise ValueError(
452
- "LLM structured_output.enabled requires an object-rooted JSON Schema with properties"
453
- )
454
- if "required" in structured_output_schema and not isinstance(
455
- structured_output_schema["required"], list
456
- ):
457
- raise ValueError("LLM structured_output.schema.required must be a list")
458
- llm_config = {
459
- "promptTemplate": prompt,
460
- "vision": {
461
- "enabled": vision_enabled,
462
- "detail": vision_detail,
463
- "mediaSelectors": vision_media_selectors,
464
- },
465
- "structuredOutput": {
466
- "enabled": structured_output_enabled,
467
- "schema": structured_output_schema,
468
- },
469
- }
470
- return {
471
- "id": _uid(),
472
- "type": "CUSTOM",
473
- "data": {
474
- "category": "LLM",
475
- "title": title,
476
- "config": {
477
- "historyRoundLimit": history_round_limit,
478
- "useLongMemory": bool(use_long_memory),
479
- "llm": llm_config,
480
- },
481
- },
482
- "position": {"x": position[0], "y": position[1]},
483
- }
484
-
485
-
486
- _LIBRARY_BLOCK_REG = re.compile(r"\{#LibraryBlock\s+(.+?)\s*#\}")
487
- _LIBRARY_BLOCK_ATTR_REG = re.compile(r"(?:^|\s+)([A-Za-z_]\w*)=")
488
-
489
-
490
- def _parse_library_block_fields(raw_fields):
491
- matches = list(_LIBRARY_BLOCK_ATTR_REG.finditer(raw_fields))
492
- if not matches or raw_fields[:matches[0].start()].strip():
493
- raise ValueError("invalid LibraryBlock attributes; expected key=value")
494
- fields = {}
495
- for index, match in enumerate(matches):
496
- key = match.group(1)
497
- if not key or key in fields:
498
- raise ValueError(f"invalid or duplicate LibraryBlock attribute '{key}'")
499
- value_end = matches[index + 1].start() if index + 1 < len(matches) else len(raw_fields)
500
- value = raw_fields[match.end():value_end].strip()
501
- if value.startswith('"') or value.endswith('"'):
502
- if len(value) < 2 or not (value.startswith('"') and value.endswith('"')):
503
- raise ValueError(f"invalid quoted LibraryBlock attribute '{key}'")
504
- value = value[1:-1]
505
- fields[key] = value
506
- return fields
507
-
508
-
509
- def _parse_library_blocks(display_prompt):
510
- if not display_prompt:
511
- return []
512
- if not isinstance(display_prompt, str):
513
- raise ValueError("AGENT prompt must be a string")
514
- matches = list(_LIBRARY_BLOCK_REG.finditer(display_prompt))
515
- if display_prompt.count("{#LibraryBlock") != len(matches):
516
- raise ValueError("AGENT prompt contains an incomplete LibraryBlock")
517
-
518
- blocks = []
519
- for match in matches:
520
- fields = _parse_library_block_fields(match.group(1))
521
- block_type = fields.get("type")
522
- if block_type == "variable":
523
- if not {"id", "type", "name"}.issubset(fields):
524
- raise ValueError("variable LibraryBlock requires id/type/name")
525
- if not fields["id"].isdigit() or int(fields["id"]) <= 0 or not fields["name"]:
526
- raise ValueError("variable LibraryBlock requires a positive field id and non-empty name")
527
- elif block_type == "tool":
528
- if not {"id", "type", "name", "tool"}.issubset(fields):
529
- raise ValueError("tool LibraryBlock requires id/type/name/tool")
530
- if not fields["id"].isdigit() or int(fields["id"]) <= 0:
531
- raise ValueError("tool LibraryBlock requires a positive id")
532
- if not fields["name"] or not fields["tool"]:
533
- raise ValueError("tool LibraryBlock requires non-empty name and tool")
534
- elif block_type == "transferHuman":
535
- if not {"tool_id", "type", "name"}.issubset(fields):
536
- raise ValueError("transferHuman LibraryBlock requires tool_id/type/name")
537
- if not fields["tool_id"].isdigit() or int(fields["tool_id"]) <= 0:
538
- raise ValueError("transferHuman LibraryBlock requires a positive numeric tool_id")
539
- if not fields["name"]:
540
- raise ValueError("transferHuman LibraryBlock requires a non-empty name")
541
- if fields.get("action") and fields["action"] not in {
542
- "NOT_REPLY", "CUSTOM_REPLY", "TEMPLATE_REPLY",
543
- }:
544
- raise ValueError("transferHuman LibraryBlock has an unsupported action")
545
- if fields.get("templateId") and (
546
- not fields["templateId"].isdigit() or int(fields["templateId"]) <= 0
547
- ):
548
- raise ValueError("transferHuman LibraryBlock templateId must be a positive integer")
549
- else:
550
- raise ValueError(f"unsupported LibraryBlock type '{block_type}'")
551
- blocks.append({
552
- "raw": match.group(0),
553
- "type": block_type,
554
- "fields": fields,
555
- "start": match.start(),
556
- "end": match.end(),
557
- })
558
- transfer_tool_ids = [
559
- block["fields"]["tool_id"] for block in blocks if block["type"] == "transferHuman"
560
- ]
561
- if len(transfer_tool_ids) != len(set(transfer_tool_ids)):
562
- raise ValueError("transferHuman LibraryBlock tool_id must be unique within one AGENT prompt")
563
- return blocks
564
-
565
-
566
- def _format_chatflow_agent_prompt(display_prompt):
567
- """Convert frontend LibraryBlock chips into runtime prompt text."""
568
- if not display_prompt:
569
- return ""
570
-
571
- blocks = _parse_library_blocks(display_prompt)
572
- by_raw = {block["raw"]: block for block in blocks}
573
-
574
- def replace_block(match):
575
- block = by_raw[match.group(0)]
576
- fields = block["fields"]
577
- block_type = block["type"]
578
- if block_type == "variable":
579
- return f" {fields.get('name', '')} "
580
- if block_type == "tool":
581
- if fields.get("tool") == "template_reply":
582
- return f" template_reply tool (template id: {fields.get('id', '')})"
583
- return f"{fields.get('name', '')} tool "
584
- if block_type == "transferHuman":
585
- return f" Transfer_To_Agent (reason: {fields.get('name', '')}) "
586
- return ""
587
-
588
- return _LIBRARY_BLOCK_REG.sub(replace_block, display_prompt)
589
-
590
-
591
- def if_else_node(title, cases, position=(0, 0)):
592
- """
593
- IF_ELSE multi-branch condition node.
594
-
595
- cases example::
596
-
597
- [
598
- {
599
- "id": "SHIPPED",
600
- "conditions": [
601
- {
602
- "variableSelector": [...],
603
- "comparisonOperator": "EQUAL",
604
- "value": "SHIPPED",
605
- }
606
- ],
607
- }
608
- ]
609
-
610
- Each condition gets an auto-generated ``id`` and default ``varType``.
611
- """
612
- title = _validate_title(title)
613
- processed_cases = []
614
- for case in cases:
615
- conds = []
616
- for c in case.get("conditions", []):
617
- cond = {"varType": c.get("varType", "string")}
618
- cond.update(c)
619
- if "id" not in cond:
620
- cond["id"] = _uid()
621
- conds.append(cond)
622
- processed_cases.append({
623
- "id": case["id"],
624
- "logicalOperator": case.get("logicalOperator", "AND"),
625
- "conditions": conds,
626
- })
627
- return {
628
- "id": _uid(),
629
- "type": "CUSTOM",
630
- "data": {
631
- "category": "IF_ELSE",
632
- "title": title,
633
- "config": {"cases": processed_cases},
634
- },
635
- "position": {"x": position[0], "y": position[1]},
636
- }
637
-
638
-
639
- def tool_node(title, tool_id, input_params=None,
640
- enable_action_status=False, tool_type="EXTERNAL_HTTP_CALL",
641
- position=(0, 0)):
642
- """TOOL node -- calls a configured project tool plugin."""
643
- title = _validate_title(title)
644
- if not isinstance(tool_type, str) or not tool_type.strip():
645
- raise ValueError("TOOL tool_type must be a non-empty plugin type")
646
- return {
647
- "id": _uid(),
648
- "type": "CUSTOM",
649
- "data": {
650
- "category": "TOOL",
651
- "title": title,
652
- "config": {
653
- "tool": {
654
- "id": tool_id,
655
- "type": tool_type,
656
- "enableActionStatus": enable_action_status,
657
- "inputParams": input_params or [],
658
- }
659
- },
660
- },
661
- "position": {"x": position[0], "y": position[1]},
662
- }
663
-
664
-
665
- def question_node(title, question=None, mode="VARIABLE", variables=None,
666
- replies=None, max_rounds=5, question_type="PROMPT",
667
- question_template_id=None, condition="AND",
668
- use_chat_history=True, position=(0, 0)):
669
- """
670
- QUESTION node.
671
-
672
- mode="VARIABLE" -- collect AI field variables; pass ``variables=[id1, ...]``
673
- mode="USER_REPLY" -- classify user reply; pass ``replies=[{"id": "r1", "name": "Option_A"}, ...]``
674
- """
675
- title = _validate_title(title)
676
- mode = (mode or "VARIABLE").upper()
677
- if mode not in {"VARIABLE", "USER_REPLY"}:
678
- raise ValueError(f"unsupported QUESTION mode: {mode}")
679
- question_type = (question_type or "PROMPT").upper()
680
- if question_type not in {"PROMPT", "TEMPLATE"}:
681
- raise ValueError(f"unsupported QUESTION question_type: {question_type}")
682
- if question_type == "TEMPLATE" and not question_template_id:
683
- raise ValueError(f"QUESTION node '{title}' type TEMPLATE requires question_template_id")
684
- if question_type == "PROMPT" and not question:
685
- raise ValueError(f"QUESTION node '{title}' type PROMPT requires question")
686
- condition = (condition or "AND").upper()
687
- if condition not in {"AND", "OR"}:
688
- raise ValueError(f"QUESTION node '{title}' condition must be AND or OR")
689
- collect_info = {
690
- "type": mode,
691
- "questionType": question_type,
692
- "question": question if question_type == "PROMPT" else None,
693
- "questionTemplateId": question_template_id if question_type == "TEMPLATE" else None,
694
- "condition": condition,
695
- }
696
- if mode == "VARIABLE":
697
- collect_info["variables"] = variables or []
698
- collect_info["replies"] = []
699
- else:
700
- collect_info["replies"] = [
701
- {"id": r["id"], "name": _validate_title(r["name"])}
702
- for r in (replies or [])
703
- ]
704
- return {
705
- "id": _uid(),
706
- "type": "CUSTOM",
707
- "data": {
708
- "category": "QUESTION",
709
- "title": title,
710
- "config": {
711
- "maxRounds": max_rounds,
712
- "useChatHistory": bool(use_chat_history),
713
- "collectInfo": collect_info,
714
- },
715
- },
716
- "position": {"x": position[0], "y": position[1]},
717
- }
718
-
719
-
720
- def collect_order_node(title="Collect_Order", max_rounds=5, exceptions=None,
721
- position=(0, 0)):
722
- """COLLECT_ORDER node -- soon-offline compatibility order collection step."""
723
- title = _validate_title(title)
724
- normalized_exceptions = []
725
- for index, item in enumerate(exceptions or []):
726
- if not isinstance(item, dict):
727
- raise ValueError(f"COLLECT_ORDER exception[{index}] must be an object")
728
- item_type = str(item.get("type") or "REPLY").upper()
729
- entry = {
730
- "key": item.get("key") or "ORDER_NUMBER_NOT_PROVIDED",
731
- "type": item_type,
732
- "reply": item.get("reply"),
733
- "templateId": item.get("template_id", item.get("templateId")),
734
- }
735
- if item_type == "TEMPLATE" and not entry["templateId"]:
736
- raise ValueError(f"COLLECT_ORDER exception[{index}] type TEMPLATE requires template_id")
737
- if item_type != "TEMPLATE" and not isinstance(entry["reply"], str):
738
- raise ValueError(f"COLLECT_ORDER exception[{index}] type REPLY requires reply")
739
- normalized_exceptions.append(entry)
740
- return {
741
- "id": _uid(),
742
- "type": "CUSTOM",
743
- "data": {
744
- "category": "COLLECT_ORDER",
745
- "title": title,
746
- "config": {
747
- "maxRounds": max_rounds,
748
- "collectOrderException": normalized_exceptions,
749
- },
750
- },
751
- "position": {"x": position[0], "y": position[1]},
752
- }
753
-
754
-
755
- def transfer_human_node(title="Transfer_To_Human",
756
- prompt="", transfer_type="NO_REPLY",
757
- template_id=None, ai_field_ids=None,
758
- disable_prev_node_results=False, explain=None,
759
- position=(0, 0)):
760
- """
761
- TRANSFER_TO_HUMAN node.
762
-
763
- transfer_type: "NO_REPLY" (default) / "PROMPT" / "TEMPLATE"
764
- prompt: used when transfer_type="PROMPT"
765
- template_id: used when transfer_type="TEMPLATE"
766
- """
767
- title = _validate_title(title)
768
- transfer_type = (transfer_type or "NO_REPLY").upper()
769
- if transfer_type not in {"NO_REPLY", "PROMPT", "TEMPLATE"}:
770
- raise ValueError(f"unsupported TRANSFER_TO_HUMAN type: {transfer_type}")
771
- if transfer_type == "TEMPLATE" and not template_id:
772
- raise ValueError(f"TRANSFER_TO_HUMAN node '{title}' type TEMPLATE requires template_id")
773
- if transfer_type == "PROMPT" and not prompt:
774
- raise ValueError(f"TRANSFER_TO_HUMAN node '{title}' type PROMPT requires prompt")
775
- if transfer_type != "PROMPT" and ai_field_ids:
776
- raise ValueError(f"TRANSFER_TO_HUMAN type {transfer_type} does not use ai_field_ids")
777
- config = {
778
- "type": transfer_type,
779
- "prompt": prompt,
780
- "explain": explain or title,
781
- "aiFieldIds": _normalize_id_list(ai_field_ids, "ai_field_ids") if transfer_type == "PROMPT" else None,
782
- }
783
- if transfer_type == "TEMPLATE" and template_id:
784
- config["templateId"] = template_id
785
- return {
786
- "id": _uid(),
787
- "type": "CUSTOM",
788
- "data": {
789
- "category": "TRANSFER_TO_HUMAN",
790
- "title": title,
791
- "config": {
792
- "transferToHuman": config,
793
- "disablePrevNodeResult": bool(disable_prev_node_results),
794
- },
795
- },
796
- "position": {"x": position[0], "y": position[1]},
797
- }
798
-
799
-
800
- def human_review_node(title="Human_Review", method="approval", description="", fields=None,
801
- midway_message_strategy="ignore", position=(0, 0)):
802
- """HUMAN_REVIEW node - human-in-the-loop approval or form fill."""
803
- title = _validate_title(title)
804
- method = (method or "approval").lower()
805
- if method not in {"approval", "fill"}:
806
- raise ValueError("HUMAN_REVIEW method must be approval or fill")
807
- midway_message_strategy = (midway_message_strategy or "ignore").lower()
808
- if midway_message_strategy not in {"ignore", "handle"}:
809
- raise ValueError("HUMAN_REVIEW midway_message_strategy must be ignore or handle")
810
- config = {
811
- "humanReview": {
812
- "method": method,
813
- "description": description,
814
- "midwayMessageStrategy": midway_message_strategy,
815
- }
816
- }
817
- if method == "fill" and fields:
818
- config["humanReview"]["fields"] = fields
819
- return {
820
- "id": _uid(),
821
- "type": "CUSTOM",
822
- "data": {
823
- "category": "HUMAN_REVIEW",
824
- "title": title,
825
- "desc": "Routes task to a human operator for review, approval, or manual input",
826
- "config": config,
827
- },
828
- "position": {"x": position[0], "y": position[1]},
829
- }
830
-
831
-
832
- def intent_node(title, intents, prompt=None, history_round_limit=None,
833
- use_chat_history=True, position=(0, 0)):
834
- """
835
- INTENT node.
836
-
837
- intents example::
838
-
839
- [{"id": "intent-refund", "name": "Refund", "desc": "...", "intentId": 12345}]
840
- """
841
- title = _validate_title(title)
842
- processed = []
843
- for i in intents:
844
- processed.append({
845
- "id": i.get("id", _uid()),
846
- "name": _validate_title(i["name"]),
847
- "desc": i.get("desc", ""),
848
- "intentId": i.get("intentId"),
849
- "examples": i.get("examples", []),
850
- })
851
- return {
852
- "id": _uid(),
853
- "type": "CUSTOM",
854
- "data": {
855
- "category": "INTENT",
856
- "title": title,
857
- "config": {
858
- "useChatHistory": bool(use_chat_history),
859
- "historyRoundLimit": history_round_limit,
860
- "prompt": prompt,
861
- "intents": processed,
862
- },
863
- },
864
- "position": {"x": position[0], "y": position[1]},
865
- }
866
-
867
-
868
- def knowledge_reply_node(title, prompt, kb_dirs, max_rounds=10,
869
- disable_prev_node_results=False, position=(0, 0)):
870
- """KNOWLEDGE_REPLY node -- must have RESOLVED and UNRESOLVED outbound edges."""
871
- title = _validate_title(title)
872
- return {
873
- "id": _uid(),
874
- "type": "CUSTOM",
875
- "data": {
876
- "category": "KNOWLEDGE_REPLY",
877
- "title": title,
878
- "config": {
879
- "maxRounds": max_rounds,
880
- "knowledgeReply": {
881
- "prompt": prompt,
882
- "knowledgeDirectoryIds": kb_dirs,
883
- },
884
- "nextStep": [
885
- {"id": "RESOLVED", "title": "Resolved", "prompt": None},
886
- {"id": "UNRESOLVED", "title": "Unresolved", "prompt": None},
887
- ],
888
- "disablePrevNodeResult": bool(disable_prev_node_results),
889
- },
890
- },
891
- "position": {"x": position[0], "y": position[1]},
892
- }
893
-
894
-
895
- def rag_reply_node(
896
- title,
897
- prompt,
898
- kb_dirs=None,
899
- knowledge_keyword_ids=None,
900
- knowledge_keyword_selector=None,
901
- customize_query=False,
902
- query_selector=None,
903
- disable_prev_node_results=False,
904
- position=(0, 0),
905
- ):
906
- """RAG_REPLY node -- must have TRUE and LACK_KNOWLEDGE outbound edges."""
907
- title = _validate_title(title)
908
- rag_reply = {
909
- "prompt": prompt,
910
- }
911
- if kb_dirs is not None:
912
- rag_reply["knowledgeDirectoryIds"] = kb_dirs
913
- if knowledge_keyword_ids is not None:
914
- rag_reply["knowledgeKeywordIds"] = knowledge_keyword_ids
915
- if knowledge_keyword_selector is not None:
916
- rag_reply["knowledgeKeywordSelector"] = knowledge_keyword_selector
917
- if customize_query:
918
- rag_reply["customizeQuery"] = True
919
- rag_reply["querySelector"] = query_selector
920
- return {
921
- "id": _uid(),
922
- "type": "CUSTOM",
923
- "data": {
924
- "category": "RAG_REPLY",
925
- "title": title,
926
- "config": {
927
- "ragReply": rag_reply,
928
- "disablePrevNodeResult": bool(disable_prev_node_results),
929
- },
930
- },
931
- "position": {"x": position[0], "y": position[1]},
932
- }
933
-
934
-
935
- def variable_extract_node(title, variables, additional_data=None,
936
- use_chat_history=False, position=(0, 0)):
937
- """
938
- VARIABLE_EXTRACT node -- extract variables from conversation context.
939
-
940
- variables example::
941
-
942
- [
943
- {
944
- "name": "order_number",
945
- "description": "The customer order number",
946
- "type": "string",
947
- "required": True,
948
- }
949
- ]
950
- """
951
- title = _validate_title(title)
952
- processed = []
953
- for v in variables:
954
- processed.append({
955
- "id": v.get("id", _uid()),
956
- "name": _validate_title(v["name"]),
957
- "describe": v.get("describe", v.get("description", "")),
958
- "type": v.get("type", "string"),
959
- })
960
- return {
961
- "id": _uid(),
962
- "type": "CUSTOM",
963
- "data": {
964
- "category": "VARIABLE_EXTRACT",
965
- "title": title,
966
- "config": {
967
- "useChatHistory": bool(use_chat_history),
968
- "variableExtract": {
969
- "additionalData": additional_data or [],
970
- "extracts": processed,
971
- },
972
- },
973
- },
974
- "position": {"x": position[0], "y": position[1]},
975
- }
976
-
977
-
978
- def _normalize_assignment(assignment):
979
- return {
980
- "id": assignment.get("id", _uid()),
981
- "operation": assignment.get("operation", "OVER_WRITE"),
982
- "source": assignment.get("source", assignment.get("variableSelector", assignment.get("value"))),
983
- "target": assignment.get("target") or assignment.get("targets") or [],
984
- "forceOverride": assignment.get("forceOverride", assignment.get("force_override", False)),
985
- }
986
-
987
-
988
- def variable_assignment_node(title, assignments, position=(0, 0)):
989
- """
990
- VARIABLE_ASSIGNMENT node -- assign values to conversation variables.
991
-
992
- assignments example::
993
-
994
- [
995
- {
996
- "variableSelector": ["TOOL", "<tool_node_id>", "status"],
997
- "targetVariable": "order_status",
998
- "value": None, # None means take from variableSelector
999
- }
1000
- ]
1001
- """
1002
- title = _validate_title(title)
1003
- processed = [_normalize_assignment(a) for a in assignments]
1004
- return {
1005
- "id": _uid(),
1006
- "type": "CUSTOM",
1007
- "data": {
1008
- "category": "VARIABLE_ASSIGNMENT",
1009
- "title": title,
1010
- "config": {
1011
- "variableAssignment": processed,
1012
- },
1013
- },
1014
- "position": {"x": position[0], "y": position[1]},
1015
- }
1016
-
1017
-
1018
- def variable_process_node(title, variables=None, assignments=None,
1019
- additional_data=None, use_chat_history=False,
1020
- position=(0, 0)):
1021
- """VARIABLE_PROCESS node -- extract variables and/or assign variable-system fields."""
1022
- title = _validate_title(title)
1023
- extracts = []
1024
- for v in variables or []:
1025
- extracts.append({
1026
- "id": v.get("id", _uid()),
1027
- "name": _validate_title(v["name"]),
1028
- "describe": v.get("describe", v.get("description", "")),
1029
- "type": v.get("type", "string"),
1030
- })
1031
- normalized_assignments = [_normalize_assignment(a) for a in assignments or []]
1032
- return {
1033
- "id": _uid(),
1034
- "type": "CUSTOM",
1035
- "data": {
1036
- "category": "VARIABLE_PROCESS",
1037
- "title": title,
1038
- "config": {
1039
- "useChatHistory": bool(use_chat_history),
1040
- "variableProcess": {
1041
- "additionalData": additional_data or [],
1042
- "extracts": extracts,
1043
- "assignments": normalized_assignments,
1044
- },
1045
- },
1046
- },
1047
- "position": {"x": position[0], "y": position[1]},
1048
- }
1049
-
1050
-
1051
- def code_node(title, source_code, inputs=None, outputs=None, position=(0, 0)):
1052
- """CODE node -- trusted Python code block with declared inputs/outputs."""
1053
- title = _validate_title(title)
1054
-
1055
- def normalize_input(item):
1056
- field_name = item.get("fieldName", item.get("field_name"))
1057
- if not field_name:
1058
- raise ValueError(f"CODE node '{title}' input missing field_name")
1059
- variable_selector = item.get("variableSelector", item.get("variable_selector"))
1060
- if variable_selector is None:
1061
- raise ValueError(f"CODE node '{title}' input '{field_name}' missing variable_selector")
1062
- return {
1063
- "id": item.get("id", _uid()),
1064
- "fieldName": field_name,
1065
- "variableSelector": variable_selector,
1066
- }
1067
-
1068
- def normalize_output(item):
1069
- field_name = item.get("fieldName", item.get("field_name"))
1070
- if not field_name:
1071
- raise ValueError(f"CODE node '{title}' output missing field_name")
1072
- return {
1073
- "id": item.get("id", _uid()),
1074
- "fieldName": field_name,
1075
- "fieldType": item.get("fieldType", item.get("field_type", "String")),
1076
- }
1077
-
1078
- return {
1079
- "id": _uid(),
1080
- "type": "CUSTOM",
1081
- "data": {
1082
- "category": "CODE",
1083
- "title": title,
1084
- "config": {
1085
- "code": {
1086
- "inputs": [normalize_input(i) for i in inputs or []],
1087
- "sourceCode": source_code,
1088
- "outputs": [normalize_output(o) for o in outputs or []],
1089
- },
1090
- },
1091
- },
1092
- "position": {"x": position[0], "y": position[1]},
1093
- }
1094
-
1095
-
1096
- def sub_graph_node(title, target_agent_id, context_turn_limit=5, position=(0, 0)):
1097
- """SUB_GRAPH node -- call another CHAT_FLOW graph. It must not have outgoing edges."""
1098
- if not target_agent_id:
1099
- raise ValueError(f"SUB_GRAPH node '{title}' missing target_agent_id")
1100
- title = _validate_title(title)
1101
- return {
1102
- "id": _uid(),
1103
- "type": "CUSTOM",
1104
- "data": {
1105
- "category": "SUB_GRAPH",
1106
- "title": title,
1107
- "config": {
1108
- "subGraph": {
1109
- "targetAgentId": int(target_agent_id),
1110
- "contextTurnLimit": int(context_turn_limit),
1111
- },
1112
- },
1113
- },
1114
- "position": {"x": position[0], "y": position[1]},
1115
- }
1116
-
1117
-
1118
- def business_track_node(title, trace_events, position=(0, 0)):
1119
- """BUSINESS_TRACK node -- emits one or more business trace event codes."""
1120
- title = _validate_title(title)
1121
- if not isinstance(trace_events, list) or not trace_events:
1122
- raise ValueError("BUSINESS_TRACK trace_events must be a non-empty list")
1123
- normalized_events = []
1124
- for index, event in enumerate(trace_events):
1125
- if not isinstance(event, str) or not event.strip():
1126
- raise ValueError(f"BUSINESS_TRACK trace_events[{index}] must be a non-empty string")
1127
- normalized_events.append(event.strip())
1128
- return {
1129
- "id": _uid(),
1130
- "type": "CUSTOM",
1131
- "data": {
1132
- "category": "BUSINESS_TRACK",
1133
- "title": title,
1134
- "config": {"businessTrack": {"traceEvents": normalized_events}},
1135
- },
1136
- "position": {"x": position[0], "y": position[1]},
1137
- }
1138
-
1139
-
1140
- # ---------------------------------------------------------------------------
1141
- # Edge builders
1142
- # ---------------------------------------------------------------------------
1143
-
1144
- def edge(source_node, target_node, source_handle="SOURCE"):
1145
- """
1146
- Create a single edge between two nodes.
1147
-
1148
- source_node / target_node are node dicts returned by builder functions.
1149
- Uses first 8 chars of node IDs to keep edge IDs readable even with UUIDs.
1150
- """
1151
- return {
1152
- "id": f"e-{_short_id(source_node['id'])}-{source_handle}-{_short_id(target_node['id'])}",
1153
- "source": source_node["id"],
1154
- "sourceHandle": source_handle,
1155
- "target": target_node["id"],
1156
- "targetHandle": "TARGET",
1157
- "type": "default",
1158
- }
1159
-
1160
-
1161
- def edges_from_if_else(if_node, case_targets, false_target=None):
1162
- """
1163
- Create all branch edges from an IF_ELSE node.
1164
-
1165
- case_targets: {"CASE_ID": target_node, ...}
1166
- false_target: target node for the FALSE (else) branch (optional)
1167
- """
1168
- result = []
1169
- for case_id, target in case_targets.items():
1170
- result.append(edge(if_node, target, source_handle=case_id))
1171
- if false_target:
1172
- result.append(edge(if_node, false_target, source_handle="FALSE"))
1173
- return result
1174
-
1175
-
1176
- def edges_from_intent(intent_node_obj, intent_targets, other_target=None):
1177
- """
1178
- Create all intent-branch edges from an INTENT node.
1179
-
1180
- intent_targets: {"intent-id": target_node, ...}
1181
- other_target: target node for the OTHER (fallback) branch (optional)
1182
- """
1183
- result = []
1184
- for intent_id, target in intent_targets.items():
1185
- result.append(edge(intent_node_obj, target, source_handle=intent_id))
1186
- if other_target:
1187
- result.append(edge(intent_node_obj, other_target, source_handle="OTHER"))
1188
- return result
1189
-
1190
-
1191
- def edges_from_question_replies(question_node_obj, reply_targets,
1192
- other_target=None):
1193
- """
1194
- Create branch edges from a USER_REPLY-mode QUESTION node.
1195
-
1196
- reply_targets: {"reply_id": target_node, ...}
1197
- """
1198
- result = []
1199
- for reply_id, target in reply_targets.items():
1200
- result.append(edge(question_node_obj, target, source_handle=reply_id))
1201
- if other_target:
1202
- result.append(edge(question_node_obj, other_target, source_handle="OTHER"))
1203
- return result
1204
-
1205
-
1206
- def edges_from_knowledge_reply(kr_node, resolved_target, unresolved_target):
1207
- """
1208
- Create the two required edges from a KNOWLEDGE_REPLY node.
1209
-
1210
- KNOWLEDGE_REPLY must always have both RESOLVED and UNRESOLVED outbound edges.
1211
- """
1212
- return [
1213
- edge(kr_node, resolved_target, source_handle="RESOLVED"),
1214
- edge(kr_node, unresolved_target, source_handle="UNRESOLVED"),
1215
- ]
1216
-
1217
-
1218
- def edges_from_rag_reply(rag_node, true_target, lack_knowledge_target):
1219
- """
1220
- Create the two required edges from a RAG_REPLY node.
1221
-
1222
- RAG_REPLY must always have both TRUE and LACK_KNOWLEDGE outbound edges.
1223
- """
1224
- return [
1225
- edge(rag_node, true_target, source_handle="TRUE"),
1226
- edge(rag_node, lack_knowledge_target, source_handle="LACK_KNOWLEDGE"),
1227
- ]
1228
-
1229
-
1230
- def edges_from_human_review(hr_node, agree_target, reject_target=None):
1231
- """Create edges from HUMAN_REVIEW(approval) node."""
1232
- result = [edge(hr_node, agree_target, "AGREE")]
1233
- if reject_target:
1234
- result.append(edge(hr_node, reject_target, "REJECT"))
1235
- return result
1236
-
1237
-
1238
- def edges_from_agent(agent_node_obj, next_step_targets, default_target=None):
1239
- """
1240
- Create all outbound edges from an AGENT node with nextStep branches.
1241
-
1242
- next_step_targets: {"next_step_id": target_node, ...}
1243
- Keys MUST match the ``id`` values defined in the agent's nextStep config.
1244
- default_target: target node for AGENT without nextStep (sourceHandle="SOURCE")
1245
-
1246
- Raises ValueError if any nextStep id has no matching target — prevents the
1247
- common bug where an AGENT branch is silently a dead-end.
1248
- """
1249
- cfg = agent_node_obj.get("data", {}).get("config", {})
1250
- next_step = cfg.get("nextStep") or []
1251
- if not next_step:
1252
- if default_target is None:
1253
- raise ValueError(
1254
- f"agent_node {agent_node_obj['id']} has no nextStep — pass default_target for the SOURCE edge"
1255
- )
1256
- return [edge(agent_node_obj, default_target, source_handle="SOURCE")]
1257
-
1258
- defined_ids = {ns["id"] for ns in next_step}
1259
- provided_ids = set(next_step_targets.keys())
1260
- missing = defined_ids - provided_ids
1261
- extra = provided_ids - defined_ids
1262
- if missing:
1263
- raise ValueError(
1264
- f"agent_node {agent_node_obj['id']} missing edges for nextStep ids: {sorted(missing)}"
1265
- )
1266
- if extra:
1267
- raise ValueError(
1268
- f"agent_node {agent_node_obj['id']} has unknown nextStep ids in targets: {sorted(extra)}"
1269
- )
1270
- return [edge(agent_node_obj, target, source_handle=ns_id)
1271
- for ns_id, target in next_step_targets.items()]
1272
-
1273
-
1274
- # ---------------------------------------------------------------------------
1275
- # Auto parameter binding
1276
- # ---------------------------------------------------------------------------
1277
-
1278
- # Common variable-system field name → field ID mapping (updated per project; these are defaults)
1279
- _DEFAULT_FIELD_MAPPING = {
1280
- "ordernumber": "OrderNumber",
1281
- "orderno": "OrderNumber",
1282
- "orderid": "OrderNumber",
1283
- "order_number": "OrderNumber",
1284
- "trackingnumbers": "TrackingNumber",
1285
- "trackingnumber": "TrackingNumber",
1286
- "tracking_number": "TrackingNumber",
1287
- "customeremail": "CustomerEmail",
1288
- "email": "CustomerEmail",
1289
- "customername": "CustomerName",
1290
- "name": "CustomerName",
1291
- }
1292
-
1293
-
1294
- def auto_bind_tool_params(client, nodes, _plugin_cache=None, _field_cache=None):
1295
- """
1296
- For every TOOL node in the graph, check plugin required params and
1297
- auto-bind them to variable-system fields via variableSelector.
1298
-
1299
- Skips params that already have inputParams configured.
1300
- Prints warnings for unmatched required params.
1301
-
1302
- Args:
1303
- client: SolveaClient instance (used to query plugins and AI fields)
1304
- nodes: list of node dicts
1305
- _plugin_cache: optional pre-fetched plugin list (avoids duplicate API call)
1306
- _field_cache: optional pre-fetched AI field list (avoids duplicate API call)
1307
-
1308
- Returns:
1309
- tuple of (plugin_map, field_lookup) for reuse by downstream validators
1310
- """
1311
- # Collect TOOL nodes
1312
- tool_nodes = [n for n in nodes
1313
- if n.get("data", {}).get("category") == "TOOL"]
1314
- if not tool_nodes:
1315
- return {}, {}
1316
-
1317
- # Fetch plugins and AI fields once (or reuse cache)
1318
- plugins = _plugin_cache if _plugin_cache is not None else client.list_plugins()
1319
- plugin_map = {}
1320
- for p in plugins:
1321
- pid = p.get("id")
1322
- if pid:
1323
- plugin_map[pid] = p
1324
-
1325
- ai_fields = _field_cache if _field_cache is not None else client.list_ai_fields()
1326
- # Build name -> (field_path, bizType) lookup. Nested fields must retain
1327
- # their complete cascader path, e.g. OrderInformation -> OrderNumber.
1328
- field_lookup = {} # lowercase name -> (field_path, bizType)
1329
- if isinstance(ai_fields, list):
1330
- def add_field(field, biz_type, parent_path=None):
1331
- if not isinstance(field, dict) or field.get("id") is None:
1332
- return
1333
- path = [*(parent_path or []), field["id"]]
1334
- if field.get("name"):
1335
- field_lookup[field["name"].lower()] = (path, biz_type)
1336
- for child in field.get("children") or []:
1337
- add_field(child, biz_type, path)
1338
-
1339
- for f in ai_fields:
1340
- if isinstance(f, dict):
1341
- biz = f.get("bizType", "Custom")
1342
- add_field(f, biz)
1343
-
1344
- for tool_nd in tool_nodes:
1345
- title = tool_nd["data"].get("title", "?")
1346
- tool_cfg = tool_nd["data"]["config"]["tool"]
1347
- plugin_id = tool_cfg["id"]
1348
- existing_params = tool_cfg.get("inputParams", [])
1349
- existing_names = {p["name"] for p in existing_params}
1350
-
1351
- plugin = plugin_map.get(plugin_id)
1352
- if not plugin:
1353
- print(f" ⚠️ WARNING: TOOL '{title}' references plugin {plugin_id} not found in project")
1354
- continue
1355
-
1356
- config = plugin.get("meta", {}).get("externalHttpCallConfig", {})
1357
- req_items = config.get("requestParam", {}).get("items", [])
1358
-
1359
- for item in req_items:
1360
- param_name = item["name"]
1361
- required = item.get("required", False)
1362
- source = item.get("source", "LLM")
1363
-
1364
- # Skip non-required or non-LLM params
1365
- if not required or source != "LLM":
1366
- continue
1367
- # Skip already configured
1368
- if param_name in existing_names:
1369
- continue
1370
-
1371
- # Try to match to a variable-system field
1372
- lookup_key = param_name.lower()
1373
- matched_field_name = _DEFAULT_FIELD_MAPPING.get(lookup_key)
1374
-
1375
- field_entry = None # (field_path, bizType)
1376
- if matched_field_name:
1377
- field_entry = field_lookup.get(matched_field_name.lower())
1378
-
1379
- if not field_entry:
1380
- # Try direct name match
1381
- field_entry = field_lookup.get(lookup_key)
1382
-
1383
- if field_entry:
1384
- field_path, biz_type = field_entry
1385
- new_param = {
1386
- "name": param_name,
1387
- "variableSelector": ["SYSTEM", biz_type, *field_path],
1388
- "varType": item.get("type", "string"),
1389
- }
1390
- existing_params.append(new_param)
1391
- existing_names.add(param_name)
1392
- print(f" [auto-bind] TOOL '{title}': {param_name} → variable-system field {matched_field_name or param_name} (path: {field_path}, bizType: {biz_type})")
1393
- else:
1394
- print(f" ⚠️ WARNING: TOOL '{title}': required param '{param_name}' has no matching variable-system field — must bind manually")
1395
-
1396
- tool_cfg["inputParams"] = existing_params
1397
-
1398
- return plugin_map, field_lookup
1399
-
1400
-
1401
- def _validate_business_rules(nodes, client=None, _plugin_map=None):
1402
- """
1403
- Business-level validation beyond structural checks.
1404
- Returns list of error strings.
1405
- """
1406
- errors = []
1407
-
1408
- for n in nodes:
1409
- cat = n.get("data", {}).get("category", "")
1410
- title = n.get("data", {}).get("title", "?")
1411
- cfg = n.get("data", {}).get("config", {})
1412
-
1413
- # TOOL: check required params are bound
1414
- if cat == "TOOL":
1415
- tool_cfg = cfg.get("tool", {})
1416
- input_params = tool_cfg.get("inputParams", [])
1417
- configured_names = {p["name"] for p in input_params}
1418
-
1419
- if client or _plugin_map:
1420
- if _plugin_map:
1421
- plugin = _plugin_map.get(tool_cfg.get("id"))
1422
- else:
1423
- plugins = client.list_plugins()
1424
- plugin = next((p for p in plugins if p.get("id") == tool_cfg.get("id")), None)
1425
- if plugin:
1426
- req_items = plugin.get("meta", {}).get("externalHttpCallConfig", {}).get("requestParam", {}).get("items", [])
1427
- for item in req_items:
1428
- if item.get("required") and item.get("source") == "LLM" and item["name"] not in configured_names:
1429
- errors.append(f"TOOL '{title}': required param '{item['name']}' not bound in inputParams")
1430
-
1431
- # IF_ELSE: check variableSelector doesn't have stale "output" layer
1432
- if cat == "IF_ELSE":
1433
- cases = cfg.get("cases", [])
1434
- for case in cases:
1435
- for cond in case.get("conditions", []):
1436
- vs = cond.get("variableSelector", [])
1437
- if len(vs) >= 3 and vs[0] == "TOOL" and vs[2] == "output":
1438
- errors.append(
1439
- f"IF_ELSE '{title}': variableSelector {vs} has invalid 'output' layer. "
1440
- f"Correct format: ['TOOL', node_id, field_name, ...]"
1441
- )
1442
-
1443
- # AGENT: check nextStep items have id
1444
- if cat == "AGENT":
1445
- for ns in cfg.get("nextStep", []):
1446
- if not ns.get("id"):
1447
- errors.append(f"AGENT '{title}': nextStep item '{ns.get('title', '?')}' missing 'id'")
1448
-
1449
- return errors
1450
-
1451
-
1452
- # ---------------------------------------------------------------------------
1453
- # Graph assembly + validation
1454
- # ---------------------------------------------------------------------------
1455
-
1456
- def build_graph(nodes, edges, client=None, ai_fields=None, agent_ai_fields=None):
1457
- """
1458
- Assemble a chatFlowGraph dict from nodes and edges.
1459
-
1460
- **Validation is mandatory** — runs validate_graph + business rules and
1461
- raises ValueError on any error. There is no opt-out switch; deploy 前
1462
- 必须 validate 通过,避免被后端拒收或运行时炸。
1463
-
1464
- When client is provided, also runs:
1465
- - auto_bind_tool_params: auto-bind TOOL required params to variable-system fields
1466
- - _validate_business_rules: check TOOL params, IF_ELSE selectors, etc.
1467
- """
1468
- # --- Auto-bind TOOL params if client provided ---
1469
- _plugin_map = {}
1470
- if client:
1471
- if ai_fields is None:
1472
- ai_fields = client.list_ai_fields()
1473
- _plugin_map, _ = auto_bind_tool_params(client, nodes, _field_cache=ai_fields)
1474
-
1475
- # --- Pre-build warnings ---
1476
- for n in nodes:
1477
- cat = n.get("data", {}).get("category", "")
1478
- title = n.get("data", {}).get("title", "?")
1479
- cfg = n.get("data", {}).get("config", {})
1480
- # Warn: empty knowledgeDirectoryIds
1481
- if cat == "KNOWLEDGE_REPLY":
1482
- kb = cfg.get("knowledgeReply", {}).get("knowledgeDirectoryIds", [])
1483
- if not kb:
1484
- print(f" ⚠️ WARNING: KNOWLEDGE_REPLY '{title}' has empty knowledgeDirectoryIds — will always go UNRESOLVED")
1485
- if cat == "RAG_REPLY":
1486
- kb = cfg.get("ragReply", {}).get("knowledgeDirectoryIds", [])
1487
- if not kb:
1488
- print(f" ⚠️ WARNING: RAG_REPLY '{title}' has empty knowledgeDirectoryIds — will always go LACK_KNOWLEDGE")
1489
-
1490
- # --- Business rule validation (reuse plugin_map from auto_bind) ---
1491
- if client:
1492
- biz_errors = _validate_business_rules(nodes, client, _plugin_map=_plugin_map)
1493
- if biz_errors:
1494
- raise ValueError(
1495
- "Business validation failed:\n"
1496
- + "\n".join(f" ERROR: {e}" for e in biz_errors)
1497
- )
1498
-
1499
- graph = {
1500
- "nodes": nodes,
1501
- "edges": edges,
1502
- "viewport": {"x": 0, "y": 0, "zoom": 0.8},
1503
- }
1504
-
1505
- # Mandatory validation — no opt-out
1506
- # validate_graph 与本文件同目录(chatflow/scripts/validate_graph.py)。
1507
- # 调用方负责把 chatflow/scripts/ 加入 sys.path。
1508
- from validate_graph import validate_graph as _validate_graph
1509
-
1510
- result = _validate_graph(
1511
- graph,
1512
- ai_fields=ai_fields,
1513
- agent_ai_fields=agent_ai_fields,
1514
- )
1515
- if isinstance(result, list):
1516
- if result:
1517
- raise ValueError(
1518
- "Graph validation failed:\n"
1519
- + "\n".join(f" ERROR: {e}" for e in result)
1520
- )
1521
- print(f" Graph OK: {len(nodes)} nodes, {len(edges)} edges")
1522
- elif isinstance(result, dict):
1523
- if not result.get("valid", True):
1524
- raise ValueError(
1525
- "Graph validation failed:\n"
1526
- + "\n".join(f" ERROR: {e}" for e in result.get("errors", []))
1527
- )
1528
- for w in result.get("warnings", []):
1529
- print(f" WARNING: {w}")
1530
- stats = result.get("stats", {})
1531
- n_count = stats.get("nodes", len(nodes))
1532
- e_count = stats.get("edges", len(edges))
1533
- print(f" Graph OK: {n_count} nodes, {e_count} edges")
1534
- else:
1535
- print(f" Graph OK: {len(nodes)} nodes, {len(edges)} edges")
1536
-
1537
- return graph
1538
-
1539
-
1540
- # ---------------------------------------------------------------------------
1541
- # One-step deploy
1542
- # ---------------------------------------------------------------------------
1543
-
1544
- @contextmanager
1545
- def _deploy_lock():
1546
- """Serialize createAgent/updateAgent deploys from parallel tool executions."""
1547
- runtime = _bootstrap_cli_runtime()
1548
- lock_dir = runtime.STATE_DIR if runtime is not None else Path("workspace") / "state" / "chatflow"
1549
- lock_dir.mkdir(parents=True, exist_ok=True)
1550
- lock_path = lock_dir / "chatflow_deploy.lock"
1551
- with open(lock_path, "w", encoding="utf-8") as lock_file:
1552
- try:
1553
- import fcntl
1554
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
1555
- yield
1556
- finally:
1557
- try:
1558
- import fcntl
1559
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
1560
- except Exception:
1561
- pass
1562
-
1563
-
1564
- def _create_agent_with_retry(client, skeleton):
1565
- try:
1566
- return client.create_agent(skeleton)
1567
- except RuntimeError as exc:
1568
- message = str(exc)
1569
- if "System Error" not in message or "createAgent" not in message:
1570
- raise
1571
- print(" WARNING: createAgent returned System Error; retrying once after deploy lock wait")
1572
- time.sleep(2)
1573
- return client.create_agent(skeleton)
1574
-
1575
-
1576
- def deploy_chatflow(client, graph, agent_name):
1577
- """
1578
- Create and deploy a new CHAT_FLOW agent end-to-end.
1579
-
1580
- Do not use this for incremental edits to an existing agent. Existing
1581
- CHAT_FLOW updates should call `client.update_chatflow(agent_id, graph)`,
1582
- which follows the UI behavior and saves a draft by default.
1583
-
1584
- 1. Create agent skeleton (START + FINISH with connecting edge)
1585
- 2. Update agent with the full chatFlowGraph
1586
-
1587
- NOTE: Intents 在 INTENT 节点里定义,不调系统意图接口。
1588
- CHAT_FLOW agent 必须走策略路由测试——本函数 **不做** 烟雾测试,因为
1589
- 裸 client.chat() 会被默认 bot 接走,根本打不到刚部署的 chatflow,
1590
- 返回的 test_result 是假阳性(来自默认 agent)。部署完后请按具体业务走
1591
- poc / delivery skill,或手动建策略路由验证。
1592
-
1593
- Returns dict with keys: agent_id.
1594
- """
1595
- result = {}
1596
-
1597
- # ------------------------------------------------------------------
1598
- # Step 1: Create skeleton with START and FINISH connected by an edge
1599
- # ------------------------------------------------------------------
1600
- skel_start = start_node()
1601
- skel_finish = finish_node()
1602
- skel_edge = edge(skel_start, skel_finish)
1603
- skeleton_graph = {
1604
- "nodes": [skel_start, skel_finish],
1605
- "edges": [skel_edge],
1606
- "viewport": {"x": 0, "y": 0, "zoom": 1},
1607
- }
1608
- skeleton = {
1609
- "name": agent_name,
1610
- "type": "CHAT_FLOW",
1611
- "category": "TEXT",
1612
- "source": "MANUAL",
1613
- "projectId": int(client.project_id),
1614
- "meta": {
1615
- # Pass as a Python dict -- NOT json.dumps
1616
- "chatFlowGraph": skeleton_graph,
1617
- },
1618
- }
1619
- with _deploy_lock():
1620
- created = _create_agent_with_retry(client, skeleton)
1621
- agent_id = created.get("id") or created.get("data", {}).get("id")
1622
- result["agent_id"] = agent_id
1623
- print(f" [1/2] Agent created: {agent_id}")
1624
-
1625
- # ------------------------------------------------------------------
1626
- # Step 2: Fetch full detail, merge in real graph, update
1627
- # ------------------------------------------------------------------
1628
- agent_detail = client.get_agent(agent_id)
1629
- agent_data = agent_detail[0] if isinstance(agent_detail, list) else agent_detail
1630
- agent_data["agentId"] = agent_id
1631
- agent_data["meta"] = agent_data.get("meta", {})
1632
- # chatFlowGraph as a Python dict (not json.dumps string)
1633
- agent_data["meta"]["chatFlowGraph"] = graph
1634
- client.update_agent(agent_data)
1635
- print(f" [2/2] Graph written: {len(graph['nodes'])} nodes, {len(graph['edges'])} edges")
1636
-
1637
- return result
1638
-
1639
-
1640
-
1641
- # ---------------------------------------------------------------------------
1642
- # Source compiler (agent-authored source JSON -> full chatFlowGraph)
1643
- # ---------------------------------------------------------------------------
1644
-
1645
- _SOURCE_TYPE_ALIASES = {
1646
- "start": "START",
1647
- "finish": "FINISH",
1648
- "reply": "REPLY",
1649
- "llm": "LLM",
1650
- "model": "LLM",
1651
- "agent": "AGENT",
1652
- "if_else": "IF_ELSE",
1653
- "ifelse": "IF_ELSE",
1654
- "tool": "TOOL",
1655
- "question": "QUESTION",
1656
- "collect_order": "COLLECT_ORDER",
1657
- "collectorder": "COLLECT_ORDER",
1658
- "transfer": "TRANSFER_TO_HUMAN",
1659
- "transfer_to_human": "TRANSFER_TO_HUMAN",
1660
- "human_review": "HUMAN_REVIEW",
1661
- "humanreview": "HUMAN_REVIEW",
1662
- "intent": "INTENT",
1663
- "knowledge_reply": "KNOWLEDGE_REPLY",
1664
- "knowledge": "RAG_REPLY",
1665
- "rag_reply": "RAG_REPLY",
1666
- "rag": "RAG_REPLY",
1667
- "variable_extract": "VARIABLE_EXTRACT",
1668
- "variableextract": "VARIABLE_EXTRACT",
1669
- "variable_assignment": "VARIABLE_ASSIGNMENT",
1670
- "variableassignment": "VARIABLE_ASSIGNMENT",
1671
- "variable_process": "VARIABLE_PROCESS",
1672
- "variableprocess": "VARIABLE_PROCESS",
1673
- "code": "CODE",
1674
- "sub_graph": "SUB_GRAPH",
1675
- "subgraph": "SUB_GRAPH",
1676
- "business_track": "BUSINESS_TRACK",
1677
- "businesstrack": "BUSINESS_TRACK",
1678
- }
1679
-
1680
-
1681
- def _source_node_type(spec):
1682
- raw = spec.get("type") or spec.get("category")
1683
- if not raw:
1684
- raise ValueError(f"source node missing type/category: {spec}")
1685
- key = str(raw).strip().lower().replace("-", "_")
1686
- return _SOURCE_TYPE_ALIASES.get(key, str(raw).strip().upper())
1687
-
1688
-
1689
- def _source_key(spec, index):
1690
- key = spec.get("key") or spec.get("name") or spec.get("title")
1691
- if key:
1692
- return str(key)
1693
- return f"node_{index}"
1694
-
1695
-
1696
- def _source_title(spec, key):
1697
- return spec.get("title") or spec.get("name") or key
1698
-
1699
-
1700
- def _source_get(spec, *names, default=None):
1701
- for name in names:
1702
- if name in spec and spec[name] is not None:
1703
- return spec[name]
1704
- return default
1705
-
1706
-
1707
- def _source_position(spec, index):
1708
- pos = spec.get("position")
1709
- if isinstance(pos, dict):
1710
- return (pos.get("x", 0), pos.get("y", 0))
1711
- if isinstance(pos, (list, tuple)) and len(pos) >= 2:
1712
- return (pos[0], pos[1])
1713
- return ((index % 5) * 320, (index // 5) * 180)
1714
-
1715
-
1716
- def _has_source_position(spec):
1717
- pos = spec.get("position")
1718
- return isinstance(pos, dict) or (isinstance(pos, (list, tuple)) and len(pos) >= 2)
1719
-
1720
-
1721
- def _source_item_id_name(item, *, default_prefix="item"):
1722
- if isinstance(item, str):
1723
- name = item
1724
- return _validate_title(name), name
1725
- if not isinstance(item, dict):
1726
- raise ValueError(f"branch item must be string or object, got {item!r}")
1727
- name = item.get("name") or item.get("title") or item.get("id") or default_prefix
1728
- item_id = item.get("id") or _validate_title(str(name))
1729
- return str(item_id), str(name)
1730
-
1731
-
1732
- def _source_branch_lookup(branch_handles, node_key, label, handle):
1733
- labels = {str(label), str(handle), _validate_title(str(label)), _validate_title(str(handle))}
1734
- for candidate in labels:
1735
- branch_handles[(node_key, candidate)] = str(handle)
1736
-
1737
-
1738
- def _merge_dict(dst, src):
1739
- for key, value in src.items():
1740
- if isinstance(value, dict) and isinstance(dst.get(key), dict):
1741
- _merge_dict(dst[key], value)
1742
- else:
1743
- dst[key] = value
1744
- return dst
1745
-
1746
-
1747
- def _apply_source_overrides(node, spec):
1748
- if "node_id" in spec:
1749
- node["id"] = str(spec["node_id"])
1750
- if "id" in spec and spec.get("preserve_id"):
1751
- node["id"] = str(spec["id"])
1752
- if isinstance(spec.get("data"), dict):
1753
- _merge_dict(node.setdefault("data", {}), spec["data"])
1754
- if isinstance(spec.get("config"), dict):
1755
- _merge_dict(node.setdefault("data", {}).setdefault("config", {}), spec["config"])
1756
- if isinstance(spec.get("extra"), dict):
1757
- _merge_dict(node, spec["extra"])
1758
- return node
1759
-
1760
-
1761
- def _normalize_replies(replies, branch_handles, node_key):
1762
- normalized = []
1763
- for item in replies or []:
1764
- item_id, name = _source_item_id_name(item, default_prefix="reply")
1765
- normalized.append({"id": item_id, "name": name})
1766
- _source_branch_lookup(branch_handles, node_key, name, item_id)
1767
- return normalized
1768
-
1769
-
1770
- def _normalize_intents(intents, branch_handles, node_key):
1771
- normalized = []
1772
- for item in intents or []:
1773
- if isinstance(item, str):
1774
- item_id, name = _source_item_id_name(item, default_prefix="intent")
1775
- entry = {"id": item_id, "name": name, "desc": ""}
1776
- elif isinstance(item, dict):
1777
- item_id, name = _source_item_id_name(item, default_prefix="intent")
1778
- entry = dict(item)
1779
- entry["id"] = item_id
1780
- entry["name"] = name
1781
- else:
1782
- raise ValueError(f"intent item must be string or object, got {item!r}")
1783
- if entry.get("intentId") is None and not str(entry.get("desc") or "").strip():
1784
- raise ValueError(f"intent '{entry.get('name') or entry.get('id')}' in node '{node_key}' missing desc")
1785
- normalized.append(entry)
1786
- _source_branch_lookup(branch_handles, node_key, entry["name"], entry["id"])
1787
- return normalized
1788
-
1789
-
1790
- def _apply_auto_layout(nodes, edges, locked_node_ids=None):
1791
- """Lay out generated nodes by graph depth while preserving explicit positions."""
1792
- locked_node_ids = set(locked_node_ids or [])
1793
- node_ids = {n.get("id") for n in nodes}
1794
- order = {n.get("id"): idx for idx, n in enumerate(nodes)}
1795
- outgoing = {}
1796
- indegree = {n.get("id"): 0 for n in nodes}
1797
- for edge_obj in edges:
1798
- source = edge_obj.get("source")
1799
- target = edge_obj.get("target")
1800
- if source not in node_ids or target not in node_ids:
1801
- continue
1802
- outgoing.setdefault(source, []).append(target)
1803
- indegree[target] = indegree.get(target, 0) + 1
1804
-
1805
- start_ids = [
1806
- n.get("id")
1807
- for n in nodes
1808
- if n.get("data", {}).get("category") == "START"
1809
- ]
1810
- roots = start_ids or [node_id for node_id, count in indegree.items() if count == 0]
1811
- depth = {node_id: 0 for node_id in roots}
1812
- queue = list(roots)
1813
- remaining_indegree = dict(indegree)
1814
- for root in roots:
1815
- remaining_indegree[root] = 0
1816
-
1817
- while queue:
1818
- source = queue.pop(0)
1819
- for target in outgoing.get(source, []):
1820
- depth[target] = max(depth.get(target, 0), depth.get(source, 0) + 1)
1821
- remaining_indegree[target] -= 1
1822
- if remaining_indegree[target] == 0:
1823
- queue.append(target)
1824
-
1825
- columns = {}
1826
- for node in nodes:
1827
- node_id = node.get("id")
1828
- columns.setdefault(depth.get(node_id, 0), []).append(node)
1829
-
1830
- for column_nodes in columns.values():
1831
- column_nodes.sort(key=lambda n: order.get(n.get("id"), 0))
1832
-
1833
- x_gap = 360
1834
- y_gap = 220
1835
- for column, column_nodes in columns.items():
1836
- for row, node in enumerate(column_nodes):
1837
- if node.get("id") in locked_node_ids:
1838
- continue
1839
- node["position"] = {"x": column * x_gap, "y": row * y_gap}
1840
- return nodes
1841
-
1842
-
1843
- def _normalize_cases(cases, branch_handles, node_key):
1844
- normalized = []
1845
- for item in cases or []:
1846
- if isinstance(item, str):
1847
- case_id, name = _source_item_id_name(item, default_prefix="case")
1848
- entry = {"id": case_id, "name": name, "conditions": []}
1849
- elif isinstance(item, dict):
1850
- case_id, name = _source_item_id_name(item, default_prefix="case")
1851
- entry = dict(item)
1852
- entry["id"] = case_id
1853
- else:
1854
- raise ValueError(f"case item must be string or object, got {item!r}")
1855
- normalized.append(entry)
1856
- _source_branch_lookup(branch_handles, node_key, entry.get("name", entry["id"]), entry["id"])
1857
- return normalized
1858
-
1859
-
1860
- def _normalize_next_steps(next_steps, branch_handles, node_key):
1861
- normalized = []
1862
- for item in next_steps or []:
1863
- if isinstance(item, str):
1864
- raise ValueError(
1865
- f"AGENT '{node_key}' next_steps item '{item}' must be an object with id/title/prompt; "
1866
- "string shorthand cannot pass frontend validation"
1867
- )
1868
- elif isinstance(item, dict):
1869
- title = item.get("title") or item.get("name") or item.get("id") or "Step"
1870
- step_id = item.get("id") or _validate_title(str(title))
1871
- entry = dict(item)
1872
- entry["id"] = str(step_id)
1873
- entry["title"] = str(title)
1874
- else:
1875
- raise ValueError(f"next step must be string or object, got {item!r}")
1876
- normalized.append(entry)
1877
- _source_branch_lookup(branch_handles, node_key, entry["title"], entry["id"])
1878
- return normalized
1879
-
1880
-
1881
- def _source_extract_items(items):
1882
- normalized = []
1883
- aliases = {}
1884
- for item in items or []:
1885
- entry = dict(item)
1886
- alias = entry.get("alias") or entry.get("key") or entry.get("id")
1887
- entry["id"] = entry.get("graph_id") or entry.get("graphId") or _uid()
1888
- if alias:
1889
- aliases[str(alias)] = entry["id"]
1890
- normalized.append(entry)
1891
- return normalized, aliases
1892
-
1893
-
1894
- def _resolve_extract_alias(extract_aliases_by_key, node_key, extract_id):
1895
- if extract_id is None:
1896
- return extract_id
1897
- return (extract_aliases_by_key or {}).get(node_key, {}).get(str(extract_id), extract_id)
1898
-
1899
-
1900
- def _normalize_variable_selector(selector, key_to_node, extract_aliases_by_key=None):
1901
- if isinstance(selector, list):
1902
- if selector and selector[0] == "TOOL_KEY":
1903
- node = key_to_node[selector[1]]
1904
- return var_tool_output(node, *selector[2:])
1905
- if selector and selector[0] == "NODE_KEY":
1906
- return var_node(key_to_node[selector[1]])
1907
- if selector and selector[0] == "EXTRACT_KEY":
1908
- extract_id = _resolve_extract_alias(extract_aliases_by_key, selector[1], selector[2])
1909
- return var_extract(key_to_node[selector[1]], extract_id)
1910
- return selector
1911
- if not isinstance(selector, dict):
1912
- return selector
1913
- if "tool" in selector:
1914
- path = selector.get("path") or selector.get("field") or []
1915
- if isinstance(path, str):
1916
- if "." in path:
1917
- raise ValueError(
1918
- f"nested TOOL output '{path}' must use path segments, for example "
1919
- f"{{'tool': '{selector['tool']}', 'path': {path.split('.')}}}"
1920
- )
1921
- path = [path]
1922
- return var_tool_output(key_to_node[selector["tool"]], *path)
1923
- if "tool_status" in selector:
1924
- return var_tool_status(key_to_node[selector["tool_status"]])
1925
- if "node" in selector:
1926
- return var_node(key_to_node[selector["node"]])
1927
- if "llm" in selector:
1928
- node_key = selector["llm"]
1929
- path = selector.get("path") or selector.get("field") or []
1930
- if isinstance(path, str):
1931
- path = [path]
1932
- if not path:
1933
- path = ["text"]
1934
- node = key_to_node[node_key]
1935
- node_id = node["id"]
1936
- if path[0] in {"text", "structured_output"}:
1937
- return ["LLM", node_id, *path]
1938
- structured_output = (node.get("data", {}).get("config", {}).get("llm", {})
1939
- .get("structuredOutput", {}))
1940
- if structured_output.get("enabled"):
1941
- return ["LLM", node_id, "structured_output", *path]
1942
- raise ValueError(
1943
- f"LLM selector for '{node_key}' must use field='text', or enable structured_output "
1944
- f"before selecting structured fields"
1945
- )
1946
- if "code" in selector:
1947
- field_name = selector.get("field") or selector.get("field_name") or selector.get("output")
1948
- if not isinstance(field_name, str) or not field_name:
1949
- raise ValueError(f"CODE selector for '{selector['code']}' missing field")
1950
- return var_code_output(key_to_node[selector["code"]], field_name)
1951
- if "extract" in selector:
1952
- node_key = selector["extract"]
1953
- extract_id = (
1954
- selector.get("alias")
1955
- or selector.get("id")
1956
- or selector.get("extract_id")
1957
- or selector.get("variable_id")
1958
- or selector.get("variable")
1959
- )
1960
- return var_extract(
1961
- key_to_node[node_key],
1962
- _resolve_extract_alias(extract_aliases_by_key, node_key, extract_id),
1963
- )
1964
- if "system" in selector:
1965
- system = selector["system"]
1966
- if isinstance(system, dict):
1967
- path = system.get("path")
1968
- if path is None:
1969
- path = system.get("field_id") or system.get("id")
1970
- return var_system(system.get("category", "Custom"), path)
1971
- if isinstance(system, list) and len(system) >= 2:
1972
- path = system[1] if len(system) == 2 and isinstance(system[1], list) else system[1:]
1973
- return var_system(system[0], path)
1974
- return selector
1975
-
1976
-
1977
- _DYNAMIC_TEMPLATE_PREFIX = "[[var:"
1978
- _DYNAMIC_TEMPLATE_SUFFIX = "]]"
1979
- _DYNAMIC_TEMPLATE_DECODER = json.JSONDecoder()
1980
-
1981
-
1982
- def _is_source_selector_shortcut(selector):
1983
- if isinstance(selector, list):
1984
- return (
1985
- bool(selector)
1986
- and isinstance(selector[0], str)
1987
- and selector[0] in {"TOOL_KEY", "NODE_KEY", "EXTRACT_KEY"}
1988
- )
1989
- if not isinstance(selector, dict):
1990
- return False
1991
- return (
1992
- isinstance(selector.get("tool"), str)
1993
- or isinstance(selector.get("tool_status"), str)
1994
- or isinstance(selector.get("node"), str)
1995
- or isinstance(selector.get("llm"), str)
1996
- or isinstance(selector.get("code"), str)
1997
- or isinstance(selector.get("extract"), str)
1998
- or isinstance(selector.get("system"), (dict, list))
1999
- )
2000
-
2001
-
2002
- def _resolve_dynamic_template_selectors(text, key_to_node, extract_aliases_by_key=None):
2003
- """Replace source selector shortcuts embedded in [[var:...]] templates.
2004
-
2005
- ChatFlow persists a variable template as ``[[var:<selector-array>]]``. Source
2006
- JSON may use the same selector shortcuts as other fields so generated node IDs
2007
- do not have to be guessed. Invalid templates are deliberately left unchanged:
2008
- the graph validator can report the original text without hiding it.
2009
- """
2010
- if not isinstance(text, str) or _DYNAMIC_TEMPLATE_PREFIX not in text:
2011
- return text
2012
-
2013
- rendered = []
2014
- cursor = 0
2015
- while True:
2016
- start = text.find(_DYNAMIC_TEMPLATE_PREFIX, cursor)
2017
- if start < 0:
2018
- rendered.append(text[cursor:])
2019
- break
2020
- rendered.append(text[cursor:start])
2021
- json_start = start + len(_DYNAMIC_TEMPLATE_PREFIX)
2022
- try:
2023
- selector, json_end = _DYNAMIC_TEMPLATE_DECODER.raw_decode(text, json_start)
2024
- except json.JSONDecodeError:
2025
- rendered.append(text[start:])
2026
- break
2027
- if not text.startswith(_DYNAMIC_TEMPLATE_SUFFIX, json_end):
2028
- rendered.append(text[start:])
2029
- break
2030
- end = json_end + len(_DYNAMIC_TEMPLATE_SUFFIX)
2031
- if _is_source_selector_shortcut(selector):
2032
- selector = _normalize_variable_selector(selector, key_to_node, extract_aliases_by_key)
2033
- rendered.append(
2034
- f"{_DYNAMIC_TEMPLATE_PREFIX}{json.dumps(selector, ensure_ascii=False, separators=(',', ':'))}{_DYNAMIC_TEMPLATE_SUFFIX}"
2035
- )
2036
- else:
2037
- rendered.append(text[start:end])
2038
- cursor = end
2039
- return "".join(rendered)
2040
-
2041
-
2042
- def _resolve_source_selectors(value, key_to_node, extract_aliases_by_key=None, current_key=None):
2043
- if isinstance(value, str):
2044
- return _resolve_dynamic_template_selectors(value, key_to_node, extract_aliases_by_key)
2045
- if isinstance(value, dict):
2046
- if "variableSelector" in value:
2047
- value["variableSelector"] = _normalize_variable_selector(value["variableSelector"], key_to_node, extract_aliases_by_key)
2048
- elif _is_source_selector_shortcut(value):
2049
- return _normalize_variable_selector(value, key_to_node, extract_aliases_by_key)
2050
- for child_key, child_value in list(value.items()):
2051
- value[child_key] = _resolve_source_selectors(child_value, key_to_node, extract_aliases_by_key, current_key)
2052
- elif isinstance(value, list):
2053
- if _is_source_selector_shortcut(value):
2054
- return _normalize_variable_selector(value, key_to_node, extract_aliases_by_key)
2055
- if value and value[0] == "SELF" and len(value) == 2 and current_key:
2056
- return ["SELF", _resolve_extract_alias(extract_aliases_by_key, current_key, value[1])]
2057
- for idx, item in enumerate(value):
2058
- value[idx] = _resolve_source_selectors(item, key_to_node, extract_aliases_by_key, current_key)
2059
- return value
2060
-
2061
-
2062
- def _build_source_node(spec, index, key, branch_handles):
2063
- position = _source_position(spec, index)
2064
- if isinstance(spec.get("node"), dict):
2065
- node = dict(spec["node"])
2066
- node.setdefault("id", _uid())
2067
- node.setdefault("type", "CUSTOM")
2068
- node.setdefault("position", {"x": position[0], "y": position[1]})
2069
- return _apply_source_overrides(node, spec)
2070
-
2071
- node_type = _source_node_type(spec)
2072
- title = _source_title(spec, key)
2073
-
2074
- if node_type == "START":
2075
- node = start_node(position=position)
2076
- elif node_type == "FINISH":
2077
- node = finish_node(title=title, position=position)
2078
- elif node_type == "REPLY":
2079
- node = reply_node(
2080
- title,
2081
- _source_get(spec, "prompt", "text", "message", default=""),
2082
- reply_type=_source_get(spec, "reply_type", "replyType", "type_value", default="PROMPT"),
2083
- template_id=_source_get(spec, "template_id", "templateId"),
2084
- template_content=_source_get(spec, "template_content", "templateContent"),
2085
- ai_field_ids=_source_get(spec, "ai_field_ids", "aiFieldIds"),
2086
- use_long_memory=_source_get(spec, "use_long_memory", "useLongMemory", default=False),
2087
- disable_prev_node_results=_source_get(
2088
- spec, "disable_prev_node_results", "disablePrevNodeResult", default=False
2089
- ),
2090
- position=position,
2091
- )
2092
- elif node_type == "AGENT":
2093
- next_steps = _normalize_next_steps(_source_get(spec, "next_steps", "nextStep"), branch_handles, key)
2094
- node = agent_node(
2095
- title,
2096
- spec.get("prompt") or "",
2097
- plugins=_source_get(spec, "plugins", "usedPlugins"),
2098
- ai_field_ids=_source_get(spec, "ai_field_ids", "aiFieldIds"),
2099
- enable_kb=_source_get(spec, "enable_kb", "enableKnowledgeBase", default=False),
2100
- kb_dirs=_source_get(spec, "kb_dirs", "knowledgeDirectoryIds"),
2101
- knowledge_keyword_ids=_source_get(spec, "knowledge_keyword_ids", "knowledgeKeywordIds"),
2102
- knowledge_keyword_selector=_source_get(
2103
- spec, "knowledge_keyword_selector", "knowledgeKeywordSelector"
2104
- ),
2105
- max_rounds=_source_get(spec, "max_rounds", "maxRounds", default=10),
2106
- max_rounds_exceeded_action=_source_get(
2107
- spec, "max_rounds_exceeded_action", "maxRoundsExceededAction",
2108
- default="TRANSFER_TO_HUMAN",
2109
- ),
2110
- next_step=next_steps or None,
2111
- use_long_memory=_source_get(spec, "use_long_memory", "useLongMemory", default=False),
2112
- disable_prev_node_results=_source_get(
2113
- spec, "disable_prev_node_results", "disablePrevNodeResult", default=False
2114
- ),
2115
- position=position,
2116
- )
2117
- elif node_type == "LLM":
2118
- unsupported_fields = [
2119
- field for field in ("ai_field_ids", "aiFieldIds", "model_id", "modelId", "outputs", "output_variables", "outputVariables")
2120
- if field in spec
2121
- ]
2122
- if unsupported_fields:
2123
- raise ValueError(
2124
- "LLM source fields are no longer supported by the current ChatFlow runtime: "
2125
- f"{', '.join(unsupported_fields)}. Use [[var:...]] templates, vision, and structured_output instead."
2126
- )
2127
- node = llm_node(
2128
- title,
2129
- spec.get("prompt") or "",
2130
- history_round_limit=_source_get(spec, "history_round_limit", "historyRoundLimit", default=0),
2131
- use_long_memory=_source_get(spec, "use_long_memory", "useLongMemory", default=False),
2132
- vision=spec.get("vision"),
2133
- structured_output=_source_get(spec, "structured_output", "structuredOutput"),
2134
- position=position,
2135
- )
2136
- elif node_type == "IF_ELSE":
2137
- node = if_else_node(title, _normalize_cases(spec.get("cases"), branch_handles, key), position=position)
2138
- elif node_type == "TOOL":
2139
- tool = spec.get("tool") if isinstance(spec.get("tool"), dict) else {}
2140
- tool_id = _source_get(spec, "tool_id", "toolId", default=tool.get("id"))
2141
- if tool_id is None:
2142
- raise ValueError(f"TOOL node '{key}' missing tool_id")
2143
- node = tool_node(
2144
- title,
2145
- tool_id,
2146
- input_params=_source_get(spec, "input_params", "inputParams"),
2147
- enable_action_status=_source_get(spec, "enable_action_status", "enableActionStatus", default=False),
2148
- tool_type=_source_get(spec, "tool_type", "toolType", default=tool.get("type", "EXTERNAL_HTTP_CALL")),
2149
- position=position,
2150
- )
2151
- elif node_type == "QUESTION":
2152
- replies = spec.get("replies") or []
2153
- mode = _source_get(spec, "mode", "collect_type", default="USER_REPLY" if replies else "VARIABLE")
2154
- node = question_node(
2155
- title,
2156
- _source_get(spec, "question", "prompt", default=""),
2157
- mode=mode,
2158
- variables=spec.get("variables"),
2159
- replies=_normalize_replies(replies, branch_handles, key),
2160
- max_rounds=_source_get(spec, "max_rounds", "maxRounds", default=5),
2161
- question_type=_source_get(spec, "question_type", "questionType", default="PROMPT"),
2162
- question_template_id=_source_get(spec, "question_template_id", "questionTemplateId"),
2163
- condition=_source_get(spec, "condition", default="AND"),
2164
- use_chat_history=_source_get(spec, "use_chat_history", "useChatHistory", default=True),
2165
- position=position,
2166
- )
2167
- elif node_type == "COLLECT_ORDER":
2168
- node = collect_order_node(
2169
- title=title,
2170
- max_rounds=_source_get(spec, "max_rounds", "maxRounds", default=5),
2171
- exceptions=_source_get(spec, "collect_order_exception", "collectOrderException", default=[]),
2172
- position=position,
2173
- )
2174
- elif node_type == "TRANSFER_TO_HUMAN":
2175
- node = transfer_human_node(
2176
- title=title,
2177
- prompt=spec.get("prompt", ""),
2178
- transfer_type=_source_get(spec, "transfer_type", "type_value", "transferType", default="NO_REPLY"),
2179
- template_id=_source_get(spec, "template_id", "templateId"),
2180
- ai_field_ids=_source_get(spec, "ai_field_ids", "aiFieldIds"),
2181
- disable_prev_node_results=_source_get(
2182
- spec, "disable_prev_node_results", "disablePrevNodeResult", default=False
2183
- ),
2184
- explain=spec.get("explain"),
2185
- position=position,
2186
- )
2187
- elif node_type == "HUMAN_REVIEW":
2188
- node = human_review_node(
2189
- title=title,
2190
- method=spec.get("method", "approval"),
2191
- description=spec.get("description", ""),
2192
- fields=spec.get("fields"),
2193
- midway_message_strategy=_source_get(
2194
- spec, "midway_message_strategy", "midwayMessageStrategy", default="ignore"
2195
- ),
2196
- position=position,
2197
- )
2198
- _source_branch_lookup(branch_handles, key, "AGREE", "AGREE")
2199
- _source_branch_lookup(branch_handles, key, "REJECT", "REJECT")
2200
- elif node_type == "INTENT":
2201
- node = intent_node(
2202
- title,
2203
- _normalize_intents(spec.get("intents"), branch_handles, key),
2204
- prompt=spec.get("prompt"),
2205
- history_round_limit=_source_get(
2206
- spec, "history_round_limit", "historyRoundLimit", default=None
2207
- ),
2208
- use_chat_history=_source_get(spec, "use_chat_history", "useChatHistory", default=True),
2209
- position=position,
2210
- )
2211
- _source_branch_lookup(branch_handles, key, "OTHER", "OTHER")
2212
- elif node_type == "KNOWLEDGE_REPLY":
2213
- node = knowledge_reply_node(
2214
- title,
2215
- spec.get("prompt") or "",
2216
- _source_get(spec, "kb_dirs", "knowledgeDirectoryIds", default=None),
2217
- max_rounds=_source_get(spec, "max_rounds", "maxRounds", default=10),
2218
- disable_prev_node_results=_source_get(
2219
- spec, "disable_prev_node_results", "disablePrevNodeResult", default=False
2220
- ),
2221
- position=position,
2222
- )
2223
- _source_branch_lookup(branch_handles, key, "RESOLVED", "RESOLVED")
2224
- _source_branch_lookup(branch_handles, key, "UNRESOLVED", "UNRESOLVED")
2225
- elif node_type == "RAG_REPLY":
2226
- node = rag_reply_node(
2227
- title,
2228
- spec.get("prompt") or "",
2229
- _source_get(spec, "kb_dirs", "knowledgeDirectoryIds", default=[]),
2230
- knowledge_keyword_ids=_source_get(spec, "knowledge_keyword_ids", "knowledgeKeywordIds"),
2231
- knowledge_keyword_selector=_source_get(
2232
- spec, "knowledge_keyword_selector", "knowledgeKeywordSelector"
2233
- ),
2234
- customize_query=_source_get(spec, "customize_query", "customizeQuery", default=False),
2235
- query_selector=_source_get(spec, "query_selector", "querySelector"),
2236
- disable_prev_node_results=_source_get(
2237
- spec, "disable_prev_node_results", "disablePrevNodeResult", default=False
2238
- ),
2239
- position=position,
2240
- )
2241
- _source_branch_lookup(branch_handles, key, "TRUE", "TRUE")
2242
- _source_branch_lookup(branch_handles, key, "LACK_KNOWLEDGE", "LACK_KNOWLEDGE")
2243
- elif node_type == "VARIABLE_EXTRACT":
2244
- variables, extract_aliases = _source_extract_items(spec.get("variables") or spec.get("extracts") or [])
2245
- node = variable_extract_node(
2246
- title,
2247
- variables,
2248
- additional_data=_source_get(spec, "additional_data", "additionalData", default=[]),
2249
- use_chat_history=_source_get(spec, "use_chat_history", "useChatHistory", default=False),
2250
- position=position,
2251
- )
2252
- if extract_aliases:
2253
- node["_sourceExtractAliases"] = extract_aliases
2254
- elif node_type == "VARIABLE_ASSIGNMENT":
2255
- node = variable_assignment_node(title, spec.get("assignments") or [], position=position)
2256
- elif node_type == "VARIABLE_PROCESS":
2257
- variables, extract_aliases = _source_extract_items(spec.get("variables") or spec.get("extracts") or [])
2258
- node = variable_process_node(
2259
- title,
2260
- variables=variables,
2261
- assignments=spec.get("assignments") or [],
2262
- additional_data=_source_get(spec, "additional_data", "additionalData", default=[]),
2263
- use_chat_history=_source_get(spec, "use_chat_history", "useChatHistory", default=False),
2264
- position=position,
2265
- )
2266
- if extract_aliases:
2267
- node["_sourceExtractAliases"] = extract_aliases
2268
- elif node_type == "CODE":
2269
- node = code_node(
2270
- title,
2271
- _source_get(spec, "source_code", "sourceCode", "code", default=""),
2272
- inputs=spec.get("inputs") or [],
2273
- outputs=spec.get("outputs") or [],
2274
- position=position,
2275
- )
2276
- elif node_type == "SUB_GRAPH":
2277
- node = sub_graph_node(
2278
- title,
2279
- _source_get(spec, "target_agent_id", "targetAgentId", "agent_id", "agentId"),
2280
- context_turn_limit=_source_get(spec, "context_turn_limit", "contextTurnLimit", default=5),
2281
- position=position,
2282
- )
2283
- elif node_type == "BUSINESS_TRACK":
2284
- node = business_track_node(
2285
- title,
2286
- _source_get(spec, "trace_events", "traceEvents"),
2287
- position=position,
2288
- )
2289
- else:
2290
- raise ValueError(f"unsupported source node type '{node_type}' for key '{key}'")
2291
-
2292
- return _apply_source_overrides(node, spec)
2293
-
2294
-
2295
- def _ensure_special_source_node(special_key, nodes, key_to_node):
2296
- if special_key in key_to_node:
2297
- return key_to_node[special_key]
2298
- position = (len(nodes) % 5 * 320, len(nodes) // 5 * 180)
2299
- if special_key == "$start":
2300
- node = start_node(position=position)
2301
- elif special_key == "$finish":
2302
- node = finish_node(title="Finish", position=position)
2303
- elif special_key == "$transfer":
2304
- node = transfer_human_node(title="Transfer_To_Human", position=position)
2305
- else:
2306
- raise ValueError(f"unknown special node key: {special_key}")
2307
- nodes.append(node)
2308
- key_to_node[special_key] = node
2309
- return node
2310
-
2311
-
2312
- def _compile_source_to_graph(source, client=None, ai_fields=None, agent_ai_fields=None):
2313
- """Compile agent-authored source JSON into a full chatFlowGraph."""
2314
- if not isinstance(source, dict):
2315
- raise ValueError("source must be a JSON object")
2316
- source_nodes = source.get("nodes") or []
2317
- if not isinstance(source_nodes, list) or not source_nodes:
2318
- raise ValueError("source.nodes must be a non-empty list")
2319
-
2320
- nodes = []
2321
- edges = []
2322
- key_to_node = {}
2323
- node_id_to_key = {}
2324
- extract_aliases_by_key = {}
2325
- branch_handles = {}
2326
- explicit_position_node_ids = set()
2327
-
2328
- for index, spec in enumerate(source_nodes):
2329
- if not isinstance(spec, dict):
2330
- raise ValueError(f"source node at index {index} must be an object")
2331
- key = _source_key(spec, index)
2332
- if key in key_to_node:
2333
- raise ValueError(f"duplicate source node key: {key}")
2334
- node = _build_source_node(spec, index, key, branch_handles)
2335
- source_extract_aliases = node.pop("_sourceExtractAliases", None)
2336
- if source_extract_aliases:
2337
- extract_aliases_by_key[key] = source_extract_aliases
2338
- nodes.append(node)
2339
- key_to_node[key] = node
2340
- node_id_to_key[node["id"]] = key
2341
- if _has_source_position(spec):
2342
- explicit_position_node_ids.add(node["id"])
2343
-
2344
- source_edges = list(source.get("edges") or [])
2345
- if source.get("entry") and not any(n.get("data", {}).get("category") == "START" for n in nodes):
2346
- _ensure_special_source_node("$start", nodes, key_to_node)
2347
- source_edges.insert(0, {"from": "$start", "to": source["entry"]})
2348
-
2349
- for node in nodes:
2350
- _resolve_source_selectors(
2351
- node,
2352
- key_to_node,
2353
- extract_aliases_by_key=extract_aliases_by_key,
2354
- current_key=node_id_to_key.get(node["id"]),
2355
- )
2356
-
2357
- for edge_spec in source_edges:
2358
- if not isinstance(edge_spec, dict):
2359
- raise ValueError(f"source edge must be an object, got {edge_spec!r}")
2360
- src_key = _source_get(edge_spec, "from", "source")
2361
- tgt_key = _source_get(edge_spec, "to", "target")
2362
- if src_key in {"$start", "$finish", "$transfer"}:
2363
- _ensure_special_source_node(src_key, nodes, key_to_node)
2364
- if tgt_key in {"$start", "$finish", "$transfer"}:
2365
- _ensure_special_source_node(tgt_key, nodes, key_to_node)
2366
- if src_key not in key_to_node:
2367
- raise ValueError(f"edge source key not found: {src_key}")
2368
- if tgt_key not in key_to_node:
2369
- raise ValueError(f"edge target key not found: {tgt_key}")
2370
- handle = _source_get(edge_spec, "source_handle", "sourceHandle", "handle")
2371
- if handle is None and "on" in edge_spec:
2372
- on = str(edge_spec["on"])
2373
- handle = branch_handles.get((src_key, on)) or branch_handles.get((src_key, _validate_title(on))) or on
2374
- if handle is None:
2375
- handle = "SOURCE"
2376
- edges.append(edge(key_to_node[src_key], key_to_node[tgt_key], source_handle=str(handle)))
2377
-
2378
- _apply_auto_layout(nodes, edges, locked_node_ids=explicit_position_node_ids)
2379
- return build_graph(
2380
- nodes,
2381
- edges,
2382
- client=client,
2383
- ai_fields=ai_fields,
2384
- agent_ai_fields=agent_ai_fields,
2385
- )
2386
-
2387
-
2388
- def _merge_source_fragments(fragment_files, integration):
2389
- """Merge keyed source fragments into one source document before compilation.
2390
-
2391
- Fragments contain only nodes and edges internal to one flow block. The
2392
- integration document owns the final entry and cross-block edges. Merging
2393
- source, rather than compiled graphs, keeps generated node IDs, START and
2394
- layout deterministic in the one final compiler pass.
2395
- """
2396
- if not fragment_files:
2397
- raise ValueError("at least one source fragment is required")
2398
- if not isinstance(integration, dict):
2399
- raise ValueError("integration document must be a JSON object")
2400
- if "nodes" in integration:
2401
- raise ValueError("integration document must not define nodes; put nodes in source fragments")
2402
- entry = integration.get("entry")
2403
- if not isinstance(entry, str) or not entry.strip():
2404
- raise ValueError("integration document requires a non-empty entry node key")
2405
- connector_edges = integration.get("edges") or []
2406
- if not isinstance(connector_edges, list):
2407
- raise ValueError("integration.edges must be a list")
2408
-
2409
- merged = {"entry": entry.strip(), "nodes": [], "edges": []}
2410
- node_keys = set()
2411
- node_origins = {}
2412
- edge_signatures = set()
2413
- fragment_edges = []
2414
-
2415
- def add_edge(edge_spec, origin):
2416
- if not isinstance(edge_spec, dict):
2417
- raise ValueError(f"{origin} edge must be an object")
2418
- source_key = _source_get(edge_spec, "from", "source")
2419
- target_key = _source_get(edge_spec, "to", "target")
2420
- if not isinstance(source_key, str) or not source_key.strip():
2421
- raise ValueError(f"{origin} edge missing source key")
2422
- if not isinstance(target_key, str) or not target_key.strip():
2423
- raise ValueError(f"{origin} edge missing target key")
2424
- signature = json.dumps(edge_spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2425
- if signature in edge_signatures:
2426
- raise ValueError(f"duplicate source edge while merging: {source_key} -> {target_key}")
2427
- edge_signatures.add(signature)
2428
- merged["edges"].append(dict(edge_spec))
2429
- return source_key.strip(), target_key.strip()
2430
-
2431
- for fragment_file in fragment_files:
2432
- path = Path(fragment_file)
2433
- fragment = json.loads(path.read_text(encoding="utf-8"))
2434
- if not isinstance(fragment, dict):
2435
- raise ValueError(f"source fragment must be a JSON object: {path}")
2436
- if "entry" in fragment:
2437
- raise ValueError(
2438
- f"source fragment must not define entry; integration owns the final entry: {path}"
2439
- )
2440
- nodes = fragment.get("nodes")
2441
- if not isinstance(nodes, list) or not nodes:
2442
- raise ValueError(f"source fragment requires a non-empty nodes list: {path}")
2443
- edges = fragment.get("edges") or []
2444
- if not isinstance(edges, list):
2445
- raise ValueError(f"source fragment edges must be a list: {path}")
2446
- fragment_node_keys = set()
2447
- for index, node in enumerate(nodes):
2448
- if not isinstance(node, dict):
2449
- raise ValueError(f"source fragment node must be an object: {path}[{index}]")
2450
- key = node.get("key")
2451
- if not isinstance(key, str) or not key.strip():
2452
- raise ValueError(f"source fragment node requires an explicit key: {path}[{index}]")
2453
- key = key.strip()
2454
- if key in node_keys:
2455
- raise ValueError(f"duplicate source node key while merging: {key}")
2456
- node_keys.add(key)
2457
- fragment_node_keys.add(key)
2458
- node_origins[key] = str(path)
2459
- merged["nodes"].append(dict(node))
2460
- for edge_spec in edges:
2461
- source_key, target_key = add_edge(edge_spec, str(path))
2462
- fragment_edges.append((source_key, target_key, str(path), fragment_node_keys))
2463
-
2464
- terminal_keys = {"$finish", "$transfer"}
2465
- for source_key, target_key, origin, fragment_node_keys in fragment_edges:
2466
- if source_key not in fragment_node_keys:
2467
- raise ValueError(
2468
- f"source fragment edge must start at a node in the same fragment: {origin}: {source_key}"
2469
- )
2470
- if target_key not in fragment_node_keys and target_key not in terminal_keys:
2471
- raise ValueError(
2472
- "source fragment edge crosses flow blocks; put cross-block edges in integration: "
2473
- f"{origin}: {source_key} -> {target_key}"
2474
- )
2475
-
2476
- for edge_spec in connector_edges:
2477
- source_key, target_key = add_edge(edge_spec, "integration")
2478
- if source_key not in node_keys:
2479
- raise ValueError(f"integration edge source key not found in fragments: {source_key}")
2480
- if target_key not in node_keys and target_key not in terminal_keys:
2481
- raise ValueError(f"integration edge target key not found in fragments: {target_key}")
2482
- if (
2483
- source_key in node_origins
2484
- and target_key in node_origins
2485
- and node_origins[source_key] == node_origins[target_key]
2486
- ):
2487
- raise ValueError(
2488
- "integration edge stays inside one source fragment; put it in that fragment instead: "
2489
- f"{source_key} -> {target_key}"
2490
- )
2491
-
2492
- if merged["entry"] not in node_keys:
2493
- raise ValueError(f"integration entry key not found in fragments: {merged['entry']}")
2494
- for edge_spec in merged["edges"]:
2495
- source_key = _source_get(edge_spec, "from", "source")
2496
- target_key = _source_get(edge_spec, "to", "target")
2497
- if source_key not in node_keys:
2498
- raise ValueError(f"merged edge source key not found in fragments: {source_key}")
2499
- if target_key not in node_keys and target_key not in terminal_keys:
2500
- raise ValueError(f"merged edge target key not found in fragments: {target_key}")
2501
- return merged
2502
-
2503
-
2504
- # ---------------------------------------------------------------------------
2505
- # CLI helpers
2506
- # ---------------------------------------------------------------------------
2507
-
2508
- def _normalize_chatflow_graph_response(response):
2509
- """Return the chatFlowGraph dict from direct or wrapped API responses."""
2510
- if not isinstance(response, dict):
2511
- raise ValueError(f"chatflow graph response must be a dict, got {type(response).__name__}")
2512
- graph = response.get("graph") if isinstance(response.get("graph"), dict) else response
2513
- if (
2514
- not isinstance(graph, dict)
2515
- or not isinstance(graph.get("nodes"), list)
2516
- or not isinstance(graph.get("edges"), list)
2517
- ):
2518
- raise ValueError("chatFlowGraph must be a dict with nodes and edges")
2519
- return graph
2520
-
2521
-
2522
- def _agent_from_detail(detail):
2523
- if isinstance(detail, list):
2524
- return detail[0] if detail else None
2525
- return detail if isinstance(detail, dict) else None
2526
-
2527
-
2528
- def _resolve_agent_id(client, *, agent_id=None, agent_name=None):
2529
- if agent_id is not None:
2530
- return int(agent_id), None
2531
- if not agent_name:
2532
- raise ValueError("provide --agent-id or --agent-name")
2533
-
2534
- agents = client.list_agents(category="text")
2535
- if not isinstance(agents, list):
2536
- raise ValueError("list_agents did not return a list")
2537
-
2538
- exact = [a for a in agents if a.get("name") == agent_name]
2539
- matches = exact or [a for a in agents if agent_name.lower() in str(a.get("name", "")).lower()]
2540
- if not matches:
2541
- raise ValueError(f"CHAT_FLOW agent not found by name: {agent_name}")
2542
- chatflow_matches = [a for a in matches if a.get("type") == "CHAT_FLOW"]
2543
- if len(chatflow_matches) != 1:
2544
- names = [f"{a.get('id')}:{a.get('name')}:{a.get('type')}" for a in chatflow_matches or matches]
2545
- raise ValueError(f"agent name is ambiguous or not CHAT_FLOW: {names}")
2546
- return int(chatflow_matches[0]["id"]), chatflow_matches[0]
2547
-
2548
-
2549
- def _summarize_graph(graph, *, agent_id=None, agent_name=None, out_file=None):
2550
- nodes = graph.get("nodes", [])
2551
- edges = graph.get("edges", [])
2552
- return {
2553
- "agent_id": agent_id,
2554
- "agent_name": agent_name,
2555
- "out_file": str(out_file) if out_file else None,
2556
- "node_count": len(nodes),
2557
- "edge_count": len(edges),
2558
- "nodes": [
2559
- {
2560
- "id": node.get("id"),
2561
- "category": node.get("data", {}).get("category"),
2562
- "title": node.get("data", {}).get("title", ""),
2563
- }
2564
- for node in nodes
2565
- ],
2566
- "edges": [
2567
- {
2568
- "source": edge.get("source"),
2569
- "sourceHandle": edge.get("sourceHandle"),
2570
- "target": edge.get("target"),
2571
- "targetHandle": edge.get("targetHandle"),
2572
- }
2573
- for edge in edges
2574
- ],
2575
- }
2576
-
2577
-
2578
- def _write_json(path, data):
2579
- path = Path(path)
2580
- path.parent.mkdir(parents=True, exist_ok=True)
2581
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
2582
- return path
2583
-
2584
-
2585
- def _load_graph_file(path):
2586
- return _normalize_chatflow_graph_response(json.loads(Path(path).read_text(encoding="utf-8")))
2587
-
2588
-
2589
- def _bootstrap_cli_runtime():
2590
- """Load runtime paths only for direct CLI execution, not library import."""
2591
- lib_root = os.environ.get("SOLVEA_LIB_DIR")
2592
- if lib_root:
2593
- skill_runtime = os.path.join(lib_root, "skill-runtime")
2594
- if skill_runtime not in sys.path:
2595
- sys.path.insert(0, skill_runtime)
2596
- import bootstrap # type: ignore
2597
- return bootstrap
2598
- return None
2599
-
2600
-
2601
- def _get_cli_client(env, project_id):
2602
- _bootstrap_cli_runtime()
2603
- from client import SolveaClient # type: ignore
2604
- return SolveaClient(env=env, project_id=project_id)
2605
-
2606
-
2607
- def _safe_list_resource(label, loader):
2608
- try:
2609
- return loader(), None
2610
- except Exception as exc:
2611
- return None, (
2612
- f"{label} unavailable; skipped related remote metadata validation. "
2613
- f"This is a remote resource lookup issue, not necessarily a graph schema error: {exc}"
2614
- )
2615
-
2616
-
2617
- def _append_resource_warnings(validation, warnings):
2618
- if warnings:
2619
- validation.setdefault("warnings", []).extend(warnings)
2620
- return validation
2621
-
2622
-
2623
- def _default_output_path(prefix, agent_id):
2624
- runtime = _bootstrap_cli_runtime()
2625
- if runtime is not None:
2626
- out_dir = runtime.STATE_DIR
2627
- else:
2628
- out_dir = Path("workspace") / "state" / "chatflow"
2629
- return out_dir / f"{prefix}_{agent_id}.json"
2630
-
2631
-
2632
- def _workspace_virtual_path(path):
2633
- runtime = _bootstrap_cli_runtime()
2634
- path = Path(path)
2635
- if runtime is None:
2636
- return path
2637
- try:
2638
- rel = path.resolve().relative_to(runtime.WORKSPACE.resolve())
2639
- except ValueError:
2640
- return path
2641
- return Path("/workspace") / rel
2642
-
2643
-
2644
- def _validate_graph_file(graph, ai_fields=None, plugins=None, agent_ai_fields=None, templates=None):
2645
- _bootstrap_cli_runtime()
2646
- from validate_graph import validate_graph # type: ignore
2647
- return validate_graph(
2648
- graph,
2649
- ai_fields=ai_fields,
2650
- plugins=plugins,
2651
- agent_ai_fields=agent_ai_fields,
2652
- templates=templates,
2653
- )
2654
-
2655
-
2656
- _AGENT_SYSTEM_PLUGIN_TYPES = {"TEMPLATE_REPLY", "TRANSFER_TO_HUMAN"}
2657
-
2658
-
2659
- def _normalize_chatflow_plugin_items(value):
2660
- """Flatten the grouped Chatflow plugin response used by the PE editor."""
2661
- if isinstance(value, list):
2662
- return [item for item in value if isinstance(item, dict)]
2663
- if not isinstance(value, dict):
2664
- return []
2665
-
2666
- if "SYSTEM" in value or "MANUAL" in value:
2667
- items = []
2668
- for group in ("SYSTEM", "MANUAL"):
2669
- items.extend(_normalize_chatflow_plugin_items(value.get(group)))
2670
- return items
2671
- for key in ("data", "list", "records", "items"):
2672
- if key in value:
2673
- return _normalize_chatflow_plugin_items(value.get(key))
2674
- return []
2675
-
2676
-
2677
- def _plugin_integer_id(plugin):
2678
- value = plugin.get("id") if isinstance(plugin, dict) else None
2679
- if isinstance(value, bool):
2680
- return None
2681
- if isinstance(value, int):
2682
- return value
2683
- if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()):
2684
- return int(value)
2685
- return None
2686
-
2687
-
2688
- def _agent_system_plugin_requirements(graph):
2689
- requirements = []
2690
- for node in graph.get("nodes", []):
2691
- data = node.get("data", {})
2692
- if data.get("category") != "AGENT":
2693
- continue
2694
- display_prompt = ((data.get("config") or {}).get("agentData") or {}).get("displayPrompt") or ""
2695
- try:
2696
- blocks = _parse_library_blocks(display_prompt)
2697
- except ValueError:
2698
- # validate_graph reports the concrete prompt error. Resource lookup
2699
- # should not replace it with a catalog failure first.
2700
- continue
2701
- required_types = set()
2702
- for block in blocks:
2703
- fields = block["fields"]
2704
- if block["type"] == "tool" and fields.get("tool") == "template_reply":
2705
- required_types.add("TEMPLATE_REPLY")
2706
- elif block["type"] == "transferHuman":
2707
- required_types.add("TRANSFER_TO_HUMAN")
2708
- if required_types:
2709
- requirements.append((node, required_types))
2710
- return requirements
2711
-
2712
-
2713
- def _get_chatflow_plugin_catalog(client):
2714
- response = client.list_chatflow_plugins(for_agent_node=True)
2715
- return _normalize_chatflow_plugin_items(response)
2716
-
2717
-
2718
- def _bind_agent_system_plugins(graph, plugin_catalog):
2719
- """Bind PE system actions to the project-specific Chatflow plugin IDs."""
2720
- requirements = _agent_system_plugin_requirements(graph)
2721
- if not requirements:
2722
- return graph
2723
-
2724
- plugins_by_type = {}
2725
- for plugin in _normalize_chatflow_plugin_items(plugin_catalog):
2726
- plugin_type = str(plugin.get("type") or "").upper()
2727
- plugin_id = _plugin_integer_id(plugin)
2728
- if plugin_type in _AGENT_SYSTEM_PLUGIN_TYPES and plugin_id is not None:
2729
- plugins_by_type.setdefault(plugin_type, plugin_id)
2730
-
2731
- for node, required_types in requirements:
2732
- missing = sorted(required_types - set(plugins_by_type))
2733
- if missing:
2734
- title = node.get("data", {}).get("title") or node.get("id")
2735
- raise ValueError(
2736
- f"AGENT '{title}' requires Chatflow system plugin type(s) {missing}, "
2737
- "but /api_v2/gpt/chatflow/queryPlugin did not return them for forAgentNode=true"
2738
- )
2739
- agent_data = node["data"]["config"]["agentData"]
2740
- used_plugins = agent_data.setdefault("usedPlugins", [])
2741
- for plugin_type in sorted(required_types):
2742
- plugin_id = plugins_by_type[plugin_type]
2743
- if plugin_id not in used_plugins:
2744
- used_plugins.append(plugin_id)
2745
- return graph
2746
-
2747
-
2748
- def _get_graph_plugin_details(client, graph, plugin_catalog=None):
2749
- # TOOL nodes serialize plugin ids as strings, while AGENT.usedPlugins uses
2750
- # integers. Normalize both before de-duplicating. System action ids come
2751
- # from the Chatflow catalog; the legacy AP endpoint cannot resolve them.
2752
- requirements = _agent_system_plugin_requirements(graph)
2753
- required_system_types = set().union(
2754
- *(required_types for _, required_types in requirements)
2755
- ) if requirements else set()
2756
-
2757
- tool_ids = set()
2758
- agent_plugin_ids = set()
2759
- for node in graph.get("nodes", []):
2760
- data = node.get("data", {})
2761
- config = data.get("config", {})
2762
- if data.get("category") == "TOOL":
2763
- tool_id = (config.get("tool") or {}).get("id")
2764
- if isinstance(tool_id, str) and tool_id.isdecimal():
2765
- tool_id = int(tool_id)
2766
- if isinstance(tool_id, int) and not isinstance(tool_id, bool) and tool_id > 0:
2767
- tool_ids.add(tool_id)
2768
-
2769
- if data.get("category") != "AGENT":
2770
- continue
2771
- agent_data = config.get("agentData") or {}
2772
- for plugin_id in agent_data.get("usedPlugins") or []:
2773
- if (
2774
- isinstance(plugin_id, int)
2775
- and not isinstance(plugin_id, bool)
2776
- and plugin_id != 0
2777
- ):
2778
- agent_plugin_ids.add(plugin_id)
2779
- if plugin_id > 0:
2780
- tool_ids.add(plugin_id)
2781
-
2782
- if (
2783
- required_system_types
2784
- or any(plugin_id < 0 for plugin_id in agent_plugin_ids)
2785
- ) and plugin_catalog is None:
2786
- plugin_catalog = _get_chatflow_plugin_catalog(client)
2787
- catalog_items = _normalize_chatflow_plugin_items(plugin_catalog)
2788
- catalog_by_id = {
2789
- str(plugin_id): plugin
2790
- for plugin in catalog_items
2791
- if (plugin_id := _plugin_integer_id(plugin)) is not None
2792
- }
2793
- details_by_id = {}
2794
- for plugin in catalog_items:
2795
- plugin_type = str(plugin.get("type") or "").upper()
2796
- plugin_id = _plugin_integer_id(plugin)
2797
- if plugin_id is not None and (
2798
- plugin_type in required_system_types
2799
- or (
2800
- plugin_id in agent_plugin_ids
2801
- and plugin_type in _AGENT_SYSTEM_PLUGIN_TYPES
2802
- )
2803
- ):
2804
- details_by_id[str(plugin_id)] = plugin
2805
- details = []
2806
- for tool_id in sorted(tool_ids):
2807
- catalog_plugin = catalog_by_id.get(str(tool_id))
2808
- if catalog_plugin and str(catalog_plugin.get("type") or "").upper() in _AGENT_SYSTEM_PLUGIN_TYPES:
2809
- details_by_id[str(tool_id)] = catalog_plugin
2810
- continue
2811
- response = client.get_plugin(tool_id)
2812
- if isinstance(response, list):
2813
- details.extend(item for item in response if isinstance(item, dict))
2814
- elif isinstance(response, dict):
2815
- details.append(response)
2816
- for item in details:
2817
- plugin_id = _plugin_integer_id(item)
2818
- if plugin_id is not None:
2819
- details_by_id[str(plugin_id)] = item
2820
- return list(details_by_id.values())
2821
-
2822
-
2823
- def _safe_get_graph_plugin_details(client, graph, plugin_catalog=None):
2824
- return _safe_list_resource(
2825
- "Plugin metadata",
2826
- lambda: _get_graph_plugin_details(
2827
- client,
2828
- graph,
2829
- plugin_catalog=plugin_catalog,
2830
- ),
2831
- )
2832
-
2833
-
2834
- def _graph_template_ids(graph):
2835
- template_ids = set()
2836
-
2837
- def add_template_id(value):
2838
- try:
2839
- template_id = int(value)
2840
- except (TypeError, ValueError):
2841
- return
2842
- if template_id > 0:
2843
- template_ids.add(template_id)
2844
-
2845
- for node in graph.get("nodes", []):
2846
- data = node.get("data", {})
2847
- config = data.get("config", {})
2848
- category = data.get("category")
2849
- if category == "REPLY":
2850
- reply = config.get("replyContent") or {}
2851
- if reply.get("type") == "TEMPLATE" and reply.get("templateId"):
2852
- add_template_id(reply["templateId"])
2853
- elif category == "TRANSFER_TO_HUMAN":
2854
- transfer = config.get("transferToHuman") or {}
2855
- if transfer.get("type") == "TEMPLATE" and transfer.get("templateId"):
2856
- add_template_id(transfer["templateId"])
2857
- elif category == "QUESTION":
2858
- collect = config.get("collectInfo") or {}
2859
- if collect.get("questionType") == "TEMPLATE" and collect.get("questionTemplateId"):
2860
- add_template_id(collect["questionTemplateId"])
2861
- elif category == "COLLECT_ORDER":
2862
- for item in config.get("collectOrderException") or []:
2863
- if item.get("type") == "TEMPLATE" and item.get("templateId"):
2864
- add_template_id(item["templateId"])
2865
- elif category == "AGENT":
2866
- display_prompt = (config.get("agentData") or {}).get("displayPrompt") or ""
2867
- try:
2868
- blocks = _parse_library_blocks(display_prompt)
2869
- except ValueError:
2870
- continue
2871
- for block in blocks:
2872
- fields = block["fields"]
2873
- if block["type"] == "tool" and fields.get("tool") == "template_reply":
2874
- add_template_id(fields["id"])
2875
- return sorted(template_ids)
2876
-
2877
-
2878
- def _normalize_template_items(value):
2879
- if isinstance(value, list):
2880
- return [item for item in value if isinstance(item, dict)]
2881
- if isinstance(value, dict):
2882
- for key in ("list", "records", "items", "data"):
2883
- if key in value:
2884
- return _normalize_template_items(value.get(key))
2885
- return []
2886
-
2887
-
2888
- def _list_graph_templates(client, template_ids, page_size=999):
2889
- wanted_ids = {str(template_id) for template_id in template_ids}
2890
- templates_by_key = {}
2891
- seen_pages = set()
2892
- page_index = 1
2893
- while True:
2894
- response = client.list_templates(
2895
- page_index=page_index,
2896
- page_size=page_size,
2897
- )
2898
- items = _normalize_template_items(response)
2899
- if not items:
2900
- break
2901
- page_signature = tuple(
2902
- json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
2903
- for item in items
2904
- )
2905
- if page_signature in seen_pages:
2906
- raise RuntimeError(
2907
- f"template pagination repeated page {page_index}; "
2908
- "cannot determine whether every referenced template was checked"
2909
- )
2910
- seen_pages.add(page_signature)
2911
- for item in items:
2912
- template_id = item.get("id") or item.get("templateId")
2913
- item_key = (
2914
- f"id:{template_id}"
2915
- if template_id is not None
2916
- else "value:" + json.dumps(
2917
- item,
2918
- ensure_ascii=False,
2919
- sort_keys=True,
2920
- default=str,
2921
- )
2922
- )
2923
- templates_by_key.setdefault(item_key, item)
2924
- found_ids = {
2925
- str(item.get("id") or item.get("templateId"))
2926
- for item in templates_by_key.values()
2927
- if item.get("id") is not None or item.get("templateId") is not None
2928
- }
2929
- if wanted_ids.issubset(found_ids) or len(items) < page_size:
2930
- break
2931
- page_index += 1
2932
- if page_index > 10_000:
2933
- raise RuntimeError("template pagination exceeded 10000 pages")
2934
- return list(templates_by_key.values())
2935
-
2936
-
2937
- def _get_graph_templates(client, graph):
2938
- template_ids = _graph_template_ids(graph)
2939
- if not template_ids:
2940
- return None, None
2941
- templates, warning = _safe_list_resource(
2942
- "Templates",
2943
- lambda: _list_graph_templates(client, template_ids),
2944
- )
2945
- return templates, warning
2946
-
2947
-
2948
- _SOURCE_HELP = {
2949
- "usage": [
2950
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py inspect --agent-id 123 --out /workspace/state/agent-builder/chatflow/patch_123_graph.json --pretty",
2951
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py validate --graph-file /workspace/state/agent-builder/chatflow/patch_123_graph.json --pretty",
2952
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py update --agent-id 123 --graph-file /workspace/state/agent-builder/chatflow/patch_123_graph.json --pretty",
2953
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py compile --source-file /workspace/state/agent-builder/chatflow/chatflow_source.json --graph-file /workspace/state/agent-builder/chatflow/chatflow_graph.json --pretty",
2954
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py merge-source --fragment-file /workspace/state/agent-builder/chatflow/blocks/block_001.json --integration-file /workspace/state/agent-builder/chatflow/blocks/integration.json --out-file /workspace/state/agent-builder/chatflow/chatflow_source.json --pretty",
2955
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py validate --graph-file /workspace/state/agent-builder/chatflow/chatflow_graph.json --pretty",
2956
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py deploy --agent-name Example_Chatflow --graph-file /workspace/state/agent-builder/chatflow/chatflow_graph.json --pretty",
2957
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py update --agent-id 123 --graph-file /workspace/state/agent-builder/chatflow/chatflow_graph.json --pretty",
2958
- "python3 /skills/agent-builder/scripts/chatflow/chatflow_builder.py publish --agent-id 123 --graph-file /workspace/state/agent-builder/chatflow/chatflow_graph.json --pretty",
2959
- ],
2960
- "source_shape": {
2961
- "entry": "optional node key; compile auto-creates START and connects it to entry",
2962
- "nodes": "list of {key, type, ...business fields}",
2963
- "edges": "list of {from, to, on?}; use $finish and $transfer for shared terminal nodes",
2964
- "source_fragments": "keyed {nodes, edges} files; integration owns entry and cross-block edges, then merge-source writes the final source",
2965
- },
2966
- "node_types": [
2967
- "start", "finish", "transfer", "reply", "question", "agent", "intent",
2968
- "llm", "if_else", "tool", "rag_reply", "knowledge", "knowledge_reply", "collect_order",
2969
- "human_review", "variable_process", "code", "sub_graph",
2970
- "variable_extract", "variable_assignment", "business_track",
2971
- ],
2972
- "node_fields": {
2973
- "reply": "reply_type=PROMPT with prompt and optional ai_field_ids, TEMPLATE with template_id, or DYNAMIC_TEMPLATE with template_content containing [[var:<selector>]]; optional use_long_memory and disable_prev_node_results",
2974
- "llm": "prompt + optional history_round_limit/use_long_memory, vision{enabled,detail,media_selectors}, and structured_output{enabled,schema}; [[var:<selector>]] templates are rendered before the model call; pure model generation without plugins or next_steps",
2975
- "transfer": "transfer_type=NO_REPLY/PROMPT/TEMPLATE; prompt or template_id only when needed; PROMPT also supports ai_field_ids, disable_prev_node_results, and explain",
2976
- "agent": "prompt may use LibraryBlock chips, plugins/usedPlugins as tool id array, ai_field_ids, optional enable_knowledge_base/knowledge_directory_ids, knowledge_keyword_ids (fixed isolation keyword ids), knowledge_keyword_selector (variable selector yielding keyword name(s)), max_rounds_exceeded_action, use_long_memory, disable_prev_node_results, and next_steps[{id,title,prompt,params?}] for flow-internal branches; online compile binds TEMPLATE_REPLY and TRANSFER_TO_HUMAN system plugins from the Chatflow catalog by type; deterministic handoff uses a separate transfer node or $transfer",
2977
- "intent": "optional prompt for global intent classification guidance (default null/None), history_round_limit (default null/None; -1=all history, positive=recent N rounds); intents[{id,name,desc,intentId?,examples?}]",
2978
- "question": "mode=VARIABLE/USER_REPLY, question_type=PROMPT/TEMPLATE, question or question_template_id, variables or replies, condition=AND/OR, and use_chat_history",
2979
- "knowledge": "alias for RAG_REPLY; supports kb_dirs/knowledgeDirectoryIds, knowledge_keyword_ids/knowledgeKeywordIds, knowledge_keyword_selector/knowledgeKeywordSelector, and optional customize_query/query_selector",
2980
- "rag_reply": "prompt + knowledgeDirectoryIds, optional knowledgeKeywordIds (isolation keyword ids), knowledgeKeywordSelector (variable selector yielding keyword name(s)), and customizeQuery/querySelector",
2981
- "collect_order": "soon-offline compatibility node for existing order-number collection flows; supports max_rounds and collect_order_exception; new graphs should use QUESTION(VARIABLE) plus TOOL/IF_ELSE/AGENT as needed",
2982
- "human_review": "method=approval/fill, description, fill fields, and midway_message_strategy=ignore/handle",
2983
- "business_track": "trace_events non-empty business event-code array; emits observability trace data and then follows SOURCE",
2984
- "variable_process": "extracts + assignments in one node; source extracts use alias/name/describe/type; preferred replacement for variable_extract/variable_assignment",
2985
- "code": "source_code, inputs[{field_name, variable_selector}], outputs[{field_name, field_type}]",
2986
- "sub_graph": "target_agent_id and optional context_turn_limit; SUB_GRAPH must not have outgoing edges",
2987
- "variable_extract": "soon-offline compatibility node; source extracts use alias/name/describe/type; prefer QUESTION(VARIABLE) for collection or AGENT/TOOL outputs for derived values",
2988
- "variable_assignment": "soon-offline compatibility node; prefer passing upstream outputs/variable-system fields directly instead of writing assignment nodes",
2989
- },
2990
- "soon_offline_node_types": [
2991
- "COLLECT_ORDER",
2992
- "KNOWLEDGE_REPLY",
2993
- "VARIABLE_ASSIGNMENT",
2994
- "VARIABLE_EXTRACT",
2995
- "AGENT version 1",
2996
- ],
2997
- "edge_on_values": [
2998
- "QUESTION reply name", "INTENT name or OTHER", "IF_ELSE case name or FALSE",
2999
- "AGENT next_steps title", "RESOLVED", "UNRESOLVED", "TRUE",
3000
- "LACK_KNOWLEDGE", "AGREE", "REJECT",
3001
- ],
3002
- "variable_selector_shortcuts": [
3003
- {"variableSelector": {"tool": "lookup_order", "field": "status"}},
3004
- {"variableSelector": {"tool_status": "lookup_order"}},
3005
- {"variableSelector": {"code": "normalize_items", "field": "has_items"}},
3006
- {"variableSelector": {"llm": "summarize_case", "field": "summary"}},
3007
- {"variableSelector": {"node": "ask_order"}},
3008
- {"variableSelector": {"extract": "extract_info", "alias": "order_no_value"}},
3009
- {"variableSelector": {"system": {"category": "Order", "path": [3248, 3249]}}},
3010
- ],
3011
- "rules": [
3012
- "For an existing CHAT_FLOW, inspect its complete graph, edit that graph, validate it, then update the same graph file.",
3013
- "Use source JSON for a new graph or an explicitly requested full rebuild; use an inspected graph for an existing graph patch.",
3014
- "Write source JSON, not Python scripts.",
3015
- "For a large flow, keep each flow block as a keyed source fragment; merge source fragments before one final compile, never merge compiled graph JSON.",
3016
- "Do not import chatflow_builder.py from a generated script.",
3017
- "Do not read /skill-lib or explore bootstrap.",
3018
- "Compile source JSON to graph JSON, then validate the graph JSON.",
3019
- "After user confirmation, reuse the same graph file for deploy/update/publish.",
3020
- ],
3021
- }
3022
-
3023
-
3024
- def _cmd_help(args):
3025
- topic = getattr(args, "topic", "source")
3026
- if topic in ("source", "compile", "schema"):
3027
- return _SOURCE_HELP
3028
- if topic == "commands":
3029
- return {"commands": _SOURCE_HELP["usage"]}
3030
- return {"topic": topic, "message": "Unknown help topic. Use source, compile, schema, or commands."}
3031
-
3032
-
3033
- def _cmd_inspect(args):
3034
- client = _get_cli_client(args.env, args.project_id)
3035
- agent_id, listed_agent = _resolve_agent_id(client, agent_id=args.agent_id, agent_name=args.agent_name)
3036
- detail_agent = _agent_from_detail(client.get_agent(agent_id))
3037
- agent_name = (
3038
- args.agent_name
3039
- or (detail_agent or {}).get("name")
3040
- or (listed_agent or {}).get("name")
3041
- )
3042
- graph = _normalize_chatflow_graph_response(client.get_chatflow_graph(agent_id))
3043
- out_file = Path(args.out) if args.out else _default_output_path("current_graph", agent_id)
3044
- _write_json(out_file, graph)
3045
- return _summarize_graph(
3046
- graph,
3047
- agent_id=agent_id,
3048
- agent_name=agent_name,
3049
- out_file=_workspace_virtual_path(out_file),
3050
- )
3051
-
3052
-
3053
- def _cmd_validate(args):
3054
- graph = _load_graph_file(args.graph_file)
3055
- offline = getattr(args, "offline", False)
3056
- client = None if offline else _get_cli_client(args.env, args.project_id)
3057
- if offline:
3058
- ai_fields, agent_ai_fields, plugins, templates, warnings = None, None, None, None, [
3059
- "offline validation: remote AI fields, plugin metadata, and template status were not checked"
3060
- ]
3061
- else:
3062
- ai_fields, ai_fields_warning = _safe_list_resource("queryAiFields", client.list_ai_fields)
3063
- agent_ai_fields, agent_ai_fields_warning = _safe_list_resource("Agent field range", client.list_agent_ai_fields)
3064
- plugins, plugins_warning = _safe_get_graph_plugin_details(client, graph)
3065
- templates, templates_warning = _get_graph_templates(client, graph)
3066
- warnings = [
3067
- item for item in (
3068
- ai_fields_warning,
3069
- agent_ai_fields_warning,
3070
- plugins_warning,
3071
- templates_warning,
3072
- ) if item
3073
- ]
3074
- result = _validate_graph_file(
3075
- graph,
3076
- ai_fields=ai_fields,
3077
- agent_ai_fields=agent_ai_fields,
3078
- plugins=plugins,
3079
- templates=templates,
3080
- )
3081
- _append_resource_warnings(result, warnings)
3082
- if not result.get("valid"):
3083
- raise SystemExit(json.dumps(result, ensure_ascii=False, indent=2))
3084
- return result
3085
-
3086
-
3087
- def _cmd_merge_source(args):
3088
- integration = json.loads(Path(args.integration_file).read_text(encoding="utf-8"))
3089
- source = _merge_source_fragments(args.fragment_file or [], integration)
3090
- out_file = _write_json(args.out_file, source)
3091
- return {
3092
- "source_file": str(out_file),
3093
- "fragment_files": list(args.fragment_file or []),
3094
- "integration_file": args.integration_file,
3095
- "node_count": len(source["nodes"]),
3096
- "edge_count": len(source["edges"]),
3097
- }
3098
-
3099
-
3100
- def _cmd_compile(args):
3101
- source = json.loads(Path(args.source_file).read_text(encoding="utf-8"))
3102
- offline = getattr(args, "offline", False)
3103
- resource_client = None if offline else _get_cli_client(args.env, args.project_id)
3104
- if offline:
3105
- ai_fields, agent_ai_fields, warnings = None, None, [
3106
- "offline compilation: remote AI fields, plugin metadata, and template status were not checked"
3107
- ]
3108
- else:
3109
- ai_fields, ai_fields_warning = _safe_list_resource("queryAiFields", resource_client.list_ai_fields)
3110
- agent_ai_fields, agent_ai_fields_warning = _safe_list_resource("Agent field range", resource_client.list_agent_ai_fields)
3111
- warnings = [item for item in (ai_fields_warning, agent_ai_fields_warning) if item]
3112
- if args.bind_tool_params and ai_fields is None:
3113
- warnings.append("--bind-tool-params skipped because queryAiFields is unavailable")
3114
- client = resource_client if args.bind_tool_params and ai_fields is not None else None
3115
- graph = _compile_source_to_graph(
3116
- source,
3117
- client=client,
3118
- ai_fields=ai_fields,
3119
- agent_ai_fields=agent_ai_fields,
3120
- )
3121
- plugin_catalog = None
3122
- system_plugin_requirements = _agent_system_plugin_requirements(graph)
3123
- if offline:
3124
- plugins = None
3125
- templates = None
3126
- if system_plugin_requirements:
3127
- warnings.append(
3128
- "offline compilation: TEMPLATE_REPLY / TRANSFER_TO_HUMAN system plugin IDs "
3129
- "were not bound; run an online compile before deploy"
3130
- )
3131
- else:
3132
- if system_plugin_requirements:
3133
- plugin_catalog = _get_chatflow_plugin_catalog(resource_client)
3134
- _bind_agent_system_plugins(graph, plugin_catalog)
3135
- templates, templates_warning = _get_graph_templates(resource_client, graph)
3136
- if templates_warning:
3137
- warnings.append(templates_warning)
3138
- plugins, plugins_warning = _safe_get_graph_plugin_details(
3139
- resource_client,
3140
- graph,
3141
- plugin_catalog=plugin_catalog,
3142
- )
3143
- if plugins_warning:
3144
- warnings.append(plugins_warning)
3145
- validation = _validate_graph_file(
3146
- graph,
3147
- ai_fields=ai_fields,
3148
- agent_ai_fields=agent_ai_fields,
3149
- plugins=plugins,
3150
- templates=templates,
3151
- )
3152
- _append_resource_warnings(validation, warnings)
3153
- if not validation.get("valid"):
3154
- raise SystemExit(json.dumps(validation, ensure_ascii=False, indent=2))
3155
- out_file = _write_json(args.graph_file, graph)
3156
- return {
3157
- "source_file": args.source_file,
3158
- "graph_file": str(out_file),
3159
- "node_count": len(graph.get("nodes", [])),
3160
- "edge_count": len(graph.get("edges", [])),
3161
- "validation_warnings": validation.get("warnings", []),
3162
- }
3163
-
3164
-
3165
- def _cmd_deploy(args):
3166
- graph = _load_graph_file(args.graph_file)
3167
- client = _get_cli_client(args.env, args.project_id)
3168
- ai_fields, ai_fields_warning = _safe_list_resource("queryAiFields", client.list_ai_fields)
3169
- agent_ai_fields, agent_ai_fields_warning = _safe_list_resource("Agent field range", client.list_agent_ai_fields)
3170
- plugins, plugins_warning = _safe_get_graph_plugin_details(client, graph)
3171
- templates, templates_warning = _get_graph_templates(client, graph)
3172
- warnings = [
3173
- item for item in (
3174
- ai_fields_warning,
3175
- agent_ai_fields_warning,
3176
- plugins_warning,
3177
- templates_warning,
3178
- ) if item
3179
- ]
3180
- validation = _validate_graph_file(
3181
- graph,
3182
- ai_fields=ai_fields,
3183
- agent_ai_fields=agent_ai_fields,
3184
- plugins=plugins,
3185
- templates=templates,
3186
- )
3187
- _append_resource_warnings(validation, warnings)
3188
- if not validation.get("valid"):
3189
- raise SystemExit(json.dumps(validation, ensure_ascii=False, indent=2))
3190
- result = deploy_chatflow(client, graph, agent_name=args.agent_name)
3191
- return {
3192
- "agent_name": args.agent_name,
3193
- "graph_file": args.graph_file,
3194
- "validation": validation,
3195
- "result": result,
3196
- }
3197
-
3198
-
3199
- def _cmd_update_or_publish(args, *, publish):
3200
- graph = _load_graph_file(args.graph_file)
3201
- client = _get_cli_client(args.env, args.project_id)
3202
- ai_fields, ai_fields_warning = _safe_list_resource("queryAiFields", client.list_ai_fields)
3203
- agent_ai_fields, agent_ai_fields_warning = _safe_list_resource("Agent field range", client.list_agent_ai_fields)
3204
- plugins, plugins_warning = _safe_get_graph_plugin_details(client, graph)
3205
- templates, templates_warning = _get_graph_templates(client, graph)
3206
- warnings = [
3207
- item for item in (
3208
- ai_fields_warning,
3209
- agent_ai_fields_warning,
3210
- plugins_warning,
3211
- templates_warning,
3212
- ) if item
3213
- ]
3214
- validation = _validate_graph_file(
3215
- graph,
3216
- ai_fields=ai_fields,
3217
- agent_ai_fields=agent_ai_fields,
3218
- plugins=plugins,
3219
- templates=templates,
3220
- )
3221
- _append_resource_warnings(validation, warnings)
3222
- if not validation.get("valid"):
3223
- raise SystemExit(json.dumps(validation, ensure_ascii=False, indent=2))
3224
- result = client.update_chatflow(args.agent_id, graph, publish=publish)
3225
- return {
3226
- "agent_id": int(args.agent_id),
3227
- "graph_file": args.graph_file,
3228
- "published": publish,
3229
- "validation": validation,
3230
- "result": result,
3231
- }
3232
-
3233
-
3234
- def _build_arg_parser():
3235
- parser = argparse.ArgumentParser(description="CHAT_FLOW builder utility CLI", add_help=False)
3236
- parser.add_argument("--help", action="store_true", dest="show_help", help="Show source JSON schema and command examples")
3237
- parser.add_argument("--env", default=os.environ.get("SOLVEA_ENV", "prod"), help="Solvea environment, defaults to SOLVEA_ENV")
3238
- parser.add_argument("--project-id", default=os.environ.get("SOLVEA_PROJECT_ID"), help="Solvea project id")
3239
- parser.add_argument("--pretty", action="store_true", help="Pretty print JSON output")
3240
- sub = parser.add_subparsers(dest="command")
3241
-
3242
- inspect_parser = sub.add_parser("inspect", help="Fetch current CHAT_FLOW graph by agent id or name")
3243
- inspect_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3244
- inspect_group = inspect_parser.add_mutually_exclusive_group(required=True)
3245
- inspect_group.add_argument("--agent-id", type=int)
3246
- inspect_group.add_argument("--agent-name")
3247
- inspect_parser.add_argument("--out", help="Output graph JSON path")
3248
-
3249
- validate_parser = sub.add_parser("validate", help="Validate a chatFlowGraph JSON file")
3250
- validate_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3251
- validate_parser.add_argument("--graph-file", required=True)
3252
- validate_parser.add_argument("--offline", action="store_true", help="Skip remote AI-field and plugin metadata checks")
3253
-
3254
- compile_parser = sub.add_parser("compile", help="Compile source JSON into a full chatFlowGraph JSON file")
3255
- compile_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3256
- compile_parser.add_argument("--source-file", required=True)
3257
- compile_parser.add_argument("--graph-file", required=True)
3258
- compile_parser.add_argument("--bind-tool-params", action="store_true", help="Use Solvea API to auto-bind TOOL LLM params to variable-system fields")
3259
- compile_parser.add_argument("--offline", action="store_true", help="Skip remote AI-field and plugin metadata checks")
3260
-
3261
- merge_source_parser = sub.add_parser(
3262
- "merge-source",
3263
- help="Merge keyed flow-block source fragments before one final compilation",
3264
- )
3265
- merge_source_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3266
- merge_source_parser.add_argument("--fragment-file", action="append", required=True)
3267
- merge_source_parser.add_argument("--integration-file", required=True)
3268
- merge_source_parser.add_argument("--out-file", required=True)
3269
-
3270
- deploy_parser = sub.add_parser("deploy", help="Create a new CHAT_FLOW agent from a graph JSON file")
3271
- deploy_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3272
- deploy_parser.add_argument("--agent-name", required=True)
3273
- deploy_parser.add_argument("--graph-file", required=True)
3274
-
3275
- update_parser = sub.add_parser("update", help="Save an existing CHAT_FLOW graph as draft")
3276
- update_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3277
- update_parser.add_argument("--agent-id", type=int, required=True)
3278
- update_parser.add_argument("--graph-file", required=True)
3279
-
3280
- publish_parser = sub.add_parser("publish", help="Publish an existing CHAT_FLOW graph")
3281
- publish_parser.add_argument("--pretty", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
3282
- publish_parser.add_argument("--agent-id", type=int, required=True)
3283
- publish_parser.add_argument("--graph-file", required=True)
3284
-
3285
- return parser
3286
-
3287
-
3288
- def main(argv=None):
3289
- parser = _build_arg_parser()
3290
- args = parser.parse_args(argv)
3291
- if args.show_help:
3292
- result = _cmd_help(args)
3293
- elif args.command == "inspect":
3294
- result = _cmd_inspect(args)
3295
- elif args.command == "validate":
3296
- result = _cmd_validate(args)
3297
- elif args.command == "compile":
3298
- result = _cmd_compile(args)
3299
- elif args.command == "merge-source":
3300
- result = _cmd_merge_source(args)
3301
- elif args.command == "deploy":
3302
- result = _cmd_deploy(args)
3303
- elif args.command == "update":
3304
- result = _cmd_update_or_publish(args, publish=False)
3305
- elif args.command == "publish":
3306
- result = _cmd_update_or_publish(args, publish=True)
3307
- else:
3308
- parser.error("command is required unless --help is provided")
3309
-
3310
- print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
3311
-
3312
-
3313
- # ---------------------------------------------------------------------------
3314
- # Resource discovery helpers
3315
- # ---------------------------------------------------------------------------
3316
-
3317
- def auto_query_plugins(client):
3318
- """
3319
- Query all plugins from the project, return a lookup dict.
3320
-
3321
- Returns::
3322
-
3323
- {
3324
- "by_id": {12345: {"id": 12345, "name": "OrderStandard", ...}},
3325
- "by_name": {"orderstandard": {"id": 12345, ...}},
3326
- }
3327
- """
3328
- plugins = client.list_plugins()
3329
- by_id = {}
3330
- by_name = {}
3331
- for p in plugins:
3332
- pid = p.get("id")
3333
- name = p.get("name", "")
3334
- if pid:
3335
- by_id[pid] = p
3336
- by_name[name.lower()] = p
3337
- print(f" [plugins] Loaded {len(by_id)} plugins")
3338
- return {"by_id": by_id, "by_name": by_name}
3339
-
3340
-
3341
- def auto_query_ai_fields(client):
3342
- """
3343
- Query all variable-system fields from the project, return a lookup dict.
3344
-
3345
- Returns::
3346
-
3347
- {
3348
- "by_id": {21545: {"id": 21545, "name": "OrderNumber", ...}},
3349
- "by_name": {"ordernumber": 21545, "customeremail": 21531, ...},
3350
- }
3351
- """
3352
- fields = client.list_ai_fields()
3353
- by_id = {}
3354
- by_name = {}
3355
- if isinstance(fields, list):
3356
- for f in fields:
3357
- if isinstance(f, dict):
3358
- fid = f.get("id")
3359
- fname = f.get("name", "")
3360
- by_id[fid] = f
3361
- by_name[fname.lower()] = fid
3362
- for c in f.get("children", []):
3363
- cid = c.get("id")
3364
- cname = c.get("name", "")
3365
- by_id[cid] = c
3366
- by_name[cname.lower()] = cid
3367
- print(f" [ai_fields] Loaded {len(by_name)} fields")
3368
- return {"by_id": by_id, "by_name": by_name}
3369
-
3370
-
3371
- def resolve_plugin(hint, plugin_map):
3372
- """
3373
- Resolve a plugin by name hint (case-insensitive, partial match).
3374
-
3375
- Args:
3376
- hint: plugin name or keyword (e.g. "OrderStandard", "shipment")
3377
- plugin_map: result from auto_query_plugins()
3378
-
3379
- Returns:
3380
- plugin dict or None
3381
- """
3382
- by_name = plugin_map["by_name"]
3383
- key = hint.lower()
3384
- if key in by_name:
3385
- return by_name[key]
3386
- # Partial match
3387
- candidates = [(k, v) for k, v in by_name.items() if key in k or k in key]
3388
- if len(candidates) == 1:
3389
- return candidates[0][1]
3390
- if len(candidates) > 1:
3391
- names = [c[0] for c in candidates]
3392
- raise ValueError(
3393
- f"Multiple plugins match '{hint}': {names}. "
3394
- f"Use the exact plugin name to disambiguate."
3395
- )
3396
- print(f" ⚠️ No plugin matches '{hint}'")
3397
- return None
3398
-
3399
-
3400
- def resolve_ai_field(name, field_map):
3401
- """
3402
- Resolve a variable-system field ID by name (case-insensitive).
3403
-
3404
- Args:
3405
- name: field name (e.g. "OrderNumber", "Attachments")
3406
- field_map: result from auto_query_ai_fields()
3407
-
3408
- Returns:
3409
- field ID (int) or None
3410
- """
3411
- by_name = field_map["by_name"]
3412
- key = name.lower()
3413
- if key in by_name:
3414
- return by_name[key]
3415
- candidates = [(k, v) for k, v in by_name.items() if key in k]
3416
- if candidates:
3417
- return candidates[0][1]
3418
- print(f" ⚠️ No variable-system field matches '{name}'")
3419
- return None
3420
-
3421
-
3422
- def build_and_deploy(client, nodes, edges, agent_name):
3423
- """
3424
- Legacy one-step helper: auto-bind params -> validate -> deploy.
3425
-
3426
- Kept for programmatic callers. Agent-facing workflows should use the CLI
3427
- compile/validate/deploy commands with source JSON and graph JSON files.
3428
-
3429
- Args:
3430
- client: SolveaClient instance
3431
- nodes: list of node dicts from builder functions
3432
- edges: list of edge dicts
3433
- agent_name: name for the new CHAT_FLOW agent
3434
-
3435
- Returns:
3436
- dict with agent_id, node_count, edge_count
3437
- """
3438
- print(f"\n{'='*60}")
3439
- print(f" Building & deploying: {agent_name}")
3440
- print(f"{'='*60}")
3441
-
3442
- print("\n[Step 1/2] Building graph...")
3443
- graph = build_graph(nodes, edges, client=client)
3444
-
3445
- print("\n[Step 2/2] Deploying...")
3446
- result = deploy_chatflow(client, graph, agent_name)
3447
- result["node_count"] = len(nodes)
3448
- result["edge_count"] = len(edges)
3449
-
3450
- print(f"\n{'='*60}")
3451
- print(f" Done! Agent ID: {result['agent_id']}")
3452
- print(f" {len(nodes)} nodes, {len(edges)} edges")
3453
- print(f"{'='*60}")
3454
- return result
3455
-
3456
-
3457
- if __name__ == "__main__":
3458
- main()