phronomy 0.1.4 → 0.2.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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "state_machines"
5
+
6
+ module Phronomy
7
+ # Execution engine for compiled workflows.
8
+ # Manages node execution, phase transitions, halt/resume, and wait states.
9
+ # Instantiated by Phronomy::Workflow and used internally.
10
+ #
11
+ # Wait states (registered via wait_states:) are virtual nodes
12
+ # that automatically halt execution when reached. They can be resumed with
13
+ # either #resume (generic) or #send_event (event-typed).
14
+ #
15
+ # Internally, a state_machines-based PhaseTracker class is generated at
16
+ # initialization time. The tracker validates phase transitions during
17
+ # execution; invalid transitions are logged as warnings without halting.
18
+ class WorkflowRunner
19
+ include Phronomy::Runnable
20
+
21
+ # Sentinel value for the terminal state of a workflow.
22
+ FINISH = :__end__
23
+
24
+ def initialize(state_class:, nodes:, edges:, conditional_edges:, entry_point:,
25
+ before_callbacks: {}, after_callbacks: {}, wait_states: {}, state_store: nil)
26
+ @state_class = state_class
27
+ @nodes = nodes
28
+ @edges = edges
29
+ @conditional_edges = conditional_edges
30
+ @entry_point = entry_point
31
+ @before_callbacks = before_callbacks.dup
32
+ @after_callbacks = after_callbacks.dup
33
+ # { wait_state_name => { resume_event: Symbol, resume_to: Symbol } }
34
+ @wait_states = wait_states.dup
35
+ @state_store_override = state_store
36
+ @phase_machine_class = build_phase_machine_class
37
+ end
38
+
39
+ # Executes the workflow from the entry point.
40
+ # @param input [Hash] initial context field values
41
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
42
+ # @return [Object] final context (includes Phronomy::WorkflowContext)
43
+ def invoke(input, config: {})
44
+ caller_meta = {}
45
+ caller_meta[:user_id] = config[:user_id] if config[:user_id]
46
+ caller_meta[:session_id] = config[:session_id] if config[:session_id]
47
+
48
+ trace("graph.invoke", input: input.inspect, **caller_meta) do |_span|
49
+ thread_id = config[:thread_id] || SecureRandom.uuid
50
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
51
+ state = @state_class.new(**input)
52
+ state.set_graph_metadata(thread_id: thread_id)
53
+ result = run_graph(state, recursion_limit: recursion_limit)
54
+ [result, nil]
55
+ end
56
+ end
57
+
58
+ # Generic resume. Routes based on the current phase encoding.
59
+ # Equivalent to +send_event(state:, event: :resume, input:)+.
60
+ #
61
+ # @param state [Object] halted context
62
+ # @param input [Hash, nil] optional field updates to merge before resuming
63
+ # @return [Object] final context
64
+ def resume(state:, input: nil)
65
+ send_event(state: state, event: :resume, input: input)
66
+ end
67
+
68
+ # Fires a named event to advance a halted workflow.
69
+ #
70
+ # The special event +:resume+ is accepted for all halt types:
71
+ # - Named wait state → resumes at +resume_to+ node
72
+ #
73
+ # Any other event name must match the +resume_event:+ declared in
74
+ # the wait_states configuration.
75
+ #
76
+ # @param state [Object] halted context
77
+ # @param event [Symbol] +:resume+ for generic resumption, or a named event
78
+ # @param input [Hash, nil] optional field updates to merge before resuming
79
+ # @return [Object] final context
80
+ def send_event(state:, event:, input: nil)
81
+ state = state.merge(input) if input
82
+ event = event.to_sym
83
+ current_phase = state.phase
84
+
85
+ if event == :resume
86
+ # Named wait state: use resume_to
87
+ if @wait_states.key?(current_phase)
88
+ return run_graph(state, from_node: @wait_states[current_phase][:resume_to])
89
+ end
90
+ raise ArgumentError, "State has no wait state registered for phase #{current_phase.inspect}"
91
+ end
92
+
93
+ # Named event lookup
94
+ _, wait_cfg = @wait_states.find { |_, c| c[:resume_event] == event }
95
+ unless wait_cfg
96
+ valid = @wait_states.values.filter_map { |c| c[:resume_event] }.uniq
97
+ raise ArgumentError, "Unknown event #{event.inspect}. Valid events: #{valid.inspect}"
98
+ end
99
+ run_graph(state, from_node: wait_cfg[:resume_to])
100
+ end
101
+
102
+ # Streaming execution. Yields { node: Symbol, state: Object } after each node completes.
103
+ # @param input [Hash]
104
+ # @param config [Hash]
105
+ # @yield [Hash]
106
+ # @return [Object] final context
107
+ def stream(input, config: {}, &block)
108
+ thread_id = config[:thread_id] || SecureRandom.uuid
109
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
110
+ state = @state_class.new(**input)
111
+ state.set_graph_metadata(thread_id: thread_id)
112
+ run_graph(state, recursion_limit: recursion_limit, &block)
113
+ end
114
+
115
+ private
116
+
117
+ def state_store
118
+ @state_store_override || Phronomy.configuration.default_state_store
119
+ end
120
+
121
+ def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
122
+ current_node = from_node || @entry_point
123
+ tracker = new_phase_machine(current_node)
124
+ step = 0
125
+
126
+ while current_node && current_node != FINISH
127
+ if step >= recursion_limit
128
+ raise Phronomy::RecursionLimitError,
129
+ "Recursion limit (#{recursion_limit}) exceeded"
130
+ end
131
+
132
+ # Auto-halt at wait states.
133
+ if @wait_states.key?(current_node)
134
+ state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
135
+ state_store&.save(state)
136
+ return state
137
+ end
138
+
139
+ node_fn = @nodes[current_node]
140
+ raise ArgumentError, "Node #{current_node} is not defined" unless node_fn
141
+
142
+ result = node_fn.call(state)
143
+ state = case result
144
+ when Hash then state.merge(result)
145
+ when @state_class then result
146
+ when nil then state
147
+ else
148
+ raise ArgumentError,
149
+ "Node #{current_node} returned #{result.class}; expected Hash, #{@state_class}, or nil"
150
+ end
151
+
152
+ event_block&.call({node: current_node, state: state})
153
+
154
+ next_n = resolve_next_node(current_node, state)
155
+ advance_phase(tracker, current_node, next_n || FINISH)
156
+ current_node = next_n
157
+
158
+ step += 1
159
+ end
160
+
161
+ state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
162
+ state_store&.save(state)
163
+ state
164
+ end
165
+
166
+ def resolve_next_node(current, state)
167
+ if (cond = @conditional_edges[current])
168
+ result = cond[:condition].call(state)
169
+ if cond[:mapping]
170
+ unless cond[:mapping].key?(result)
171
+ raise ArgumentError,
172
+ "Conditional edge from #{current.inspect} returned #{result.inspect}, " \
173
+ "which is not present in the mapping (#{cond[:mapping].keys.inspect})"
174
+ end
175
+ return cond[:mapping][result]
176
+ end
177
+ return result
178
+ end
179
+
180
+ edges = @edges[current]
181
+ return nil unless edges&.any?
182
+
183
+ matched = edges.find { |edge| edge[:condition].nil? || edge[:condition].call(state) }
184
+ matched&.fetch(:to)
185
+ end
186
+
187
+ # Builds a state_machines-based PhaseTracker class encoding the workflow topology.
188
+ # Returns nil if the build fails (execution continues without phase validation).
189
+ def build_phase_machine_class
190
+ entry = @entry_point
191
+ nodes = @nodes.keys
192
+ ws_names = @wait_states.keys
193
+
194
+ # Collect all valid (from, to) pairs; use a Hash to deduplicate.
195
+ trans = {}
196
+
197
+ @edges.each do |from, edge_list|
198
+ edge_list.each do |edge|
199
+ to = (edge[:to] == FINISH) ? :__end__ : edge[:to]
200
+ trans[[from, to]] = true
201
+ end
202
+ end
203
+
204
+ @conditional_edges.each do |from, cfg|
205
+ targets = cfg[:mapping] ? cfg[:mapping].values : (nodes + ws_names + [:__end__])
206
+ targets.each do |to|
207
+ t = (to == FINISH) ? :__end__ : to
208
+ trans[[from, t]] = true
209
+ end
210
+ end
211
+
212
+ # Any node can be terminal (no outgoing edge = implicit advance to :__end__).
213
+ nodes.each { |n| trans[[n, :__end__]] = true }
214
+
215
+ all_states = (nodes + ws_names + [:__end__]).uniq
216
+ trans_pairs = trans.keys
217
+
218
+ Class.new do
219
+ state_machine :phase, initial: entry do
220
+ all_states.each { |s| state s }
221
+ trans_pairs.each do |from, to|
222
+ event :"advance_#{from}_to_#{to}" do
223
+ transition from => to
224
+ end
225
+ end
226
+ end
227
+ end
228
+ rescue => e
229
+ warn "[Phronomy] Could not build phase machine: #{e.message}"
230
+ nil
231
+ end
232
+
233
+ # Creates a PhaseTracker instance initialised to +from_node+.
234
+ def new_phase_machine(from_node)
235
+ return nil unless @phase_machine_class && from_node
236
+
237
+ machine = @phase_machine_class.new
238
+ machine.instance_variable_set(:@phase, from_node.to_s)
239
+ machine
240
+ rescue => e
241
+ warn "[Phronomy] Phase machine init failed: #{e.message}"
242
+ nil
243
+ end
244
+
245
+ # Fires a transition event on the tracker from +from+ to +to+.
246
+ # Logs a warning if the transition is not declared; does not raise.
247
+ def advance_phase(tracker, from, to)
248
+ return unless tracker && from
249
+
250
+ to_sym = case to
251
+ when nil, FINISH then :__end__
252
+ else to
253
+ end
254
+ event_name = :"advance_#{from}_to_#{to_sym}"
255
+ unless tracker.fire_events(event_name)
256
+ warn "[Phronomy] Unexpected phase transition #{from.inspect} → #{to_sym.inspect}"
257
+ end
258
+ rescue => e
259
+ warn "[Phronomy] Phase tracker error (#{from}→#{to}): #{e.message}"
260
+ end
261
+ end
262
+ end
data/lib/phronomy.rb CHANGED
@@ -35,46 +35,42 @@ module Phronomy
35
35
  end
