@prompd/cli 0.4.9 → 0.4.11

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.
Files changed (60) hide show
  1. package/dist/commands/ask.d.ts +3 -0
  2. package/dist/commands/ask.d.ts.map +1 -0
  3. package/dist/commands/ask.js +121 -0
  4. package/dist/commands/ask.js.map +1 -0
  5. package/dist/commands/mcp.d.ts.map +1 -1
  6. package/dist/commands/mcp.js +192 -42
  7. package/dist/commands/mcp.js.map +1 -1
  8. package/dist/commands/run.d.ts.map +1 -1
  9. package/dist/commands/run.js +62 -4
  10. package/dist/commands/run.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/compiler/package-resolver.d.ts.map +1 -1
  15. package/dist/lib/compiler/package-resolver.js +5 -1
  16. package/dist/lib/compiler/package-resolver.js.map +1 -1
  17. package/dist/lib/executor.d.ts +16 -6
  18. package/dist/lib/executor.d.ts.map +1 -1
  19. package/dist/lib/executor.js +121 -245
  20. package/dist/lib/executor.js.map +1 -1
  21. package/dist/lib/index.d.ts +4 -2
  22. package/dist/lib/index.d.ts.map +1 -1
  23. package/dist/lib/index.js +21 -4
  24. package/dist/lib/index.js.map +1 -1
  25. package/dist/lib/mcp.d.ts +13 -2
  26. package/dist/lib/mcp.d.ts.map +1 -1
  27. package/dist/lib/mcp.js +447 -77
  28. package/dist/lib/mcp.js.map +1 -1
  29. package/dist/lib/nodeTypeRegistry.d.ts.map +1 -1
  30. package/dist/lib/nodeTypeRegistry.js +5 -1
  31. package/dist/lib/nodeTypeRegistry.js.map +1 -1
  32. package/dist/lib/providers/base.d.ts +92 -0
  33. package/dist/lib/providers/base.d.ts.map +1 -0
  34. package/dist/lib/providers/base.js +741 -0
  35. package/dist/lib/providers/base.js.map +1 -0
  36. package/dist/lib/providers/factory.d.ts +37 -0
  37. package/dist/lib/providers/factory.d.ts.map +1 -0
  38. package/dist/lib/providers/factory.js +82 -0
  39. package/dist/lib/providers/factory.js.map +1 -0
  40. package/dist/lib/providers/index.d.ts +12 -0
  41. package/dist/lib/providers/index.d.ts.map +1 -0
  42. package/dist/lib/providers/index.js +25 -0
  43. package/dist/lib/providers/index.js.map +1 -0
  44. package/dist/lib/providers/types.d.ts +129 -0
  45. package/dist/lib/providers/types.d.ts.map +1 -0
  46. package/dist/lib/providers/types.js +156 -0
  47. package/dist/lib/providers/types.js.map +1 -0
  48. package/dist/lib/workflowExecutor.d.ts +12 -1
  49. package/dist/lib/workflowExecutor.d.ts.map +1 -1
  50. package/dist/lib/workflowExecutor.js +771 -304
  51. package/dist/lib/workflowExecutor.js.map +1 -1
  52. package/dist/lib/workflowParser.d.ts.map +1 -1
  53. package/dist/lib/workflowParser.js +2 -0
  54. package/dist/lib/workflowParser.js.map +1 -1
  55. package/dist/lib/workflowTypes.d.ts +19 -1
  56. package/dist/lib/workflowTypes.d.ts.map +1 -1
  57. package/dist/lib/workflowTypes.js.map +1 -1
  58. package/dist/types/index.d.ts +3 -0
  59. package/dist/types/index.d.ts.map +1 -1
  60. package/package.json +6 -1
@@ -3,6 +3,9 @@
3
3
  * WorkflowExecutor - Execute workflow graphs with sequential, conditional, and parallel support
4
4
  * Includes comprehensive execution tracing, debugging, and step-through capabilities
5
5
  */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
6
9
  Object.defineProperty(exports, "__esModule", { value: true });
7
10
  exports.executeWorkflow = executeWorkflow;
8
11
  exports.createPromptExecutor = createPromptExecutor;
@@ -11,6 +14,11 @@ exports.exportTraceAsJson = exportTraceAsJson;
11
14
  exports.downloadTrace = downloadTrace;
12
15
  exports.formatTraceEntry = formatTraceEntry;
13
16
  exports.getTraceSummary = getTraceSummary;
17
+ const child_process_1 = require("child_process");
18
+ const fs_1 = require("fs");
19
+ const os_1 = require("os");
20
+ const path_1 = require("path");
21
+ const vm_1 = __importDefault(require("vm"));
14
22
  const workflowParser_1 = require("./workflowParser");
15
23
  const memoryBackend_1 = require("./memoryBackend");
