phronomy 0.14.0 → 0.15.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +236 -57
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +471 -0
  14. data/lib/phronomy/agent/base.rb +500 -411
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -7
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
@@ -3,39 +3,33 @@
3
3
  module Phronomy
4
4
  # Event-driven execution wrapper for a single FSM session.
5
5
  #
6
- # Used by both WorkflowRunner (for Workflow) and Agent::InvocationSession
7
- # (for Agent invoke). Not Workflow-specific.
8
- #
9
- # Created by a runner and registered with EventLoop. All public methods
10
- # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
11
- # not be accessed concurrently from multiple threads.
12
- #
6
+ # All public methods are called from the Runtime-owned EventLoop thread.
7
+ # FSMSession owns FSM execution only; it does not own external Task handles,
8
+ # activity tokens, callback correlation, or domain-specific stale-event policy.
13
9
  class FSMSession
14
10
  FINISH = WorkflowRunner::FINISH
15
11
 
16
- # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
17
- attr_reader :id
18
-
19
- # @param id [String]
20
- # @param context [Object] includes Phronomy::WorkflowContext
21
- # @param entry_point [Symbol] initial state name
22
- # @param entry_actions [Hash] { state_name => [callable, ...] }
23
- # @param auto_state_set [Hash] { state_name => true }
24
- # @param declared_states [Array<Symbol>] all action state names
25
- # @param wait_state_names [Array<Symbol>]
26
- # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
27
- # @param phase_machine_class [Class] state_machines-backed phase tracker class
28
- # @param recursion_limit [Integer]
29
- # @param action_timeouts [Hash] { state_name => seconds }
30
- # @param resume_event [Symbol, nil] external event to fire when resuming
31
- # @param resume_phase [Symbol, nil] wait state name to resume from
32
- # @api private
33
- def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
34
- declared_states:, wait_state_names:, external_events:, phase_machine_class:,
35
- recursion_limit:, event_loop:, timer_queue_provider:, action_timeouts: {},
36
- resume_event: nil, resume_phase: nil)
12
+ attr_reader :id, :context
13
+
14
+ def initialize(
15
+ id:,
16
+ context:,
17
+ entry_point:,
18
+ entry_actions:,
19
+ auto_state_set:,
20
+ declared_states:,
21
+ wait_state_names:,
22
+ external_events:,
23
+ phase_machine_class:,
24
+ recursion_limit:,
25
+ event_loop:,
26
+ resume_event: nil,
27
+ resume_phase: nil,
28
+ stable_observer: nil
29
+ )
37
30
  @id = id
38
31
  @ctx = context
32
+ @context = context
39
33
  @entry_point = entry_point
40
34
  @entry_actions = entry_actions
41
35
  @auto_state_set = auto_state_set
@@ -44,155 +38,145 @@ module Phronomy
44
38
  @external_events = external_events
45
39
  @phase_machine_class = phase_machine_class
46
40
  @recursion_limit = recursion_limit
47
- @action_timeouts = action_timeouts
48
41
  @event_loop = event_loop
49
- @timer_queue_provider = timer_queue_provider
50
42
  @resume_event = resume_event
51
43
  @resume_phase = resume_phase
44
+ @stable_observer = stable_observer
52
45
  @step = 0
53
46
  @done = false
54
47
  @current_state = nil
55
48
  @tracker = nil
56
49
  end
57
50
 
58
- # Begins workflow execution. Called by EventLoop on :start event.
59
51
  def start
60
52
  if @resume_event
61
- # Resume from wait state: position tracker at the wait state, then fire the
62
- # external event. state_machines fires before_transition (exit) and
63
- # after_transition (entry) callbacks, so both actions execute here.
64
53
  @current_state = @resume_phase
65
54
  @tracker = build_tracker(@current_state)
66
55
  @tracker.context = @ctx
67
- @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
68
- fire_and_advance!(@resume_event)
56
+ fire_and_advance!(
57
+ Phronomy::Event.new(
58
+ type: @resume_event,
59
+ target_id: @id,
60
+ payload: nil
61
+ )
62
+ )
69
63
  else
70
- # Fresh start: state_machines does not fire callbacks on initialization,
71
- # so we invoke the entry action for the initial state manually.
72
64
  @current_state = @entry_point
73
65
  @tracker = build_tracker(@current_state)
74
66
  @tracker.context = @ctx