36
36
  end
37
37
 
38
- # Namespace for graph-related classes (StateGraph, State, ParallelNode, …).
39
- # Also serves as the registry for State classes that may be serialized to
40
- # external stores (Redis, DB). Call +register_state_class+ at application
41
- # startup so that only known classes can be deserialized.
42
- module Graph
43
- @state_class_registry = nil
44
- @registry_mutex = Mutex.new
45
-
46
- class << self
47
- # Register one or more State classes that are allowed to be deserialized
48
- # by StateStore backends. When at least one class is registered, only
49
- # registered classes will be accepted by +StateStore::Base#safe_state_class+.
50
- #
51
- # Call this once at application startup (e.g. in a Rails initializer).
52
- #
53
- # @param classes [Array<Class>] classes including Phronomy::Graph::State
54
- # @example
55
- # Phronomy::Graph.register_state_class(MyWorkflowState, OtherState)
56
- def register_state_class(*classes)
57
- @registry_mutex.synchronize do
58
- @state_class_registry ||= {}
59
- classes.each do |klass|
60
- raise ArgumentError, "#{klass.inspect} is not a Class" unless klass.is_a?(Class)
61
- @state_class_registry[klass.name] = klass
62
- end
38
+ # Registry for WorkflowContext classes that may be serialized to external stores
39
+ # (Redis, DB). Call +register_workflow_context+ at application startup so that
40
+ # only known classes can be deserialized.
41
+ @workflow_context_registry = nil
42
+ @registry_mutex = Mutex.new
43
+
44
+ class << self
45
+ # Register one or more WorkflowContext classes that are allowed to be
46
+ # deserialized by StateStore backends. When at least one class is registered,
47
+ # only registered classes will be accepted by
48
+ # +StateStore::Base#safe_state_class+.
49
+ #
50
+ # Call this once at application startup (e.g. in a Rails initializer).
51
+ #
52
+ # @param classes [Array<Class>] classes including Phronomy::WorkflowContext
53
+ # @example
54
+ # Phronomy.register_workflow_context(ScanContext, OtherContext)
55
+ def register_workflow_context(*classes)
56
+ @registry_mutex.synchronize do
57
+ @workflow_context_registry ||= {}
58
+ classes.each do |klass|
59
+ raise ArgumentError, "#{klass.inspect} is not a Class" unless klass.is_a?(Class)
60
+ @workflow_context_registry[klass.name] = klass
63
61
  end
