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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +49 -38
- data/docs/trustworthy_ai_enhancements.md +4 -4
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +80 -52
- data/lib/phronomy/context/context_version_cache.rb +10 -33
- data/lib/phronomy/memory/conversation_manager.rb +9 -38
- data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
- data/lib/phronomy/memory/storage/active_record.rb +20 -0
- data/lib/phronomy/memory/storage/base.rb +22 -0
- data/lib/phronomy/memory/storage/in_memory.rb +65 -26
- data/lib/phronomy/state_store/active_record.rb +1 -1
- data/lib/phronomy/state_store/base.rb +14 -16
- data/lib/phronomy/state_store/in_memory.rb +23 -10
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +1 -1
- data/lib/phronomy/tool/mcp_tool.rb +10 -9
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/trust_pipeline.rb +41 -49
- data/lib/phronomy/vector_store/in_memory.rb +5 -7
- data/lib/phronomy/vector_store/redis_search.rb +4 -6
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +281 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +262 -0
- data/lib/phronomy.rb +30 -34
- metadata +25 -10
- data/lib/phronomy/graph/compiled_graph.rb +0 -191
- data/lib/phronomy/graph/parallel_node.rb +0 -193
- data/lib/phronomy/graph/state.rb +0 -105
- data/lib/phronomy/graph/state_graph.rb +0 -149
- data/lib/phronomy/graph.rb +0 -13
|
@@ -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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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
|
+
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|