@ema.co/mcp-toolkit 2026.2.23 → 2026.2.24

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.
@@ -301,6 +301,134 @@ export const DEMO_SCENARIOS = {
301
301
  },
302
302
  ],
303
303
  },
304
+ "finance-dunning": {
305
+ id: "finance-dunning",
306
+ name: "Finance Automated Dunning",
307
+ description: "AR dunning assistant: balance inquiries, payment links, disputes, payment plans, and escalation",
308
+ persona_types: ["chat", "voice"],
309
+ tags: ["finance", "ar", "dunning", "collections", "receivables", "billing"],
310
+ intents: [
311
+ {
312
+ name: "balance_inquiry",
313
+ description: "What do I owe? Current balance and aging",
314
+ example_questions: ["What's my balance?", "Do I have any overdue invoices?", "What do I owe?"],
315
+ },
316
+ {
317
+ name: "payment_made",
318
+ description: "Customer says they already paid",
319
+ example_questions: ["I paid last week", "I already sent the wire", "Check your records"],
320
+ },
321
+ {
322
+ name: "dispute",
323
+ description: "Dispute invoice or amount",
324
+ example_questions: ["This invoice is wrong", "We never received that order", "I'm disputing this charge"],
325
+ },
326
+ {
327
+ name: "payment_plan",
328
+ description: "Request installments or extension",
329
+ example_questions: ["Can I pay in two installments?", "I need 30 more days", "Can we set up a payment plan?"],
330
+ },
331
+ {
332
+ name: "how_to_pay",
333
+ description: "How to pay or payment link",
334
+ example_questions: ["How do I pay?", "Where's the payment link?", "What payment methods do you accept?"],
335
+ },
336
+ {
337
+ name: "escalate",
338
+ description: "Request to speak to a person",
339
+ example_questions: ["I want to talk to someone", "Transfer me to collections", "I need to speak to AR"],
340
+ },
341
+ {
342
+ name: "Fallback",
343
+ description: "Anything else",
344
+ example_questions: ["What's the weather?", "Tell me about your company"],
345
+ },
346
+ ],
347
+ entities: [
348
+ {
349
+ type: "customer",
350
+ count: 3,
351
+ template: {
352
+ id: "CUST-AR-{{id}}",
353
+ name: "{{name}}",
354
+ email: "{{email}}",
355
+ payment_terms_days: 30,
356
+ payment_plan_active: false,
357
+ },
358
+ },
359
+ {
360
+ type: "invoice",
361
+ count: 3,
362
+ template: {
363
+ invoice_id: "INV-{{id}}",
364
+ customer_id: "CUST-AR-{{cust}}",
365
+ amount_due: "{{amount}}",
366
+ due_date: "{{due_date}}",
367
+ days_past_due: "{{days}}",
368
+ dunning_level: "{{level}}",
369
+ payment_link: "{{payment_link}}",
370
+ },
371
+ },
372
+ {
373
+ type: "dunning_policy",
374
+ count: 1,
375
+ template: {
376
+ reminder_1_days: "1-7",
377
+ reminder_2_days: "8-14",
378
+ final_notice_days: "15-30",
379
+ escalation_days: "31+",
380
+ payment_plan_note: "Payment plan requests are reviewed by AR within 1 business day.",
381
+ },
382
+ },
383
+ ],
384
+ qa_pairs: [
385
+ {
386
+ phase: "intro",
387
+ question: "Hi, I have a question about my invoice.",
388
+ answer_template: "Hi! I'm here to help with your invoice and payment questions. I can look up your balance, share the payment link, or help with a dispute or payment plan request. What would you like to do?",
389
+ intent: "balance_inquiry",
390
+ },
391
+ {
392
+ phase: "main",
393
+ question: "What do I owe?",
394
+ answer_template: "Your current balance is {{amount_due}} (invoice {{invoice_id}}, due {{due_date}}). You can pay here: {{payment_link}}. If you need a payment plan or have already paid, tell me and I'll help.",
395
+ intent: "balance_inquiry",
396
+ required_entity_type: "invoice",
397
+ },
398
+ {
399
+ phase: "main",
400
+ question: "How do I pay?",
401
+ answer_template: "You can pay online using this link: {{payment_link}}. We accept major credit cards and ACH. If you've already paid, please have your payment reference or date ready and I can help verify.",
402
+ intent: "how_to_pay",
403
+ required_entity_type: "invoice",
404
+ },
405
+ {
406
+ phase: "main",
407
+ question: "I already paid last week.",
408
+ answer_template: "Thanks for letting us know. Our AR team will verify the payment. Can you share the payment reference number or the date you paid? We'll follow up if anything doesn't match.",
409
+ intent: "payment_made",
410
+ },
411
+ {
412
+ phase: "advanced",
413
+ question: "This invoice is wrong.",
414
+ answer_template: "I'm sorry to hear that. I'm not able to adjust invoices here. I've escalated this to our AR team—they'll contact you to resolve the dispute. You should hear back within 1 business day.",
415
+ intent: "dispute",
416
+ },
417
+ {
418
+ phase: "advanced",
419
+ question: "Can I pay in two installments?",
420
+ answer_template: "Payment plan requests are reviewed by our AR team. I've submitted your request; someone will get back to you within 1 business day with options.",
421
+ intent: "payment_plan",
422
+ required_entity_type: "dunning_policy",
423
+ },
424
+ {
425
+ phase: "closing",
426
+ question: "I want to talk to someone.",
427
+ answer_template: "No problem. I've requested that our AR team contact you. You should hear from someone within 1 business day. Is there anything else I can help with in the meantime?",
428
+ intent: "escalate",
429
+ },
430
+ ],
431
+ },
304
432
  };