64
62
  end
63
+ end
65
64
 
66
- # Returns the current registry Hash, or nil when no class has been registered.
67
- # @return [Hash{String => Class}, nil]
68
- attr_reader :state_class_registry
65
+ # Returns the current registry Hash, or nil when no class has been registered.
66
+ # @return [Hash{String => Class}, nil]
67
+ attr_reader :workflow_context_registry
69
68
 
70
- # Clears the registry. Primarily used in tests.
71
- def reset_state_class_registry!
72
- @registry_mutex.synchronize { @state_class_registry = nil }
73
- end
69
+ # Clears the registry. Primarily used in tests.
70
+ def reset_workflow_context_registry!
71
+ @registry_mutex.synchronize { @workflow_context_registry = nil }
74
72
  end
75
- end
76
73
 
77
- class << self
78
74
  def configuration
79
75
  @configuration ||= Configuration.new
80
76
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-11 00:00:00.000000000 Z
11
+ date: 2026-05-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -38,9 +38,23 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.6'
41
- description: Phronomy provides Agent, Graph, Memory, Tool, Guardrail, RAG, and Multi-agent
42
- capabilities for building AI agents in Ruby and Rails. Powered by RubyLLM for LLM
43
- abstraction.
41
+ - !ruby/object:Gem::Dependency
42
+ name: state_machines
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.6'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.6'
55
+ description: Phronomy provides Agent, Workflow, Memory, Tool, Guardrail, RAG, and
56
+ Multi-agent capabilities for building AI agents in Ruby and Rails. Powered by RubyLLM
57
+ for LLM abstraction.
44
58
  email:
45
59
  - raizo.tcs@gmail.com
46
60
  executables: []
@@ -48,6 +62,7 @@ extensions: []
48
62
  extra_rdoc_files: []
49
63
  files:
50
64
  - ".yardopts"
65
+ - CHANGELOG.md
51
66
  - README.md
52
67
  - Rakefile
53
68
  - docs/trustworthy_ai_enhancements.md
@@ -60,6 +75,7 @@ files:
60
75
  - lib/phronomy/active_record/checkpoint.rb
61
76
  - lib/phronomy/active_record/extensions.rb
62
77
  - lib/phronomy/active_record/message.rb
78
+ - lib/phronomy/actor.rb
63
79
  - lib/phronomy/agent.rb
64
80
  - lib/phronomy/agent/base.rb
65
81
  - lib/phronomy/agent/before_completion_context.rb
@@ -91,11 +107,6 @@ files:
91
107
  - lib/phronomy/eval/scorer/exact_match.rb
92
108
  - lib/phronomy/eval/scorer/includes_scorer.rb
93
109
  - lib/phronomy/eval/scorer/llm_judge.rb
94
- - lib/phronomy/graph.rb
95
- - lib/phronomy/graph/compiled_graph.rb
96
- - lib/phronomy/graph/parallel_node.rb
97
- - lib/phronomy/graph/state.rb
98
- - lib/phronomy/graph/state_graph.rb
99
110
  - lib/phronomy/guardrail.rb
