phronomy 0.1.4 → 0.2.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/phronomy/actor.rb +68 -0
  6. data/lib/phronomy/agent/base.rb +80 -52
  7. data/lib/phronomy/context/context_version_cache.rb +10 -33
  8. data/lib/phronomy/memory/conversation_manager.rb +9 -38
  9. data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
  10. data/lib/phronomy/memory/storage/active_record.rb +20 -0
  11. data/lib/phronomy/memory/storage/base.rb +22 -0
  12. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  13. data/lib/phronomy/state_store/active_record.rb +1 -1
  14. data/lib/phronomy/state_store/base.rb +14 -16
  15. data/lib/phronomy/state_store/file.rb +85 -0
  16. data/lib/phronomy/state_store/in_memory.rb +23 -10
  17. data/lib/phronomy/state_store/redis.rb +1 -1
  18. data/lib/phronomy/thread_actor_registry.rb +52 -0
  19. data/lib/phronomy/tool/base.rb +1 -1
  20. data/lib/phronomy/tool/mcp_tool.rb +10 -9
  21. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  22. data/lib/phronomy/trust_pipeline.rb +41 -49
  23. data/lib/phronomy/vector_store/in_memory.rb +5 -7
  24. data/lib/phronomy/vector_store/redis_search.rb +4 -6
  25. data/lib/phronomy/version.rb +1 -1
  26. data/lib/phronomy/workflow.rb +221 -0
  27. data/lib/phronomy/workflow_context.rb +119 -0
  28. data/lib/phronomy/workflow_runner.rb +285 -0
  29. data/lib/phronomy.rb +30 -34
  30. metadata +26 -10
  31. data/lib/phronomy/graph/compiled_graph.rb +0 -191
  32. data/lib/phronomy/graph/parallel_node.rb +0 -193
  33. data/lib/phronomy/graph/state.rb +0 -105
  34. data/lib/phronomy/graph/state_graph.rb +0 -149
  35. data/lib/phronomy/graph.rb +0 -13