305
433
  // ─────────────────────────────────────────────────────────────────────────────
306
434
  // Sample Entity Data
@@ -473,6 +601,55 @@ export const SAMPLE_ENTITIES = {
473
601
  manager: "Bob Director",
474
602
  },
475
603
  ],
604
+ invoice: [
605
+ {
606
+ id: "INV-2025-001234",
607
+ invoice_id: "INV-2025-001234",
608
+ customer_id: "CUST-AR-001",
609
+ amount_due: 12500.0,
610
+ currency: "USD",
611
+ due_date: "2025-01-20",
612
+ days_past_due: 34,
613
+ dunning_level: 3,
614
+ payment_link: "https://pay.example.com/INV-2025-001234",
615
+ line_items_summary: "Subscription Q1 2025",
616
+ },
617
+ {
618
+ id: "INV-2025-005678",
619
+ invoice_id: "INV-2025-005678",
620
+ customer_id: "CUST-AR-002",
621
+ amount_due: 4850.0,
622
+ currency: "USD",
623
+ due_date: "2025-02-01",
624
+ days_past_due: 22,
625
+ dunning_level: 3,
626
+ payment_link: "https://pay.example.com/INV-2025-005678",
627
+ line_items_summary: "Professional services February 2025",
628
+ },
629
+ {
630
+ id: "INV-2025-009999",
631
+ invoice_id: "INV-2025-009999",
632
+ customer_id: "CUST-AR-003",
633
+ amount_due: 899.0,
634
+ currency: "USD",
635
+ due_date: "2025-02-15",
636
+ days_past_due: 8,
637
+ dunning_level: 2,
638
+ payment_link: "https://pay.example.com/INV-2025-009999",
639
+ line_items_summary: "Monthly license March 2025",
640
+ },
641
+ ],
642
+ dunning_policy: [
643
+ {
644
+ id: "dunning-policy",
645
+ name: "Dunning Policy",
646
+ reminder_1_days: "1-7",
647
+ reminder_2_days: "8-14",
648
+ final_notice_days: "15-30",
649
+ escalation_days: "31+",
650
+ payment_plan_note: "Payment plan requests are reviewed by AR within 1 business day. Do not send reminders to customers on active payment plans.",
651
+ },
652
+ ],
476
653
  benefit: [
477
654
  {
478
655
  id: "BEN-001",
@@ -68,6 +68,19 @@ export const INPUT_SOURCE_RULES = [
68
68
  severity: "warning",
69
69
  fix: "Use user_query for query, pass chat_conversation via named_inputs if needed",
70
70
  },
71
+ // Rule validation inputs
72
+ {
73
+ actionPattern: "rule_validation_with_documents",
74
+ recommended: "entity_extraction.extraction_columns for map_of_extracted_columns, workflowInput.document for primary_docs",
75
+ avoid: ["omitting primary_docs", "wiring phases in parallel instead of sequentially"],
76
+ reason: "rule_validation_with_documents requires both extracted columns and original documents. " +
77
+ "primary_docs gives the validator context from the original document. " +
78
+ "When chaining multiple validation phases, wire them sequentially (phase1.ruleset_output → phase2.map_of_extracted_columns) " +
79
+ "so each phase builds on prior validation results.",
80
+ severity: "critical",
81
+ fix: "Wire entity_extraction.extraction_columns → rule_validation.map_of_extracted_columns AND " +
82
+ "workflowInput.document → rule_validation.primary_docs. For multi-phase: chain sequentially, not in parallel.",
83
+ },
71
84
  // Email-specific rules - GUIDANCE ONLY (not hard backend constraints)
72
85
  // NOTE: Backend accepts TEXT_WITH_SOURCES for email_to, so this is soft guidance
73
86
  // The correct pattern is: entity_extraction → fixed_response with {{email}} template → send_email
@@ -12,7 +12,7 @@ import { AUTO_TRANSFORMS, ADVISORY_TRANSFORMS, } from "./workflow-graph-transfor
12
12
  function deepClone(obj) {
13
13
  return JSON.parse(JSON.stringify(obj));
14
14
  }
15
- function calculateMetrics(workflowDef, graph) {
15
+ function calculateMetrics(graph) {
16
16
  const nodeCount = graph.nodes.size;
17
17
  const edgeCount = graph.edges.length;
18
18
  // Dead nodes: total - reachable from trigger
@@ -50,6 +50,83 @@ function generateImprovementSummary(before, after) {
50
50
  summary += `. ${before.nodeCount} \u2192 ${after.nodeCount} nodes.`;
51
51
  return summary;
52
52
  }
53
+ /**
54
+ * Show the immediate neighborhood (parents → node → children) of a set of nodes
55
+ * from the graph state BEFORE a transform ran. Used to explain why nodes were
56
+ * classified as dead/trailing so the viewer can verify the reasoning.
57
+ *
58
+ * Example output:
59
+ * orphaned_search (search)
60
+ * ← (no parents — unreachable from trigger)
61
+ * → respond_unused
62
+ * respond_unused (respond_for_external_actions)
63
+ * ← orphaned_search
64
+ * → (no children — does not reach any output)
65
+ */
66
+ function buildNodeNeighborhood(nodeIds, graph) {
67
+ if (nodeIds.length === 0)
68
+ return undefined;
69
+ const resultNodeIds = new Set(graph.outputMappings.map((m) => m.nodeId));
70
+ const lines = [];
71
+ for (const nodeId of nodeIds) {
72
+ const node = graph.nodes.get(nodeId);
73
+ if (!node)
74
+ continue;
75
+ const parents = [...(graph.reverse.get(nodeId) ?? [])];
76
+ const children = [...(graph.forward.get(nodeId) ?? [])];
77
+ const parentStr = parents.length > 0
78
+ ? parents.join(" · ")
79
+ : "(no parents)";
80
+ const childStr = children.length > 0
81
+ ? children.map((c) => resultNodeIds.has(c) ? `${c} [→OUTPUT]` : c).join(" · ")
82
+ : "(no children — does not reach any output)";
83
+ lines.push(` ${nodeId} (${node.actionType})`);
84
+ lines.push(` ← ${parentStr}`);
85
+ lines.push(` → ${childStr}`);
86
+ }
87
+ return lines.join("\n") || undefined;
88
+ }
89
+ /**
90
+ * Build a compact edge-list flow diagram of the workflow graph.
91
+ *
92
+ * Lists nodes in topological order (or declaration order for triggerless workflows),
93
+ * showing depth, type, children, and which nodes map to outputs.
94
+ */
95
+ function buildFlowDiagram(graph) {
96
+ if (graph.nodes.size === 0)
97
+ return "(empty workflow — no nodes)";
98
+ const resultNodeIds = new Set(graph.outputMappings.map((m) => m.nodeId));
99
+ const lines = [];
100
+ // Header line
101
+ const triggerNote = graph.trigger
102
+ ? `entry: ${graph.trigger}`
103
+ : "no recognized trigger (dashboard/document workflow — all nodes kept)";
104
+ lines.push(`${graph.nodes.size} nodes · ${graph.edges.length} edges · ${triggerNote}`);
105
+ // Topological order for consistent depth-first traversal; fall back to declaration order
106
+ const topoOrder = getTopologicalOrder(graph);
107
+ const nodeOrder = topoOrder.length === graph.nodes.size ? topoOrder : [...graph.nodes.keys()];
108
+ for (const nodeId of nodeOrder) {
109
+ const node = graph.nodes.get(nodeId);
110
+ if (!node)
111
+ continue;
112
+ const children = [...(graph.forward.get(nodeId) ?? [])];
113
+ const tags = [];
114
+ if (node.isTrigger)
115
+ tags.push("ENTRY");
116
+ if (resultNodeIds.has(nodeId))
117
+ tags.push("→OUTPUT");
118
+ if (node.hasSideEffects && !node.isTrigger)
119
+ tags.push("side-effect");
120
+ const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
121
+ // Indent up to 6 levels to avoid runaway indentation on deep graphs
122
+ const indent = " ".repeat(Math.min(node.depth, 6));
123
+ lines.push(`${indent}${nodeId} (${node.actionType})${tagStr}`);
124
+ if (children.length > 0) {
125
+ lines.push(`${indent} └─► ${children.join(" · ")}`);
126
+ }
127
+ }
128
+ return lines.join("\n");
129
+ }
53
130
  function validateWorkflow(workflowDef) {
54
131
  const errors = [];
55
132
  const warnings = [];
@@ -97,7 +174,7 @@ export function optimizeWorkflow(workflowDef, options) {
97
174
  // Graceful handling of invalid input
98
175
  if (!workflowDef || typeof workflowDef !== "object") {
99
176
  return {
100
- workflowDef: workflowDef ?? {},
177
+ workflowDef: (workflowDef ?? {}),
101
178
  modified: false,
102
179
  appliedTransforms: [],
103
180
  advisories: [],
@@ -107,6 +184,7 @@ export function optimizeWorkflow(workflowDef, options) {
107
184
  improvement: "No changes needed.",
108
185
  },
109
186
  validation: { valid: false, errors: ["Invalid workflow_def input"], warnings: [] },
187
+ flow_diagram: "(no workflow_def provided)",
110
188
  };
111
189
  }
112
190
  try {
@@ -114,14 +192,16 @@ export function optimizeWorkflow(workflowDef, options) {
114
192
  let currentWorkflowDef = deepClone(workflowDef);
115
193
  // Step 2: Calculate "before" metrics
116
194
  let graph = buildWorkflowGraph(currentWorkflowDef);
117
- const beforeMetrics = calculateMetrics(currentWorkflowDef, graph);
195
+ const beforeMetrics = calculateMetrics(graph);
118
196
  // Step 3: Auto-apply transforms (convergence loop)
197
+ // Captures pass number and before-graph neighborhood for each change.
119
198
  const appliedTransforms = [];
120
199
  if (autoApply && maxPasses > 0) {
121
200
  for (let pass = 0; pass < maxPasses; pass++) {
122
201
  let anyApplied = false;
123
202
  for (const transform of AUTO_TRANSFORMS) {
124
203
  try {
204
+ const graphBefore = graph; // snapshot before this transform
125
205
  const result = transform.apply(currentWorkflowDef, graph);
126
206
  if (result && result.applied) {
127
207
  currentWorkflowDef = result.workflowDef;
@@ -131,6 +211,12 @@ export function optimizeWorkflow(workflowDef, options) {
131
211
  description: result.description,
132
212
  nodesRemoved: result.nodesRemoved,
133
213
  nodesModified: result.nodesModified,
214
+ pass: pass + 1,
215
+ // Show neighborhood of affected nodes in the before-graph so the
216
+ // viewer can verify why each node was classified as removable.
217
+ flow_context: result.nodesRemoved.length > 0
218
+ ? buildNodeNeighborhood(result.nodesRemoved, graphBefore)
219
+ : undefined,
134
220
  });
135
221
  anyApplied = true;
136
222
  }
@@ -143,17 +229,15 @@ export function optimizeWorkflow(workflowDef, options) {
143
229
  break; // Converged
144
230
  }
145
231
  }
146
- // Step 4: Calculate "after" metrics
232
+ // Step 4: Calculate "after" metrics and rebuild final graph
147
233
  graph = buildWorkflowGraph(currentWorkflowDef);
148
- const afterMetrics = calculateMetrics(currentWorkflowDef, graph);
234
+ const afterMetrics = calculateMetrics(graph);
149
235
  // Step 5: Run advisory transforms
150
236
  const advisories = [];
151
237
  for (const transform of ADVISORY_TRANSFORMS) {
152
238
  try {
153
239
  const result = transform.apply(currentWorkflowDef, graph);
154
240
  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
241
  const affectedNodes = extractAffectedNodes(result);
158
242
  advisories.push({
159
243
  id: transform.id,
@@ -166,21 +250,18 @@ export function optimizeWorkflow(workflowDef, options) {
166
250
  // Advisory failure: skip
167
251
  }
168
252
  }
169
- // Step 6: Generate improvement summary
253
+ // Step 6: Generate improvement summary + validation + flow diagram
170
254
  const improvement = generateImprovementSummary(beforeMetrics, afterMetrics);
171
- // Step 7: Post-optimization validation
172
255
  const validation = validateWorkflow(currentWorkflowDef);
256
+ const flow_diagram = buildFlowDiagram(graph);
173
257
  return {
174
258
  workflowDef: currentWorkflowDef,
175
259
  modified: appliedTransforms.length > 0,
176
260
  appliedTransforms,
177
261
  advisories,
178
- metrics: {
179
- before: beforeMetrics,
180
- after: afterMetrics,
181
- improvement,
182
- },
262
+ metrics: { before: beforeMetrics, after: afterMetrics, improvement },
183
263
  validation,
264
+ flow_diagram,
184
265
  };
185
266
  }
186
267
  catch {
@@ -196,6 +277,7 @@ export function optimizeWorkflow(workflowDef, options) {
196
277
  improvement: "No changes needed.",
197
278
  },
198
279
  validation: { valid: false, errors: ["Optimization failed unexpectedly"], warnings: [] },
280
+ flow_diagram: "(optimization failed)",
199
281
  };
200
282
  }
201
283
  }
@@ -66,32 +66,46 @@ function nodeSignature(action) {
66
66
  }
67
67
  return JSON.stringify({ actionType, version, inputWiring });
68
68
  }
69
- /** Check if an action has side effects based on its type. */
70
- function hasSideEffects(action) {
71
- const actionType = extractActionType(action);
72
- const SIDE_EFFECT_FREE = new Set([
73
- "search", "call_llm", "entity_extraction", "respond_with_sources",
74
- "respond_for_external_actions", "conversation_to_search_query",
75
- "combine_search_results", "text_categorizer", "chat_categorizer",
76
- "response_validator", "fixed_response", "live_web_search",
77
- "ai_web_search", "json_mapper", "custom_agent",
78
- ]);
79
- const TRIGGER_TYPES = new Set(["chat_trigger", "voice_trigger", "document_trigger"]);
80
- if (TRIGGER_TYPES.has(actionType))
81
- return false;
82
- if (SIDE_EFFECT_FREE.has(actionType))
83
- return false;
84
- return true;
85
- }
86
- /** Check if an action is a trigger node. */
87
- function isTriggerAction(action) {
88
- const actionType = extractActionType(action);
89
- return actionType === "chat_trigger" || actionType === "voice_trigger" || actionType === "document_trigger";
90
- }
91
69
  /** Get all node names from actions array. */
92
70
  function getActionNames(actions) {
93
71
  return new Set(actions.map((a) => a.name).filter(Boolean));
94
72
  }
73
+ /**
74
+ * Recursively rewire actionOutput references from oldName → newName in a binding.
75
+ * Mirrors extractEdgesFromBinding in workflow-graph.ts: handles the same nesting
76
+ * depth so graph construction and reference rewiring stay symmetric.
77
+ *
78
+ * Handles:
79
+ * actionOutput → base case: mutate actionName if matches
80
+ * multiBinding → recurse into elements[]
81
+ * namedBinding → recurse into .value
82
+ */
83
+ function rewireBinding(binding, oldName, newName) {
84
+ if (!binding || typeof binding !== "object")
85
+ return;
86
+ if (binding.actionOutput) {
87
+ const ao = binding.actionOutput;
88
+ if (ao.actionName === oldName) {
89
+ ao.actionName = newName;
90
+ }
91
+ return;
92
+ }
93
+ if (binding.multiBinding) {
94
+ const mb = binding.multiBinding;
95
+ const elements = mb.elements ?? [];
96
+ for (const elem of elements) {
97
+ rewireBinding(elem, oldName, newName);
98
+ }
99
+ return;
100
+ }
101
+ if (binding.namedBinding) {
102
+ const nb = binding.namedBinding;
103
+ const value = nb.value;
104
+ if (value) {
105
+ rewireBinding(value, oldName, newName);
106
+ }
107
+ }
108
+ }
95
109
  /**
96
110
  * Rewire all references from oldName to newName in the actions array.
97
111
  * This updates actionOutput bindings, multiBinding refs, and runIf refs.
@@ -101,35 +115,7 @@ function rewireReferences(actions, oldName, newName) {
101
115
  const inputs = action.inputs;
102
116
  if (inputs) {
103
117
  for (const val of Object.values(inputs)) {
104
- const binding = val;
105
- if (binding?.actionOutput) {
106
- const ao = binding.actionOutput;
107
- if (ao.actionName === oldName) {
108
- ao.actionName = newName;
109
- }
110
- }
111
- if (binding?.multiBinding) {
112
- const mb = binding.multiBinding;
113
- const elements = mb.elements ?? [];
114
- for (const elem of elements) {
115
- if (elem.namedBinding) {
116
- const nb = elem.namedBinding;
117
- const value = nb.value;
118
- if (value?.actionOutput) {
119
- const ao = value.actionOutput;
120
- if (ao.actionName === oldName) {
121
- ao.actionName = newName;
122
- }
123
- }
124
- }
125
- else if (elem.actionOutput) {
126
- const ao = elem.actionOutput;
127
- if (ao.actionName === oldName) {
128
- ao.actionName = newName;
129
- }
130
- }
131
- }
132
- }
118
+ rewireBinding(val, oldName, newName);
133
119
  }
134
120
  }
135
121
  // Rewire runIf
@@ -187,7 +173,7 @@ const removeDeadNodes = {
187
173
  const node = graph.nodes.get(nodeId);
188
174
  const hasDisplayName = node.displayName && node.displayName !== nodeId;
189
175
  const isReferencedInResults = Object.values(results).some((r) => r.actionName === nodeId);
190
- const hasActionOutputInputs = [...node.inputs.values()].some((ref) => ref.type === "action_output");
176
+ const hasActionOutputInputs = [...node.inputs.values()].some((ref) => ref.type === "action_output" || ref.type === "multi_binding");
191
177
  if (hasDisplayName || isReferencedInResults || hasActionOutputInputs) {
192
178
  // Intentional node: skip removal (could attempt wiring, but
193
179
  // wiring heuristics are risky so we just keep it)
@@ -310,9 +296,10 @@ const mergeIdenticalNodes = {
310
296
  // Group by signature, skipping triggers and side-effect nodes
311
297
  const groups = new Map();
312
298
  for (const action of actions) {
313
- if (isTriggerAction(action))
299
+ const graphNode = graph.nodes.get(action.name);
300
+ if (graphNode?.isTrigger)
314
301
  continue;
315
- if (hasSideEffects(action))
302
+ if (graphNode?.hasSideEffects ?? true)
316
303
  continue;
317
304
  const sig = nodeSignature(action);
318
305
  if (!groups.has(sig))
@@ -521,6 +508,15 @@ const removeTrailingNodes = {
521
508
  try {
522
509
  if (graph.nodes.size === 0)
523
510
  return null;
511
+ // Dashboard/document workflows have no recognized trigger action.
512
+ // Without a trigger, every supplier node (which has no forward path to a result
513
+ // because it IS an upstream input) would be incorrectly classified as trailing.
514
+ if (!graph.trigger)
515
+ return null;
516
+ // Without any result mappings we cannot identify what contributes to output,
517
+ // so every non-trigger side-effect-free node would appear trailing.
518
+ if (graph.outputMappings.length === 0)
519
+ return null;
524
520
  // Build set of nodes that contribute to output mappings
525
521
  const resultNodes = new Set(graph.outputMappings.map((m) => m.nodeId));
526
522
  // Find nodes that are downstream of result nodes but don't contribute to results
@@ -117,23 +117,8 @@ export function buildWorkflowGraph(workflowDef) {
117
117
  }
118
118
  else if (binding.multiBinding) {
119
119
  inputRefs.set(inputName, { type: "multi_binding" });
120
- // Parse elements for nested actionOutput refs
121
- const mb = binding.multiBinding;
122
- const elements = mb.elements ?? [];
123
- for (const elem of elements) {
124
- if (elem.namedBinding) {
125
- const nb = elem.namedBinding;
126
- const value = nb.value;
127
- if (value?.actionOutput) {
128
- const ao = value.actionOutput;
129
- addEdge(edges, forward, reverse, ao.actionName, nodeId, ao.output, inputName);
130
- }
131
- }
132
- else if (elem.actionOutput) {
133
- const ao = elem.actionOutput;
134
- addEdge(edges, forward, reverse, ao.actionName, nodeId, ao.output, inputName);
135
- }
136
- }
120
+ // Recursively traverse nested multiBinding/namedBinding structures to find all actionOutput refs
121
+ extractEdgesFromBinding(binding, nodeId, inputName, edges, forward, reverse);
137
122
  }
138
123
  else if (binding.widgetConfig) {
139
124
  inputRefs.set(inputName, { type: "widget" });
@@ -363,6 +348,43 @@ function extractEnumTypeName(et) {
363
348
  }
364
349
  return undefined;
365
350
  }
351
+ /**
352
+ * Recursively extract actionOutput edges from an arbitrarily nested binding structure.
353
+ *
354
+ * Handles:
355
+ * actionOutput → base case: emit edge
356
+ * multiBinding → recurse into elements[]
357
+ * namedBinding → recurse into .value
358
+ *
359
+ * This covers nested proto patterns like:
360
+ * { multiBinding: { elements: [{ namedBinding: { value: { multiBinding: { elements: [{ actionOutput: {...} }] } } } }] } }
361
+ */
362
+ function extractEdgesFromBinding(binding, targetNode, targetInput, edges, forward, reverse) {
363
+ if (!binding || typeof binding !== "object")
364
+ return;
365
+ if (binding.actionOutput) {
366
+ const ao = binding.actionOutput;
367
+ if (ao.actionName) {
368
+ addEdge(edges, forward, reverse, ao.actionName, targetNode, ao.output ?? "", targetInput);
369
+ }
370
+ return;
371
+ }
372
+ if (binding.multiBinding) {
373
+ const mb = binding.multiBinding;
374
+ const elements = mb.elements ?? [];
375
+ for (const elem of elements) {
376
+ extractEdgesFromBinding(elem, targetNode, targetInput, edges, forward, reverse);
377
+ }
378
+ return;
379
+ }
380
+ if (binding.namedBinding) {
381
+ const nb = binding.namedBinding;
382
+ const value = nb.value;
383
+ if (value) {
384
+ extractEdgesFromBinding(value, targetNode, targetInput, edges, forward, reverse);
385
+ }
386
+ }
387
+ }
366
388
  function addEdge(edges, forward, reverse, source, target, sourceOutput, targetInput) {
367
389
  edges.push({ source, target, sourceOutput, targetInput });
368
390
  if (!forward.has(source))