100
111
  - lib/phronomy/guardrail/base.rb
101
112
  - lib/phronomy/guardrail/builtin.rb
@@ -148,6 +159,7 @@ files:
148
159
  - lib/phronomy/state_store/encryptor/base.rb
149
160
  - lib/phronomy/state_store/in_memory.rb
150
161
  - lib/phronomy/state_store/redis.rb
162
+ - lib/phronomy/thread_actor_registry.rb
151
163
  - lib/phronomy/token_usage.rb
152
164
  - lib/phronomy/tool.rb
153
165
  - lib/phronomy/tool/agent_tool.rb
@@ -165,6 +177,9 @@ files:
165
177
  - lib/phronomy/vector_store/pgvector.rb
166
178
  - lib/phronomy/vector_store/redis_search.rb
167
179
  - lib/phronomy/version.rb
180
+ - lib/phronomy/workflow.rb
181
+ - lib/phronomy/workflow_context.rb
182
+ - lib/phronomy/workflow_runner.rb
168
183
  - sig/phronomy.rbs
169
184
  - vendor/bundle/ruby/3.2.0/bin/erb
170
185
  - vendor/bundle/ruby/3.2.0/bin/htmldiff
@@ -1,191 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Graph
7
- # Executable graph produced by StateGraph#compile.
8
- # Includes Runnable so it can be embedded in a larger pipeline.
9
- class CompiledGraph
10
- include Phronomy::Runnable
11
-
12
- def initialize(state_class:, nodes:, edges:, conditional_edges:, entry_point:,
13
- before_callbacks: {}, after_callbacks: {}, state_store: nil)
14
- @state_class = state_class
15
- @nodes = nodes
16
- @edges = edges
17
- @conditional_edges = conditional_edges
18
- @entry_point = entry_point
19
- @before_callbacks = before_callbacks
20
- @after_callbacks = after_callbacks
21
- @state_store_override = state_store
22
- end
23
-
24
- # Registers a callback to run before the given node executes.
25
- # Return :halt from the block to pause execution; any other value continues.
26
- # @param node [Symbol]
27
- # @yield [state] the current state
28
- # @return [self]
29
- def interrupt_before(node, &block)
30
- @before_callbacks[node] = block
31
- self
32
- end
33
-
34
- # Registers a callback to run after the given node completes.
35
- # Return :halt from the block to pause execution; any other value continues.
36
- # @param node [Symbol]
37
- # @yield [state] the state after the node ran
38
- # @return [self]
39
- def interrupt_after(node, &block)
40
- @after_callbacks[node] = block
41
- self
42
- end
43
-
44
- # Executes the graph from the entry point.
45
- # Automatically assigns a thread_id if not supplied via config.
46
- # @param input [Hash] initial state field values
47
- # @param config [Hash] { thread_id: String, recursion_limit: Integer,
48
- # user_id: String (optional), session_id: String (optional) }
49
- # @return [Object] final state (includes Phronomy::Graph::State)
50
- def invoke(input, config: {})
51
- caller_meta = {}
52
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
53
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
54
-
55
- trace("graph.invoke", input: input.inspect, **caller_meta) do |_span|
56
- thread_id = config[:thread_id] || SecureRandom.uuid
57
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
58
- state = @state_class.new(**input)
59
- state.set_graph_metadata(thread_id: thread_id, current_nodes: [], halted_before: false)
60
- result = execute_graph(state, recursion_limit: recursion_limit)
61
- [result, nil]
62
- end
63
- end
64
-
65
- # Resumes a halted graph from the state returned by a previous invoke/resume.
66
- # @param state [Object] state object (includes Phronomy::Graph::State) with current_nodes set
67
- # @param input [Hash, nil] optional field updates to merge before resuming
68
- # @return [Object] final state
69
- def resume(state:, input: nil)
70
- state = state.merge(input) if input
71
- from_nodes = state.current_nodes
72
- raise ArgumentError, "State has no pending nodes to resume from" if from_nodes.nil? || from_nodes.empty?
73
-
74
- execute_graph(state, from_node: from_nodes.first,
75
- skip_first_before: state.halted_before)
76
- end
77
-
78
- # Streaming execution. Yields { node: Symbol, state: State } after each node completes.
79
- # @param input [Hash]
80
- # @param config [Hash]
81
- # @yield [Hash] { node: Symbol, state: State }
82
- # @return [Object] final state
83
- def stream(input, config: {}, &block)
84
- thread_id = config[:thread_id] || SecureRandom.uuid
85
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
86
- state = @state_class.new(**input)
87
- state.set_graph_metadata(thread_id: thread_id, current_nodes: [], halted_before: false)
88
- execute_graph(state, recursion_limit: recursion_limit, &block)
89
- end
90
-
91
- private
92
-
93
- def state_store
94
- @state_store_override || Phronomy.configuration.default_state_store
95
- end
96
-
97
- def execute_graph(state, from_node: nil, recursion_limit: 25,
98
- skip_first_before: false, &event_block)
99
- current_node = from_node || @entry_point
100
- step = 0
101
- first_step = true
102
-
103
- while current_node && current_node != StateGraph::FINISH
104
- if step >= recursion_limit
105
- raise Phronomy::RecursionLimitError,
106
- "Recursion limit (#{recursion_limit}) exceeded"
107
- end
108
-
109
- # interrupt_before callback
110
- unless skip_first_before && first_step
111
- if (cb = @before_callbacks[current_node])
112
- if cb.call(state) == :halt
113
- state.set_graph_metadata(
114
- thread_id: state.thread_id,
115
- current_nodes: [current_node],
116
- halted_before: true
117
- )
118
- state_store&.save(state)
119
- return state
120
- end
121
- end
122
- end
123
- first_step = false
124
-
125
- node_fn = @nodes[current_node]
126
- raise ArgumentError, "Node #{current_node} is not defined" unless node_fn
127
-
128
- result = node_fn.call(state)
129
- state = case result
130
- when Hash then state.merge(result)
131
- when @state_class then result
132
- when nil then state
133
- else
134
- raise ArgumentError,
135
- "Node #{current_node} returned #{result.class}; expected Hash, #{@state_class}, or nil"
136
- end
137
-
138
- event_block&.call({node: current_node, state: state})
139
-
140
- # interrupt_after callback
141
- if (cb = @after_callbacks[current_node])
142
- next_n = next_node(current_node, state)
143
- if cb.call(state) == :halt
144
- state.set_graph_metadata(
145
- thread_id: state.thread_id,
146
- current_nodes: [next_n].compact,
147
- halted_before: false
148
- )
149
- state_store&.save(state)
150
- return state
151
- end
152
- current_node = next_n
153
- else
154
- current_node = next_node(current_node, state)
155
- end
156
-
157
- step += 1
158
- end
159
-
160
- state.set_graph_metadata(
161
- thread_id: state.thread_id,
162
- current_nodes: [],
163
- halted_before: false
164
- )
165
- state_store&.save(state)
166
- state
167
- end
168
-
169
- def next_node(current, state)
170
- if (cond = @conditional_edges[current])
171
- result = cond[:condition].call(state)
172
- if cond[:mapping]
173
- unless cond[:mapping].key?(result)
174
- raise ArgumentError,
175
- "Conditional edge from #{current.inspect} returned #{result.inspect}, " \
176
- "which is not present in the mapping (#{cond[:mapping].keys.inspect})"
177
- end
178
- return cond[:mapping][result]
179
- end
180
- return result
181
- end
182
-
183
- edges = @edges[current]
184
- return nil unless edges&.any?
185
-
186
- matched = edges.find { |edge| edge[:condition].nil? || edge[:condition].call(state) }
187
- matched&.fetch(:to)
188
- end
189
- end
190
- end
191
- end