@@ -0,0 +1,285 @@
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
+ # == Design principle
12
+ #
13
+ # State transitions are driven entirely by state_machines. The PhaseTracker
14
+ # holds a reference to the current WorkflowContext via +attr_accessor :context+,
15
+ # and guard lambdas evaluate +m.context+ (the WorkflowContext) rather than
16
+ # the PhaseTracker itself. This ensures that "what happens next" is always
17
+ # determined by the declared state machine topology, never by Phronomy internals.
18
+ #
19
+ # == Three transition categories registered in PhaseTracker
20
+ #
21
+ # 1. advance_<from> — automatic, unconditional after-transitions
22
+ # fired when an action state's action completes
23
+ # (declared with +after :foo, to: :bar+)
24
+ #
25
+ # 2. route — a single event that carries all guarded transitions
26
+ # (declared with +event :route, from: :foo, guard: ..., to: :bar+)
27
+ # Guards are evaluated in declaration order; first match wins.
28
+ # An unguarded fallback, if declared, is evaluated last.
29
+ #
30
+ # 3. <event_name> — external events triggered by human input, originating
31
+ # from wait states
32
+ # (declared with +event :approve, from: :awaiting, to: :run+)
33
+ class WorkflowRunner
34
+ include Phronomy::Runnable
35
+
36
+ # Sentinel value for the terminal state of a workflow.
37
+ FINISH = :__end__
38
+
39
+ def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
40
+ external_events:, entry_point:, wait_state_names: [],
41
+ before_callbacks: {}, after_callbacks: {}, state_store: nil)
42
+ @state_class = state_class
43
+ @nodes = nodes
44
+ @after_transitions = after_transitions # { from => to }
45
+ @route_transitions = route_transitions # { from => [{guard:, to:}, ...] }
46
+ @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
47
+ @entry_point = entry_point
48
+ @wait_state_names = wait_state_names
49
+ @before_callbacks = before_callbacks.dup
50
+ @after_callbacks = after_callbacks.dup
51
+ @state_store_override = state_store
52
+ @phase_machine_class = build_phase_machine_class
53
+ end
54
+
55
+ # Executes the workflow from the initial state.
56
+ # @param input [Hash] initial context field values
57
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
58
+ # @return [Object] final context (includes Phronomy::WorkflowContext)
59
+ def invoke(input, config: {})
60
+ caller_meta = {}
61
+ caller_meta[:user_id] = config[:user_id] if config[:user_id]
62
+ caller_meta[:session_id] = config[:session_id] if config[:session_id]
63
+
64
+ trace("graph.invoke", input: input.inspect, **caller_meta) do |_span|
65
+ thread_id = config[:thread_id] || SecureRandom.uuid
66
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
67
+ state = @state_class.new(**input)
68
+ state.set_graph_metadata(thread_id: thread_id)
69
+ result = run_graph(state, recursion_limit: recursion_limit)
70
+ [result, nil]
71
+ end
72
+ end
73
+
74
+ # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
75
+ # @param state [Object] halted context
76
+ # @param input [Hash, nil] optional field updates to merge before resuming
77
+ # @return [Object] final context
78
+ def resume(state:, input: nil)
79
+ send_event(state: state, event: :resume, input: input)
80
+ end
81
+
82
+ # Fires a named event to advance a halted workflow.
83
+ #
84
+ # The special event +:resume+ selects the first external event registered
85
+ # for the current wait state and fires it.
86
+ #
87
+ # @param state [Object] halted context
88
+ # @param event [Symbol] named event or +:resume+ for generic resumption
89
+ # @param input [Hash, nil] optional field updates to merge before resuming
90
+ # @return [Object] final context
91
+ def send_event(state:, event:, input: nil)
92
+ state = state.merge(input) if input
93
+ event = event.to_sym
94
+ current_phase = state.phase
95
+
96
+ tracker = new_phase_machine(current_phase)
97
+ tracker.context = state
98
+
99
+ ev_to_fire = if event == :resume
100
+ # Find the first external event that can originate from the current wait state.
101
+ name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
102
+ unless name
103
+ raise ArgumentError,
104
+ "No external event registered for wait state #{current_phase.inspect}"
105
+ end
106
+ name
107
+ else
108
+ unless @external_events.key?(event)
109
+ raise ArgumentError,
110
+ "Unknown event #{event.inspect}. Valid events: #{@external_events.keys.inspect}"
111
+ end
112
+ event
113
+ end
114
+
115
+ fire_event!(tracker, ev_to_fire, current_phase)
116
+
117
+ next_phase = tracker.phase.to_sym
118
+ next_node = (next_phase == :__end__) ? FINISH : next_phase
119
+ run_graph(state, from_node: next_node)
120
+ end
121
+
122
+ # Streaming execution. Yields { node: Symbol, state: Object } after each node completes.
123
+ # @param input [Hash]
124
+ # @param config [Hash]
125
+ # @yield [Hash]
126
+ # @return [Object] final context
127
+ def stream(input, config: {}, &block)
128
+ thread_id = config[:thread_id] || SecureRandom.uuid
129
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
130
+ state = @state_class.new(**input)
131
+ state.set_graph_metadata(thread_id: thread_id)
132
+ run_graph(state, recursion_limit: recursion_limit, &block)
133
+ end
134
+
135
+ private
136
+
137
+ def state_store
138
+ @state_store_override || Phronomy.configuration.default_state_store
139
+ end
140
+
141
+ def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
142
+ current_node = from_node || @entry_point
143
+ tracker = new_phase_machine(current_node)
144
+ tracker.context = state
145
+ step = 0
146
+
147
+ while current_node && current_node != FINISH
148
+ if step >= recursion_limit
149
+ raise Phronomy::RecursionLimitError,
150
+ "Recursion limit (#{recursion_limit}) exceeded"
151
+ end
152
+
153
+ # Auto-halt at wait states: save context and return to caller.
154
+ if @wait_state_names.include?(current_node)
155
+ state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
156
+ state_store&.save(state)
157
+ return state
158
+ end
159
+
160
+ node_fn = @nodes[current_node]
161
+ raise ArgumentError, "Node #{current_node.inspect} is not defined" unless node_fn
162
+
163
+ result = node_fn.call(state)
164
+ state = case result
165
+ when Hash then state.merge(result)
166
+ when @state_class then result
167
+ when nil then state
168
+ else
169
+ raise ArgumentError,
170
+ "Node #{current_node} returned #{result.class}; " \
171
+ "expected Hash, #{@state_class}, or nil"
172
+ end
173
+
174
+ # Update tracker so guards see the freshest context.
175
+ tracker.context = state
176
+
177
+ event_block&.call({node: current_node, state: state})
178
+
179
+ # Delegate transition decision to state_machines.
180
+ if @after_transitions.key?(current_node)
181
+ fire_event!(tracker, :"advance_#{current_node}", current_node)
182
+ elsif @route_transitions.key?(current_node)
183
+ ev_name = @route_transitions[current_node][:event_name]
184
+ fire_event!(tracker, ev_name, current_node)
185
+ end
186
+ # Nodes with no declared outgoing transition are treated as terminal:
187
+ # next_phase == current_node triggers the FINISH assignment below.
188
+
189
+ next_phase = tracker.phase.to_sym
190
+ # When next_phase == current_node: no transition fired (terminal node) → end.
191
+ # When next_phase == :__end__ (== FINISH): route led to finish → exit loop.
192
+ current_node = (next_phase == current_node) ? FINISH : next_phase
193
+
194
+ step += 1
195
+ end
196
+
197
+ state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
198
+ state_store&.save(state)
199
+ state
200
+ end
201
+
202
+ # Fires +event_name+ on +tracker+, raising a descriptive error if no
203
+ # transition matches. state_machines event methods return false when no
204
+ # transition can be taken (invalid state or all guards fail).
205
+ def fire_event!(tracker, event_name, from_node)
206
+ return if tracker.send(event_name)
207
+
208
+ raise ArgumentError,
209
+ "Transition from #{from_node.inspect} via event #{event_name.inspect} failed. " \
210
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
211
+ end
212
+
213
+ # Builds the PhaseTracker class backed by state_machines.
214
+ #
215
+ # Three event types are registered:
216
+ # advance_<from> — unconditional after-transitions
217
+ # route — all guarded routing transitions (one event, multiple transitions)
218
+ # <external_name> — external events originating from wait states
219
+ #
220
+ # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
221
+ def build_phase_machine_class
222
+ entry = @entry_point
223
+ all_states = (@nodes.keys + @wait_state_names + [:__end__]).uniq
224
+ after_trans = @after_transitions # { from => to }
225
+ route_trans = @route_transitions # { from => [{guard:, to:}, ...] }
226
+ ext_events = @external_events # { name => [{from:, to:, guard:}, ...] }
227
+
228
+ Class.new do
229
+ # Holds the current WorkflowContext so guards can read it.
230
+ attr_accessor :context
231
+
232
+ state_machine :phase, initial: entry do
233
+ all_states.each { |s| state s }
234
+
235
+ # 1. After-transitions: unconditional, fire on action completion.
236
+ after_trans.each do |from, to|
237
+ event :"advance_#{from}" do
238
+ transition from => to
239
+ end
240
+ end
241
+
242
+ # 2. Route events: one named event per from-state (name may vary).
243
+ # Declaration order is preserved; guards first, unguarded fallback last.
244
+ route_trans.each do |from, routing|
245
+ event routing[:event_name] do
246
+ routing[:entries].each do |t|
247
+ if t[:guard]
248
+ guard_proc = t[:guard]
249
+ transition from => t[:to], :if => ->(m) { guard_proc.call(m.context) }
250
+ else
251
+ transition from => t[:to]
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ # 3. External events: human-in-the-loop triggers from wait states.
258
+ ext_events.each do |ev_name, transitions|
259
+ event ev_name do
260
+ transitions.each do |t|
261
+ if t[:guard]
262
+ guard_proc = t[:guard]
263
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
264
+ else
265
+ transition t[:from] => t[:to]
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
272
+ rescue => e
273
+ raise ArgumentError, "Failed to build phase machine: #{e.message}"
274
+ end
275
+
276
+ # Creates a PhaseTracker instance initialized to +from_node+.
277
+ def new_phase_machine(from_node)
278
+ machine = @phase_machine_class.new
279
+ # Override the initial state set by state_machine's initializer so we can
280
+ # resume from an arbitrary node (e.g. after a wait state).
281
+ machine.instance_variable_set(:@phase, from_node.to_s)
282
+ machine
283
+ end
284
+ end
285
+ 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.1
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-15 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
@@ -146,8 +157,10 @@ files:
146
157
  - lib/phronomy/state_store/encryptor.rb
147
158
  - lib/phronomy/state_store/encryptor/active_support.rb
148
159
  - lib/phronomy/state_store/encryptor/base.rb
160
+ - lib/phronomy/state_store/file.rb
149
161
  - lib/phronomy/state_store/in_memory.rb
150
162
  - lib/phronomy/state_store/redis.rb
163
+ - lib/phronomy/thread_actor_registry.rb
151
164
  - lib/phronomy/token_usage.rb
152
165
  - lib/phronomy/tool.rb
153
166
  - lib/phronomy/tool/agent_tool.rb
@@ -165,6 +178,9 @@ files:
165
178
  - lib/phronomy/vector_store/pgvector.rb
166
179
  - lib/phronomy/vector_store/redis_search.rb
167
180
  - lib/phronomy/version.rb
181
+ - lib/phronomy/workflow.rb
182
+ - lib/phronomy/workflow_context.rb
183
+ - lib/phronomy/workflow_runner.rb
168
184
  - sig/phronomy.rbs
169
185
  - vendor/bundle/ruby/3.2.0/bin/erb
170
186
  - 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