16
24
  /**
@@ -639,8 +647,29 @@ async function executeWorkflow(workflow, params, options = {}) {
639
647
  message: `Starting workflow execution with ${executionOrder.length} nodes`,
640
648
  data: { executionOrder, mode: executionMode },
641
649
  }, options);
642
- // Track previous output for auto-inject
650
+ // Track previous output for auto-inject (fallback for nodes with no incoming edges)
643
651
  let previousOutput = undefined;
652
+ // Build a predecessor map: for each node, find its graph predecessor(s) from incoming execution edges.
653
+ // This ensures previous_output resolves to the correct upstream node's output,
654
+ // not just the chronologically last-executed node (which breaks parallel branches).
655
+ const predecessorMap = new Map();
656
+ for (const edge of workflowFile.edges) {
657
+ // Only consider execution flow edges (same filtering as topological sort)
658
+ if (edge.sourceHandle === 'loop-end' || edge.sourceHandle === 'parallel-end')
659
+ continue;
660
+ if (edge.targetHandle && edge.targetHandle.startsWith('fork-'))
661
+ continue;
662
+ const eventBasedHandles = ['onError', 'onCheckpoint', 'onProgress', 'toolResult'];
663
+ if (edge.sourceHandle && eventBasedHandles.includes(edge.sourceHandle))
664
+ continue;
665
+ // Skip merge input handles — merge nodes have their own edge-based collection logic
666
+ if (edge.targetHandle && edge.targetHandle.startsWith('input-'))
667
+ continue;
668
+ if (!predecessorMap.has(edge.target)) {
669
+ predecessorMap.set(edge.target, []);
670
+ }
671
+ predecessorMap.get(edge.target).push(edge.source);
672
+ }
644
673
  // Track which nodes should be skipped due to condition branching
645
674
  // When a condition node evaluates, only the target branch should execute
646
675
  const skippedNodes = new Set();
@@ -771,247 +800,161 @@ async function executeWorkflow(workflow, params, options = {}) {
771
800
  state.nodeStates[nodeId].status = 'running';
772
801
  state.nodeStates[nodeId].startTime = nodeStartTime;
773
802
  options.onProgress?.(deepClone(state));
803
+ // Resolve previous_output from graph predecessor(s) instead of chronological last-executed node.
804
+ // This ensures nodes in parallel branches get the correct upstream output.
805
+ let resolvedPreviousOutput = previousOutput;
806
+ const predecessors = predecessorMap.get(nodeId);
807
+ if (predecessors && predecessors.length > 0) {
808
+ // Use the first predecessor that has a completed output
809
+ for (const predId of predecessors) {
810
+ if (state.nodeOutputs[predId] !== undefined) {
811
+ resolvedPreviousOutput = state.nodeOutputs[predId];
812
+ break;
813
+ }
814
+ }
815
+ }
774
816
  // Build context for expression evaluation
775
817
  const context = {
776
818
  nodeOutputs: state.nodeOutputs,
777
819
  variables: state.variables,
778
820
  workflow: params,
779
- previous_output: previousOutput,
821
+ previous_output: resolvedPreviousOutput,
780
822
  };
781
- // Execute based on node type
782
- let output;
783
- switch (node.type) {
784
- case 'prompt':
785
- output = await executePromptNode(node, context, options, trace, state, workflowFile);
786
- break;
787
- case 'condition': {
788
- output = executeConditionNode(node, context);
789
- // Handle condition branching - mark non-selected branches as skipped
790
- const conditionOutput = output;
791
- const conditionInfo = branchingTargetMap.get(nodeId);
792
- if (conditionInfo) {
793
- // Determine which handle was selected
794
- // The branch ID maps to the handle ID: 'condition-{branchId}' or 'default'
795
- const selectedHandle = conditionOutput.branch === 'default'
796
- ? 'default'
797
- : `condition-${conditionOutput.branch}`;
798
- // Get the selected target from edges
799
- const selectedTarget = conditionInfo.edgeTargets.get(selectedHandle);
800
- // Find merge nodes that act as convergence points
801
- const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
802
- // For each non-selected branch, mark downstream nodes as skipped
803
- for (const [handleId, targetNodeId] of conditionInfo.edgeTargets) {
804
- if (handleId !== selectedHandle) {
805
- // Get all nodes downstream of this non-selected branch
806
- // Stop at merge nodes since those are where branches converge
807
- const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
808
- for (const skipNodeId of downstreamNodes) {
809
- // Don't skip merge nodes - they may receive input from the selected branch
810
- if (!mergeNodes.has(skipNodeId)) {
811
- skippedNodes.add(skipNodeId);
812
- }
823
+ // Execute node via shared dispatch (single source of truth for node type -> handler mapping)
824
+ let output = await dispatchNode(node, context, options, state, workflowFile, trace, memoryBackend, { executionOrder, branchingTargetMap, skippedNodes });
825
+ // ── Post-dispatch branching logic ──
826
+ // Certain node types produce branching outputs that determine which downstream
827
+ // paths should be skipped. This logic is main-loop-only (not needed in subset execution).
828
+ if (node.type === 'condition') {
829
+ const conditionOutput = output;
830
+ const conditionInfo = branchingTargetMap.get(nodeId);
831
+ if (conditionInfo) {
832
+ const selectedHandle = conditionOutput.branch === 'default'
833
+ ? 'default'
834
+ : `condition-${conditionOutput.branch}`;
835
+ const selectedTarget = conditionInfo.edgeTargets.get(selectedHandle);
836
+ const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
837
+ for (const [handleId, targetNodeId] of conditionInfo.edgeTargets) {
838
+ if (handleId !== selectedHandle) {
839
+ const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
840
+ for (const skipNodeId of downstreamNodes) {
841
+ if (!mergeNodes.has(skipNodeId)) {
842
+ skippedNodes.add(skipNodeId);
813
843
  }
814
- addTraceEntry(trace, {
815
- type: 'debug_step',
816
- nodeId,
817
- message: `Skipping branch '${handleId}' (not selected), marking ${downstreamNodes.size} downstream nodes`,
818
- data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
819
- }, options);
820
844
  }
845
+ addTraceEntry(trace, {
846
+ type: 'debug_step',
847
+ nodeId,
848
+ message: `Skipping branch '${handleId}' (not selected), marking ${downstreamNodes.size} downstream nodes`,
849
+ data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
850
+ }, options);
821
851
  }
822
- addTraceEntry(trace, {
823
- type: 'expression_eval',
824
- nodeId,
825
- nodeName: node.data.label,
826
- message: `Condition selected branch '${conditionOutput.branch}' -> target '${selectedTarget || 'none'}'`,
827
- data: { branch: conditionOutput.branch, target: selectedTarget, handle: selectedHandle },
828
- }, options);
829
- }
830
- break;
831
- }
832
- case 'loop':
833
- output = await executeLoopNode(node, context, options, state, workflowFile, trace, memoryBackend);
834
- break;
835
- case 'parallel':
836
- output = await executeParallelNode(node, context, options, state, workflowFile, trace, memoryBackend);
837
- break;
838
- case 'merge': {
839
- const mergeResult = executeMergeNode(node, context, workflowFile, skippedNodes);
840
- // Check if merge is waiting for more inputs
841
- if (mergeResult && typeof mergeResult === 'object' && 'waiting' in mergeResult && mergeResult.waiting) {
842
- // In wait mode, the merge node needs more inputs
843
- // This shouldn't happen with proper topological ordering, but handle it gracefully
844
- addTraceEntry(trace, {
845
- type: 'debug_step',
846
- nodeId,
847
- nodeName: node.data.label,
848
- message: `Merge node waiting for inputs: ${mergeResult.missingInputs.join(', ')}`,
849
- data: { missingInputs: mergeResult.missingInputs },
850
- }, options);
851
- // For now, proceed with empty result - proper handling would require reordering execution
852
- output = {};
853
- }
854
- else {
855
- output = mergeResult;
856
852
  }
857
- break;
853
+ addTraceEntry(trace, {
854
+ type: 'expression_eval',
855
+ nodeId,
856
+ nodeName: node.data.label,
857
+ message: `Condition selected branch '${conditionOutput.branch}' -> target '${selectedTarget || 'none'}'`,
858
+ data: { branch: conditionOutput.branch, target: selectedTarget, handle: selectedHandle },
859
+ }, options);
858
860
  }
859
- case 'transformer':
860
- output = executeTransformerNode(node, context);
861
- break;
862
- case 'memory':
863
- output = await executeMemoryNode(node, context, state, memoryBackend);
864
- break;
865
- case 'callback':
866
- case 'checkpoint': // Alias for callback
867
- output = await executeCallbackNode(node, context, options, state, workflowFile, executionOrder, trace);
868
- break;
869
- case 'user-input':
870
- output = await executeUserInputNode(node, context, options, trace);
871
- break;
872
- case 'tool':
873
- output = await executeToolNode(node, context, options, trace);
874
- break;
875
- case 'tool-call-parser': {
876
- output = executeToolCallParserNode(node, context, trace, options);
877
- // Handle branching based on whether a tool call was found
878
- const parserOutput = output;
879
- const parserInfo = branchingTargetMap.get(nodeId);
880
- if (parserInfo) {
881
- // Select handle based on hasToolCall result
882
- const selectedHandle = parserOutput.hasToolCall ? 'found' : 'not-found';
883
- const selectedTarget = parserInfo.edgeTargets.get(selectedHandle);
884
- // Find merge nodes that act as convergence points
885
- const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
886
- // For the non-selected branch, mark downstream nodes as skipped
887
- for (const [handleId, targetNodeId] of parserInfo.edgeTargets) {
888
- if (handleId !== selectedHandle) {
889
- const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
890
- for (const skipNodeId of downstreamNodes) {
891
- if (!mergeNodes.has(skipNodeId)) {
892
- skippedNodes.add(skipNodeId);
893
- }
861
+ }
862
+ else if (node.type === 'tool-call-parser') {
863
+ const parserOutput = output;
864
+ const parserInfo = branchingTargetMap.get(nodeId);
865
+ if (parserInfo) {
866
+ const selectedHandle = parserOutput.hasToolCall ? 'found' : 'not-found';
867
+ const selectedTarget = parserInfo.edgeTargets.get(selectedHandle);
868
+ const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
869
+ for (const [handleId, targetNodeId] of parserInfo.edgeTargets) {
870
+ if (handleId !== selectedHandle) {
871
+ const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
872
+ for (const skipNodeId of downstreamNodes) {
873
+ if (!mergeNodes.has(skipNodeId)) {
874
+ skippedNodes.add(skipNodeId);
894
875
  }
895
- addTraceEntry(trace, {
896
- type: 'debug_step',
897
- nodeId,
898
- message: `Skipping branch '${handleId}' (tool call ${parserOutput.hasToolCall ? 'found' : 'not found'}), marking ${downstreamNodes.size} downstream nodes`,
899
- data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
900
- }, options);
901
876
  }
877
+ addTraceEntry(trace, {
878
+ type: 'debug_step',
879
+ nodeId,
880
+ message: `Skipping branch '${handleId}' (tool call ${parserOutput.hasToolCall ? 'found' : 'not found'}), marking ${downstreamNodes.size} downstream nodes`,
881
+ data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
882
+ }, options);
902
883
  }
903
- addTraceEntry(trace, {
904
- type: 'expression_eval',
905
- nodeId,
906
- nodeName: node.data.label,
907
- message: `Tool call parser: ${parserOutput.hasToolCall ? `found '${parserOutput.toolName}'` : 'no tool call'} -> ${selectedHandle}`,
908
- data: { hasToolCall: parserOutput.hasToolCall, toolName: parserOutput.toolName, selectedHandle, selectedTarget },
909
- }, options);
910
884
  }
911
- break;
885
+ addTraceEntry(trace, {
886
+ type: 'expression_eval',
887
+ nodeId,
888
+ nodeName: node.data.label,
889
+ message: `Tool call parser: ${parserOutput.hasToolCall ? `found '${parserOutput.toolName}'` : 'no tool call'} -> ${selectedHandle}`,
890
+ data: { hasToolCall: parserOutput.hasToolCall, toolName: parserOutput.toolName, selectedHandle, selectedTarget },
891
+ }, options);
912
892
  }
913
- case 'agent':
914
- output = await executeAgentNode(node, context, options, trace, state, workflowFile, memoryBackend);
915
- break;
916
- case 'chat-agent': {
917
- output = await executeChatAgentNode(node, context, options, trace, state, workflowFile, branchingTargetMap, skippedNodes, memoryBackend);
918
- // Handle branching based on guardrail rejection (if guardrail is enabled)
919
- const chatAgentOutput = output;
920
- const chatAgentInfo = branchingTargetMap.get(nodeId);
921
- if (chatAgentInfo && chatAgentOutput?.rejected !== undefined) {
922
- const selectedHandle = chatAgentOutput.rejected ? 'rejected' : 'output';
923
- const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
924
- for (const [handleId, targetNodeId] of chatAgentInfo.edgeTargets) {
925
- if (handleId !== selectedHandle) {
926
- const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
927
- for (const skipNodeId of downstreamNodes) {
928
- if (!mergeNodes.has(skipNodeId)) {
929
- skippedNodes.add(skipNodeId);
930
- }
893
+ }
894
+ else if (node.type === 'chat-agent') {
895
+ const chatAgentOutput = output;
896
+ const chatAgentInfo = branchingTargetMap.get(nodeId);
897
+ if (chatAgentInfo && chatAgentOutput?.rejected !== undefined) {
898
+ const selectedHandle = chatAgentOutput.rejected ? 'rejected' : 'output';
899
+ const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
900
+ for (const [handleId, targetNodeId] of chatAgentInfo.edgeTargets) {
901
+ if (handleId !== selectedHandle) {
902
+ const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
903
+ for (const skipNodeId of downstreamNodes) {
904
+ if (!mergeNodes.has(skipNodeId)) {
905
+ skippedNodes.add(skipNodeId);
931
906
  }
932
907
  }
933
908
  }
934
909
  }
935
- break;
936
910
  }
937
- case 'guardrail': {
938
- output = await executeGuardrailNode(node, context, options, trace, workflowFile);
939
- // Handle branching based on pass/reject result
940
- const guardrailOutput = output;
941
- const guardrailInfo = branchingTargetMap.get(nodeId);
942
- if (guardrailInfo) {
943
- // Select handle based on rejected result
944
- // If rejected, use 'rejected' handle; otherwise use 'output' handle
945
- const selectedHandle = guardrailOutput.rejected ? 'rejected' : 'output';
946
- const selectedTarget = guardrailInfo.edgeTargets.get(selectedHandle);
947
- // Find merge nodes that act as convergence points
948
- const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
949
- // For the non-selected branch, mark downstream nodes as skipped
950
- for (const [handleId, targetNodeId] of guardrailInfo.edgeTargets) {
951
- if (handleId !== selectedHandle) {
952
- const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
953
- for (const skipNodeId of downstreamNodes) {
954
- if (!mergeNodes.has(skipNodeId)) {
955
- skippedNodes.add(skipNodeId);
956
- }
911
+ }
912
+ else if (node.type === 'guardrail') {
913
+ const guardrailOutput = output;
914
+ const guardrailInfo = branchingTargetMap.get(nodeId);
915
+ if (guardrailInfo) {
916
+ const selectedHandle = guardrailOutput.rejected ? 'rejected' : 'output';
917
+ const selectedTarget = guardrailInfo.edgeTargets.get(selectedHandle);
918
+ const mergeNodes = findMergeNodesDownstream(nodeId, workflowFile);
919
+ for (const [handleId, targetNodeId] of guardrailInfo.edgeTargets) {
920
+ if (handleId !== selectedHandle) {
921
+ const downstreamNodes = getDownstreamNodes(targetNodeId, workflowFile, mergeNodes);
922
+ for (const skipNodeId of downstreamNodes) {
923
+ if (!mergeNodes.has(skipNodeId)) {
924
+ skippedNodes.add(skipNodeId);
957
925
  }
958
- addTraceEntry(trace, {
959
- type: 'debug_step',
960
- nodeId,
961
- message: `Skipping branch '${handleId}' (input ${guardrailOutput.rejected ? 'rejected' : 'passed'}), marking ${downstreamNodes.size} downstream nodes`,
962
- data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
963
- }, options);
964
926
  }
927
+ addTraceEntry(trace, {
928
+ type: 'debug_step',
929
+ nodeId,
930
+ message: `Skipping branch '${handleId}' (input ${guardrailOutput.rejected ? 'rejected' : 'passed'}), marking ${downstreamNodes.size} downstream nodes`,
931
+ data: { handleId, targetNodeId, skippedCount: downstreamNodes.size },
932
+ }, options);
965
933
  }
966
- addTraceEntry(trace, {
967
- type: 'expression_eval',
968
- nodeId,
969
- nodeName: node.data.label,
970
- message: `Guardrail: input ${guardrailOutput.rejected ? 'rejected' : 'passed'}${guardrailOutput.score !== undefined ? ` (score: ${guardrailOutput.score})` : ''} -> ${selectedHandle}`,
971
- data: { rejected: guardrailOutput.rejected, score: guardrailOutput.score, selectedHandle, selectedTarget },
972
- }, options);
973
934
  }
974
- break;
975
- }
976
- case 'command':
977
- output = await executeCommandNode(node, context, options, trace);
978
- break;
979
- case 'web-search':
980
- output = await executeWebSearchNode(node, context, options, trace);
981
- break;
982
- case 'claude-code':
983
- // Claude Code node - requires Electron for SSH support
984
935
  addTraceEntry(trace, {
985
- type: 'debug_step',
936
+ type: 'expression_eval',
986
937
  nodeId,
987
938
  nodeName: node.data.label,
988
- message: 'Claude Code node execution not yet implemented (requires Electron SSH)',
939
+ message: `Guardrail: input ${guardrailOutput.rejected ? 'rejected' : 'passed'}${guardrailOutput.score !== undefined ? ` (score: ${guardrailOutput.score})` : ''} -> ${selectedHandle}`,
940
+ data: { rejected: guardrailOutput.rejected, score: guardrailOutput.score, selectedHandle, selectedTarget },
989
941
  }, options);
990
- output = previousOutput;
991
- break;
992
- case 'workflow':
993
- // Sub-workflow node - recursive workflow execution
942
+ }
943
+ }
944
+ else if (node.type === 'merge') {
945
+ // Check if merge is waiting for more inputs
946
+ const mergeOut = output;
947
+ if (mergeOut && typeof mergeOut === 'object' && mergeOut.waiting) {
948
+ const missingInputs = mergeOut.missingInputs || [];
994
949
  addTraceEntry(trace, {
995
950
  type: 'debug_step',
996
951
  nodeId,
997
952
  nodeName: node.data.label,
998
- message: 'Sub-workflow node execution not yet implemented',
953
+ message: `Merge node waiting for inputs: ${missingInputs.join(', ')}`,
954
+ data: { missingInputs },
999
955
  }, options);
1000
- output = previousOutput;
1001
- break;
1002
- case 'mcp-tool':
1003
- output = await executeMcpToolNode(node, context, options, trace);
1004
- break;
1005
- case 'output':
1006
- // Output node just passes through the previous output
1007
- output = previousOutput;
1008
- break;
1009
- case 'trigger':
1010
- // Trigger node outputs the workflow parameters so downstream nodes can access them
1011
- output = Object.keys(params).length > 0 ? params : previousOutput;
1012
- break;
1013
- default:
1014
- output = previousOutput;
956
+ output = {};
957
+ }
1015
958
  }
1016
959
  const nodeDuration = Date.now() - nodeStartTime;
1017
960
  // Store output
@@ -1246,9 +1189,10 @@ async function executePromptNode(node, context, options, trace, state, workflowF
1246
1189
  },
1247
1190
  };
1248
1191
  await emitAgentCheckpoint(afterEvent, options, workflowFile);
1249
- // Apply guardrail validation if configured
1250
- if (data.guardrail) {
1192
+ // Apply guardrail validation if configured and enabled
1193
+ if (data.guardrail && data.guardrail.enabled !== false) {
1251
1194
  const guardrail = data.guardrail;
1195
+ const originalLlmResult = result; // Preserve LLM response before guardrail may modify it
1252
1196
  let isRejected = false;
1253
1197
  // Evaluate rejection condition
1254
1198
  if (guardrail.rejectionExpression) {
@@ -1333,8 +1277,8 @@ async function executePromptNode(node, context, options, trace, state, workflowF
1333
1277
  message: `Guardrail PASSED content, outputMode=${outputMode}`,
1334
1278
  }, options);
1335
1279
  if (outputMode === 'original') {
1336
- // Return the original input that was sent to the guardrail prompt
1337
- result = context.previous_output;
1280
+ // Return the original LLM response (before guardrail evaluation)
1281
+ result = originalLlmResult;
1338
1282
  }
1339
1283
  else if (outputMode === 'reject-message') {
1340
1284
  // Return custom message even on pass (useful for custom routing)
@@ -1610,6 +1554,82 @@ function executeConditionNode(node, context) {
1610
1554
  // Return default
1611
1555
  return { branch: 'default', target: data.default || '' };
1612
1556
  }
1557
+ /**
1558
+ * Dispatch a single node for execution based on its type.
1559
+ * This is the single source of truth for which function handles which node type.
1560
+ * Both the main execution loop and executeNodeSubset call this to avoid
1561
+ * duplicating the node-type switch statement.
1562
+ *
1563
+ * NOTE: This function handles pure execution only. Branching logic (condition,
1564
+ * tool-call-parser, guardrail, chat-agent) that manipulates skippedNodes and
1565
+ * branchingTargetMap is handled by the caller (main loop) after dispatch.
1566
+ */
1567
+ async function dispatchNode(node, context, options, state, workflowFile, trace, memoryBackend, extra) {
1568
+ const previousOutput = context.previous_output;
1569
+ switch (node.type) {
1570
+ case 'prompt':
1571
+ return executePromptNode(node, context, options, trace, state, workflowFile);
1572
+ case 'condition':
1573
+ return executeConditionNode(node, context);
1574
+ case 'loop':
1575
+ return executeLoopNode(node, context, options, state, workflowFile, trace, memoryBackend);
1576
+ case 'parallel':
1577
+ return executeParallelNode(node, context, options, state, workflowFile, trace, memoryBackend, extra?.skippedNodes);
1578
+ case 'merge':
1579
+ return executeMergeNode(node, context, workflowFile, extra?.skippedNodes);
1580
+ case 'transformer':
1581
+ return executeTransformerNode(node, context);
1582
+ case 'code':
1583
+ return executeCodeNode(node, context);
1584
+ case 'memory':
1585
+ return executeMemoryNode(node, context, state, memoryBackend);
1586
+ case 'callback':
1587
+ case 'checkpoint':
1588
+ return executeCallbackNode(node, context, options, state, workflowFile, extra?.executionOrder || [], trace);
1589
+ case 'user-input':
1590
+ return executeUserInputNode(node, context, options, trace);
1591
+ case 'tool':
1592
+ return executeToolNode(node, context, options, trace);
1593
+ case 'tool-call-parser':
1594
+ return executeToolCallParserNode(node, context, trace, options);
1595
+ case 'agent':
1596
+ return executeAgentNode(node, context, options, trace, state, workflowFile, memoryBackend);
1597
+ case 'chat-agent':
1598
+ return executeChatAgentNode(node, context, options, trace, state, workflowFile, extra?.branchingTargetMap || new Map(), extra?.skippedNodes || new Set(), memoryBackend);
1599
+ case 'guardrail':
1600
+ return executeGuardrailNode(node, context, options, trace, workflowFile);
1601
+ case 'command':
1602
+ return executeCommandNode(node, context, options, trace);
1603
+ case 'web-search':
1604
+ return executeWebSearchNode(node, context, options, trace);
1605
+ case 'claude-code':
1606
+ addTraceEntry(trace, {
1607
+ type: 'debug_step',
1608
+ nodeId: node.id,
1609
+ nodeName: node.data.label,
1610
+ message: 'Claude Code node execution not yet implemented (requires Electron SSH)',
1611
+ }, options);
1612
+ return previousOutput;
1613
+ case 'workflow':
1614
+ addTraceEntry(trace, {
1615
+ type: 'debug_step',
1616
+ nodeId: node.id,
1617
+ nodeName: node.data.label,
1618
+ message: 'Sub-workflow node execution not yet implemented',
1619
+ }, options);
1620
+ return previousOutput;
1621
+ case 'mcp-tool':
1622
+ return executeMcpToolNode(node, context, options, trace);
1623
+ case 'output':
1624
+ return previousOutput;
1625
+ case 'trigger':
1626
+ return Object.keys(context.workflow).length > 0 ? context.workflow : previousOutput;
1627
+ case 'database-query':
1628
+ return executeDatabaseQueryNode(node, context, options, trace);
1629
+ default:
1630
+ return previousOutput;
1631
+ }
1632
+ }
1613
1633
  /**
1614
1634
  * Execute a subset of nodes (used by loop and parallel nodes)
1615
1635
  * Returns the output of the last executed node
@@ -1674,60 +1694,8 @@ async function executeNodeSubset(nodeIds, workflowFile, context, options, state,
1674
1694
  workflow: context.workflow,
1675
1695
  previous_output: lastOutput,
1676
1696
  };
1677
- // Execute based on node type
1678
- let output;
1679
- switch (node.type) {
1680
- case 'prompt':
1681
- output = await executePromptNode(node, nodeContext, options, trace, state, workflowFile);
1682
- break;
1683
- case 'condition':
1684
- output = executeConditionNode(node, nodeContext);
1685
- break;
1686
- case 'transformer':
1687
- output = executeTransformerNode(node, nodeContext);
1688
- break;
1689
- case 'memory':
1690
- output = await executeMemoryNode(node, nodeContext, state, memoryBackend);
1691
- break;
1692
- case 'merge':
1693
- output = executeMergeNode(node, nodeContext);
1694
- break;
1695
- case 'callback':
1696
- case 'checkpoint':
1697
- // Execute callback node with full checkpoint support (pause, report, etc.)
1698
- // Need to pass execution order for next node info - use nodeIds as the subset order
1699
- output = await executeCallbackNode(node, nodeContext, options, state, workflowFile, nodeIds, trace);
1700
- break;
1701
- case 'user-input':
1702
- output = await executeUserInputNode(node, nodeContext, options, trace);
1703
- break;
1704
- case 'command':
1705
- output = await executeCommandNode(node, nodeContext, options, trace);
1706
- break;
1707
- case 'web-search':
1708
- output = await executeWebSearchNode(node, nodeContext, options, trace);
1709
- break;
1710
- case 'mcp-tool':
1711
- output = await executeMcpToolNode(node, nodeContext, options, trace);
1712
- break;
1713
- case 'agent':
1714
- output = await executeAgentNode(node, nodeContext, options, trace, state, workflowFile, memoryBackend);
1715
- break;
1716
- case 'chat-agent':
1717
- // Chat Agent uses a simplified context since branching is handled internally
1718
- output = await executeChatAgentNode(node, nodeContext, options, trace, state, workflowFile, new Map(), // branchingTargetMap - not used in parallel execution
1719
- new Set(), // skippedNodes - not used in parallel execution
1720
- memoryBackend);
1721
- break;
1722
- case 'guardrail':
1723
- output = await executeGuardrailNode(node, nodeContext, options, trace, workflowFile);
1724
- break;
1725
- case 'output':
1726
- output = lastOutput;
1727
- break;
1728
- default:
1729
- output = lastOutput;
1730
- }
1697
+ // Dispatch via shared single-source-of-truth switch
1698
+ const output = await dispatchNode(node, nodeContext, options, state, workflowFile, trace, memoryBackend, { executionOrder: nodeIds });
1731
1699
  const nodeDuration = Date.now() - nodeStartTime;
1732
1700
  // Store output
1733
1701
  state.nodeOutputs[nodeId] = output;
@@ -1884,8 +1852,133 @@ async function executeLoopNode(node, context, options, state, workflowFile, trac
1884
1852
  /**
1885
1853
  * Execute a parallel node - executes all branches concurrently
1886
1854
  */