75
- @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
76
- (@entry_actions[@current_state] || []).each do |c|
77
- result = c.call(@ctx)
78
- if result.is_a?(Phronomy::Task)
79
- # Awaitable action: resume via on_complete without blocking EventLoop.
80
- @tracker.async_pending = true
81
- session_id = @id
82
- current_state_name = @current_state
83
- timeout_secs = @action_timeouts[current_state_name]
84
- if timeout_secs
85
- @timer_queue_provider.call.schedule(seconds: timeout_secs) do
86
- next if result.done?
87
-
88
- @event_loop.post(
89
- Event.new(
90
- type: :error,
91
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
92
- payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
93
- "Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
94
- )}
95
- )
96
- )
97
- end
98
- end
99
- result.on_complete do |task_result, error|
100
- if error
101
- @event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error}))
102
- next
103
- end
104
- if _fsm_context?(task_result)
105
- @event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
106
- else
107
- @event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
108
- end
109
- end
110
- break # Only one async action at a time per state
111
- elsif _fsm_context?(result)
112
- @ctx = result
113
- end
114
- end
67
+ run_initial_entry_actions!
115
68
  @tracker.context = @ctx
116
- advance_or_halt unless @tracker.async_pending
69
+ advance_or_halt
117
70
  end
118
- rescue => e
119
- finish_with_error(e)
71
+ rescue => error
72
+ finish_with_error(error)
120
73
  end
121
74
 
122
- # Processes an event dispatched from EventLoop.
123
- # Called for :state_completed, :action_completed, and all user-defined external events.
124
- #
125
- # @param event [Phronomy::Event]
126
- # @api private
127
75
  def handle(event)
128
76
  return if @done
129
77
 
130
- if event.type == :action_completed
131
- # An awaitable entry action completed: update context and advance.
132
- @ctx = event.payload if _fsm_context?(event.payload)
133
- @tracker.context = @ctx
134
- @tracker.async_pending = false # Reset flag set by start or fire_and_advance!
135
- advance_or_halt
78
+ context_disposition = apply_context_event(event)
79
+ return if context_disposition == :consume
80
+
81
+ if context_disposition &&
82
+ !has_external_event_from?(@current_state, event.type)
136
83
  return
137
84
  end
138
85
 
139
- # When :state_completed arrives from an async Task (non-WorkflowContext result),
140
- # async_pending may still be true from the spawn. Clear it before advancing.
141
- @tracker.async_pending = false if event.type == :state_completed && @tracker.async_pending
142
-
143
- fire_and_advance!(event.type)
144
- rescue => e
145
- finish_with_error(e)
86
+ fire_and_advance!(event)
87
+ rescue => error
88
+ finish_with_error(error)
146
89
  end
147
90
 
148
91
  private
149
92
 
150
- # Fires event_name on the phase tracker, updates @current_state, then
151
- # calls advance_or_halt to decide what to do next.
152
- def fire_and_advance!(event_name)
93
+ def run_initial_entry_actions!
94
+ Array(@entry_actions[@current_state]).each do |callable|
95
+ result = callable.call(@ctx)
96
+ apply_synchronous_action_result!(result, @current_state)
97
+ end
98
+ end
99
+
100
+ def apply_synchronous_action_result!(result, state_name)
101
+ if result.is_a?(Phronomy::Task)
102
+ raise Phronomy::InvalidAsyncEntryActionError,
103
+ "Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
104
+ "Start the asynchronous operation, register its callback/listener, " \
105
+ "and return the WorkflowContext or nil."
106
+ end
107
+
108
+ if _fsm_context?(result)
109
+ @ctx = result
110
+ @context = result
111
+ end
112
+ end
113
+
114
+ def apply_context_event(event)
115
+ return false unless @ctx.respond_to?(:handle_fsm_event)
116
+
117
+ result = @ctx.handle_fsm_event(event)
118
+ return :consume if result == :consume
119
+
120
+ if _fsm_context?(result)
121
+ @ctx = result
122
+ @context = result
123
+ @tracker.context = @ctx
124
+ true
125
+ else
126
+ !!result
127
+ end
128
+ end
129
+
130
+ def fire_and_advance!(event)
153
131
  if @step >= @recursion_limit
154
132
  raise Phronomy::RecursionLimitError,
155
133
  "Recursion limit (#{@recursion_limit}) exceeded"
156
134
  end
157
135
 
