flow_nodes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # A node designed for asynchronous execution.
5
+ class AsyncNode < Node
6
+ # Runs the node asynchronously. Use with `AsyncFlow` to chain successors.
7
+ def run_async(s)
8
+ warn("Node won't run successors. Use AsyncFlow.") unless @successors.empty?
9
+ _run_async(s)
10
+ end
11
+
12
+ def _run(_s)
13
+ raise "Use run_async for AsyncNode."
14
+ end
15
+
16
+ protected
17
+
18
+ def prep_async(_s) = nil
19
+ def exec_async(_p) = nil
20
+ def post_async(_s, _p, _e) = nil
21
+
22
+ def exec_fallback_async(p, exc)
23
+ exec_fallback(p, exc)
24
+ end
25
+
26
+ def _exec_async(p)
27
+ last_exception = nil
28
+ @max_retries.times do |i|
29
+ @current_retry = i
30
+ begin
31
+ return exec_async(p)
32
+ rescue StandardError => e
33
+ last_exception = e
34
+ sleep @wait if @wait.positive? && i < @max_retries - 1
35
+ end
36
+ end
37
+ exec_fallback_async(p, last_exception)
38
+ end
39
+
40
+ def _run_async(s)
41
+ prepared_params = prep_async(s)
42
+ actual_params = prepared_params || @params
43
+ result = _exec_async(actual_params)
44
+ post_async(s, actual_params, result)
45
+ result
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # An async flow that processes a batch of items in parallel using threads.
5
+ class AsyncParallelBatchFlow < AsyncFlow
6
+ protected
7
+
8
+ def _run_async(s)
9
+ batch_params = prep_async(s) || []
10
+ threads = batch_params.map do |item_params|
11
+ Thread.new { _orch_async(s, params: @params.merge(item_params)) }
12
+ end
13
+ threads.map(&:value)
14
+ post_async(s, batch_params, nil)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # An async node that processes a batch of items in parallel using threads.
5
+ class AsyncParallelBatchNode < AsyncNode
6
+ protected
7
+
8
+ # @note This uses standard Ruby threads and is subject to the Global VM Lock (GVL).
9
+ # It is best suited for I/O-bound tasks, not for parallelizing CPU-bound work.
10
+ def _exec_async(items)
11
+ return [] if items.nil?
12
+
13
+ items_array = items.is_a?(Array) ? items : [items]
14
+ threads = items_array.map { |item| Thread.new { super(item) } }
15
+ threads.map(&:value)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # Base class for all nodes in a flow. Defines the core API for connecting
5
+ # nodes and executing logic.
6
+ class BaseNode
7
+ # @return [Hash] parameters passed to the node during execution.
8
+ attr_accessor :params
9
+
10
+ # @return [Hash<String, BaseNode>] a hash mapping action names to successor nodes.
11
+ attr_accessor :successors
12
+
13
+ def initialize
14
+ @params = {}
15
+ @successors = {}
16
+ end
17
+
18
+ # Creates a deep copy of the node. This is critical for ensuring that each
19
+ # execution of a flow operates on its own isolated set of node instances,
20
+ # preventing state bleed.
21
+ #
22
+ # @param other [BaseNode] The original node being duplicated.
23
+ def initialize_copy(other)
24
+ super
25
+ @params = Marshal.load(Marshal.dump(other.params))
26
+ # Successors are other nodes. The orchestration loop handles duplicating them
27
+ # as they are traversed. A shallow copy of the hash is sufficient here.
28
+ @successors = other.successors.dup
29
+ end
30
+
31
+ # Sets the parameters for the node. To ensure thread safety and prevent
32
+ # state bleed, the parameters are deep-copied.
33
+ #
34
+ # @param p [Hash] The parameters to set.
35
+ def set_params(p)
36
+ @params = Marshal.load(Marshal.dump(p || {}))
37
+ end
38
+
39
+ # Connects this node to a successor for a given action.
40
+ #
41
+ # @param node [BaseNode] The successor node.
42
+ # @param action [String] The action name that triggers the transition.
43
+ # @return [BaseNode] The successor node.
44
+ def nxt(node, action = "default")
45
+ warn("Overwriting successor for action '#{action}'") if @successors.key?(action)
46
+ @successors[action] = node
47
+ node
48
+ end
49
+ alias next nxt
50
+
51
+ # Defines the default transition to the next node.
52
+ # @param other [BaseNode] The node to transition to.
53
+ def >>(other)
54
+ nxt(other)
55
+ end
56
+
57
+ # Creates a conditional transition to a successor node.
58
+ # @param action [String, Symbol] The action that triggers this transition.
59
+ # @return [ConditionalTransition] An object to define the target node.
60
+ def -(other)
61
+ raise TypeError, "Action must be a String or Symbol" unless other.is_a?(String) || other.is_a?(Symbol)
62
+
63
+ ConditionalTransition.new(self, other.to_s)
64
+ end
65
+
66
+ # Executes the main logic of the node.
67
+ # This is intended to be overridden by subclasses.
68
+ #
69
+ # @param _p [Hash] The parameters for execution.
70
+ # @return [String, Symbol, nil] The result action to determine the next node in a flow.
71
+ def exec(_p)
72
+ nil
73
+ end
74
+
75
+ # Runs the full lifecycle of the node: prep, exec, and post.
76
+ # If not part of a Flow, successors will not be executed.
77
+ #
78
+ # @param state [Object] An optional shared state object passed through the flow.
79
+ def run(state)
80
+ warn("Node won't run successors. Use Flow.") unless @successors.empty?
81
+ _run(state)
82
+ end
83
+
84
+ protected
85
+
86
+ # Pre-execution hook. Can be used to prepare data.
87
+ # @param _state [Object] The shared state object.
88
+ def prep(_state)
89
+ nil
90
+ end
91
+
92
+ # Post-execution hook. Can be used for cleanup or logging.
93
+ # @param _state [Object] The shared state object.
94
+ # @param _params [Hash] The parameters used in execution.
95
+ # @param _result [Object] The value returned by `exec`.
96
+ def post(_state, _params, _result)
97
+ nil
98
+ end
99
+
100
+ # Internal execution wrapper.
101
+ # @param p [Hash] The parameters for execution.
102
+ def _exec(p)
103
+ exec(p)
104
+ end
105
+
106
+ # Internal run lifecycle.
107
+ # @param s [Object] The shared state object.
108
+ def _run(s)
109
+ prepared_params = prep(s)
110
+ # Use the node's params if prep returns nil
111
+ params_to_use = prepared_params || @params
112
+ execution_result = _exec(params_to_use)
113
+ post(s, prepared_params, execution_result)
114
+ execution_result
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # A flow that processes a batch of items sequentially.
5
+ class BatchFlow < Flow
6
+ protected
7
+
8
+ def _run(s)
9
+ batch_params = prep(s) || []
10
+ batch_params.each do |item_params|
11
+ _orch(@params.merge(item_params))
12
+ end
13
+ post(s, batch_params, nil)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # A node that processes a batch of items sequentially.
5
+ class BatchNode < Node
6
+ protected
7
+
8
+ def _exec(items)
9
+ return [] if items.nil?
10
+
11
+ items_array = items.is_a?(Array) ? items : [items]
12
+ items_array.map { |item| super(item) }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # Represents a pending conditional transition from one node to another.
5
+ class ConditionalTransition
6
+ def initialize(source_node, action)
7
+ @source_node = source_node
8
+ @action = action
9
+ end
10
+
11
+ # Completes the transition by connecting the source node to the target.
12
+ # @param target_node [BaseNode] The node to transition to.
13
+ def >>(other)
14
+ @source_node.nxt(other, @action)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # Orchestrates a sequence of connected nodes, managing state and transitions.
5
+ class Flow < BaseNode
6
+ attr_accessor :start_node
7
+
8
+ def initialize(start: nil)
9
+ super()
10
+ @start_node = start
11
+ end
12
+
13
+ # Sets the starting node of the flow.
14
+ # @param node [BaseNode] The node to start the flow with.
15
+ # @return [BaseNode] The starting node.
16
+ def start(node)
17
+ @start_node = node
18
+ node
19
+ end
20
+
21
+ protected
22
+
23
+ # Main orchestration logic that walks through the node graph.
24
+ def _orch(initial_params)
25
+ raise "Flow has no start node" unless @start_node
26
+
27
+ current_node = @start_node.dup
28
+ current_params = initial_params
29
+
30
+ loop do
31
+ # Merge the node's own params with the incoming params from the flow.
32
+ # The flow's params take precedence.
33
+ merged_params = current_node.params.merge(current_params || {})
34
+ current_node.set_params(merged_params)
35
+ current_params = merged_params # Ensure current_params is updated for the next iteration
36
+
37
+ action = current_node._run(current_node.params)
38
+
39
+ # If the node returns a symbol, it's an action to determine the next node.
40
+ current_node = get_next_node(current_node, action)&.dup
41
+ break unless current_node
42
+ end
43
+ end
44
+
45
+ def _run(s)
46
+ prepared_params = prep(s)
47
+ result = _orch(prepared_params || @params)
48
+ post(s, prepared_params, result)
49
+ result
50
+ end
51
+
52
+ # Determines the next node based on the result of the current node.
53
+ # For routing to work predictably, the return value of a node's `exec` method
54
+ # should be a String or Symbol that matches a defined successor action.
55
+ def get_next_node(current_node, action)
56
+ action_key = action.nil? || action == "" ? "default" : action.to_s
57
+ successor = current_node.successors[action_key]
58
+
59
+ if !successor && !current_node.successors.empty?
60
+ warn("Flow ends: action '#{action_key}' not found in successors: #{current_node.successors.keys.inspect}")
61
+ end
62
+ successor
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ # A node with built-in retry logic.
5
+ class Node < BaseNode
6
+ attr_reader :max_retries, :wait, :current_retry
7
+
8
+ def initialize(max_retries: 1, wait: 0)
9
+ super()
10
+ @max_retries = max_retries
11
+ @wait = wait
12
+ @current_retry = 0
13
+ end
14
+
15
+ # Public method to execute the node's logic with retries.
16
+ # This is the entry point for a flow to run a node.
17
+ def _run(s)
18
+ prepared_params = prep(s)
19
+ actual_params = prepared_params || @params
20
+ execution_result = _exec(actual_params)
21
+ post(s, actual_params, execution_result)
22
+ execution_result
23
+ end
24
+
25
+ protected
26
+
27
+ # Internal execution logic with retries.
28
+ # @note If your `exec` method performs actions with side effects (e.g., API calls,
29
+ # database writes), ensure they are idempotent. Retries will re-execute the logic,
30
+ # which could cause unintended repeated effects if not designed carefully.
31
+ def _exec(p)
32
+ last_exception = nil
33
+ @max_retries.times do |i|
34
+ @current_retry = i
35
+ begin
36
+ return exec(p)
37
+ rescue StandardError => e
38
+ last_exception = e
39
+ sleep @wait if @wait.positive? && i < @max_retries - 1
40
+ end
41
+ end
42
+ exec_fallback(p, last_exception)
43
+ end
44
+
45
+ # Fallback method called after all retries have been exhausted.
46
+ # The default behavior is to re-raise the last exception.
47
+ #
48
+ # @param _params [Hash] The parameters that caused the failure.
49
+ # @param exception [Exception] The last exception that was caught.
50
+ def exec_fallback(_params, exception)
51
+ raise exception
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowNodes
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flow_nodes.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flow_nodes/version"
4
+ require_relative "flow_nodes/base_node"
5
+ require_relative "flow_nodes/conditional_transition"
6
+ require_relative "flow_nodes/node"
7
+ require_relative "flow_nodes/batch_node"
8
+ require_relative "flow_nodes/flow"
9
+ require_relative "flow_nodes/batch_flow"
10
+ require_relative "flow_nodes/async_node"
11
+ require_relative "flow_nodes/async_batch_node"
12
+ require_relative "flow_nodes/async_parallel_batch_node"
13
+ require_relative "flow_nodes/async_flow"
14
+ require_relative "flow_nodes/async_batch_flow"
15
+ require_relative "flow_nodes/async_parallel_batch_flow"
16
+
17
+ # FlowNodes is a minimalist, graph-based framework for building complex workflows
18
+ # and agentic systems in Ruby. It is a port of the Python PocketFlow library.
19
+ module FlowNodes
20
+ end
@@ -0,0 +1,4 @@
1
+ module FlowNodes
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flow_nodes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - RJ Robinson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-07-17 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: FlowNodes is a Ruby port of PocketFlow, the Python framework created
14
+ by The Pocket. It brings the power and simplicity of PocketFlow's graph-based architecture
15
+ to the Ruby ecosystem. Build powerful LLM applications like Agents, Workflows, and
16
+ RAG systems with minimal code and maximum expressiveness.
17
+ email:
18
+ - rj@trainual.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - ".qlty.yml"
24
+ - ".rspec"
25
+ - ".rubocop.yml"
26
+ - CHANGELOG.md
27
+ - CODE_OF_CONDUCT.md
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - examples/advanced_workflow.rb
32
+ - examples/batch_processing.rb
33
+ - examples/chatbot.rb
34
+ - examples/llm_calendar_parser.rb
35
+ - examples/llm_content_processor.rb
36
+ - examples/llm_document_analyzer.rb
37
+ - examples/simple_llm_example.rb
38
+ - examples/workflow.rb
39
+ - lib/flow_nodes.rb
40
+ - lib/flow_nodes/async_batch_flow.rb
41
+ - lib/flow_nodes/async_batch_node.rb
42
+ - lib/flow_nodes/async_flow.rb
43
+ - lib/flow_nodes/async_node.rb
44
+ - lib/flow_nodes/async_parallel_batch_flow.rb
45
+ - lib/flow_nodes/async_parallel_batch_node.rb
46
+ - lib/flow_nodes/base_node.rb
47
+ - lib/flow_nodes/batch_flow.rb
48
+ - lib/flow_nodes/batch_node.rb
49
+ - lib/flow_nodes/conditional_transition.rb
50
+ - lib/flow_nodes/flow.rb
51
+ - lib/flow_nodes/node.rb
52
+ - lib/flow_nodes/version.rb
53
+ - sig/flow_nodes.rbs
54
+ homepage: https://github.com/rjrobinson/flow_nodes
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ allowed_push_host: https://rubygems.org
59
+ homepage_uri: https://github.com/rjrobinson/flow_nodes
60
+ source_code_uri: https://github.com/rjrobinson/flow_nodes
61
+ changelog_uri: https://github.com/rjrobinson/flow_nodes/blob/main/CHANGELOG.md
62
+ rubygems_mfa_required: 'true'
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.1.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.22
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: A Ruby port of PocketFlow, the minimalist LLM framework.
82
+ test_files: []