1887
- async function executeParallelNode(node, context, options, state, workflowFile, trace, memoryBackend) {
1855
+ async function executeParallelNode(node, context, options, state, workflowFile, trace, memoryBackend, skippedNodes) {
1888
1856
  const data = node.data;
1857
+ // ── Fork mode: edge-based parallelism ──
1858
+ // In fork mode, the parallel node is NOT a container. It has fork-0, fork-1, etc.
1859
+ // output handles that connect to downstream nodes. Each fork branch is traced
1860
+ // forward through edges until reaching a merge node (or dead end), then all
1861
+ // branches run concurrently via Promise.all.
1862
+ if (data.mode === 'fork') {
1863
+ // Find edges from this node's fork handles
1864
+ const forkEdges = workflowFile.edges.filter(edge => edge.source === node.id && edge.sourceHandle?.startsWith('fork-'));
1865
+ if (forkEdges.length === 0) {
1866
+ addTraceEntry(trace, {
1867
+ type: 'debug_step',
1868
+ nodeId: node.id,
1869
+ nodeName: node.data.label,
1870
+ nodeType: 'parallel',
1871
+ message: 'Fork mode: no fork edges found, passing through input',
1872
+ }, options);
1873
+ return context.previous_output;
1874
+ }
1875
+ // For each fork edge, trace the branch chain forward until a merge node or dead end
1876
+ const forkBranches = [];
1877
+ // Collect all merge node IDs so we know where to stop tracing
1878
+ const mergeNodeIds = new Set(workflowFile.nodes.filter(n => n.type === 'merge').map(n => n.id));
1879
+ for (const forkEdge of forkEdges) {
1880
+ const branchNodeIds = [];
1881
+ const visited = new Set();
1882
+ const queue = [forkEdge.target];
1883
+ while (queue.length > 0) {
1884
+ const currentId = queue.shift();
1885
+ if (visited.has(currentId) || mergeNodeIds.has(currentId))
1886
+ continue;
1887
+ visited.add(currentId);
1888
+ branchNodeIds.push(currentId);
1889
+ // Follow outgoing execution edges from this node
1890
+ for (const edge of workflowFile.edges) {
1891
+ if (edge.source !== currentId)
1892
+ continue;
1893
+ // Skip event-based and back-edges
1894
+ if (edge.sourceHandle === 'loop-end' || edge.sourceHandle === 'parallel-end')
1895
+ continue;
1896
+ if (edge.targetHandle?.startsWith('fork-'))
1897
+ continue;
1898
+ const eventHandles = ['onError', 'onCheckpoint', 'onProgress', 'toolResult'];
1899
+ if (edge.sourceHandle && eventHandles.includes(edge.sourceHandle))
1900
+ continue;
1901
+ // Skip edges going to merge input handles (that's the convergence point)
1902
+ if (edge.targetHandle?.startsWith('input-'))
1903
+ continue;
1904
+ if (!visited.has(edge.target) && !mergeNodeIds.has(edge.target)) {
1905
+ queue.push(edge.target);
1906
+ }
1907
+ }
1908
+ }
1909
+ const handleIndex = forkEdge.sourceHandle?.replace('fork-', '') || '0';
1910
+ const label = data.forkLabels?.[parseInt(handleIndex, 10)] || `Branch ${parseInt(handleIndex, 10) + 1}`;
1911
+ forkBranches.push({ handle: forkEdge.sourceHandle || `fork-${handleIndex}`, label, nodeIds: branchNodeIds });
1912
+ }
1913
+ addTraceEntry(trace, {
1914
+ type: 'debug_step',
1915
+ nodeId: node.id,
1916
+ nodeName: node.data.label,
1917
+ nodeType: 'parallel',
1918
+ message: `Fork mode: starting ${forkBranches.length} parallel branches (waitFor: ${data.waitFor})`,
1919
+ data: {
1920
+ branches: forkBranches.map(b => ({ handle: b.handle, label: b.label, nodeCount: b.nodeIds.length, nodes: b.nodeIds }))
1921
+ },
1922
+ }, options);
1923
+ // Mark all fork branch nodes so the main loop skips them —
1924
+ // they will be executed here concurrently instead of sequentially.
1925
+ if (skippedNodes) {
1926
+ for (const branch of forkBranches) {
1927
+ for (const nid of branch.nodeIds) {
1928
+ skippedNodes.add(nid);
1929
+ }
1930
+ }
1931
+ }
1932
+ // Run all branches concurrently
1933
+ const branchPromises = forkBranches.map(async (branch) => {
1934
+ try {
1935
+ const branchOutput = await executeNodeSubset(branch.nodeIds, workflowFile, { ...context, previous_output: context.previous_output }, options, state, trace, memoryBackend);
1936
+ addTraceEntry(trace, {
1937
+ type: 'debug_step',
1938
+ nodeId: node.id,
1939
+ message: `Fork branch '${branch.label}' completed`,
1940
+ data: { handle: branch.handle, output: branchOutput },
1941
+ }, options);
1942
+ return { branchId: branch.label, result: branchOutput, success: true };
1943
+ }
1944
+ catch (error) {
1945
+ const msg = error instanceof Error ? error.message : String(error);
1946
+ addTraceEntry(trace, {
1947
+ type: 'node_error',
1948
+ nodeId: node.id,
1949
+ message: `Fork branch '${branch.label}' failed: ${msg}`,
1950
+ data: { handle: branch.handle, error: msg },
1951
+ }, options);
1952
+ return { branchId: branch.label, result: null, success: false, error: msg };
1953
+ }
1954
+ });
1955
+ // Wait based on strategy
1956
+ let results;
1957
+ if (data.waitFor === 'race') {
1958
+ results = [await Promise.race(branchPromises)];
1959
+ }
1960
+ else if (data.waitFor === 'any') {
1961
+ const all = await Promise.all(branchPromises);
1962
+ const first = all.find(r => r.success);
1963
+ results = first ? [first] : all;
1964
+ }
1965
+ else {
1966
+ results = await Promise.all(branchPromises);
1967
+ }
1968
+ // Merge based on strategy
1969
+ if (data.mergeStrategy === 'object') {
1970
+ const merged = {};
1971
+ for (const r of results)
1972
+ merged[r.branchId] = r.result;
1973
+ return merged;
1974
+ }
1975
+ if (data.mergeStrategy === 'first') {
1976
+ const first = results.find(r => r.success);
1977
+ return first ? { result: first.result } : { error: 'All branches failed' };
1978
+ }
1979
+ return results.map(r => r.result);
1980
+ }
1981
+ // ── Broadcast mode: container-based parallelism ──
1889
1982
  // Get child nodes from parentId relationship
1890
1983
  // Each child node becomes its own parallel "branch"
1891
1984
  const childNodeIds = getChildNodeIds(node.id, workflowFile, data);
@@ -2110,49 +2203,272 @@ function executeMergeNode(node, context, workflowFile, skippedNodes) {
2110
2203
  return merged;
2111
2204
  }
2112
2205
  /**
2113
- * Execute a transformer node - applies JSON template transformation
2206
+ * Execute a transformer node - applies data transformation via template or expression
2207
+ *
2208
+ * Modes:
2209
+ * - template: JSON template with {{ variable }} interpolation (default)
2210
+ * - expression: JavaScript expression evaluated via Function constructor
2211
+ * - jq: Reserved for future JQ-style query support
2114
2212
  */
2115
2213
  function executeTransformerNode(node, context) {
2116
2214
  const data = node.data;
2117
- if (!data.transform) {
2215
+ const mode = data.mode || 'template';
2216
+ try {
2217
+ if (mode === 'expression') {
2218
+ return executeTransformerExpression(data, context);
2219
+ }
2220
+ // Template mode (default) — also handles legacy data.transform field
2221
+ return executeTransformerTemplate(data, context);
2222
+ }
2223
+ catch (error) {
2224
+ console.warn(`[TransformerNode] Transform error (mode=${mode}):`, error);
2225
+ if (data.passthroughOnError) {
2226
+ return context.previous_output;
2227
+ }
2228
+ throw error;
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Execute transformer in template mode — JSON template with {{ }} variable interpolation
2233
+ */
2234
+ function executeTransformerTemplate(data, context) {
2235
+ // Support both new 'template' field and legacy 'transform' field
2236
+ const transformTemplate = data.template || data.transform;
2237
+ if (!transformTemplate) {
2118
2238
  return context.previous_output;
2119
2239
  }
2120
- // The transform is a JSON template string with {{ }} expressions
2121
- // We need to parse it and evaluate all expressions
2240
+ // Find all {{ }} expressions and evaluate them
2241
+ const expressionRegex = /\{\{([^}]+)\}\}/g;
2242
+ const evaluatedTemplate = transformTemplate.replace(expressionRegex, (match) => {
2243
+ const value = evaluateExpression(match, context);
2244
+ // Convert to JSON-safe string representation
2245
+ if (value === undefined || value === null) {
2246
+ return 'null';
2247
+ }
2248
+ if (typeof value === 'string') {
2249
+ // Escape for JSON string context
2250
+ return JSON.stringify(value).slice(1, -1); // Remove surrounding quotes
2251
+ }
2252
+ if (typeof value === 'object') {
2253
+ return JSON.stringify(value);
2254
+ }
2255
+ return String(value);
2256
+ });
2257
+ // Try to parse the result as JSON
2122
2258
  try {
2123
- // First, try to parse as JSON with template replacements
2124
- let transformTemplate = data.transform;
2125
- // Find all {{ }} expressions and evaluate them
2126
- const expressionRegex = /\{\{([^}]+)\}\}/g;
2127
- const evaluatedTemplate = transformTemplate.replace(expressionRegex, (match) => {
2128
- const value = evaluateExpression(match, context);
2129
- // Convert to JSON-safe string representation
2130
- if (value === undefined || value === null) {
2131
- return 'null';
2132
- }
2133
- if (typeof value === 'string') {
2134
- // Escape for JSON string context - but we might be inside a JSON string already
2135
- // For safety, return the raw value and let JSON.parse handle it
2136
- return JSON.stringify(value).slice(1, -1); // Remove surrounding quotes
2137
- }
2138
- if (typeof value === 'object') {
2139
- return JSON.stringify(value);
2140
- }
2141
- return String(value);
2259
+ return JSON.parse(evaluatedTemplate);
2260
+ }
2261
+ catch {
2262
+ // If not valid JSON, return as string
2263
+ return evaluatedTemplate;
2264
+ }
2265
+ }
2266
+ /**
2267
+ * Execute transformer in expression mode — JavaScript expression via Function constructor
2268
+ *
2269
+ * The expression has access to:
2270
+ * - previous_output / input: output from the connected upstream node
2271
+ * - Custom inputVariable name (defaults to 'input')
2272
+ * - workflow: workflow parameters
2273
+ * - All node outputs by node ID
2274
+ */
2275
+ function executeTransformerExpression(data, context) {
2276
+ const expression = data.expression;
2277
+ if (!expression) {
2278
+ return context.previous_output;
2279
+ }
2280
+ const inputVarName = data.inputVariable || 'input';
2281
+ // Build the execution context with all available variables.
2282
+ // Node output keys (e.g. "web-search-1770610749352") contain hyphens which are not
2283
+ // valid JS identifiers. We sanitize them (hyphens -> underscores) so they can be
2284
+ // used as Function parameter names. Node labels are also added as aliases.
2285
+ const execContext = {};
2286
+ // Add node outputs with sanitized keys and label-based aliases
2287
+ for (const [key, value] of Object.entries(context.nodeOutputs)) {
2288
+ const sanitized = key.replace(/[^a-zA-Z0-9_$]/g, '_');
2289
+ execContext[sanitized] = value;
2290
+ }
2291
+ // Add workflow variables (these should already be valid identifiers)
2292
+ for (const [key, value] of Object.entries(context.variables)) {
2293
+ execContext[key] = value;
2294
+ }
2295
+ execContext.workflow = context.workflow;
2296
+ execContext.previous_output = context.previous_output;
2297
+ execContext.input = context.previous_output;
2298
+ execContext.previous_step = context.previous_output;
2299
+ // Also set the custom inputVariable name
2300
+ if (inputVarName !== 'input' && inputVarName !== 'previous_output') {
2301
+ execContext[inputVarName] = context.previous_output;
2302
+ }
2303
+ // Build parameter names and values for the Function constructor
2304
+ const paramNames = Object.keys(execContext);
2305
+ const paramValues = Object.values(execContext);
2306
+ // Wrap the expression in a function body that supports multi-line code with return
2307
+ // eslint-disable-next-line no-new-func
2308
+ const fn = new Function(...paramNames, expression);
2309
+ return fn(...paramValues);
2310
+ }
2311
+ /**
2312
+ * Execute code in a sandboxed context.
2313
+ *
2314
+ * - TS/JS: new Function() with context variables injected as parameters
2315
+ * - Python: Writes a temp .py file, passes input as JSON via stdin, reads JSON output
2316
+ * - C#: Writes a temp .csx file for dotnet-script, same stdin/stdout JSON pattern
2317
+ *
2318
+ * For Python/C#, the previous node's output is serialized as JSON and passed via
2319
+ * stdin. The script must print its result as JSON to stdout.
2320
+ */
2321
+ function executeInlineCode(code, language, inputVarName, timeoutMs, nodeLabel, nodeId, context, executionContext = 'isolated') {
2322
+ if (!code) {
2323
+ return context.previous_output;
2324
+ }
2325
+ // TS/JS: execute via vm (isolated) or Function constructor (main)
2326
+ if (language === 'typescript' || language === 'javascript') {
2327
+ const execVars = {};
2328
+ // Sanitize node output keys — node IDs contain hyphens (e.g. "web-search-...")
2329
+ // which are not valid JS identifiers for Function constructor params
2330
+ for (const [key, value] of Object.entries(context.nodeOutputs)) {
2331
+ execVars[key.replace(/[^a-zA-Z0-9_$]/g, '_')] = value;
2332
+ }
2333
+ for (const [key, value] of Object.entries(context.variables)) {
2334
+ execVars[key] = value;
2335
+ }
2336
+ execVars.workflow = context.workflow;
2337
+ execVars.previous_output = context.previous_output;
2338
+ execVars.input = context.previous_output;
2339
+ execVars.previous_step = context.previous_output;
2340
+ if (inputVarName !== 'input' && inputVarName !== 'previous_output') {
2341
+ execVars[inputVarName] = context.previous_output;
2342
+ }
2343
+ if (executionContext === 'isolated') {
2344
+ // Sandboxed execution via Node.js vm module — no access to require, process, etc.
2345
+ const sandbox = {
2346
+ ...execVars,
2347
+ console: { log: console.log, warn: console.warn, error: console.error },
2348
+ JSON,
2349
+ Math,
2350
+ Date,
2351
+ Array,
2352
+ Object,
2353
+ String,
2354
+ Number,
2355
+ Boolean,
2356
+ RegExp,
2357
+ Map,
2358
+ Set,
2359
+ Promise,
2360
+ parseInt,
2361
+ parseFloat,
2362
+ isNaN,
2363
+ isFinite,
2364
+ encodeURIComponent,
2365
+ decodeURIComponent,
2366
+ encodeURI,
2367
+ decodeURI,
2368
+ };
2369
+ const vmContext = vm_1.default.createContext(sandbox);
2370
+ // Wrap in an async IIFE so user code can use return statements
2371
+ const wrapped = `(function() {\n${code}\n})()`;
2372
+ const script = new vm_1.default.Script(wrapped, { filename: `${nodeLabel}.js` });
2373
+ return script.runInContext(vmContext, { timeout: timeoutMs });
2374
+ }
2375
+ // Main context — full access via Function constructor
2376
+ const paramNames = Object.keys(execVars);
2377
+ const paramValues = Object.values(execVars);
2378
+ // eslint-disable-next-line no-new-func
2379
+ const fn = new Function(...paramNames, code);
2380
+ return fn(...paramValues);
2381
+ }
2382
+ // Python: pass code via -c flag, input data via stdin as JSON
2383
+ if (language === 'python') {
2384
+ const inputJson = JSON.stringify(context.previous_output ?? null);
2385
+ // Wrap user code in a function so 'return' works, pipe input via stdin
2386
+ const wrapper = [
2387
+ 'import sys, json',
2388
+ `${inputVarName} = json.loads(sys.stdin.read())`,
2389
+ 'workflow = json.loads(' + JSON.stringify(JSON.stringify(context.workflow)) + ')',
2390
+ 'def __user_fn__():',
2391
+ ...code.split('\n').map(line => ' ' + line),
2392
+ '__result__ = __user_fn__()',
2393
+ 'if __result__ is not None:',
2394
+ ' print(json.dumps(__result__))',
2395
+ ].join('\n');
2396
+ // execFileSync bypasses shell — args passed directly to process, no escaping needed
2397
+ const stdout = (0, child_process_1.execFileSync)('python', ['-c', wrapper], {
2398
+ input: inputJson,
2399
+ encoding: 'utf-8',
2400
+ timeout: timeoutMs,
2401
+ windowsHide: true,
2142
2402
  });
2143
- // Try to parse the result as JSON
2403
+ const trimmed = stdout.trim();
2404
+ if (!trimmed)
2405
+ return context.previous_output;
2144
2406
  try {
2145
- return JSON.parse(evaluatedTemplate);
2407
+ return JSON.parse(trimmed);
2146
2408
  }
2147
2409
  catch {
2148
- // If not valid JSON, return as string
2149
- return evaluatedTemplate;
2410
+ return trimmed;
2411
+ }
2412
+ }
2413
+ // C#: write temp .cs file, run via `dotnet <file>.cs` (.NET 10 file-based programs)
2414
+ // Uses System.Text.Json (built-in, no external NuGet needed) for JSON serialization
2415
+ if (language === 'csharp') {
2416
+ const inputJson = JSON.stringify(context.previous_output ?? null);
2417
+ const wrapper = [
2418
+ 'using System;',
2419
+ 'using System.IO;',
2420
+ 'using System.Text.Json;',
2421
+ 'using System.Text.Json.Nodes;',
2422
+ '',
2423
+ `var ${inputVarName} = JsonNode.Parse(Console.In.ReadToEnd());`,
2424
+ 'var workflow = JsonNode.Parse(' + JSON.stringify(JSON.stringify(context.workflow)) + ');',
2425
+ '',
2426
+ code,
2427
+ ].join('\n');
2428
+ const tempDir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'prompd-code-'));
2429
+ const tempFile = (0, path_1.join)(tempDir, 'script.cs');
2430
+ try {
2431
+ (0, fs_1.writeFileSync)(tempFile, wrapper, 'utf-8');
2432
+ const stdout = (0, child_process_1.execFileSync)('dotnet', ['run', tempFile], {
2433
+ input: inputJson,
2434
+ encoding: 'utf-8',
2435
+ timeout: timeoutMs,
2436
+ windowsHide: true,
2437
+ });
2438
+ const trimmed = stdout.trim();
2439
+ if (!trimmed)
2440
+ return context.previous_output;
2441
+ try {
2442
+ return JSON.parse(trimmed);
2443
+ }
2444
+ catch {
2445
+ return trimmed;
2446
+ }
2447
+ }
2448
+ finally {
2449
+ try {
2450
+ (0, fs_1.unlinkSync)(tempFile);
2451
+ }
2452
+ catch { /* ignore cleanup errors */ }
2150
2453
  }
2151
2454
  }
2152
- catch (error) {
2153
- console.warn(`[TransformerNode] Transform error:`, error);
2154
- return context.previous_output;
2155
- }
2455
+ throw new Error(`Unsupported code language '${language}'.\n` +
2456
+ `Supported: typescript, javascript, python, csharp.\n` +
2457
+ `Node: "${nodeLabel}" (${nodeId})`);
2458
+ }
2459
+ /**
2460
+ * Execute a code node — dispatches to executeInlineCode with CodeNodeData fields.
2461
+ */
2462
+ function executeCodeNode(node, context) {
2463
+ const data = node.data;
2464
+ return executeInlineCode(data.code, data.language || 'javascript', data.inputVariable || 'input', data.timeoutMs ?? 30000, node.data.label, node.id, context, data.executionContext || 'isolated');
2465
+ }
2466
+ /**
2467
+ * Execute code from a ToolNode with toolType 'code'.
2468
+ * Same pattern as executeCodeNode but reads from ToolNodeData's code fields.
2469
+ */
2470
+ function executeToolCodeSnippet(data, node, context) {
2471
+ return executeInlineCode(data.codeSnippet || '', data.codeLanguage || 'javascript', data.codeInputVariable || 'input', 30000, node.data.label, node.id, context, data.codeExecutionContext || 'isolated');
2156
2472
  }
2157
2473
  /**
2158
2474
  * Execute a memory node - KV store, conversation history, or cache operations
@@ -2849,6 +3165,111 @@ async function executeWebSearchNode(node, context, options, trace) {
2849
3165
  };
2850
3166
  }
2851
3167
  }
3168
+ /**
3169
+ * Execute a database query node - delegates to IPC via onToolCall
3170
+ *
3171
+ * Resolves the connection from the workflow's connections array to determine
3172
+ * the database type, then sends a ToolCallRequest with databaseConfig.
3173
+ * The actual DB driver execution happens in the Electron main process.
3174
+ */
3175
+ async function executeDatabaseQueryNode(node, context, options, trace) {
3176
+ const data = node.data;
3177
+ // Resolve query template if it contains expressions
3178
+ let resolvedQuery = data.query || '';
3179
+ if (resolvedQuery.includes('{{')) {
3180
+ const result = evaluateExpression(resolvedQuery, context);
3181
+ resolvedQuery = typeof result === 'string' ? result : String(result);
3182
+ }
3183
+ // Resolve parameters template if present
3184
+ let resolvedParameters = data.parameters || '';
3185
+ if (resolvedParameters.includes('{{')) {
3186
+ const result = evaluateExpression(resolvedParameters, context);
3187
+ resolvedParameters = typeof result === 'string' ? result : String(result);
3188
+ }
3189
+ // Resolve collection template if present (MongoDB)
3190
+ let resolvedCollection = data.collection || '';
3191
+ if (resolvedCollection.includes('{{')) {
3192
+ const result = evaluateExpression(resolvedCollection, context);
3193
+ resolvedCollection = typeof result === 'string' ? result : String(result);
3194
+ }
3195
+ addTraceEntry(trace, {
3196
+ type: 'debug_step',
3197
+ nodeId: node.id,
3198
+ nodeName: node.data.label,
3199
+ nodeType: 'database-query',
3200
+ message: `Executing ${data.queryType || 'select'} query`,
3201
+ data: {
3202
+ connectionId: data.connectionId,
3203
+ queryType: data.queryType,
3204
+ query: resolvedQuery.length > 100 ? resolvedQuery.slice(0, 100) + '...' : resolvedQuery,
3205
+ collection: resolvedCollection || undefined,
3206
+ },
3207
+ }, options);
3208
+ if (options.onToolCall) {
3209
+ try {
3210
+ // dbType is resolved by the IPC handler (main.js) from the connection config.
3211
+ // We do not hardcode it here — the connection's dbType is the source of truth.
3212
+ const toolCallRequest = {
3213
+ nodeId: node.id,
3214
+ toolName: 'database-query',
3215
+ toolType: 'database-query',
3216
+ parameters: {},
3217
+ databaseConfig: {
3218
+ connectionId: data.connectionId,
3219
+ queryType: data.queryType || 'select',
3220
+ query: resolvedQuery,
3221
+ parameters: resolvedParameters || undefined,
3222
+ collection: resolvedCollection || undefined,
3223
+ maxRows: data.maxRows ?? 1000,
3224
+ timeoutMs: data.timeoutMs ?? 30000,
3225
+ },
3226
+ };
3227
+ const result = await options.onToolCall(toolCallRequest);
3228
+ if (!result.success) {
3229
+ throw new Error(result.error || 'Database query failed');
3230
+ }
3231
+ addTraceEntry(trace, {
3232
+ type: 'debug_step',
3233
+ nodeId: node.id,
3234
+ nodeName: node.data.label,
3235
+ nodeType: 'database-query',
3236
+ message: 'Database query completed successfully',
3237
+ data: {
3238
+ rowCount: Array.isArray(result.result) ? result.result.length : 1,
3239
+ },
3240
+ }, options);
3241
+ return result.result;
3242
+ }
3243
+ catch (error) {
3244
+ const errorMessage = error instanceof Error ? error.message : String(error);
3245
+ addTraceEntry(trace, {
3246
+ type: 'node_error',
3247
+ nodeId: node.id,
3248
+ nodeName: node.data.label,
3249
+ nodeType: 'database-query',
3250
+ message: `Database query error: ${errorMessage}`,
3251
+ data: { error: errorMessage },
3252
+ }, options);
3253
+ throw error;
3254
+ }
3255
+ }
3256
+ else {
3257
+ addTraceEntry(trace, {
3258
+ type: 'debug_step',
3259
+ nodeId: node.id,
3260
+ nodeName: node.data.label,
3261
+ nodeType: 'database-query',
3262
+ message: 'Database query requires onToolCall callback',
3263
+ data: { connectionId: data.connectionId, query: resolvedQuery },
3264
+ }, options);
3265
+ return {
3266
+ skipped: true,
3267
+ reason: 'onToolCall callback required for database query execution',
3268
+ connectionId: data.connectionId,
3269
+ queryType: data.queryType,
3270
+ };
3271
+ }
3272
+ }
2852
3273
  /**
2853
3274
  * Execute an MCP tool node - calls external MCP server tools
2854
3275
  */
@@ -3041,6 +3462,39 @@ async function executeToolNode(node, context, options, trace) {
3041
3462
  serverName: data.mcpServerName,
3042
3463
  };
3043
3464
  }
3465
+ // Code tools execute directly via Function constructor — no callback needed
3466
+ if (data.toolType === 'code') {
3467
+ try {
3468
+ const result = executeToolCodeSnippet(data, node, context);
3469
+ addTraceEntry(trace, {
3470
+ type: 'node_complete',
3471
+ nodeId: node.id,
3472
+ nodeName: node.data.label,
3473
+ nodeType: 'tool',
3474
+ message: `Code tool completed: ${toolNameDisplay}`,
3475
+ data: {
3476
+ toolName: data.toolName || '',
3477
+ toolNameDisplay,
3478
+ toolType: 'code',
3479
+ nodeLabel: node.data.label,
3480
+ result
3481
+ },
3482
+ }, options);
3483
+ // Apply output transform if defined
3484
+ if (data.outputTransform) {
3485
+ return evaluateExpression(data.outputTransform, {
3486
+ ...context,
3487
+ nodeOutputs: { ...context.nodeOutputs, result },
3488
+ });
3489
+ }
3490
+ return result;
3491
+ }
3492
+ catch (error) {
3493
+ const errorMessage = error instanceof Error ? error.message : String(error);
3494
+ throw new Error(`Code tool '${toolNameDisplay}' failed: ${errorMessage}\n` +
3495
+ `Node: "${node.data.label}" (${node.id})`);
3496
+ }
3497
+ }
3044
3498
  // If no callback is provided, we can't execute the tool
3045
3499
  if (!options.onToolCall) {
3046
3500
  // For HTTP tools, we can execute directly
@@ -3649,6 +4103,10 @@ async function executeAgentNode(node, context, options, trace, state, workflowFi
3649
4103
  const lastAssistantMessage = conversationHistory.filter(m => m.role === 'assistant').pop();
3650
4104
  finalResponse = lastAssistantMessage?.content || 'Agent reached maximum iterations without completing.';
3651
4105
  }
4106
+ // Strip any unexecuted <tool_call> XML from final response so raw tags don't leak to output
4107
+ if (finalResponse) {
4108
+ finalResponse = finalResponse.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim();
4109
+ }
3652
4110
  // Determine output based on outputMode
3653
4111
  let output;
3654
4112
  switch (data.outputMode) {
@@ -4659,6 +5117,10 @@ Analyze the input above. Return a JSON object:
4659
5117
  const lastAssistantMessage = conversationHistory.filter(m => m.role === 'assistant').pop();
4660
5118
  finalResponse = lastAssistantMessage?.content || 'Agent reached maximum iterations.';
4661
5119
  }
5120
+ // Strip any unexecuted <tool_call> XML from final response so raw tags don't leak to output
5121
+ if (finalResponse) {
5122
+ finalResponse = finalResponse.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '').trim();
5123
+ }
4662
5124
  // Emit onAgentComplete checkpoint
4663
5125
  await emitChatAgentCheckpoint('onAgentComplete', {
4664
5126
  finalResponse,
@@ -4750,17 +5212,22 @@ function parseToolCall(response, format, allowedTools) {
4750
5212
  toolParameters: null,
4751
5213
  };
4752
5214
  const responseText = typeof response === 'string' ? response : JSON.stringify(response);
4753
- // Try XML format
5215
+ // Try XML format — match first <tool_call> block, <params> is optional
4754
5216
  if (format === 'auto' || format === 'xml') {
4755
- const xmlMatch = responseText.match(/<tool_call>\s*<name>([^<]+)<\/name>\s*<params>([\s\S]*?)<\/params>\s*<\/tool_call>/i);
5217
+ const xmlMatch = responseText.match(/<tool_call>\s*<name>([^<]+)<\/name>(?:\s*<params>([\s\S]*?)<\/params>)?\s*<\/tool_call>/i);
4756
5218
  if (xmlMatch) {
4757
5219
  result.hasToolCall = true;
4758
5220
  result.toolName = xmlMatch[1].trim();
4759
- try {
4760
- result.toolParameters = JSON.parse(xmlMatch[2].trim());
5221
+ if (xmlMatch[2]) {
5222
+ try {
5223
+ result.toolParameters = JSON.parse(xmlMatch[2].trim());
5224
+ }
5225
+ catch {
5226
+ result.toolParameters = { raw: xmlMatch[2].trim() };
5227
+ }
4761
5228
  }
4762
- catch {
4763
- result.toolParameters = { raw: xmlMatch[2].trim() };
5229
+ else {
5230
+ result.toolParameters = {};
4764
5231
  }
4765
5232
  return result;
4766
5233
  }