158
- fire_event!(@tracker, event_name, @current_state)
136
+ @tracker.context = @ctx
137
+ clear_selected_transition!
138
+ @tracker.current_event = event if @tracker.respond_to?(:current_event=)
139
+ transitioned = fire_event!(@tracker, event.type, @current_state)
140
+ return unless transitioned
141
+
159
142
  @ctx = @tracker.context
160
- next_phase = @tracker.phase.to_sym
161
- # When next_phase == @current_state, no transition matched → treat as terminal.
162
- @current_state = (next_phase == @current_state) ? FINISH : next_phase
143
+ @context = @ctx
144
+ @current_state = @tracker.phase.to_sym
163
145
  @step += 1
146
+ advance_or_halt
147
+ ensure
148
+ @tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
149
+ clear_selected_transition!
150
+ end
164
151
 
165
- # If an entry action returned a Task, the after_transition callback set
166
- # async_pending = true and spawned a thread. Skip advance_or_halt — the
167
- # background thread will post :action_completed or :state_completed.
168
- if @tracker.async_pending
169
- @tracker.async_pending = false
170
- return
171
- end
152
+ def clear_selected_transition!
153
+ return unless @tracker
172
154
 
173
- advance_or_halt
155
+ if @tracker.respond_to?(:selected_transition_action=)
156
+ @tracker.selected_transition_action = nil
157
+ end
158
+ if @tracker.respond_to?(:selected_transition_metadata=)
159
+ @tracker.selected_transition_metadata = nil
160
+ end
174
161
  end
175
162
 
176
- # Determines the next action after the FSM has entered @current_state.
177
163
  def advance_or_halt
178
164
  return finish! if @current_state == FINISH
179
165
 
166
+ notify_stable_state!
167
+
180
168
  if @wait_state_names.include?(@current_state)
181
- return halt!
169
+ halt!
170
+ return
182
171
  end
183
172
 
184
173
  if @auto_state_set.key?(@current_state)
185
- @event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
174
+ post_session_event(:state_completed)
186
175
  return
187
176
  end
188
177
 
189
- if has_external_event_from?(@current_state)
190
- # Async IO pattern: the entry action spawned an IO thread that will post
191
- # an external event back. Stay registered; do nothing here.
192
- return
193
- end
178
+ return if has_external_event_from?(@current_state)
194
179
 
195
- # No transition declared — validate the state is known, then treat as terminal.
196
180
  unless @declared_states.include?(@current_state)
197
181
  raise ArgumentError, "State #{@current_state.inspect} is not defined"
198
182
  end
@@ -200,52 +184,106 @@ module Phronomy
200
184
  finish!
201
185
  end
202
186
 
187
+ def notify_stable_state!
188
+ return unless @stable_observer
189
+
190
+ @stable_observer.call(
191
+ {
192
+ state: @current_state,
193
+ context: @ctx
194
+ }
195
+ )
196
+ end
197
+
198
+ def post_session_event(type, payload = nil)
199
+ event = Phronomy::Event.new(
200
+ type: type,
201
+ target_id: @id,
202
+ payload: payload
203
+ )
204
+ accepted =
205
+ if @event_loop.respond_to?(:post_to_session)
206
+ @event_loop.post_to_session(event)
207
+ else
208
+ @event_loop.post(event)
209
+ end
210
+ return if accepted
211
+
212
+ raise Phronomy::RuntimeShutdownError,
213
+ "EventLoop rejected #{type.inspect} for FSMSession #{@id}"
214
+ end
215
+
203
216
  def finish!
217
+ return if @done
218
+
204
219
  @done = true
205
220
  @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
