@ema.co/mcp-toolkit 1.4.3 → 1.5.0
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.
Potentially problematic release.
This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.
- package/dist/mcp/handlers-consolidated.js +647 -85
- package/dist/mcp/tools-consolidated.js +73 -42
- package/dist/sdk/index.js +4 -0
- package/dist/sdk/knowledge.js +934 -0
- package/dist/sdk/workflow-execution-analyzer.js +412 -0
- package/dist/sdk/workflow-fixer.js +272 -0
- package/dist/sdk/workflow-transformer.js +600 -0
- package/docs/llm-native-workflow-design.md +252 -0
- package/package.json +1 -1
package/dist/sdk/knowledge.js
CHANGED
|
@@ -1171,6 +1171,348 @@ export const GUIDANCE_TOPICS = {
|
|
|
1171
1171
|
"conversation_summarizer may still be needed for downstream agent format requirements",
|
|
1172
1172
|
],
|
|
1173
1173
|
},
|
|
1174
|
+
"workflow-structure": {
|
|
1175
|
+
title: "Workflow Structure Best Practices",
|
|
1176
|
+
content: "Recommended pattern: Conversation Summarizer → Entity Extractor → JSON Mapper → Downstream Consumers. Extract once, pass outputs onward. This ensures client context is available before knowledge base searches.",
|
|
1177
|
+
examples: [
|
|
1178
|
+
"✅ Good: trigger → summarizer → extractor → json_mapper → [search, routing, responses]",
|
|
1179
|
+
"✅ Good: Single extraction node feeds JSON mapper, which distributes to multiple consumers",
|
|
1180
|
+
"❌ Bad: Multiple extraction nodes extracting same data from same input",
|
|
1181
|
+
"❌ Bad: KB search before extraction (can't use client context)",
|
|
1182
|
+
],
|
|
1183
|
+
criticalRules: [
|
|
1184
|
+
"Extract entities ONCE, then pass structured data via JSON mapper",
|
|
1185
|
+
"Place extraction BEFORE knowledge base searches that need client context",
|
|
1186
|
+
"JSON mapper output (output_json) should feed downstream nodes that need structured context",
|
|
1187
|
+
"Avoid duplicate extraction nodes - use single extractor → JSON mapper pattern",
|
|
1188
|
+
"Early KB search only sees summarized conversation, not extracted client details",
|
|
1189
|
+
],
|
|
1190
|
+
},
|
|
1191
|
+
"search-node-timing": {
|
|
1192
|
+
title: "Knowledge Base Search Node Timing and Usage",
|
|
1193
|
+
content: "Search nodes execute based on their input connections. Early search (after summarizer) only sees conversation summary. Late search (after JSON mapper) can use client context. Use multiple search nodes if you need both generic and context-aware retrieval.",
|
|
1194
|
+
examples: [
|
|
1195
|
+
"Early search: trigger → summarizer → knowledge_search_1 (sees only summary, no client context)",
|
|
1196
|
+
"Late search: trigger → summarizer → extractor → json_mapper → knowledge_search_2 (can use client_id, client_name from output_json)",
|
|
1197
|
+
"Template search: json_mapper → fixed_response (query builder) → template_search (uses client context in query)",
|
|
1198
|
+
"Dual search pattern: Early generic search + late context-aware search for comprehensive results",
|
|
1199
|
+
],
|
|
1200
|
+
criticalRules: [
|
|
1201
|
+
"Search nodes only see data from their input connections - check what flows into query input",
|
|
1202
|
+
"Early search (before extraction) = generic retrieval, may miss client-specific docs",
|
|
1203
|
+
"Late search (after JSON mapper) = can incorporate client context into query or filters",
|
|
1204
|
+
"Template search should run AFTER JSON mapper to use client context",
|
|
1205
|
+
"If search needs client details, it MUST run after entity extraction + JSON mapping",
|
|
1206
|
+
"Multiple search nodes are valid if they serve different purposes (generic vs context-aware)",
|
|
1207
|
+
],
|
|
1208
|
+
},
|
|
1209
|
+
"node-execution-conditions": {
|
|
1210
|
+
title: "When Nodes Execute (Execution Conditions)",
|
|
1211
|
+
content: "Nodes execute based on: 1) Input connections (data must flow in), 2) runIf conditions (must evaluate to true), 3) trigger_when conditions (for HITL/conditional nodes). Nodes without valid input paths or with false runIf conditions will NOT execute.",
|
|
1212
|
+
examples: [
|
|
1213
|
+
"Orphan node: No input connection from trigger → node never executes",
|
|
1214
|
+
"runIf false: Node has input but runIf condition evaluates false → node skipped",
|
|
1215
|
+
"Missing category edge: Categorizer output not connected → downstream nodes never receive category",
|
|
1216
|
+
"Dead end: Node executes but output not connected → data lost, no response to user",
|
|
1217
|
+
],
|
|
1218
|
+
criticalRules: [
|
|
1219
|
+
"Every node MUST have a path from trigger (or be reachable via runIf conditions)",
|
|
1220
|
+
"runIf conditions use: lhs (actionOutput reference), operator (1=equals), rhs (enumValue or value)",
|
|
1221
|
+
"If runIf evaluates false, node is skipped (doesn't execute)",
|
|
1222
|
+
"Nodes without input connections are 'orphans' and never execute",
|
|
1223
|
+
"Categorizer nodes need downstream nodes with runIf conditions for each category",
|
|
1224
|
+
"Check execution flow: trigger → [conditional paths] → response → WORKFLOW_OUTPUT",
|
|
1225
|
+
"Use workflow(mode='analyze', include=['execution_flow']) to detect execution issues",
|
|
1226
|
+
],
|
|
1227
|
+
},
|
|
1228
|
+
"array-preservation": {
|
|
1229
|
+
title: "Array Preservation in Workflows",
|
|
1230
|
+
content: "Arrays from entity extraction may be flattened or truncated unless explicitly configured. JSON mappers must preserve array structure. Downstream nodes need array-aware handling for multi-value operations (e.g., multiple email recipients).",
|
|
1231
|
+
examples: [
|
|
1232
|
+
"✅ Entity extraction schema: { 'participants': { 'type': 'array', 'items': 'string' } }",
|
|
1233
|
+
"✅ JSON mapper: Map participants → participants (preserve as array, not first element)",
|
|
1234
|
+
"❌ Without array config: participants[] may return only first element or serialize as string",
|
|
1235
|
+
"⚠️ Send email with multiple recipients: Use array in to_email OR iterator node for individual sends",
|
|
1236
|
+
],
|
|
1237
|
+
criticalRules: [
|
|
1238
|
+
"Entity extraction MUST define array types in schema: { 'field': { 'type': 'array', 'items': 'string' } }",
|
|
1239
|
+
"JSON mapper MUST explicitly map arrays (not flatten to first element)",
|
|
1240
|
+
"Arrays like participants[], topics[], recipients[] require explicit array type preservation",
|
|
1241
|
+
"Downstream nodes consuming arrays must handle array input (e.g., to_email accepts array OR use iterator)",
|
|
1242
|
+
"Without proper array config, arrays may: flatten to first element, serialize as string, or truncate",
|
|
1243
|
+
"For email to multiple recipients: configure to_email as array OR add iterator node before send_email",
|
|
1244
|
+
"Check extraction schema, JSON mapper mappings, and downstream node array handling",
|
|
1245
|
+
],
|
|
1246
|
+
},
|
|
1247
|
+
"search-filtering": {
|
|
1248
|
+
title: "Search Filtering Best Practices",
|
|
1249
|
+
content: "Use BOTH semantic query + structured filters (filename/path/metadata). Filter-first when you have reliable context, but never rely on filename/path alone unless doing known-item lookup. Always include security filters (tenant_id/client_id).",
|
|
1250
|
+
examples: [
|
|
1251
|
+
"✅ Best: semantic query + tenant_id filter + path_prefix filter + doc_type filter",
|
|
1252
|
+
"✅ Fallback: If filtered search returns < N results, rerun without path/filename filters (keep security filters)",
|
|
1253
|
+
"❌ Bad: Only filename/path filter without semantic query (misses relevant docs)",
|
|
1254
|
+
"❌ Bad: Over-filtering from uncertain extraction (causes 'no results' and hallucination risk)",
|
|
1255
|
+
],
|
|
1256
|
+
criticalRules: [
|
|
1257
|
+
"ALWAYS use semantic query + structured filters together (not either/or)",
|
|
1258
|
+
"Security trimming is mandatory: always filter by tenant_id/client_id and enforce ACLs",
|
|
1259
|
+
"Filter-first approach: use filters when you have reliable extracted context (high confidence)",
|
|
1260
|
+
"Never rely on path/filename filters alone unless user explicitly provided exact name",
|
|
1261
|
+
"Prefer prefix/path filters over exact filename (exact matching is brittle)",
|
|
1262
|
+
"If filtered search returns too few results, fallback to query-only (keeping security filters)",
|
|
1263
|
+
"For templates: filter by category, language, channel, brand/client; then semantic query within filtered set",
|
|
1264
|
+
"Treat extracted entities as probabilistic - only apply restrictive filters if confidence is high",
|
|
1265
|
+
"Index normalized paths, path_prefixes, file_name, tenant_id, doc_type, ACL principals",
|
|
1266
|
+
],
|
|
1267
|
+
},
|
|
1268
|
+
"fallback-response-inputs": {
|
|
1269
|
+
title: "Fallback Response Input Best Practices",
|
|
1270
|
+
content: "Fallback responses should use full conversation + routing context, NOT just summarized query. Summary removes exact phrasing, tone, and last user message - exactly what fallback needs for clarifying questions.",
|
|
1271
|
+
examples: [
|
|
1272
|
+
"✅ Good: trigger.chat_conversation → fallback.query + routing category + JSON context in named_inputs",
|
|
1273
|
+
"✅ Good: Build fallback context JSON merging: router category + caller status + extracted JSON + summary",
|
|
1274
|
+
"❌ Bad: Only summarized_conversation → fallback.query (loses user wording, causes generic responses)",
|
|
1275
|
+
"⚠️ Optional: Conditional RAG-on-fallback if summary looks like factual question",
|
|
1276
|
+
],
|
|
1277
|
+
criticalRules: [
|
|
1278
|
+
"Fallback query should use trigger.chat_conversation (full conversation), not just summary",
|
|
1279
|
+
"Include routing context: why it fell back (router category) + caller identification status",
|
|
1280
|
+
"Include extracted JSON context (json_mapper output) so fallback knows what was extracted",
|
|
1281
|
+
"Keep summary as secondary context (in named_inputs), not primary query",
|
|
1282
|
+
"Fallback prompt should explain: 'You are in fallback; ask clarifying question or explain limitations'",
|
|
1283
|
+
"Optional: Add conditional RAG-on-fallback if router category suggests factual question",
|
|
1284
|
+
"Don't automatically run heavy RAG in fallback (fallback is often about clarification, not retrieval)",
|
|
1285
|
+
],
|
|
1286
|
+
},
|
|
1287
|
+
"separate-vs-merged-inputs": {
|
|
1288
|
+
title: "Separate vs Merged Inputs for Multi-Source Data",
|
|
1289
|
+
content: "Keep separate inputs for semantic clarity and flexible processing. Use named_inputs_* for multi-source data (conversation, templates, client data). Only merge if you need to perform a single operation (like keyword search) on combined text.",
|
|
1290
|
+
examples: [
|
|
1291
|
+
"✅ Good: agent receives named_inputs_Conversation + named_inputs_Templates + named_inputs_ClientData",
|
|
1292
|
+
"✅ Good: Separate inputs allow agent to reason over templates independently from conversation",
|
|
1293
|
+
"❌ Bad: Merging all into single input loses semantic structure and processing flexibility",
|
|
1294
|
+
"⚠️ Merge only if: performing single operation (keyword search) on combined text",
|
|
1295
|
+
],
|
|
1296
|
+
criticalRules: [
|
|
1297
|
+
"Use separate named_inputs_* for different data sources (conversation, templates, client data)",
|
|
1298
|
+
"Separate inputs provide semantic clarity: agent knows 'this is conversation' vs 'these are templates'",
|
|
1299
|
+
"Separate inputs enable flexible processing: agent can reason over each source independently",
|
|
1300
|
+
"Template selection benefits from separate inputs: agent can choose/apply template dynamically",
|
|
1301
|
+
"Only merge inputs if performing single operation (like keyword search) on combined text",
|
|
1302
|
+
"Ensure all relevant data sources are connected: client RAG results, templates, conversation context",
|
|
1303
|
+
],
|
|
1304
|
+
},
|
|
1305
|
+
"folder-path-filtering": {
|
|
1306
|
+
title: "Folder Path Filtering in Search",
|
|
1307
|
+
content: "You can filter on folder paths, but should combine with semantic query. Use path_prefix filters (not exact paths) for flexibility. Always combine with tenant_id/client_id security filters and semantic query.",
|
|
1308
|
+
examples: [
|
|
1309
|
+
"✅ Good: path_prefix = /Clients/Acme/Renewals/2026/ + semantic query + tenant_id filter",
|
|
1310
|
+
"✅ Good: Index normalized paths, path_prefixes for fast 'startsWith' filtering",
|
|
1311
|
+
"❌ Bad: Exact path matching (brittle - versions, spacing, extensions)",
|
|
1312
|
+
"❌ Bad: Path filter without semantic query (misses relevant docs in other folders)",
|
|
1313
|
+
],
|
|
1314
|
+
criticalRules: [
|
|
1315
|
+
"Folder path filtering should ALWAYS be combined with semantic query (not used alone)",
|
|
1316
|
+
"Use path_prefix filters (not exact paths) for flexibility and version tolerance",
|
|
1317
|
+
"Index normalized paths (lowercased, consistent separators) + path_prefixes",
|
|
1318
|
+
"Always combine path filters with security filters (tenant_id/client_id) and semantic query",
|
|
1319
|
+
"Path filters are probabilistic - only apply if extracted context has high confidence",
|
|
1320
|
+
"If path-filtered search returns too few results, fallback to query-only (keeping security filters)",
|
|
1321
|
+
"Path filters help narrow scope but semantic query ensures relevance",
|
|
1322
|
+
],
|
|
1323
|
+
},
|
|
1324
|
+
"automated-extraction-json": {
|
|
1325
|
+
title: "Automating Extraction and JSON Mapping",
|
|
1326
|
+
content: "The extraction → JSON mapper pattern can be automated when following best practices. Use single extraction node → JSON mapper → multiple downstream consumers. Configure extraction schema with array types, then map to normalized JSON structure for consistent downstream access.",
|
|
1327
|
+
examples: [
|
|
1328
|
+
"✅ Automated pattern: trigger → summarizer → extractor → json_mapper → [search, routing, responses]",
|
|
1329
|
+
"✅ JSON mapper normalizes: client_name, client_id, doc_type, template_type, filter fields",
|
|
1330
|
+
"✅ Add retrieval-ready fields: client_folder_prefix, template_folder_prefix, filter_strength",
|
|
1331
|
+
"❌ Manual: Multiple extraction nodes extracting same data from same input",
|
|
1332
|
+
],
|
|
1333
|
+
criticalRules: [
|
|
1334
|
+
"Extract ONCE per conversation, then pass structured data via JSON mapper to all consumers",
|
|
1335
|
+
"Configure extraction schema with explicit array types: { 'field': { 'type': 'array', 'items': 'string' } }",
|
|
1336
|
+
"JSON mapper should normalize values: canonical client name, canonical doc_type, derived filter fields",
|
|
1337
|
+
"Add retrieval-specific fields to JSON: client_folder_prefix, doc_type_keywords, template_folder_prefix",
|
|
1338
|
+
"Include confidence hints: use_client_filter, use_template_filter, filter_strength (none|broad|narrow)",
|
|
1339
|
+
"Downstream nodes consume json_mapper.output_json as custom_data - no need to re-extract",
|
|
1340
|
+
"This pattern enables automated filter construction, query building, and context passing",
|
|
1341
|
+
],
|
|
1342
|
+
},
|
|
1343
|
+
"when-filters-necessary": {
|
|
1344
|
+
title: "When Filters Are Necessary vs Optional",
|
|
1345
|
+
content: "Filters are necessary for security/isolation unless your storage layer already enforces per-id/tenant access control. Internal document annotations (like id=123 in text) don't prevent cross-id retrieval. Always filter by tenant_id/client_id for security. Path/filename filters are optional for precision but should have fallback.",
|
|
1346
|
+
examples: [
|
|
1347
|
+
"✅ Necessary: tenant_id/client_id filters (security/isolation) - always required",
|
|
1348
|
+
"✅ Optional but recommended: path_prefix filters when you have reliable extracted context",
|
|
1349
|
+
"❌ Not sufficient: Internal document annotations alone (id=123 in text) - still need filters",
|
|
1350
|
+
"⚠️ Skip only if: Separate index/collection per id/tenant OR enforced server-side ACL filtering",
|
|
1351
|
+
],
|
|
1352
|
+
criticalRules: [
|
|
1353
|
+
"ALWAYS filter by tenant_id/client_id for security/isolation (unless storage enforces it)",
|
|
1354
|
+
"Internal document annotations (id in text) don't prevent cross-id retrieval - still need filters",
|
|
1355
|
+
"Path/filename filters are optional for precision but should have semantic query fallback",
|
|
1356
|
+
"You can skip workflow-level filtering ONLY if storage/retrieval layer guarantees isolation",
|
|
1357
|
+
"Separate index per id/tenant OR enforced metadata/ACL filtering at query time = can skip",
|
|
1358
|
+
"Best practice: Use filters even with annotations - they provide explicit, reliable isolation",
|
|
1359
|
+
"Progressive relaxation: strict filters → broad filters → no filters (keep security filters)",
|
|
1360
|
+
],
|
|
1361
|
+
},
|
|
1362
|
+
"filter-query-guidance": {
|
|
1363
|
+
title: "Filter vs Query: When to Use Each",
|
|
1364
|
+
content: "Use filters when user intent implies known document scope and being wrong is costly. Use semantic queries when user describes a topic but not a specific file. Use both when you have reliable scope plus a topical question. Filters reduce noise; queries capture meaning. Best practice: filter-first when reliable context, but never rely on filename/path alone.",
|
|
1365
|
+
examples: [
|
|
1366
|
+
"✅ Use filters: User names specific doc ('the MSA', 'SOW 2024-01'), references folder ('in HR policies'), extracted strong entities (client_name, project_name)",
|
|
1367
|
+
"✅ Use query: User describes topic ('How do we handle SOC2?'), intent ('Draft follow-up email'), weak/ambiguous entities",
|
|
1368
|
+
"✅ Use both: Filter to Clients/Acme/ + query 'termination for convenience notice period'",
|
|
1369
|
+
"❌ Filters alone: Only for known-item lookup (user names exact file) - still include short query",
|
|
1370
|
+
],
|
|
1371
|
+
criticalRules: [
|
|
1372
|
+
"Filters: Best when user names specific doc, references folder, or extracted entities are strong",
|
|
1373
|
+
"Semantic queries: Best when user describes topic/intent, entities are weak/ambiguous",
|
|
1374
|
+
"Use both: Apply broad filters (client folder, doc type) + semantic query for best results",
|
|
1375
|
+
"Progressive relaxation: strict filters → broad filters → query-only (keep security filters)",
|
|
1376
|
+
"Confidence thresholds: Apply restrictive filters only when confidence is high (>=0.75)",
|
|
1377
|
+
"Fallback strategy: If filtered search returns < N results, rerun without path/filename filters",
|
|
1378
|
+
"Never rely on filename/path filters alone unless doing known-item lookup",
|
|
1379
|
+
"Security filters (tenant_id/client_id) are mandatory - never drop these",
|
|
1380
|
+
],
|
|
1381
|
+
},
|
|
1382
|
+
"node-selection": {
|
|
1383
|
+
title: "Node Selection Guide: When to Use What",
|
|
1384
|
+
content: `Choose the RIGHT node type for each task. Using wrong nodes wastes compute, increases latency, and reduces quality.
|
|
1385
|
+
|
|
1386
|
+
## Decision Tree
|
|
1387
|
+
|
|
1388
|
+
### Q1: Do you need EXTERNAL KNOWLEDGE?
|
|
1389
|
+
- YES → Use search node first, then generation
|
|
1390
|
+
- NO → Skip search, use generation or transform
|
|
1391
|
+
|
|
1392
|
+
### Q2: Do you need AI REASONING/GENERATION?
|
|
1393
|
+
- YES → Use LLM-based node
|
|
1394
|
+
- NO → Use transform node (json_mapper, fixed_response)
|
|
1395
|
+
|
|
1396
|
+
### Q3: What KIND of generation?
|
|
1397
|
+
- Simple Q&A with citations → respond_with_sources
|
|
1398
|
+
- Custom generation → call_llm
|
|
1399
|
+
- Complex multi-step reasoning → custom_agent
|
|
1400
|
+
- Search + synthesize in one → document_synthesis
|
|
1401
|
+
|
|
1402
|
+
### Q4: Do you need STRUCTURED DATA extraction?
|
|
1403
|
+
- YES → entity_extraction (NOT call_llm!)
|
|
1404
|
+
- NO → Continue with generation
|
|
1405
|
+
|
|
1406
|
+
## Node Cost/Latency
|
|
1407
|
+
|
|
1408
|
+
| Node | LLM Calls | Latency | Use When |
|
|
1409
|
+
|------|-----------|---------|----------|
|
|
1410
|
+
| fixed_response | 0 | <50ms | Static content, config, templates |
|
|
1411
|
+
| json_mapper | 0 | <100ms | Transform JSON without reasoning |
|
|
1412
|
+
| entity_extraction | 1 | 1-2s | Extract structured data |
|
|
1413
|
+
| respond_with_sources | 1 | 2-4s | Simple search + response |
|
|
1414
|
+
| call_llm | 1 | 2-4s | Custom generation |
|
|
1415
|
+
| custom_agent | 1-3 | 3-8s | Complex reasoning |
|
|
1416
|
+
| document_synthesis | 2-5 | 5-15s | Multi-search + synthesis |`,
|
|
1417
|
+
examples: [
|
|
1418
|
+
"✅ Static template → fixed_response (0 LLM calls)",
|
|
1419
|
+
"✅ Extract client_name, email → entity_extraction (1 LLM call, typed output)",
|
|
1420
|
+
"✅ Simple KB Q&A → respond_with_sources (1 LLM call)",
|
|
1421
|
+
"✅ Custom email generation → call_llm with search results in named_inputs",
|
|
1422
|
+
"❌ Using call_llm to extract JSON → Use entity_extraction instead",
|
|
1423
|
+
"❌ Using document_synthesis for simple Q&A → Use respond_with_sources",
|
|
1424
|
+
"❌ Using custom_agent for static response → Use fixed_response",
|
|
1425
|
+
],
|
|
1426
|
+
criticalRules: [
|
|
1427
|
+
"NEVER use LLM for transforms that json_mapper can do",
|
|
1428
|
+
"NEVER use call_llm for structured extraction - use entity_extraction",
|
|
1429
|
+
"NEVER use document_synthesis when respond_with_sources suffices",
|
|
1430
|
+
"ALWAYS use fixed_response for static content (templates, config, help text)",
|
|
1431
|
+
"Use entity_extraction for typed, reliable structured data",
|
|
1432
|
+
"Use call_llm when you need custom instructions or reasoning",
|
|
1433
|
+
"Use custom_agent when task requires role context + multi-step reasoning",
|
|
1434
|
+
"Use document_synthesis only when you need search-plan-search-synthesize pattern",
|
|
1435
|
+
],
|
|
1436
|
+
},
|
|
1437
|
+
"llm-node-selection": {
|
|
1438
|
+
title: "LLM Node Selection: Which One to Use",
|
|
1439
|
+
content: `Different LLM nodes serve different purposes. Choose based on your needs:
|
|
1440
|
+
|
|
1441
|
+
## respond_with_sources
|
|
1442
|
+
- **Best for**: Simple Q&A with KB citations
|
|
1443
|
+
- **Input**: Search results (SEARCH_RESULT type)
|
|
1444
|
+
- **Output**: Response with source citations
|
|
1445
|
+
- **When**: User asks question, you search KB, return answer
|
|
1446
|
+
- **NOT for**: Custom generation, complex reasoning
|
|
1447
|
+
|
|
1448
|
+
## call_llm
|
|
1449
|
+
- **Best for**: Custom text generation with instructions
|
|
1450
|
+
- **Input**: Query + optional search/context via named_inputs
|
|
1451
|
+
- **Output**: Generated text
|
|
1452
|
+
- **When**: Need custom prompts, specific format, controlled generation
|
|
1453
|
+
- **NOT for**: Simple Q&A (use respond_with_sources)
|
|
1454
|
+
|
|
1455
|
+
## custom_agent
|
|
1456
|
+
- **Best for**: Complex tasks requiring role + instructions
|
|
1457
|
+
- **Input**: Role instructions + task instructions + named_inputs
|
|
1458
|
+
- **Output**: Structured or free-form response
|
|
1459
|
+
- **When**: Multi-step reasoning, persona-based responses, tool-like behavior
|
|
1460
|
+
- **NOT for**: Simple generation (overkill)
|
|
1461
|
+
|
|
1462
|
+
## document_synthesis
|
|
1463
|
+
- **Best for**: Multi-source research with planning
|
|
1464
|
+
- **Input**: User request + search results
|
|
1465
|
+
- **Output**: Synthesized document
|
|
1466
|
+
- **When**: Need to search → plan → search again → synthesize
|
|
1467
|
+
- **NOT for**: Simple KB lookup (too heavy)`,
|
|
1468
|
+
examples: [
|
|
1469
|
+
"User asks 'What is our refund policy?' → respond_with_sources",
|
|
1470
|
+
"Need to generate email with specific template → call_llm",
|
|
1471
|
+
"Need to analyze portfolio and recommend actions → custom_agent",
|
|
1472
|
+
"Need to research topic across multiple sources → document_synthesis",
|
|
1473
|
+
],
|
|
1474
|
+
criticalRules: [
|
|
1475
|
+
"respond_with_sources: 1 LLM call, simple Q&A, requires SEARCH_RESULT input",
|
|
1476
|
+
"call_llm: 1 LLM call, custom generation, accepts named_inputs",
|
|
1477
|
+
"custom_agent: 1-3 LLM calls, complex reasoning, has role context",
|
|
1478
|
+
"document_synthesis: 2-5 LLM calls, heavy, only for research tasks",
|
|
1479
|
+
"Default to respond_with_sources for KB Q&A",
|
|
1480
|
+
"Upgrade to call_llm when you need custom instructions",
|
|
1481
|
+
"Upgrade to custom_agent for complex reasoning",
|
|
1482
|
+
"Use document_synthesis sparingly (latency expensive)",
|
|
1483
|
+
],
|
|
1484
|
+
},
|
|
1485
|
+
"search-vs-no-search": {
|
|
1486
|
+
title: "When to Search vs Direct Generation",
|
|
1487
|
+
content: `Not every request needs KB search. Search adds latency and can introduce irrelevant context.
|
|
1488
|
+
|
|
1489
|
+
## SEARCH when:
|
|
1490
|
+
- User asks factual question about domain knowledge
|
|
1491
|
+
- Request references specific documents/data
|
|
1492
|
+
- Response accuracy depends on up-to-date information
|
|
1493
|
+
- User asks about policies, procedures, product details
|
|
1494
|
+
|
|
1495
|
+
## DON'T SEARCH when:
|
|
1496
|
+
- User needs help with task (drafting, formatting)
|
|
1497
|
+
- Response is conversational/procedural
|
|
1498
|
+
- Information is already in context (extracted entities)
|
|
1499
|
+
- User asks general questions (time, greeting)`,
|
|
1500
|
+
examples: [
|
|
1501
|
+
"✅ Search: 'What is our return policy?' - needs KB",
|
|
1502
|
+
"✅ Search: 'Tell me about client Thompson' - needs client data",
|
|
1503
|
+
"❌ Don't search: 'Can you draft an email?' - procedural",
|
|
1504
|
+
"❌ Don't search: 'Format this as a table' - transform",
|
|
1505
|
+
"❌ Don't search: 'Hello, how are you?' - greeting",
|
|
1506
|
+
],
|
|
1507
|
+
criticalRules: [
|
|
1508
|
+
"Search is NOT free - adds 0.5-2s latency per search",
|
|
1509
|
+
"Search can introduce noise if results aren't relevant",
|
|
1510
|
+
"Fallback/greeting responses often don't need search",
|
|
1511
|
+
"Task-based requests (draft, format, summarize) usually don't need search",
|
|
1512
|
+
"Use categorizer to route search vs no-search paths",
|
|
1513
|
+
"If entity extraction already has the data, don't search for it again",
|
|
1514
|
+
],
|
|
1515
|
+
},
|
|
1174
1516
|
};
|
|
1175
1517
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1176
1518
|
// Helper Functions
|
|
@@ -2211,6 +2553,457 @@ function detectMalformedRunIf(workflowDef) {
|
|
|
2211
2553
|
}
|
|
2212
2554
|
return issues;
|
|
2213
2555
|
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Detect unused categories - categories defined in enumType but with no handler
|
|
2558
|
+
* This causes SILENT FAILURES where requests match the category but nothing executes
|
|
2559
|
+
*/
|
|
2560
|
+
function detectUnusedCategories(nodes, workflowDef) {
|
|
2561
|
+
const issues = [];
|
|
2562
|
+
const def = workflowDef;
|
|
2563
|
+
if (!def)
|
|
2564
|
+
return issues;
|
|
2565
|
+
const enumTypes = def.enumTypes;
|
|
2566
|
+
const actions = def.actions;
|
|
2567
|
+
if (!enumTypes || !actions)
|
|
2568
|
+
return issues;
|
|
2569
|
+
// Find all categorizer nodes
|
|
2570
|
+
const categorizers = nodes.filter(n => n.action_name === "chat_categorizer" ||
|
|
2571
|
+
n.action_name === "intent_classifier");
|
|
2572
|
+
for (const categorizer of categorizers) {
|
|
2573
|
+
// Find the enumType for this categorizer
|
|
2574
|
+
const enumTypeName = categorizer.id + "_enumType";
|
|
2575
|
+
const enumType = enumTypes.find(e => {
|
|
2576
|
+
const name = e.name;
|
|
2577
|
+
const innerName = name?.name;
|
|
2578
|
+
const nameStr = String(innerName?.name ?? name?.name ?? "");
|
|
2579
|
+
return nameStr.includes(categorizer.id) || nameStr.includes("enumType");
|
|
2580
|
+
});
|
|
2581
|
+
if (!enumType)
|
|
2582
|
+
continue;
|
|
2583
|
+
const options = enumType.options;
|
|
2584
|
+
if (!options)
|
|
2585
|
+
continue;
|
|
2586
|
+
// Get all defined categories
|
|
2587
|
+
const definedCategories = options.map(o => String(o.name ?? "")).filter(n => n.length > 0);
|
|
2588
|
+
// Find which categories have handlers (via runIf conditions)
|
|
2589
|
+
const handledCategories = new Set();
|
|
2590
|
+
for (const action of actions) {
|
|
2591
|
+
const runIf = action.runIf;
|
|
2592
|
+
if (!runIf)
|
|
2593
|
+
continue;
|
|
2594
|
+
const lhs = runIf.lhs;
|
|
2595
|
+
const actionOutput = lhs?.actionOutput;
|
|
2596
|
+
// Check if this runIf references our categorizer
|
|
2597
|
+
if (actionOutput?.actionName === categorizer.id) {
|
|
2598
|
+
const rhs = runIf.rhs;
|
|
2599
|
+
const inline = rhs?.inline;
|
|
2600
|
+
const enumValue = String(inline?.enumValue ?? "");
|
|
2601
|
+
if (enumValue) {
|
|
2602
|
+
handledCategories.add(enumValue);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
// Check for config-based routing (document_synthesis with Config.tasks pattern)
|
|
2607
|
+
// Find Config.tasks keys from fixed_response nodes
|
|
2608
|
+
const configTaskKeys = new Set();
|
|
2609
|
+
for (const action of actions) {
|
|
2610
|
+
const actionInfo = action.action;
|
|
2611
|
+
const actionName = actionInfo?.name;
|
|
2612
|
+
if (actionName?.name !== "fixed_response")
|
|
2613
|
+
continue;
|
|
2614
|
+
const inputs = action.inputs;
|
|
2615
|
+
const template = inputs?.template;
|
|
2616
|
+
const inline = template?.inline;
|
|
2617
|
+
const wellKnown = inline?.wellKnown;
|
|
2618
|
+
const textWithSources = wellKnown?.textWithSources;
|
|
2619
|
+
const text = String(textWithSources?.text ?? "");
|
|
2620
|
+
try {
|
|
2621
|
+
if (text.includes('"tasks"')) {
|
|
2622
|
+
const config = JSON.parse(text);
|
|
2623
|
+
const tasks = config.tasks;
|
|
2624
|
+
if (tasks) {
|
|
2625
|
+
for (const taskKey of Object.keys(tasks)) {
|
|
2626
|
+
configTaskKeys.add(taskKey);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
catch {
|
|
2632
|
+
// Not JSON, skip
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
// Check if category is passed to any downstream node (including via named_inputs)
|
|
2636
|
+
// Use flexible string matching to handle any JSON formatting
|
|
2637
|
+
let usesConfigBasedRouting = false;
|
|
2638
|
+
for (const action of actions) {
|
|
2639
|
+
const inputs = action.inputs;
|
|
2640
|
+
if (!inputs)
|
|
2641
|
+
continue;
|
|
2642
|
+
// Check regular inputs and named_inputs for reference to categorizer's category output
|
|
2643
|
+
const inputStr = JSON.stringify(inputs).replace(/\s+/g, "");
|
|
2644
|
+
const categorizerRef = `"actionName":"${categorizer.id}"`.replace(/\s+/g, "");
|
|
2645
|
+
const categoryOutput = `"output":"category"`.replace(/\s+/g, "");
|
|
2646
|
+
if (inputStr.includes(categorizerRef) && inputStr.includes(categoryOutput)) {
|
|
2647
|
+
// This action references the categorizer's category output
|
|
2648
|
+
const actionInfo = action.action;
|
|
2649
|
+
const actionName = actionInfo?.name;
|
|
2650
|
+
const actionType = String(actionName?.name ?? "");
|
|
2651
|
+
// If it's a synthesis/generation node and we have config tasks, it's config-based routing
|
|
2652
|
+
if (["document_synthesis", "call_llm", "custom_agent"].includes(actionType) && configTaskKeys.size > 0) {
|
|
2653
|
+
usesConfigBasedRouting = true;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
// If we have config tasks defined, assume config-based routing even if not explicitly detected
|
|
2658
|
+
// This handles cases where the config might be in an orphan node but still used at runtime
|
|
2659
|
+
if (configTaskKeys.size > 0 && !usesConfigBasedRouting) {
|
|
2660
|
+
// Check if any downstream node is a document_synthesis or custom_agent
|
|
2661
|
+
for (const node of nodes) {
|
|
2662
|
+
if (node.action_name === "document_synthesis" || node.action_name === "custom_agent") {
|
|
2663
|
+
usesConfigBasedRouting = true;
|
|
2664
|
+
break;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
// Find unused categories
|
|
2669
|
+
const unusedCategories = definedCategories.filter(c => !handledCategories.has(c));
|
|
2670
|
+
for (const category of unusedCategories) {
|
|
2671
|
+
// Skip Fallback - already detected by missing_fallback
|
|
2672
|
+
if (category === "Fallback")
|
|
2673
|
+
continue;
|
|
2674
|
+
// If using config-based routing, check if category has a task
|
|
2675
|
+
if (usesConfigBasedRouting) {
|
|
2676
|
+
if (!configTaskKeys.has(category)) {
|
|
2677
|
+
issues.push({
|
|
2678
|
+
type: "unused_category",
|
|
2679
|
+
severity: "critical",
|
|
2680
|
+
node: categorizer.id,
|
|
2681
|
+
category,
|
|
2682
|
+
categories: Array.from(configTaskKeys),
|
|
2683
|
+
auto_fixable: false,
|
|
2684
|
+
reason: `Category "${category}" is defined but has NO TASK in Config.tasks. ` +
|
|
2685
|
+
`Requests matching this category will SILENTLY FAIL - the workflow looks for Config.tasks["${category}"] which doesn't exist. ` +
|
|
2686
|
+
`Config has tasks for: ${Array.from(configTaskKeys).join(", ")}. ` +
|
|
2687
|
+
`Either add "${category}" to Config.tasks, or remove the category from the enum.`,
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
else {
|
|
2692
|
+
// No config-based routing - need explicit runIf handler
|
|
2693
|
+
issues.push({
|
|
2694
|
+
type: "unused_category",
|
|
2695
|
+
severity: "critical",
|
|
2696
|
+
node: categorizer.id,
|
|
2697
|
+
category,
|
|
2698
|
+
categories: unusedCategories,
|
|
2699
|
+
auto_fixable: false,
|
|
2700
|
+
reason: `Category "${category}" is defined in ${categorizer.display_name || categorizer.id} but has NO HANDLER. ` +
|
|
2701
|
+
`Requests matching this category will SILENTLY FAIL - no response sent to user. ` +
|
|
2702
|
+
`Either add a node with runIf condition for "${category}", add to Config.tasks, or remove the category.`,
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
return issues;
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Detect category name mismatches - e.g., SEND_EMAIL in enum vs SEND_COMMUNICATION in config
|
|
2711
|
+
*/
|
|
2712
|
+
function detectCategoryNameMismatches(nodes, workflowDef) {
|
|
2713
|
+
const issues = [];
|
|
2714
|
+
const def = workflowDef;
|
|
2715
|
+
if (!def)
|
|
2716
|
+
return issues;
|
|
2717
|
+
const enumTypes = def.enumTypes;
|
|
2718
|
+
const actions = def.actions;
|
|
2719
|
+
if (!enumTypes || !actions)
|
|
2720
|
+
return issues;
|
|
2721
|
+
// Find fixed_response nodes that might contain config with tasks
|
|
2722
|
+
const configNodes = actions.filter(a => {
|
|
2723
|
+
const actionInfo = a.action;
|
|
2724
|
+
const actionName = actionInfo?.name;
|
|
2725
|
+
return actionName?.name === "fixed_response";
|
|
2726
|
+
});
|
|
2727
|
+
for (const configNode of configNodes) {
|
|
2728
|
+
// Try to extract config content
|
|
2729
|
+
const inputs = configNode.inputs;
|
|
2730
|
+
const template = inputs?.template;
|
|
2731
|
+
const inline = template?.inline;
|
|
2732
|
+
const wellKnown = inline?.wellKnown;
|
|
2733
|
+
const textWithSources = wellKnown?.textWithSources;
|
|
2734
|
+
const text = String(textWithSources?.text ?? "");
|
|
2735
|
+
// Try to parse as JSON to find tasks object
|
|
2736
|
+
try {
|
|
2737
|
+
if (text.includes('"tasks"')) {
|
|
2738
|
+
const config = JSON.parse(text);
|
|
2739
|
+
const tasks = config.tasks;
|
|
2740
|
+
if (!tasks)
|
|
2741
|
+
continue;
|
|
2742
|
+
const taskNames = new Set(Object.keys(tasks));
|
|
2743
|
+
// Compare with enum categories
|
|
2744
|
+
for (const enumType of enumTypes) {
|
|
2745
|
+
const options = enumType.options;
|
|
2746
|
+
if (!options)
|
|
2747
|
+
continue;
|
|
2748
|
+
const enumCategories = options.map(o => String(o.name ?? "")).filter(n => n.length > 0);
|
|
2749
|
+
// Find similar but not exact matches
|
|
2750
|
+
for (const enumCat of enumCategories) {
|
|
2751
|
+
if (enumCat === "Fallback")
|
|
2752
|
+
continue;
|
|
2753
|
+
// Check for name mismatches (similar but different)
|
|
2754
|
+
const normalizedEnum = enumCat.toLowerCase().replace(/_/g, "");
|
|
2755
|
+
for (const taskName of taskNames) {
|
|
2756
|
+
const normalizedTask = taskName.toLowerCase().replace(/_/g, "");
|
|
2757
|
+
// Similar names but not exact match
|
|
2758
|
+
if (normalizedEnum !== normalizedTask &&
|
|
2759
|
+
(normalizedEnum.includes(normalizedTask.slice(0, 4)) ||
|
|
2760
|
+
normalizedTask.includes(normalizedEnum.slice(0, 4)))) {
|
|
2761
|
+
// Check if it's actually a mismatch (enum not in tasks)
|
|
2762
|
+
if (!taskNames.has(enumCat)) {
|
|
2763
|
+
issues.push({
|
|
2764
|
+
type: "category_name_mismatch",
|
|
2765
|
+
severity: "critical",
|
|
2766
|
+
node: String(configNode.name ?? "config"),
|
|
2767
|
+
category: enumCat,
|
|
2768
|
+
config_key: taskName,
|
|
2769
|
+
auto_fixable: true,
|
|
2770
|
+
reason: `Category name mismatch: Enum has "${enumCat}" but Config.tasks has "${taskName}". ` +
|
|
2771
|
+
`This routing will FAIL because Config.tasks["${enumCat}"] doesn't exist. ` +
|
|
2772
|
+
`Either rename the enum category to "${taskName}" or add "${enumCat}" to Config.tasks.`,
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
// Not JSON config, skip
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
return issues;
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* Detect late categorizer placement - categorizer should be early to avoid wasted processing
|
|
2789
|
+
*/
|
|
2790
|
+
function detectLateCategorizer(nodes) {
|
|
2791
|
+
const issues = [];
|
|
2792
|
+
const categorizers = nodes.filter(n => n.action_name === "chat_categorizer" ||
|
|
2793
|
+
n.action_name === "intent_classifier");
|
|
2794
|
+
// Heavy processing nodes
|
|
2795
|
+
const heavyNodes = new Set(["call_llm", "custom_agent", "document_synthesis", "search", "knowledge_search"]);
|
|
2796
|
+
for (const categorizer of categorizers) {
|
|
2797
|
+
// Find all nodes upstream of this categorizer
|
|
2798
|
+
const upstreamHeavy = [];
|
|
2799
|
+
const visited = new Set();
|
|
2800
|
+
function findUpstream(nodeId) {
|
|
2801
|
+
if (visited.has(nodeId))
|
|
2802
|
+
return;
|
|
2803
|
+
visited.add(nodeId);
|
|
2804
|
+
const node = nodes.find(n => n.id === nodeId);
|
|
2805
|
+
if (!node?.incoming_edges)
|
|
2806
|
+
return;
|
|
2807
|
+
for (const edge of node.incoming_edges) {
|
|
2808
|
+
const sourceNode = nodes.find(n => n.id === edge.source_node_id);
|
|
2809
|
+
if (sourceNode) {
|
|
2810
|
+
if (heavyNodes.has(sourceNode.action_name || "")) {
|
|
2811
|
+
upstreamHeavy.push(sourceNode.display_name || sourceNode.id);
|
|
2812
|
+
}
|
|
2813
|
+
findUpstream(sourceNode.id);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
findUpstream(categorizer.id);
|
|
2818
|
+
if (upstreamHeavy.length > 0) {
|
|
2819
|
+
issues.push({
|
|
2820
|
+
type: "late_categorizer",
|
|
2821
|
+
severity: "warning",
|
|
2822
|
+
node: categorizer.id,
|
|
2823
|
+
nodes: upstreamHeavy,
|
|
2824
|
+
auto_fixable: false,
|
|
2825
|
+
reason: `Categorizer "${categorizer.display_name || categorizer.id}" runs AFTER heavy processing: ${upstreamHeavy.join(", ")}. ` +
|
|
2826
|
+
`This wastes compute - all that processing happens even for simple requests. ` +
|
|
2827
|
+
`RECOMMENDATION: Move categorizer earlier in the flow. Categorize FIRST, then process only the relevant branch.`,
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
return issues;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Detect excessive LLM calls per execution path
|
|
2835
|
+
*/
|
|
2836
|
+
function detectExcessiveLLMCalls(nodes) {
|
|
2837
|
+
const issues = [];
|
|
2838
|
+
const trigger = nodes.find(n => n.action_name === "trigger" || n.id === "trigger");
|
|
2839
|
+
if (!trigger)
|
|
2840
|
+
return issues;
|
|
2841
|
+
const llmNodes = new Set(["call_llm", "custom_agent", "document_synthesis"]);
|
|
2842
|
+
// Build adjacency list
|
|
2843
|
+
const adj = new Map();
|
|
2844
|
+
for (const node of nodes) {
|
|
2845
|
+
adj.set(node.id, new Set());
|
|
2846
|
+
}
|
|
2847
|
+
for (const node of nodes) {
|
|
2848
|
+
if (node.incoming_edges) {
|
|
2849
|
+
for (const edge of node.incoming_edges) {
|
|
2850
|
+
adj.get(edge.source_node_id)?.add(node.id);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
// Find all paths from trigger and count LLMs
|
|
2855
|
+
const pathsWithHighLLMCount = [];
|
|
2856
|
+
function dfs(nodeId, path, llmCount) {
|
|
2857
|
+
const node = nodes.find(n => n.id === nodeId);
|
|
2858
|
+
const newPath = [...path, nodeId];
|
|
2859
|
+
const newLLMCount = llmCount + (node && llmNodes.has(node.action_name || "") ? 1 : 0);
|
|
2860
|
+
const neighbors = adj.get(nodeId) || new Set();
|
|
2861
|
+
if (neighbors.size === 0) {
|
|
2862
|
+
// End of path
|
|
2863
|
+
if (newLLMCount > 3) {
|
|
2864
|
+
pathsWithHighLLMCount.push({ path: newPath, llm_count: newLLMCount });
|
|
2865
|
+
}
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
// Limit depth to avoid infinite loops
|
|
2869
|
+
if (newPath.length > 15)
|
|
2870
|
+
return;
|
|
2871
|
+
for (const neighbor of neighbors) {
|
|
2872
|
+
if (!path.includes(neighbor)) {
|
|
2873
|
+
dfs(neighbor, newPath, newLLMCount);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
dfs(trigger.id, [], 0);
|
|
2878
|
+
// Report worst paths
|
|
2879
|
+
pathsWithHighLLMCount.sort((a, b) => b.llm_count - a.llm_count);
|
|
2880
|
+
for (const { path, llm_count } of pathsWithHighLLMCount.slice(0, 3)) {
|
|
2881
|
+
const llmNodesInPath = path.filter(p => {
|
|
2882
|
+
const node = nodes.find(n => n.id === p);
|
|
2883
|
+
return node && llmNodes.has(node.action_name || "");
|
|
2884
|
+
});
|
|
2885
|
+
issues.push({
|
|
2886
|
+
type: "excessive_llm_calls",
|
|
2887
|
+
severity: llm_count > 5 ? "warning" : "info",
|
|
2888
|
+
nodes: llmNodesInPath,
|
|
2889
|
+
llm_count,
|
|
2890
|
+
path,
|
|
2891
|
+
auto_fixable: false,
|
|
2892
|
+
reason: `Execution path has ${llm_count} LLM calls: ${llmNodesInPath.join(" → ")}. ` +
|
|
2893
|
+
`Each LLM call adds 1-3 seconds latency. Users typically wait max 5-8 seconds. ` +
|
|
2894
|
+
`RECOMMENDATION: Consolidate LLM calls, parallelize where possible, or cache repeated lookups.`,
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
return issues;
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Detect suboptimal node choices - using heavy nodes where lighter ones suffice
|
|
2901
|
+
*/
|
|
2902
|
+
function detectSuboptimalNodeChoices(nodes) {
|
|
2903
|
+
const issues = [];
|
|
2904
|
+
for (const node of nodes) {
|
|
2905
|
+
const actionName = node.action_name || "";
|
|
2906
|
+
const displayName = node.display_name || node.id;
|
|
2907
|
+
// Check for document_synthesis when respond_with_sources might suffice
|
|
2908
|
+
if (actionName === "document_synthesis") {
|
|
2909
|
+
// Check if it has simple inputs (not complex multi-source)
|
|
2910
|
+
const hasSearchInput = node.incoming_edges?.some(e => e.source_output?.includes("search_results"));
|
|
2911
|
+
const hasMultipleSearchInputs = (node.incoming_edges?.filter(e => e.source_output?.includes("search_results")) || []).length > 1;
|
|
2912
|
+
if (hasSearchInput && !hasMultipleSearchInputs) {
|
|
2913
|
+
issues.push({
|
|
2914
|
+
type: "suboptimal_node_choice",
|
|
2915
|
+
severity: "info",
|
|
2916
|
+
node: node.id,
|
|
2917
|
+
actual_node: "document_synthesis",
|
|
2918
|
+
recommended_node: "respond_with_sources or call_llm",
|
|
2919
|
+
use_case: "single search source",
|
|
2920
|
+
reason: `"${displayName}" uses document_synthesis (2-5 LLM calls) but only has one search source. ` +
|
|
2921
|
+
`Consider using respond_with_sources (1 call) for simple Q&A, or call_llm with named_inputs for custom generation. ` +
|
|
2922
|
+
`document_synthesis is best for multi-source research with search-plan-search patterns.`,
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
// Check for call_llm/custom_agent that might be doing extraction
|
|
2927
|
+
if (["call_llm", "custom_agent"].includes(actionName)) {
|
|
2928
|
+
// Look for JSON extraction patterns in instructions
|
|
2929
|
+
const inputStr = JSON.stringify(node.incoming_edges || []);
|
|
2930
|
+
const nodeId = node.id.toLowerCase();
|
|
2931
|
+
const display = displayName.toLowerCase();
|
|
2932
|
+
// Check if this looks like an extraction node
|
|
2933
|
+
if (nodeId.includes("extract") ||
|
|
2934
|
+
display.includes("extract") ||
|
|
2935
|
+
nodeId.includes("parse") ||
|
|
2936
|
+
display.includes("parse")) {
|
|
2937
|
+
issues.push({
|
|
2938
|
+
type: "suboptimal_node_choice",
|
|
2939
|
+
severity: "warning",
|
|
2940
|
+
node: node.id,
|
|
2941
|
+
actual_node: actionName,
|
|
2942
|
+
recommended_node: "entity_extraction",
|
|
2943
|
+
use_case: "structured data extraction",
|
|
2944
|
+
reason: `"${displayName}" appears to be doing extraction but uses ${actionName}. ` +
|
|
2945
|
+
`Use entity_extraction instead - it's optimized for structured data, provides typed output, ` +
|
|
2946
|
+
`and is more reliable for extracting specific fields (names, emails, dates, amounts).`,
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
// Check for LLM nodes that might be doing simple transforms
|
|
2951
|
+
if (["call_llm", "custom_agent", "document_synthesis"].includes(actionName)) {
|
|
2952
|
+
const display = displayName.toLowerCase();
|
|
2953
|
+
const nodeId = node.id.toLowerCase();
|
|
2954
|
+
// Check if it looks like a simple formatter/transformer
|
|
2955
|
+
if ((display.includes("format") && !display.includes("information")) ||
|
|
2956
|
+
display.includes("convert") ||
|
|
2957
|
+
display.includes("template") ||
|
|
2958
|
+
nodeId.includes("formatter") ||
|
|
2959
|
+
nodeId.includes("converter")) {
|
|
2960
|
+
issues.push({
|
|
2961
|
+
type: "suboptimal_node_choice",
|
|
2962
|
+
severity: "info",
|
|
2963
|
+
node: node.id,
|
|
2964
|
+
actual_node: actionName,
|
|
2965
|
+
recommended_node: "json_mapper or fixed_response",
|
|
2966
|
+
use_case: "formatting/transformation",
|
|
2967
|
+
reason: `"${displayName}" might be doing simple formatting with ${actionName} (LLM-based). ` +
|
|
2968
|
+
`If this is just data transformation without reasoning, consider json_mapper (no LLM, <100ms) ` +
|
|
2969
|
+
`or fixed_response with template variables (no LLM, <50ms).`,
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
return issues;
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Detect unnecessary search nodes (search before simple tasks that don't need KB)
|
|
2978
|
+
*/
|
|
2979
|
+
function detectUnnecessarySearch(nodes) {
|
|
2980
|
+
const issues = [];
|
|
2981
|
+
// Find search nodes
|
|
2982
|
+
const searchNodes = nodes.filter(n => n.action_name === "search" ||
|
|
2983
|
+
n.action_name === "knowledge_search");
|
|
2984
|
+
// Check what consumes each search
|
|
2985
|
+
for (const searchNode of searchNodes) {
|
|
2986
|
+
// Find downstream consumers
|
|
2987
|
+
const consumers = nodes.filter(n => n.incoming_edges?.some(e => e.source_node_id === searchNode.id));
|
|
2988
|
+
// Check if downstream is just fallback/greeting
|
|
2989
|
+
for (const consumer of consumers) {
|
|
2990
|
+
const consumerName = (consumer.display_name || consumer.id).toLowerCase();
|
|
2991
|
+
if (consumerName.includes("fallback") ||
|
|
2992
|
+
consumerName.includes("greeting") ||
|
|
2993
|
+
consumerName.includes("clarif")) {
|
|
2994
|
+
issues.push({
|
|
2995
|
+
type: "unnecessary_search",
|
|
2996
|
+
severity: "info",
|
|
2997
|
+
node: searchNode.id,
|
|
2998
|
+
reason: `Search "${searchNode.display_name || searchNode.id}" feeds into "${consumer.display_name || consumer.id}" (fallback/greeting). ` +
|
|
2999
|
+
`Fallback responses typically don't need KB search - they're about clarification, not retrieval. ` +
|
|
3000
|
+
`Consider removing search from fallback path to reduce latency.`,
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
return issues;
|
|
3006
|
+
}
|
|
2214
3007
|
export function detectWorkflowIssues(workflowDef) {
|
|
2215
3008
|
const nodes = parseWorkflowDef(workflowDef);
|
|
2216
3009
|
if (nodes.length === 0) {
|
|
@@ -2231,6 +3024,14 @@ export function detectWorkflowIssues(workflowDef) {
|
|
|
2231
3024
|
...detectEmailIssues(nodes),
|
|
2232
3025
|
...detectPerformanceIssues(nodes),
|
|
2233
3026
|
...detectMalformedRunIf(workflowDef),
|
|
3027
|
+
// NEW: Category/routing structure issues
|
|
3028
|
+
...detectUnusedCategories(nodes, workflowDef),
|
|
3029
|
+
...detectCategoryNameMismatches(nodes, workflowDef),
|
|
3030
|
+
...detectLateCategorizer(nodes),
|
|
3031
|
+
...detectExcessiveLLMCalls(nodes),
|
|
3032
|
+
// NEW: Node selection issues
|
|
3033
|
+
...detectSuboptimalNodeChoices(nodes),
|
|
3034
|
+
...detectUnnecessarySearch(nodes),
|
|
2234
3035
|
];
|
|
2235
3036
|
// Add type mismatch issues from connection validation
|
|
2236
3037
|
const connections = validateWorkflowConnections(workflowDef);
|
|
@@ -2805,6 +3606,139 @@ ${issue.missing?.includes("failure") || issue.missing?.includes("both") ? `
|
|
|
2805
3606
|
validation: "Verify consolidation doesn't lose intent-specific response quality",
|
|
2806
3607
|
};
|
|
2807
3608
|
break;
|
|
3609
|
+
// NEW: Category/routing structure fixes
|
|
3610
|
+
case "unused_category":
|
|
3611
|
+
fix = {
|
|
3612
|
+
issue_type: issue.type,
|
|
3613
|
+
description: `Add handler for unused category "${issue.category}"`,
|
|
3614
|
+
after: `# CRITICAL: Category "${issue.category}" has NO HANDLER - requests will SILENTLY FAIL!
|
|
3615
|
+
#
|
|
3616
|
+
# OPTION 1: Add a handler node with runIf condition:
|
|
3617
|
+
- name: "${issue.category?.toLowerCase().replace(/_/g, "_")}_handler"
|
|
3618
|
+
action:
|
|
3619
|
+
name:
|
|
3620
|
+
namespaces: ["actions", "emainternal"]
|
|
3621
|
+
name: "call_llm"
|
|
3622
|
+
runIf:
|
|
3623
|
+
lhs:
|
|
3624
|
+
actionOutput:
|
|
3625
|
+
actionName: "${issue.node}"
|
|
3626
|
+
output: "category"
|
|
3627
|
+
operator: 1 # EQUALS
|
|
3628
|
+
rhs:
|
|
3629
|
+
inline:
|
|
3630
|
+
enumValue: "${issue.category}"
|
|
3631
|
+
inputs:
|
|
3632
|
+
# ... configure LLM for this category ...
|
|
3633
|
+
displaySettings:
|
|
3634
|
+
displayName: "${issue.category} Handler"
|
|
3635
|
+
|
|
3636
|
+
# OPTION 2: Add to Config.tasks if using config-based routing:
|
|
3637
|
+
# In your fixed_response config node, add:
|
|
3638
|
+
"tasks": {
|
|
3639
|
+
"${issue.category}": "Task instructions for handling ${issue.category} requests..."
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
# OPTION 3: Remove the category if not needed:
|
|
3643
|
+
# Delete "${issue.category}" from enumType.options`,
|
|
3644
|
+
validation: `Verify "${issue.category}" has a handler (runIf condition or Config.tasks entry)`,
|
|
3645
|
+
};
|
|
3646
|
+
break;
|
|
3647
|
+
case "category_name_mismatch":
|
|
3648
|
+
fix = {
|
|
3649
|
+
issue_type: issue.type,
|
|
3650
|
+
description: `Fix category name mismatch: "${issue.category}" vs "${issue.config_key}"`,
|
|
3651
|
+
before: `# PROBLEM: Enum defines "${issue.category}" but Config.tasks has "${issue.config_key}"
|
|
3652
|
+
# The workflow looks for Config.tasks["${issue.category}"] which doesn't exist!`,
|
|
3653
|
+
after: `# OPTION 1: Rename the enum category to match config:
|
|
3654
|
+
# In enumType.options, change:
|
|
3655
|
+
- name: "${issue.category}" # OLD
|
|
3656
|
+
+ name: "${issue.config_key}" # NEW (matches Config.tasks)
|
|
3657
|
+
|
|
3658
|
+
# OPTION 2: Add the missing key to Config.tasks:
|
|
3659
|
+
# In your fixed_response config, add:
|
|
3660
|
+
"tasks": {
|
|
3661
|
+
"${issue.category}": "... task instructions ..." # ADD THIS
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
# OPTION 3: If both names should exist, add both to Config.tasks:
|
|
3665
|
+
"tasks": {
|
|
3666
|
+
"${issue.config_key}": "...", # existing
|
|
3667
|
+
"${issue.category}": "..." # add alias or separate task
|
|
3668
|
+
}`,
|
|
3669
|
+
validation: `Verify enum category name matches Config.tasks key exactly`,
|
|
3670
|
+
};
|
|
3671
|
+
break;
|
|
3672
|
+
case "late_categorizer":
|
|
3673
|
+
fix = {
|
|
3674
|
+
issue_type: issue.type,
|
|
3675
|
+
description: `Move categorizer "${issue.node}" earlier in the workflow`,
|
|
3676
|
+
before: `# PROBLEM: Heavy processing runs BEFORE categorization
|
|
3677
|
+
# Nodes before categorizer: ${issue.nodes?.join(", ")}
|
|
3678
|
+
# This wastes compute for ALL requests, not just the branch that needs it.`,
|
|
3679
|
+
after: `# RECOMMENDED STRUCTURE: Categorize FIRST, process LATE
|
|
3680
|
+
#
|
|
3681
|
+
# ✅ EFFICIENT:
|
|
3682
|
+
# trigger → categorizer → [branch A: search + LLM]
|
|
3683
|
+
# → [branch B: search + LLM]
|
|
3684
|
+
# → [branch C: just LLM]
|
|
3685
|
+
#
|
|
3686
|
+
# ❌ WASTEFUL:
|
|
3687
|
+
# trigger → summarize → search → categorizer → [branches]
|
|
3688
|
+
# (ALL requests pay this cost!)
|
|
3689
|
+
#
|
|
3690
|
+
# TO FIX:
|
|
3691
|
+
# 1. Move summarization/search to AFTER categorizer, inside each branch
|
|
3692
|
+
# 2. OR use a lightweight "pre-categorizer" that only needs trigger.user_query
|
|
3693
|
+
#
|
|
3694
|
+
# Minimal categorizer pattern:
|
|
3695
|
+
- name: "intent_classifier"
|
|
3696
|
+
action:
|
|
3697
|
+
name:
|
|
3698
|
+
namespaces: ["actions", "emainternal"]
|
|
3699
|
+
name: "chat_categorizer"
|
|
3700
|
+
inputs:
|
|
3701
|
+
conversation:
|
|
3702
|
+
actionOutput:
|
|
3703
|
+
actionName: "trigger"
|
|
3704
|
+
output: "chat_conversation" # Light input, no heavy processing
|
|
3705
|
+
# Optional: provide examples in custom_data for better accuracy`,
|
|
3706
|
+
validation: `Verify categorizer runs early with minimal upstream processing`,
|
|
3707
|
+
};
|
|
3708
|
+
break;
|
|
3709
|
+
case "excessive_llm_calls":
|
|
3710
|
+
fix = {
|
|
3711
|
+
issue_type: issue.type,
|
|
3712
|
+
description: `Reduce LLM calls from ${issue.llm_count} to ≤3 per request`,
|
|
3713
|
+
before: `# Current path has ${issue.llm_count} LLM calls: ${issue.nodes?.join(" → ")}
|
|
3714
|
+
# Each call adds 1-3 seconds. Total: ${(issue.llm_count ?? 0) * 2}+ seconds worst case.
|
|
3715
|
+
# Users typically abandon after 5-8 seconds.`,
|
|
3716
|
+
after: `# STRATEGIES TO REDUCE LLM CALLS:
|
|
3717
|
+
#
|
|
3718
|
+
# 1. CONSOLIDATE: Merge multiple LLM tasks into one
|
|
3719
|
+
# Instead of: summarize → extract → respond (3 calls)
|
|
3720
|
+
# Use: single call_llm with combined instructions
|
|
3721
|
+
#
|
|
3722
|
+
# 2. PARALLELIZE: Run independent LLMs simultaneously
|
|
3723
|
+
# Instead of: A → B → C (sequential)
|
|
3724
|
+
# Use: [A, B, C in parallel] → combine (1 effective wait)
|
|
3725
|
+
#
|
|
3726
|
+
# 3. ELIMINATE: Remove unnecessary LLM steps
|
|
3727
|
+
# - Use json_mapper instead of LLM for simple transformations
|
|
3728
|
+
# - Use fixed_response for static content
|
|
3729
|
+
# - Use entity_extraction (1 call) instead of custom_agent (1 call)
|
|
3730
|
+
#
|
|
3731
|
+
# 4. CACHE: Reuse results across requests
|
|
3732
|
+
# - Pre-compute common responses
|
|
3733
|
+
# - Store client summaries in knowledge base
|
|
3734
|
+
#
|
|
3735
|
+
# RECOMMENDED: Max 3 LLM calls per request
|
|
3736
|
+
# - 1x categorizer/extraction (optional, can use rules)
|
|
3737
|
+
# - 1x main processing (search + reasoning)
|
|
3738
|
+
# - 1x response formatting (if needed)`,
|
|
3739
|
+
validation: `Verify critical paths have ≤3 LLM calls total`,
|
|
3740
|
+
};
|
|
3741
|
+
break;
|
|
2808
3742
|
}
|
|
2809
3743
|
if (fix) {
|
|
2810
3744
|
fixes.push(fix);
|