phronomy 0.5.4 → 0.6.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 +41 -0
- data/README.md +24 -25
- data/lib/phronomy/agent/base.rb +88 -2
- data/lib/phronomy/agent/fsm.rb +165 -0
- data/lib/phronomy/agent/parallel_tool_chat.rb +75 -0
- data/lib/phronomy/configuration.rb +6 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +147 -0
- data/lib/phronomy/fsm_session.rb +194 -0
- data/lib/phronomy/generator_verifier.rb +22 -22
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +83 -71
- data/lib/phronomy/workflow_context.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +167 -112
- data/lib/phronomy.rb +4 -0
- metadata +7 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- data/lib/phronomy/guardrail/builtin.rb +0 -16
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Singleton event loop that manages all FSMSession instances.
|
|
5
|
+
#
|
|
6
|
+
# A single background thread reads from a global Thread::Queue and dispatches
|
|
7
|
+
# events to their target FSMSession. IO work (LLM calls, tool calls) runs in
|
|
8
|
+
# separate IO threads that post events back to the loop via EventLoop#post.
|
|
9
|
+
#
|
|
10
|
+
# Activated with: +Phronomy.configure { |c| c.event_loop = true }+
|
|
11
|
+
#
|
|
12
|
+
# == Fork safety
|
|
13
|
+
#
|
|
14
|
+
# +EventLoop.instance+ is lazily initialized. The background thread is not
|
|
15
|
+
# created until the first call, so Puma worker forking does not duplicate the
|
|
16
|
+
# thread. No +after_fork+ hook is required.
|
|
17
|
+
#
|
|
18
|
+
# == Deadlock warning
|
|
19
|
+
#
|
|
20
|
+
# Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
|
|
21
|
+
# entry action. The entry action runs on the EventLoop thread; a nested
|
|
22
|
+
# +invoke+ would block waiting for the same thread to process events →
|
|
23
|
+
# deadlock. Use the async IO pattern instead (spawn a Thread, post events
|
|
24
|
+
# back to the EventLoop).
|
|
25
|
+
class EventLoop
|
|
26
|
+
# Returns the singleton instance, creating and starting it on first call.
|
|
27
|
+
def self.instance
|
|
28
|
+
@instance ||= new.tap(&:start)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stops and destroys the singleton. Primarily used in tests.
|
|
32
|
+
# @api private
|
|
33
|
+
def self.reset!
|
|
34
|
+
@instance&.stop
|
|
35
|
+
@instance = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
|
|
40
|
+
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
41
|
+
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Registers an FSMSession for execution and returns a completion queue.
|
|
45
|
+
#
|
|
46
|
+
# The session and its completion queue are handed off to the EventLoop thread
|
|
47
|
+
# via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
|
|
48
|
+
# and read by the EventLoop thread. No Mutex is required.
|
|
49
|
+
#
|
|
50
|
+
# The caller blocks on +completion_queue.pop+ to receive the final context
|
|
51
|
+
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
52
|
+
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
53
|
+
#
|
|
54
|
+
# @param fsm_session [Phronomy::FSMSession]
|
|
55
|
+
# @return [Thread::Queue] resolves to final/halted context, or an Exception
|
|
56
|
+
def register(fsm_session)
|
|
57
|
+
if Thread.current[:phronomy_event_loop_thread]
|
|
58
|
+
raise Phronomy::Error,
|
|
59
|
+
"Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
|
|
60
|
+
"entry action. Use the async IO pattern: spawn a Thread, post events " \
|
|
61
|
+
"back via Phronomy::EventLoop.instance.post(...) instead."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
completion_queue = Thread::Queue.new
|
|
65
|
+
# Pass both session and completion_queue in the event payload so that the
|
|
66
|
+
# EventLoop thread is the sole writer of @fsms and @waiting.
|
|
67
|
+
@queue.push(Event.new(type: :start, target_id: fsm_session.id,
|
|
68
|
+
payload: {session: fsm_session, completion: completion_queue}))
|
|
69
|
+
completion_queue
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Enqueues an {AgentFSM} as a fire-and-forget child session.
|
|
73
|
+
#
|
|
74
|
+
# Unlike {#register}, this method:
|
|
75
|
+
# - Is safe to call from the EventLoop thread (entry actions).
|
|
76
|
+
# - Does NOT block — no completion queue is created.
|
|
77
|
+
# - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
|
|
78
|
+
#
|
|
79
|
+
# @param agent_fsm [Phronomy::Agent::FSM]
|
|
80
|
+
# @return [nil]
|
|
81
|
+
def enqueue_child(agent_fsm)
|
|
82
|
+
@queue.push(Event.new(type: :start, target_id: agent_fsm.id,
|
|
83
|
+
payload: {session: agent_fsm, completion: nil}))
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Posts an event to the loop. Safe to call from any thread (including IO threads).
|
|
88
|
+
#
|
|
89
|
+
# @param event [Phronomy::Event]
|
|
90
|
+
def post(event)
|
|
91
|
+
@queue.push(event)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Starts the background event loop thread.
|
|
95
|
+
# @return [self]
|
|
96
|
+
def start
|
|
97
|
+
@running = true
|
|
98
|
+
@thread = Thread.new do
|
|
99
|
+
Thread.current[:phronomy_event_loop_thread] = true
|
|
100
|
+
run_loop
|
|
101
|
+
end
|
|
102
|
+
@thread.abort_on_exception = false
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Stops the background thread. Used in tests only.
|
|
107
|
+
# @api private
|
|
108
|
+
def stop
|
|
109
|
+
@running = false
|
|
110
|
+
@thread&.kill
|
|
111
|
+
@thread = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def run_loop
|
|
117
|
+
while @running
|
|
118
|
+
event = @queue.pop
|
|
119
|
+
|
|
120
|
+
case event.type
|
|
121
|
+
when :finished, :halted, :error
|
|
122
|
+
# All three terminal events share the same cleanup path.
|
|
123
|
+
# Both @fsms and @waiting are exclusively owned by this thread.
|
|
124
|
+
@fsms.delete(event.target_id)
|
|
125
|
+
cq = @waiting.delete(event.target_id)
|
|
126
|
+
cq&.push(event.payload)
|
|
127
|
+
|
|
128
|
+
when :start
|
|
129
|
+
# session and completion_queue arrive together in the payload so that
|
|
130
|
+
# this thread is the sole writer of @fsms and @waiting.
|
|
131
|
+
# completion may be nil for fire-and-forget child sessions (AgentFSM).
|
|
132
|
+
@fsms[event.target_id] = event.payload[:session]
|
|
133
|
+
cq = event.payload[:completion]
|
|
134
|
+
@waiting[event.target_id] = cq if cq
|
|
135
|
+
event.payload[:session].start
|
|
136
|
+
|
|
137
|
+
else
|
|
138
|
+
@fsms[event.target_id]&.handle(event)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
rescue => e
|
|
142
|
+
# Unblock all waiting callers if the loop dies unexpectedly.
|
|
143
|
+
@waiting.values.each { |cq| cq.push(e) }
|
|
144
|
+
raise
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
5
|
+
#
|
|
6
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
7
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
8
|
+
# not be accessed concurrently from multiple threads.
|
|
9
|
+
#
|
|
10
|
+
# == Lifecycle
|
|
11
|
+
#
|
|
12
|
+
# register(session) → EventLoop posts :start → session.start
|
|
13
|
+
# ↓ (auto-transition present)
|
|
14
|
+
# EventLoop posts :state_completed → session.handle
|
|
15
|
+
# ↓ (repeat)
|
|
16
|
+
# session posts :finished or :halted
|
|
17
|
+
# ↓
|
|
18
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
19
|
+
#
|
|
20
|
+
# == Async IO pattern (EventLoop mode only)
|
|
21
|
+
#
|
|
22
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
23
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
24
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
25
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
26
|
+
#
|
|
27
|
+
# entry :fetching, ->(ctx) {
|
|
28
|
+
# Thread.new {
|
|
29
|
+
# ctx.result = http.get(ctx.url)
|
|
30
|
+
# Phronomy::EventLoop.instance.post(
|
|
31
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
32
|
+
# )
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
36
|
+
class FSMSession
|
|
37
|
+
FINISH = WorkflowRunner::FINISH
|
|
38
|
+
|
|
39
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
40
|
+
attr_reader :id
|
|
41
|
+
|
|
42
|
+
# @param id [String]
|
|
43
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
44
|
+
# @param entry_point [Symbol] initial state name
|
|
45
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
46
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
47
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
48
|
+
# @param wait_state_names [Array<Symbol>]
|
|
49
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
50
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
51
|
+
# @param recursion_limit [Integer]
|
|
52
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
53
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
54
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
55
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
56
|
+
recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
57
|
+
@id = id
|
|
58
|
+
@ctx = context
|
|
59
|
+
@entry_point = entry_point
|
|
60
|
+
@entry_actions = entry_actions
|
|
61
|
+
@auto_state_set = auto_state_set
|
|
62
|
+
@declared_states = declared_states
|
|
63
|
+
@wait_state_names = wait_state_names
|
|
64
|
+
@external_events = external_events
|
|
65
|
+
@phase_machine_class = phase_machine_class
|
|
66
|
+
@recursion_limit = recursion_limit
|
|
67
|
+
@resume_event = resume_event
|
|
68
|
+
@resume_phase = resume_phase
|
|
69
|
+
@step = 0
|
|
70
|
+
@done = false
|
|
71
|
+
@current_state = nil
|
|
72
|
+
@tracker = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
76
|
+
def start
|
|
77
|
+
if @resume_event
|
|
78
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
79
|
+
# external event. state_machines fires before_transition (exit) and
|
|
80
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
81
|
+
@current_state = @resume_phase
|
|
82
|
+
@tracker = build_tracker(@current_state)
|
|
83
|
+
@tracker.context = @ctx
|
|
84
|
+
fire_and_advance!(@resume_event)
|
|
85
|
+
else
|
|
86
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
87
|
+
# so we invoke the entry action for the initial state manually.
|
|
88
|
+
@current_state = @entry_point
|
|
89
|
+
@tracker = build_tracker(@current_state)
|
|
90
|
+
@tracker.context = @ctx
|
|
91
|
+
(@entry_actions[@current_state] || []).each { |c| c.call(@ctx) }
|
|
92
|
+
advance_or_halt
|
|
93
|
+
end
|
|
94
|
+
rescue => e
|
|
95
|
+
finish_with_error(e)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Processes an event dispatched from EventLoop.
|
|
99
|
+
# Called for :state_completed and all user-defined external events.
|
|
100
|
+
#
|
|
101
|
+
# @param event [Phronomy::Event]
|
|
102
|
+
def handle(event)
|
|
103
|
+
return if @done
|
|
104
|
+
|
|
105
|
+
fire_and_advance!(event.type)
|
|
106
|
+
rescue => e
|
|
107
|
+
finish_with_error(e)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
113
|
+
# calls advance_or_halt to decide what to do next.
|
|
114
|
+
def fire_and_advance!(event_name)
|
|
115
|
+
if @step >= @recursion_limit
|
|
116
|
+
raise Phronomy::RecursionLimitError,
|
|
117
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
121
|
+
next_phase = @tracker.phase.to_sym
|
|
122
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
123
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
124
|
+
@step += 1
|
|
125
|
+
advance_or_halt
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
129
|
+
def advance_or_halt
|
|
130
|
+
return finish! if @current_state == FINISH
|
|
131
|
+
|
|
132
|
+
if @wait_state_names.include?(@current_state)
|
|
133
|
+
return halt!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if @auto_state_set.key?(@current_state)
|
|
137
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if has_external_event_from?(@current_state)
|
|
142
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
143
|
+
# an external event back. Stay registered; do nothing here.
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
148
|
+
unless @declared_states.include?(@current_state)
|
|
149
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
finish!
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def finish!
|
|
156
|
+
@done = true
|
|
157
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
158
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def halt!
|
|
162
|
+
@done = true
|
|
163
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
164
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def finish_with_error(err)
|
|
168
|
+
@done = true
|
|
169
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fire_event!(tracker, event_name, from_state)
|
|
173
|
+
return if tracker.send(event_name)
|
|
174
|
+
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
177
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def has_external_event_from?(state)
|
|
181
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_tracker(from_state)
|
|
185
|
+
machine = @phase_machine_class.new
|
|
186
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
187
|
+
machine
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def event_loop
|
|
191
|
+
Phronomy::EventLoop.instance
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -133,7 +133,7 @@ module Phronomy
|
|
|
133
133
|
@threshold = confidence_threshold.to_f
|
|
134
134
|
@max_iterations = max_iterations.to_i
|
|
135
135
|
@raise_if_untrusted = raise_if_untrusted
|
|
136
|
-
@
|
|
136
|
+
@compiled_workflow = nil
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
# Run the generator-verifier pipeline.
|
|
@@ -144,7 +144,7 @@ module Phronomy
|
|
|
144
144
|
# @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
|
|
145
145
|
# and the result does not meet the confidence threshold
|
|
146
146
|
def invoke(input, config: {})
|
|
147
|
-
app =
|
|
147
|
+
app = compiled_workflow
|
|
148
148
|
state = app.invoke({input: input}, config: config)
|
|
149
149
|
confidence = combined_confidence(state)
|
|
150
150
|
trusted = confidence >= @threshold
|
|
@@ -166,8 +166,8 @@ module Phronomy
|
|
|
166
166
|
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
-
def
|
|
170
|
-
@
|
|
169
|
+
def compiled_workflow
|
|
170
|
+
@compiled_workflow ||= build_workflow
|
|
171
171
|
end
|
|
172
172
|
|
|
173
173
|
def build_workflow
|
|
@@ -184,42 +184,42 @@ module Phronomy
|
|
|
184
184
|
Phronomy::Workflow.define(PipelineState) do
|
|
185
185
|
initial :draft
|
|
186
186
|
|
|
187
|
-
state :draft
|
|
187
|
+
state :draft
|
|
188
|
+
state :review
|
|
189
|
+
state :finalize
|
|
190
|
+
|
|
191
|
+
entry :draft, ->(state) {
|
|
188
192
|
feedback = state.review_notes.last
|
|
189
193
|
prompt = dpb.call(state.input, feedback)
|
|
190
194
|
result = draft_agent.invoke(prompt)
|
|
191
195
|
parsed = drp.call(result[:output])
|
|
192
|
-
state.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
iteration: state.iteration + 1
|
|
197
|
-
)
|
|
196
|
+
state.draft = parsed[:answer].to_s
|
|
197
|
+
state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
|
|
198
|
+
state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
|
|
199
|
+
state.iteration = state.iteration + 1
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
|
|
202
|
+
entry :review, ->(state) {
|
|
201
203
|
prompt = rpb.call(state.input, state.draft, state.citations)
|
|
202
204
|
result = review_agent.invoke(prompt)
|
|
203
205
|
parsed = rrp.call(result[:output])
|
|
204
|
-
state.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
review_notes: parsed[:feedback].to_s
|
|
208
|
-
)
|
|
206
|
+
state.review_score = pipeline.__send__(:clamp, parsed[:score])
|
|
207
|
+
state.approved = parsed[:approved] == true
|
|
208
|
+
state.review_notes << parsed[:feedback].to_s
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
entry :finalize, ->(state) { state.output = state.draft }
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
transition from: :draft, to: :review
|
|
214
|
+
transition from: :finalize, to: :__finish__
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
transition from: :review,
|
|
217
217
|
guard: ->(state) {
|
|
218
218
|
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
219
219
|
(confidence >= threshold && state.approved) || state.iteration >= max_iter
|
|
220
220
|
},
|
|
221
221
|
to: :finalize
|
|
222
|
-
|
|
222
|
+
transition from: :review, to: :draft
|
|
223
223
|
end
|
|
224
224
|
end
|
|
225
225
|
|
data/lib/phronomy/guardrail.rb
CHANGED
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -8,18 +8,21 @@ module Phronomy
|
|
|
8
8
|
#
|
|
9
9
|
# Defines agent workflows in terms of *states* and *events* backed by
|
|
10
10
|
# Phronomy::WorkflowRunner. This is the primary high-level API
|
|
11
|
-
# for
|
|
11
|
+
# for workflow-based execution in phronomy.
|
|
12
12
|
#
|
|
13
13
|
# == Basic usage
|
|
14
14
|
#
|
|
15
15
|
# app = Phronomy::Workflow.define(MyContext) do
|
|
16
16
|
# initial :fetch
|
|
17
17
|
#
|
|
18
|
-
# state :fetch
|
|
19
|
-
# state :process
|
|
18
|
+
# state :fetch
|
|
19
|
+
# state :process
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
21
|
+
# entry :fetch, FETCH_NODE
|
|
22
|
+
# entry :process, PROCESS_NODE
|
|
23
|
+
#
|
|
24
|
+
# transition from: :fetch, to: :process
|
|
25
|
+
# transition from: :process, to: :__finish__
|
|
23
26
|
# end
|
|
24
27
|
#
|
|
25
28
|
# result = app.invoke({ url: "https://example.com" })
|
|
@@ -29,15 +32,18 @@ module Phronomy
|
|
|
29
32
|
# app = Phronomy::Workflow.define(MyContext) do
|
|
30
33
|
# initial :propose
|
|
31
34
|
#
|
|
32
|
-
# state :propose
|
|
35
|
+
# state :propose
|
|
33
36
|
# wait_state :awaiting_approval
|
|
34
|
-
# state :execute
|
|
37
|
+
# state :execute
|
|
38
|
+
#
|
|
39
|
+
# entry :propose, PROPOSE_NODE
|
|
40
|
+
# entry :execute, EXECUTE_NODE
|
|
35
41
|
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
42
|
+
# transition from: :propose, to: :awaiting_approval
|
|
43
|
+
# transition from: :execute, to: :__finish__
|
|
38
44
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
45
|
+
# transition from: :awaiting_approval, on: :approve, to: :execute
|
|
46
|
+
# transition from: :awaiting_approval, on: :reject, to: :propose
|
|
41
47
|
# end
|
|
42
48
|
#
|
|
43
49
|
# halted = app.invoke({ ... })
|
|
@@ -45,8 +51,8 @@ module Phronomy
|
|
|
45
51
|
#
|
|
46
52
|
# == Conditional transitions
|
|
47
53
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
54
|
+
# transition from: :decide, guard: ->(s) { s.score > 5 }, to: :high
|
|
55
|
+
# transition from: :decide, to: :low # fallback (no guard)
|
|
50
56
|
#
|
|
51
57
|
class Workflow
|
|
52
58
|
include Phronomy::Runnable
|
|
@@ -91,7 +97,7 @@ module Phronomy
|
|
|
91
97
|
@runner.send_event(state: state, event: event, input: input)
|
|
92
98
|
end
|
|
93
99
|
|
|
94
|
-
# Streaming execution. Yields {
|
|
100
|
+
# Streaming execution. Yields { state: Symbol, context: Object } after each state action.
|
|
95
101
|
# @param input [Hash]
|
|
96
102
|
# @param config [Hash]
|
|
97
103
|
# @yield [Hash]
|
|
@@ -112,12 +118,14 @@ module Phronomy
|
|
|
112
118
|
def initialize(context_class)
|
|
113
119
|
@context_class = context_class
|
|
114
120
|
@initial = nil
|
|
115
|
-
#
|
|
116
|
-
@
|
|
117
|
-
#
|
|
118
|
-
@
|
|
119
|
-
#
|
|
120
|
-
@
|
|
121
|
+
# Ordered list of declared state names (action states only, not wait states).
|
|
122
|
+
@declared_states = []
|
|
123
|
+
# { state_name => [callable, ...] } — entry actions registered via entry()
|
|
124
|
+
@entry_actions = {}
|
|
125
|
+
# { state_name => [callable, ...] } — exit actions registered via exit()
|
|
126
|
+
@exit_actions = {}
|
|
127
|
+
# Array of { from:, to:, guard:, on: } — all transitions in declaration order
|
|
128
|
+
@transitions = []
|
|
121
129
|
# Set of wait state names
|
|
122
130
|
@wait_state_names = []
|
|
123
131
|
end
|
|
@@ -131,83 +139,87 @@ module Phronomy
|
|
|
131
139
|
# rubocop:enable Style/TrivialAccessors
|
|
132
140
|
|
|
133
141
|
# Declares an action state.
|
|
134
|
-
# @param name
|
|
135
|
-
# @param action [#call, nil]
|
|
136
|
-
#
|
|
142
|
+
# @param name [Symbol] state name
|
|
143
|
+
# @param action [#call, nil] optional entry action shorthand.
|
|
144
|
+
# +state :generate, action: MY_PROC+ is equivalent to
|
|
145
|
+
# +state :generate; entry :generate, MY_PROC+.
|
|
137
146
|
def state(name, action: nil)
|
|
138
|
-
@
|
|
147
|
+
@declared_states << name
|
|
148
|
+
entry(name, action) if action
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Declares an entry action for a state.
|
|
152
|
+
# The callable is invoked when the workflow enters +name+.
|
|
153
|
+
# It receives the current context and should mutate it in place.
|
|
154
|
+
# Return value is ignored.
|
|
155
|
+
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
156
|
+
# @param name [Symbol] state name
|
|
157
|
+
# @param callable [#call] receives context, mutates it in place
|
|
158
|
+
def entry(name, callable)
|
|
159
|
+
(@entry_actions[name] ||= []) << callable
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Declares an exit action for a state.
|
|
163
|
+
# The callable is invoked when the workflow leaves +name+.
|
|
164
|
+
# It receives the current context and should mutate it in place.
|
|
165
|
+
# Return value is ignored.
|
|
166
|
+
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
167
|
+
# @param name [Symbol] state name
|
|
168
|
+
# @param callable [#call] receives context, mutates it in place
|
|
169
|
+
def exit(name, callable)
|
|
170
|
+
(@exit_actions[name] ||= []) << callable
|
|
139
171
|
end
|
|
140
172
|
|
|
141
173
|
# Declares a wait state that automatically halts execution when reached.
|
|
142
|
-
# No action is registered; the workflow pauses here until an event resumes it.
|
|
174
|
+
# No entry action is registered; the workflow pauses here until an event resumes it.
|
|
143
175
|
# @param name [Symbol] wait state name (conventionally :awaiting_something)
|
|
144
176
|
def wait_state(name)
|
|
145
177
|
@wait_state_names << name
|
|
146
178
|
end
|
|
147
179
|
|
|
148
|
-
# Declares
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# Declares an event-driven transition.
|
|
157
|
-
# When +guard:+ is provided, the transition is taken only if the guard
|
|
158
|
-
# returns truthy for the current context. Multiple events with the same
|
|
159
|
-
# name and source are evaluated in declaration order; the first passing
|
|
160
|
-
# guard wins.
|
|
161
|
-
# @param name [Symbol] event name
|
|
162
|
-
# @param from [Symbol] source state where this event can be fired
|
|
180
|
+
# Declares a transition between states.
|
|
181
|
+
# Auto-fire transitions (no +on:+) fire automatically when an action state's
|
|
182
|
+
# action completes. External transitions (+on: :event_name+) are triggered
|
|
183
|
+
# manually via +send_event+.
|
|
184
|
+
# When +guard:+ is provided the transition is taken only if the guard returns
|
|
185
|
+
# truthy for the current context. Multiple transitions from the same source are
|
|
186
|
+
# evaluated in declaration order; the first passing guard wins.
|
|
187
|
+
# @param from [Symbol] source state
|
|
163
188
|
# @param to [Symbol] destination state or :__finish__
|
|
164
189
|
# @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
|
|
165
|
-
|
|
190
|
+
# @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
|
|
191
|
+
def transition(from:, to:, guard: nil, on: nil)
|
|
166
192
|
dest = (to == :__finish__) ? FINISH : to
|
|
167
|
-
@
|
|
193
|
+
@transitions << {from: from, to: dest, guard: guard, on: on}
|
|
168
194
|
end
|
|
169
195
|
|
|
170
196
|
# Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
|
|
171
197
|
def build
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# After-transitions: { from => to }
|
|
175
|
-
# Unconditional transitions that fire automatically after an action state completes.
|
|
176
|
-
after_transitions = @after_transitions.each_with_object({}) do |t, h|
|
|
177
|
-
h[t[:from]] = t[:to]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# Route transitions: { from => {event_name:, entries: [{guard:, to:}, ...]} }
|
|
181
|
-
# Events declared from action states (not wait states) fire automatically
|
|
182
|
-
# after the action completes. The event name is used to register the
|
|
183
|
-
# state_machines event and may be any symbol (e.g. :route, :route_review).
|
|
184
|
-
# Declaration order is preserved so guarded entries appear before fallbacks.
|
|
185
|
-
route_transitions = {}
|
|
198
|
+
entry_actions = @entry_actions.dup
|
|
199
|
+
exit_actions = @exit_actions.dup
|
|
186
200
|
|
|
187
|
-
#
|
|
188
|
-
#
|
|
201
|
+
# Auto-fire transitions (no :on): fire automatically when action completes.
|
|
202
|
+
# External events (with :on): triggered manually via send_event.
|
|
203
|
+
auto_transitions = []
|
|
189
204
|
external_events = {}
|
|
190
205
|
|
|
191
|
-
@
|
|
192
|
-
if
|
|
193
|
-
|
|
194
|
-
external_events[t[:
|
|
195
|
-
external_events[t[:name]] << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
206
|
+
@transitions.each do |t|
|
|
207
|
+
if t[:on]
|
|
208
|
+
external_events[t[:on]] ||= []
|
|
209
|
+
external_events[t[:on]] << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
196
210
|
else
|
|
197
|
-
|
|
198
|
-
# The event name is taken from the first declaration for each from-state.
|
|
199
|
-
route_transitions[t[:from]] ||= {event_name: t[:name], entries: []}
|
|
200
|
-
route_transitions[t[:from]][:entries] << {guard: t[:guard], to: t[:to]}
|
|
211
|
+
auto_transitions << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
201
212
|
end
|
|
202
213
|
end
|
|
203
214
|
|
|
204
215
|
runner = Phronomy::WorkflowRunner.new(
|
|
205
216
|
state_class: @context_class,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
entry_actions: entry_actions,
|
|
218
|
+
exit_actions: exit_actions,
|
|
219
|
+
declared_states: @declared_states.dup,
|
|
220
|
+
auto_transitions: auto_transitions,
|
|
209
221
|
external_events: external_events,
|
|
210
|
-
entry_point: @initial ||
|
|
222
|
+
entry_point: @initial || @declared_states.first,
|
|
211
223
|
wait_state_names: @wait_state_names
|
|
212
224
|
)
|
|
213
225
|
|