@ema.co/mcp-toolkit 2026.2.5 → 2026.2.19
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/.context/public/guides/ema-user-guide.md +12 -16
- package/.context/public/guides/mcp-tools-guide.md +203 -334
- package/LICENSE +29 -21
- package/README.md +58 -35
- package/dist/mcp/domain/loop-detection.js +97 -0
- package/dist/mcp/domain/proto-constraints.js +284 -0
- package/dist/mcp/domain/structural-rules.js +12 -5
- package/dist/mcp/domain/validation-rules.js +107 -20
- package/dist/mcp/domain/workflow-graph-optimizer.js +235 -0
- package/dist/mcp/domain/workflow-graph-transforms.js +808 -0
- package/dist/mcp/domain/workflow-graph.js +374 -0
- package/dist/mcp/domain/workflow-optimizer.js +10 -4
- package/dist/mcp/guidance.js +54 -31
- package/dist/mcp/handlers/feedback/index.js +139 -0
- package/dist/mcp/handlers/feedback/store.js +262 -0
- package/dist/mcp/handlers/persona/index.js +237 -8
- package/dist/mcp/handlers/persona/schema.js +27 -0
- package/dist/mcp/handlers/reference/index.js +6 -4
- package/dist/mcp/handlers/workflow/index.js +25 -28
- package/dist/mcp/handlers/workflow/optimize.js +73 -33
- package/dist/mcp/handlers/workflow/validation.js +1 -1
- package/dist/mcp/knowledge-types.js +7 -0
- package/dist/mcp/knowledge.js +146 -834
- package/dist/mcp/resources.js +610 -18
- package/dist/mcp/server.js +233 -2156
- package/dist/mcp/tools.js +91 -5
- package/dist/sdk/generated/agent-catalog.js +615 -0
- package/dist/sdk/generated/deprecated-actions.js +182 -96
- package/dist/sdk/generated/proto-fields.js +2 -1
- package/dist/sdk/generated/protos/service/agent_qa/v1/agent_qa_pb.js +460 -21
- package/dist/sdk/generated/protos/service/auth/v1/auth_pb.js +11 -1
- package/dist/sdk/generated/protos/service/dataingest/v1/dataingest_pb.js +173 -66
- package/dist/sdk/generated/protos/service/feedback/v1/feedback_pb.js +43 -1
- package/dist/sdk/generated/protos/service/llmservice/v1/llmservice_pb.js +26 -21
- package/dist/sdk/generated/protos/service/persona/v1/persona_config_pb.js +100 -89
- package/dist/sdk/generated/protos/service/persona/v1/persona_pb.js +126 -116
- package/dist/sdk/generated/protos/service/persona/v1/shared_widgets/widget_types_pb.js +33 -1
- package/dist/sdk/generated/protos/service/persona/v1/voicebot_widgets/widget_types_pb.js +60 -11
- package/dist/sdk/generated/protos/service/tenant/v1/tenant_pb.js +1 -1
- package/dist/sdk/generated/protos/service/user/v1/user_pb.js +1 -1
- package/dist/sdk/generated/protos/service/utils/v1/agent_qa_pb.js +35 -0
- package/dist/sdk/generated/protos/service/workflows/v1/action_registry_pb.js +1 -1
- package/dist/sdk/generated/protos/service/workflows/v1/action_type_pb.js +6 -1
- package/dist/sdk/generated/protos/service/workflows/v1/chatbot_pb.js +106 -11
- package/dist/sdk/generated/protos/service/workflows/v1/common_forms_pb.js +1 -1
- package/dist/sdk/generated/protos/service/workflows/v1/coordinator_pb.js +1 -1
- package/dist/sdk/generated/protos/service/workflows/v1/external_actions_pb.js +31 -1
- package/dist/sdk/generated/protos/service/workflows/v1/well_known_pb.js +5 -1
- package/dist/sdk/generated/protos/service/workflows/v1/workflow_pb.js +1 -1
- package/dist/sdk/generated/protos/util/tracking_metadata_pb.js +1 -1
- package/dist/sdk/generated/widget-catalog.js +60 -0
- package/docs/README.md +17 -9
- package/package.json +2 -2
- package/.context/public/guides/dashboard-operations.md +0 -286
- package/.context/public/guides/email-patterns.md +0 -125
- package/dist/mcp/domain/intent-architect.js +0 -914
- package/dist/mcp/domain/quality-gates.js +0 -110
- package/dist/mcp/domain/workflow-execution-analyzer.js +0 -412
- package/dist/mcp/domain/workflow-intent.js +0 -1806
- package/dist/mcp/domain/workflow-merge.js +0 -449
- package/dist/mcp/domain/workflow-tracer.js +0 -648
- package/dist/mcp/domain/workflow-transformer.js +0 -742
- package/dist/mcp/handlers/persona/intent.js +0 -141
- package/dist/mcp/handlers/workflow/analyze.js +0 -119
- package/dist/mcp/handlers/workflow/compare.js +0 -70
- package/dist/mcp/handlers/workflow/generate.js +0 -384
- package/dist/mcp/handlers-consolidated.js +0 -333
|
@@ -135,7 +135,7 @@ export const ANTI_PATTERNS = [
|
|
|
135
135
|
pattern: "send_email_agent without entity_extraction to validate recipient data",
|
|
136
136
|
problem: "Sending emails without extracting and validating recipient data risks sending to wrong people or with wrong content. Emails are high-impact actions with external side effects.",
|
|
137
137
|
solution: "Always: 1) Extract required fields (email_address, subject) via entity_extraction, 2) Validate completeness via categorizer, 3) Ask user if missing. " +
|
|
138
|
-
"For approval: enable the HITL flag on send_email_agent (
|
|
138
|
+
"For approval: enable the HITL flag on send_email_agent (disable_human_interaction: false). general_hitl is NOT deployable.",
|
|
139
139
|
detection: {
|
|
140
140
|
issueType: "incomplete_email_validation",
|
|
141
141
|
condition: "send_email_agent without preceding entity_extraction node to extract/validate recipient data",
|
|
@@ -195,15 +195,15 @@ export const ANTI_PATTERNS = [
|
|
|
195
195
|
name: "Incomplete HITL Paths",
|
|
196
196
|
pattern: "HITL with only success path",
|
|
197
197
|
problem: "Rejected requests have no handling, leaving users without response.",
|
|
198
|
-
solution: "If workflow already has general_hitl node: ALWAYS implement both success AND failure paths. For NEW workflows: use HITL flag on the agent
|
|
198
|
+
solution: "If workflow already has general_hitl node: ALWAYS implement both success AND failure paths. For NEW workflows: use HITL flag on the agent (only entity_extraction_with_documents and send_email_agent support HITL). general_hitl is NOT deployable.",
|
|
199
199
|
detection: {
|
|
200
200
|
issueType: "incomplete_hitl",
|
|
201
201
|
condition: "HITL node missing 'hitl_status_HITL Success' or 'hitl_status_HITL Failure' edge (note: space, not underscore)",
|
|
202
202
|
},
|
|
203
203
|
severity: "critical",
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// This
|
|
204
|
+
// general_hitl is NOT deployable — it appears in catalogs but cannot be deployed.
|
|
205
|
+
// HITL is a flag on entity_extraction_with_documents and send_email_agent only.
|
|
206
|
+
// external_action_caller does NOT support HITL. This rule still fires for legacy workflows.
|
|
207
207
|
},
|
|
208
208
|
{
|
|
209
209
|
id: "orphan-nodes",
|
|
@@ -255,13 +255,15 @@ export const ANTI_PATTERNS = [
|
|
|
255
255
|
},
|
|
256
256
|
{
|
|
257
257
|
id: "missing-workflow-output",
|
|
258
|
-
name: "Missing
|
|
259
|
-
pattern: "
|
|
260
|
-
problem: "
|
|
261
|
-
solution: "
|
|
258
|
+
name: "Missing results Mapping",
|
|
259
|
+
pattern: "Action outputs not mapped in workflow results",
|
|
260
|
+
problem: "Outputs never reach the user or dashboard.",
|
|
261
|
+
solution: "Chat/Voice: map terminal response nodes to WORKFLOW_OUTPUT. " +
|
|
262
|
+
"Dashboard: map extraction/validation outputs using dot-notation keys " +
|
|
263
|
+
"(e.g., 'extraction_node.extraction_columns': { actionName: '...', outputName: 'extraction_columns' }).",
|
|
262
264
|
detection: {
|
|
263
265
|
issueType: "missing_workflow_output",
|
|
264
|
-
condition: "No
|
|
266
|
+
condition: "No results mapping, or terminal output nodes not connected to results",
|
|
265
267
|
},
|
|
266
268
|
severity: "critical",
|
|
267
269
|
},
|
|
@@ -439,30 +441,30 @@ export const ANTI_PATTERNS = [
|
|
|
439
441
|
{
|
|
440
442
|
id: "chat-conversation-without-chat-response",
|
|
441
443
|
name: "Chat Conversation Not Wired to Chat Response Node",
|
|
442
|
-
pattern: "Chat workflow where chat_conversation is consumed by processing nodes but never wired to a chat-aware response node (
|
|
444
|
+
pattern: "Chat workflow where chat_conversation is consumed by processing nodes but never wired to a chat-aware response node (respond_for_external_actions)",
|
|
443
445
|
problem: "When chat_conversation is processed by nodes like entity_extraction, call_llm, or search, " +
|
|
444
446
|
"but the final response is generated by a generic call_llm (not a chat-aware response node), " +
|
|
445
447
|
"the response node lacks conversation history context. This causes:\n" +
|
|
446
448
|
"1. Duplicate/repetitive responses — the LLM re-asks questions already answered in conversation\n" +
|
|
447
449
|
"2. Lost context — follow-up messages lose prior context\n" +
|
|
448
450
|
"3. Hallucinated greetings — the LLM generates its own greeting instead of continuing the conversation\n\n" +
|
|
449
|
-
"The
|
|
450
|
-
"
|
|
451
|
+
"The respond_for_external_actions node is specifically designed to handle conversation history " +
|
|
452
|
+
"and produce contextually appropriate responses (replacing the deprecated respond_with_sources).",
|
|
451
453
|
solution: "Wire chat_conversation through to a chat-aware response node:\n" +
|
|
452
|
-
"• For search
|
|
453
|
-
"• For tool/action results: use respond_for_external_actions (takes query + external_action_result)\n" +
|
|
454
|
+
"• For search results or tool/action results: use respond_for_external_actions (takes query + conversation + external_action_result)\n" +
|
|
454
455
|
"• If using call_llm as responder: wire chat_conversation into named_inputs so the LLM sees full history\n\n" +
|
|
455
456
|
"Correct patterns:\n" +
|
|
456
|
-
" chat_trigger → search →
|
|
457
|
+
" chat_trigger → search → respond_for_external_actions → WORKFLOW_OUTPUT\n" +
|
|
457
458
|
" chat_trigger → external_action_caller → respond_for_external_actions → WORKFLOW_OUTPUT\n" +
|
|
458
459
|
" chat_trigger → call_llm(named_inputs includes chat_conversation) → WORKFLOW_OUTPUT\n\n" +
|
|
459
460
|
"Anti-patterns:\n" +
|
|
460
461
|
" ❌ chat_trigger → search → call_llm (no conversation context) → WORKFLOW_OUTPUT\n" +
|
|
461
|
-
" ❌ chat_trigger → entity_extraction → call_llm (stateless) → WORKFLOW_OUTPUT"
|
|
462
|
+
" ❌ chat_trigger → entity_extraction → call_llm (stateless) → WORKFLOW_OUTPUT\n" +
|
|
463
|
+
" ❌ chat_trigger → search → respond_with_sources (DEPRECATED) → WORKFLOW_OUTPUT",
|
|
462
464
|
detection: {
|
|
463
465
|
issueType: "chat_conversation_not_wired_to_response",
|
|
464
466
|
condition: "Chat workflow (chat_trigger) where terminal response node is call_llm without chat_conversation in named_inputs, " +
|
|
465
|
-
"and
|
|
467
|
+
"and respond_for_external_actions is not used",
|
|
466
468
|
},
|
|
467
469
|
severity: "critical",
|
|
468
470
|
},
|
|
@@ -494,6 +496,75 @@ export const ANTI_PATTERNS = [
|
|
|
494
496
|
},
|
|
495
497
|
severity: "critical",
|
|
496
498
|
},
|
|
499
|
+
{
|
|
500
|
+
id: "partial-output-wiring",
|
|
501
|
+
name: "Gated Respond Nodes Missing WORKFLOW_OUTPUT Wiring",
|
|
502
|
+
pattern: "Multiple call_llm/respond nodes gated by categorizer (trigger_when/runIf), but only some connected to WORKFLOW_OUTPUT",
|
|
503
|
+
problem: "When intent routing gates multiple respond nodes via trigger_when, ALL branches must wire to WORKFLOW_OUTPUT. " +
|
|
504
|
+
"If only one branch is wired (e.g., get_respond → WORKFLOW_OUTPUT but schedule_respond is not), " +
|
|
505
|
+
"the other branches execute and produce responses that are silently lost — the user gets no reply for those intents.",
|
|
506
|
+
solution: "Ensure every gated respond node maps to WORKFLOW_OUTPUT:\n\n" +
|
|
507
|
+
"Option A — Wire all respond nodes to WORKFLOW_OUTPUT:\n" +
|
|
508
|
+
" get_respond.response_with_sources → WORKFLOW_OUTPUT\n" +
|
|
509
|
+
" schedule_respond.response_with_sources → WORKFLOW_OUTPUT\n" +
|
|
510
|
+
" reschedule_respond.response_with_sources → WORKFLOW_OUTPUT\n" +
|
|
511
|
+
" fallback.response → WORKFLOW_OUTPUT\n\n" +
|
|
512
|
+
"Option B — Consolidate into a single respond node that receives the category as named_input:\n" +
|
|
513
|
+
" categorizer.category → call_llm.named_inputs_Intent\n" +
|
|
514
|
+
" call_llm.response_with_sources → WORKFLOW_OUTPUT\n\n" +
|
|
515
|
+
"Option B is preferred when all branches share similar prompts and differ only by intent.",
|
|
516
|
+
detection: {
|
|
517
|
+
issueType: "partial_output_wiring",
|
|
518
|
+
condition: "Workflow has categorizer/router with multiple gated call_llm/respond nodes, " +
|
|
519
|
+
"but not all of them are mapped to WORKFLOW_OUTPUT resultMappings",
|
|
520
|
+
},
|
|
521
|
+
severity: "critical",
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: "duplicate-identical-llm-nodes",
|
|
525
|
+
name: "Duplicate LLM Nodes Differing Only by Gate",
|
|
526
|
+
pattern: "Multiple call_llm nodes with identical inputs (query, named_inputs, instructions) that differ only in trigger_when",
|
|
527
|
+
problem: "When several call_llm nodes share the same prompt, search results, and conversation inputs — " +
|
|
528
|
+
"differing only in their trigger_when condition — the workflow has unnecessary complexity. " +
|
|
529
|
+
"Each duplicate requires separate output wiring, separate prompt maintenance, and increases " +
|
|
530
|
+
"the risk of partial-output-wiring bugs (one branch silently loses responses).",
|
|
531
|
+
solution: "Consolidate into a single call_llm node and pass the intent as context:\n\n" +
|
|
532
|
+
"1. Remove the duplicate respond nodes\n" +
|
|
533
|
+
"2. Create one call_llm node that always runs (or runs when not Fallback)\n" +
|
|
534
|
+
"3. Wire categorizer.category → call_llm.named_inputs_Intent\n" +
|
|
535
|
+
"4. Update the prompt to: 'Based on the detected intent ({{Intent}}), respond accordingly'\n" +
|
|
536
|
+
"5. Wire the single call_llm.response_with_sources → WORKFLOW_OUTPUT\n\n" +
|
|
537
|
+
"Keep separate nodes ONLY when intents require different tools, search sources, or safety constraints.",
|
|
538
|
+
detection: {
|
|
539
|
+
issueType: "duplicate_identical_llm_nodes",
|
|
540
|
+
condition: "Two or more call_llm nodes with same query source, same named_inputs (search results, conversation), " +
|
|
541
|
+
"and only trigger_when differs",
|
|
542
|
+
},
|
|
543
|
+
severity: "warning",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: "overscoped-extraction-inputs",
|
|
547
|
+
name: "Extraction Nodes Receiving Excessive Context",
|
|
548
|
+
pattern: "Entity extraction call_llm nodes wired to full search_results and conversation when only current_message is needed",
|
|
549
|
+
problem: "When extracting specific entities (emails, dates, IDs) from the user's current message, " +
|
|
550
|
+
"passing full search_results and complete conversation history increases token usage and " +
|
|
551
|
+
"can cause hallucinated extractions — the model may 'find' entities in search results " +
|
|
552
|
+
"that weren't in the user's actual message.",
|
|
553
|
+
solution: "Scope extraction inputs to the minimum needed:\n\n" +
|
|
554
|
+
"For extracting from user's current message:\n" +
|
|
555
|
+
" ✅ trigger.user_query → extract.named_inputs_Current_Message\n" +
|
|
556
|
+
" ❌ search.search_results → extract.named_inputs_Search_Results (unnecessary)\n\n" +
|
|
557
|
+
"For extracting from documents/search results:\n" +
|
|
558
|
+
" ✅ search.search_results → extract.named_inputs_Documents (intentional)\n" +
|
|
559
|
+
" Use entity_extraction_with_documents instead of call_llm for grounded extraction\n\n" +
|
|
560
|
+
"Include conversation only when entity might span multiple turns (e.g., user gave email 3 messages ago).",
|
|
561
|
+
detection: {
|
|
562
|
+
issueType: "overscoped_extraction_inputs",
|
|
563
|
+
condition: "call_llm node configured for extraction (prompt mentions 'extract') " +
|
|
564
|
+
"receives search_results when extraction target is in user_query",
|
|
565
|
+
},
|
|
566
|
+
severity: "info",
|
|
567
|
+
},
|
|
497
568
|
];
|
|
498
569
|
export const OPTIMIZATION_RULES = [
|
|
499
570
|
{
|
|
@@ -516,8 +587,8 @@ export const OPTIMIZATION_RULES = [
|
|
|
516
587
|
id: "use-purpose-built",
|
|
517
588
|
name: "Use Purpose-Built Response Nodes",
|
|
518
589
|
currentState: "Using call_llm for search-based responses",
|
|
519
|
-
recommendation: "Use
|
|
520
|
-
benefit: "Better citations, grounded responses, less configuration",
|
|
590
|
+
recommendation: "Use respond_for_external_actions instead - it has built-in citation handling and conversation awareness (respond_with_sources is deprecated)",
|
|
591
|
+
benefit: "Better citations, grounded responses, conversation context, less configuration",
|
|
521
592
|
priority: "medium",
|
|
522
593
|
},
|
|
523
594
|
{
|
|
@@ -528,6 +599,22 @@ export const OPTIMIZATION_RULES = [
|
|
|
528
599
|
benefit: "Reduced token usage, faster processing",
|
|
529
600
|
priority: "low",
|
|
530
601
|
},
|
|
602
|
+
{
|
|
603
|
+
id: "consolidate-respond-nodes",
|
|
604
|
+
name: "Consolidate Identical Respond Nodes",
|
|
605
|
+
currentState: "Multiple call_llm respond nodes with same inputs (query, search_results, conversation) differing only by trigger_when gate",
|
|
606
|
+
recommendation: "Replace with single call_llm that receives categorizer.category as named_inputs_Intent, with prompt adapting by intent",
|
|
607
|
+
benefit: "Single output wiring to WORKFLOW_OUTPUT, single prompt to maintain, eliminates partial-output-wiring bugs",
|
|
608
|
+
priority: "high",
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "scope-extraction-inputs",
|
|
612
|
+
name: "Scope Extraction Inputs to Minimum Needed",
|
|
613
|
+
currentState: "Extraction nodes (call_llm for entity extraction) receiving full search_results and conversation when only user_query is needed",
|
|
614
|
+
recommendation: "Wire only trigger.user_query to extraction nodes. Add conversation only when entity may span multiple turns.",
|
|
615
|
+
benefit: "Reduced token usage, fewer hallucinated extractions from irrelevant context",
|
|
616
|
+
priority: "medium",
|
|
617
|
+
},
|
|
531
618
|
];
|
|
532
619
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
620
|
// Helper Functions
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Graph Optimizer Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Ties the graph and transforms together into a single `optimizeWorkflow()` function.
|
|
5
|
+
* Pure function — never mutates input, never throws, no side effects.
|
|
6
|
+
*/
|
|
7
|
+
import { buildWorkflowGraph, getReachableNodes, getTopologicalOrder, } from "./workflow-graph.js";
|
|
8
|
+
import { AUTO_TRANSFORMS, ADVISORY_TRANSFORMS, } from "./workflow-graph-transforms.js";
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Helpers
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
function deepClone(obj) {
|
|
13
|
+
return JSON.parse(JSON.stringify(obj));
|
|
14
|
+
}
|
|
15
|
+
function calculateMetrics(workflowDef, graph) {
|
|
16
|
+
const nodeCount = graph.nodes.size;
|
|
17
|
+
const edgeCount = graph.edges.length;
|
|
18
|
+
// Dead nodes: total - reachable from trigger
|
|
19
|
+
const reachable = getReachableNodes(graph);
|
|
20
|
+
const deadNodes = nodeCount - reachable.size;
|
|
21
|
+
// Cycles: if topological order cannot include all nodes, there are cycles
|
|
22
|
+
const topoOrder = getTopologicalOrder(graph);
|
|
23
|
+
const cycles = nodeCount > 0 && topoOrder.length < nodeCount
|
|
24
|
+
? nodeCount - topoOrder.length
|
|
25
|
+
: 0;
|
|
26
|
+
return { nodeCount, edgeCount, deadNodes, cycles };
|
|
27
|
+
}
|
|
28
|
+
function generateImprovementSummary(before, after) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
const removedDead = before.deadNodes - after.deadNodes;
|
|
31
|
+
if (removedDead > 0) {
|
|
32
|
+
parts.push(`Removed ${removedDead} dead node${removedDead > 1 ? "s" : ""}`);
|
|
33
|
+
}
|
|
34
|
+
const brokenCycles = before.cycles - after.cycles;
|
|
35
|
+
if (brokenCycles > 0) {
|
|
36
|
+
parts.push(`broke ${brokenCycles} cycle${brokenCycles > 1 ? "s" : ""}`);
|
|
37
|
+
}
|
|
38
|
+
const removedNodes = before.nodeCount - after.nodeCount;
|
|
39
|
+
if (removedNodes > 0 && removedDead === 0) {
|
|
40
|
+
parts.push(`Removed ${removedNodes} node${removedNodes > 1 ? "s" : ""}`);
|
|
41
|
+
}
|
|
42
|
+
const removedEdges = before.edgeCount - after.edgeCount;
|
|
43
|
+
if (removedEdges > 0) {
|
|
44
|
+
parts.push(`removed ${removedEdges} edge${removedEdges > 1 ? "s" : ""}`);
|
|
45
|
+
}
|
|
46
|
+
if (parts.length === 0) {
|
|
47
|
+
return "No changes needed.";
|
|
48
|
+
}
|
|
49
|
+
let summary = parts.join(", ");
|
|
50
|
+
summary += `. ${before.nodeCount} \u2192 ${after.nodeCount} nodes.`;
|
|
51
|
+
return summary;
|
|
52
|
+
}
|
|
53
|
+
function validateWorkflow(workflowDef) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
// Check actions array exists and is non-empty
|
|
57
|
+
const actions = workflowDef?.actions;
|
|
58
|
+
if (!actions || !Array.isArray(actions)) {
|
|
59
|
+
errors.push("Workflow has no actions array");
|
|
60
|
+
return { valid: false, errors, warnings };
|
|
61
|
+
}
|
|
62
|
+
if (actions.length === 0) {
|
|
63
|
+
errors.push("Workflow actions array is empty");
|
|
64
|
+
return { valid: false, errors, warnings };
|
|
65
|
+
}
|
|
66
|
+
// Check all nodes have name and action fields
|
|
67
|
+
for (let i = 0; i < actions.length; i++) {
|
|
68
|
+
const action = actions[i];
|
|
69
|
+
if (!action || typeof action !== "object") {
|
|
70
|
+
errors.push(`Action at index ${i} is not an object`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!action.name) {
|
|
74
|
+
errors.push(`Action at index ${i} has no name`);
|
|
75
|
+
}
|
|
76
|
+
if (!action.action) {
|
|
77
|
+
errors.push(`Action "${action.name ?? `index ${i}`}" has no action definition`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Check results object exists
|
|
81
|
+
if (!workflowDef.results || typeof workflowDef.results !== "object") {
|
|
82
|
+
warnings.push("Workflow has no results object");
|
|
83
|
+
}
|
|
84
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
85
|
+
}
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// Main Entry Point
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Optimize a workflow_def by applying auto-transforms and collecting advisories.
|
|
91
|
+
*
|
|
92
|
+
* Pure function: never mutates input, never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function optimizeWorkflow(workflowDef, options) {
|
|
95
|
+
const autoApply = options?.autoApply ?? true;
|
|
96
|
+
const maxPasses = options?.maxPasses ?? 5;
|
|
97
|
+
// Graceful handling of invalid input
|
|
98
|
+
if (!workflowDef || typeof workflowDef !== "object") {
|
|
99
|
+
return {
|
|
100
|
+
workflowDef: workflowDef ?? {},
|
|
101
|
+
modified: false,
|
|
102
|
+
appliedTransforms: [],
|
|
103
|
+
advisories: [],
|
|
104
|
+
metrics: {
|
|
105
|
+
before: { nodeCount: 0, edgeCount: 0, deadNodes: 0, cycles: 0 },
|
|
106
|
+
after: { nodeCount: 0, edgeCount: 0, deadNodes: 0, cycles: 0 },
|
|
107
|
+
improvement: "No changes needed.",
|
|
108
|
+
},
|
|
109
|
+
validation: { valid: false, errors: ["Invalid workflow_def input"], warnings: [] },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
// Step 1: Deep-clone input (never mutate)
|
|
114
|
+
let currentWorkflowDef = deepClone(workflowDef);
|
|
115
|
+
// Step 2: Calculate "before" metrics
|
|
116
|
+
let graph = buildWorkflowGraph(currentWorkflowDef);
|
|
117
|
+
const beforeMetrics = calculateMetrics(currentWorkflowDef, graph);
|
|
118
|
+
// Step 3: Auto-apply transforms (convergence loop)
|
|
119
|
+
const appliedTransforms = [];
|
|
120
|
+
if (autoApply && maxPasses > 0) {
|
|
121
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
122
|
+
let anyApplied = false;
|
|
123
|
+
for (const transform of AUTO_TRANSFORMS) {
|
|
124
|
+
try {
|
|
125
|
+
const result = transform.apply(currentWorkflowDef, graph);
|
|
126
|
+
if (result && result.applied) {
|
|
127
|
+
currentWorkflowDef = result.workflowDef;
|
|
128
|
+
graph = buildWorkflowGraph(currentWorkflowDef);
|
|
129
|
+
appliedTransforms.push({
|
|
130
|
+
transformId: transform.id,
|
|
131
|
+
description: result.description,
|
|
132
|
+
nodesRemoved: result.nodesRemoved,
|
|
133
|
+
nodesModified: result.nodesModified,
|
|
134
|
+
});
|
|
135
|
+
anyApplied = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Individual transform failure: skip, continue with others
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!anyApplied)
|
|
143
|
+
break; // Converged
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Step 4: Calculate "after" metrics
|
|
147
|
+
graph = buildWorkflowGraph(currentWorkflowDef);
|
|
148
|
+
const afterMetrics = calculateMetrics(currentWorkflowDef, graph);
|
|
149
|
+
// Step 5: Run advisory transforms
|
|
150
|
+
const advisories = [];
|
|
151
|
+
for (const transform of ADVISORY_TRANSFORMS) {
|
|
152
|
+
try {
|
|
153
|
+
const result = transform.apply(currentWorkflowDef, graph);
|
|
154
|
+
if (result && !result.applied) {
|
|
155
|
+
// Advisory: applied=false means it's a finding, not a fix
|
|
156
|
+
// Extract affected nodes from the description or result fields
|
|
157
|
+
const affectedNodes = extractAffectedNodes(result);
|
|
158
|
+
advisories.push({
|
|
159
|
+
id: transform.id,
|
|
160
|
+
description: result.description,
|
|
161
|
+
affectedNodes,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Advisory failure: skip
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Step 6: Generate improvement summary
|
|
170
|
+
const improvement = generateImprovementSummary(beforeMetrics, afterMetrics);
|
|
171
|
+
// Step 7: Post-optimization validation
|
|
172
|
+
const validation = validateWorkflow(currentWorkflowDef);
|
|
173
|
+
return {
|
|
174
|
+
workflowDef: currentWorkflowDef,
|
|
175
|
+
modified: appliedTransforms.length > 0,
|
|
176
|
+
appliedTransforms,
|
|
177
|
+
advisories,
|
|
178
|
+
metrics: {
|
|
179
|
+
before: beforeMetrics,
|
|
180
|
+
after: afterMetrics,
|
|
181
|
+
improvement,
|
|
182
|
+
},
|
|
183
|
+
validation,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Top-level safety net: never throw
|
|
188
|
+
return {
|
|
189
|
+
workflowDef: deepClone(workflowDef),
|
|
190
|
+
modified: false,
|
|
191
|
+
appliedTransforms: [],
|
|
192
|
+
advisories: [],
|
|
193
|
+
metrics: {
|
|
194
|
+
before: { nodeCount: 0, edgeCount: 0, deadNodes: 0, cycles: 0 },
|
|
195
|
+
after: { nodeCount: 0, edgeCount: 0, deadNodes: 0, cycles: 0 },
|
|
196
|
+
improvement: "No changes needed.",
|
|
197
|
+
},
|
|
198
|
+
validation: { valid: false, errors: ["Optimization failed unexpectedly"], warnings: [] },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Extract affected node IDs from a transform result.
|
|
204
|
+
* Combines nodesRemoved and nodesModified, falling back to parsing description.
|
|
205
|
+
*/
|
|
206
|
+
function extractAffectedNodes(result) {
|
|
207
|
+
const nodes = new Set();
|
|
208
|
+
for (const n of result.nodesRemoved)
|
|
209
|
+
nodes.add(n);
|
|
210
|
+
for (const n of result.nodesModified)
|
|
211
|
+
nodes.add(n);
|
|
212
|
+
for (const n of result.nodesAdded)
|
|
213
|
+
nodes.add(n);
|
|
214
|
+
// If no nodes found from fields, try to extract from description
|
|
215
|
+
if (nodes.size === 0 && result.description) {
|
|
216
|
+
// Match quoted node names in descriptions like: search_1 <-> search_2
|
|
217
|
+
const matches = result.description.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g);
|
|
218
|
+
if (matches) {
|
|
219
|
+
// Filter out common English words to keep only node-like identifiers
|
|
220
|
+
const commonWords = new Set([
|
|
221
|
+
"Found", "pair", "pairs", "of", "similar", "nodes", "with", "the",
|
|
222
|
+
"same", "action", "type", "but", "different", "inputs", "Categorizer",
|
|
223
|
+
"has", "only", "non", "Fallback", "category", "branching", "adds",
|
|
224
|
+
"no", "value", "Nested", "categorizer", "chain", "consider",
|
|
225
|
+
"flattening", "a", "and", "or", "is", "in", "for",
|
|
226
|
+
]);
|
|
227
|
+
for (const m of matches) {
|
|
228
|
+
if (!commonWords.has(m) && m.length > 2) {
|
|
229
|
+
nodes.add(m);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [...nodes];
|
|
235
|
+
}
|