@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.

Files changed (67) hide show
  1. package/.context/public/guides/ema-user-guide.md +12 -16
  2. package/.context/public/guides/mcp-tools-guide.md +203 -334
  3. package/LICENSE +29 -21
  4. package/README.md +58 -35
  5. package/dist/mcp/domain/loop-detection.js +97 -0
  6. package/dist/mcp/domain/proto-constraints.js +284 -0
  7. package/dist/mcp/domain/structural-rules.js +12 -5
  8. package/dist/mcp/domain/validation-rules.js +107 -20
  9. package/dist/mcp/domain/workflow-graph-optimizer.js +235 -0
  10. package/dist/mcp/domain/workflow-graph-transforms.js +808 -0
  11. package/dist/mcp/domain/workflow-graph.js +374 -0
  12. package/dist/mcp/domain/workflow-optimizer.js +10 -4
  13. package/dist/mcp/guidance.js +54 -31
  14. package/dist/mcp/handlers/feedback/index.js +139 -0
  15. package/dist/mcp/handlers/feedback/store.js +262 -0
  16. package/dist/mcp/handlers/persona/index.js +237 -8
  17. package/dist/mcp/handlers/persona/schema.js +27 -0
  18. package/dist/mcp/handlers/reference/index.js +6 -4
  19. package/dist/mcp/handlers/workflow/index.js +25 -28
  20. package/dist/mcp/handlers/workflow/optimize.js +73 -33
  21. package/dist/mcp/handlers/workflow/validation.js +1 -1
  22. package/dist/mcp/knowledge-types.js +7 -0
  23. package/dist/mcp/knowledge.js +146 -834
  24. package/dist/mcp/resources.js +610 -18
  25. package/dist/mcp/server.js +233 -2156
  26. package/dist/mcp/tools.js +91 -5
  27. package/dist/sdk/generated/agent-catalog.js +615 -0
  28. package/dist/sdk/generated/deprecated-actions.js +182 -96
  29. package/dist/sdk/generated/proto-fields.js +2 -1
  30. package/dist/sdk/generated/protos/service/agent_qa/v1/agent_qa_pb.js +460 -21
  31. package/dist/sdk/generated/protos/service/auth/v1/auth_pb.js +11 -1
  32. package/dist/sdk/generated/protos/service/dataingest/v1/dataingest_pb.js +173 -66
  33. package/dist/sdk/generated/protos/service/feedback/v1/feedback_pb.js +43 -1
  34. package/dist/sdk/generated/protos/service/llmservice/v1/llmservice_pb.js +26 -21
  35. package/dist/sdk/generated/protos/service/persona/v1/persona_config_pb.js +100 -89
  36. package/dist/sdk/generated/protos/service/persona/v1/persona_pb.js +126 -116
  37. package/dist/sdk/generated/protos/service/persona/v1/shared_widgets/widget_types_pb.js +33 -1
  38. package/dist/sdk/generated/protos/service/persona/v1/voicebot_widgets/widget_types_pb.js +60 -11
  39. package/dist/sdk/generated/protos/service/tenant/v1/tenant_pb.js +1 -1
  40. package/dist/sdk/generated/protos/service/user/v1/user_pb.js +1 -1
  41. package/dist/sdk/generated/protos/service/utils/v1/agent_qa_pb.js +35 -0
  42. package/dist/sdk/generated/protos/service/workflows/v1/action_registry_pb.js +1 -1
  43. package/dist/sdk/generated/protos/service/workflows/v1/action_type_pb.js +6 -1
  44. package/dist/sdk/generated/protos/service/workflows/v1/chatbot_pb.js +106 -11
  45. package/dist/sdk/generated/protos/service/workflows/v1/common_forms_pb.js +1 -1
  46. package/dist/sdk/generated/protos/service/workflows/v1/coordinator_pb.js +1 -1
  47. package/dist/sdk/generated/protos/service/workflows/v1/external_actions_pb.js +31 -1
  48. package/dist/sdk/generated/protos/service/workflows/v1/well_known_pb.js +5 -1
  49. package/dist/sdk/generated/protos/service/workflows/v1/workflow_pb.js +1 -1
  50. package/dist/sdk/generated/protos/util/tracking_metadata_pb.js +1 -1
  51. package/dist/sdk/generated/widget-catalog.js +60 -0
  52. package/docs/README.md +17 -9
  53. package/package.json +2 -2
  54. package/.context/public/guides/dashboard-operations.md +0 -286
  55. package/.context/public/guides/email-patterns.md +0 -125
  56. package/dist/mcp/domain/intent-architect.js +0 -914
  57. package/dist/mcp/domain/quality-gates.js +0 -110
  58. package/dist/mcp/domain/workflow-execution-analyzer.js +0 -412
  59. package/dist/mcp/domain/workflow-intent.js +0 -1806
  60. package/dist/mcp/domain/workflow-merge.js +0 -449
  61. package/dist/mcp/domain/workflow-tracer.js +0 -648
  62. package/dist/mcp/domain/workflow-transformer.js +0 -742
  63. package/dist/mcp/handlers/persona/intent.js +0 -141
  64. package/dist/mcp/handlers/workflow/analyze.js +0 -119
  65. package/dist/mcp/handlers/workflow/compare.js +0 -70
  66. package/dist/mcp/handlers/workflow/generate.js +0 -384
  67. 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 (do NOT add a standalone general_hitl node).",
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 instead of standalone general_hitl nodes (see hitl-patterns guidance).",
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
- // TODO: Revisit if general_hitl is re-enabled as a standalone node.
205
- // Currently HITL is a flag on agents (send_email_agent, external_action_caller).
206
- // This anti-pattern still fires for existing workflows that use general_hitl.
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 WORKFLOW_OUTPUT",
259
- pattern: "Response nodes not connected to WORKFLOW_OUTPUT",
260
- problem: "Responses never reach the user.",
261
- solution: "Connect all terminal response nodes to WORKFLOW_OUTPUT.",
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 WORKFLOW_OUTPUT node, or response nodes not connected to it",
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 (respond_with_sources or respond_for_external_actions)",
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 chat-aware response nodes (respond_with_sources, respond_for_external_actions) are specifically " +
450
- "designed to handle conversation history and produce contextually appropriate responses.",
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-based responses: use respond_with_sources (takes query + search_results)\n" +
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 → respond_with_sources → WORKFLOW_OUTPUT\n" +
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 respond_with_sources / respond_for_external_actions are not used",
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 respond_with_sources instead - it has built-in citation handling",
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
+ }