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