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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -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/file.rb +85 -0
- 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 +221 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +285 -0
- data/lib/phronomy.rb +30 -34
- metadata +26 -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,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
|
-
#
|
|
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.1
|
|
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
|
+
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
|
-
|
|
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
|
|
@@ -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
|