206
- @event_loop.post(Event.new(type: :finished, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
221
+ post_terminal_event(:finished, @ctx)
207
222
  end
208
223
 
209
224
  def halt!
225
+ return if @done
226
+
210
227
  @done = true
211
228
  @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
212
- @event_loop.post(Event.new(type: :halted, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
229
+ post_terminal_event(:halted, @ctx)
213
230
  end
214
231
 
215
- def finish_with_error(err)
232
+ def finish_with_error(error)
233
+ return if @done
234
+
216
235
  @done = true
217
- @event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: err}))
236
+ post_terminal_event(:error, error)
237
+ end
238
+
239
+ def post_terminal_event(type, result)
240
+ accepted = @event_loop.post(
241
+ Phronomy::Event.new(
242
+ type: type,
243
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
244
+ payload: {session_id: @id, result: result}
245
+ )
246
+ )
247
+ return if accepted
248
+
249
+ Phronomy.configuration.logger&.warn(
250
+ "[Phronomy::FSMSession] EventLoop rejected terminal event " \
251
+ "#{type.inspect} for #{@id}"
252
+ )
218
253
  end
219
254
 
220
255
  def fire_event!(tracker, event_name, from_state)
221
- return if tracker.send(event_name)
256
+ unless tracker.respond_to?(event_name)
257
+ raise ArgumentError,
258
+ "Unknown FSM event #{event_name.inspect} for state #{from_state.inspect}"
259
+ end
260
+
261
+ return true if tracker.public_send(event_name)
262
+
263
+ # A declared external event whose guards all reject is a valid no-op.
264
+ # Applications use this to reject stale or unrelated correlated events.
265
+ return false if has_external_event_from?(from_state, event_name)
222
266
 
223
267
  raise ArgumentError,
224
268
  "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
225
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
269
+ "The event is not declared for the current state."
226
270
  end
227
271
 
228
- def has_external_event_from?(state)
229
- @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
272
+ def has_external_event_from?(state, event_name = nil)
273
+ events = event_name ? {event_name => @external_events[event_name]} : @external_events
274
+ events.any? do |_name, transitions|
275
+ Array(transitions).any? { |transition| transition[:from] == state }
276
+ end
230
277
  end
231
278
 
232
279
  def build_tracker(from_state)
233
280
  machine = @phase_machine_class.new
234
281
  machine.instance_variable_set(:@phase, from_state.to_s)
235
- machine.event_loop = @event_loop if machine.respond_to?(:event_loop=)
236
- if machine.respond_to?(:timer_queue_provider=)
237
- machine.timer_queue_provider = @timer_queue_provider
238
- end
239
282
  machine
240
283
  end
241
284
 
242
- # Returns true when +obj+ is an FSM execution context (responds to
243
- # +set_graph_metadata+). Used to distinguish context objects from other
244
- # Task return values (strings, hashes, etc.) without hard-coding a specific
245
- # class. Both WorkflowContext and Agent::InvocationContext qualify.
246
- # @api private
247
- def _fsm_context?(obj)
248
- obj.respond_to?(:set_graph_metadata)
285
+ def _fsm_context?(object)
286
+ object.respond_to?(:set_graph_metadata)
249
287
  end
250
288
  end
251
289
  end
@@ -212,17 +212,12 @@ module Phronomy
212
212
  # If this task fails or is cancelled, the mapped task also fails/is
213
213
  # cancelled with the same error. The block is never called in error cases.
214
214
  #
215
- # The primary use-case is transforming an agent result into a
216
- # {WorkflowContext} so that a Workflow entry action can return a Task
217
- # whose value is picked up by {FSMSession} via the existing
218
- # +:action_completed+ path:
215
+ # The transformation runs from the source Task's completion callback.
216
+ # It is a generic value-composition API; Workflow entry actions do not await
217
+ # either the source Task or the mapped Task.
219
218
  #
220
- # @example Returning agent output into a Workflow state field
221
- # entry :translate, ->(ctx) {
222
- # TranslationAgent.new.invoke_async(ctx.query).map do |result|
223
- # ctx.merge(answer: result[:output]) # returns WorkflowContext
224
- # end
225
- # }
219
+ # @example Transforming an Agent result outside a Workflow entry action
220
+ # output_task = agent.invoke_async("hello").map { |result| result[:output] }
226
221
  #
227
222
  # @yield [value] the completed value of this task
228
223
  # @yieldreturn [Object] the value for the mapped task
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Immutable event struct used for inter-FSM communication via EventLoop.
4
+ # Immutable event used for EventLoop communication.
5
5
  #
6
- # @param type [Symbol] event identifier (:start, :state_completed,
7
- # :finished, :halted, :error, or any user-defined name)
8
- # @param target_id [String] FSMSession identifier — matches WorkflowContext#thread_id
9
- # @param payload [Object] optional data attached to the event:
10
- # - final/halted context for :finished/:halted
11
- # - Exception for :error
12
- # - nil for :start / :state_completed
6
+ # User-defined Workflow events carry application-owned payloads unchanged.
7
+ # Correlation identifiers, stale-event decisions, and domain interpretation
8
+ # remain application concerns.
9
+ #
10
+ # @param type [Symbol] event identifier
11
+ # @param target_id [String] FSMSession identifier
12
+ # @param payload [Object] optional event data
13
13
  Event = Data.define(:type, :target_id, :payload)
14
14
  end