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
@@ -4,41 +4,20 @@ require "state_machines"
4
4
 
5
5
  module Phronomy
6
6
  class Workflow
7
- # Builds the anonymous state-machine Class used by {WorkflowRunner} to track
8
- # workflow phase transitions.
7
+ # Builds the anonymous state-machine Class used by WorkflowRunner.
9
8
  #
10
- # Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
11
- # span of WorkflowRunner's initializer and to give the FSM construction
12
- # logic an explicit, testable home.
13
- #
14
- # Call {#build} to obtain the generated +Class+. The returned class responds
15
- # to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
16
- # has a +state_machine :phase+ definition with all registered transitions and
17
- # callbacks.
9
+ # This class compiles Workflow topology into state_machines declarations.
10
+ # It intentionally does not await Tasks, register completion callbacks,
11
+ # cancel external work, or interpret application event payloads.
18
12
  #
19
13
  # @api private
20
14
  class PhaseMachineBuilder
21
- # @param entry_point [Symbol] initial state for the phase machine
22
- # @param declared_states [Array<Symbol>] all states declared in the workflow
23
- # @param wait_state_names [Array<Symbol>] states that wait for external events
24
- # @param external_events [Hash{Symbol => Array<Hash>}]
25
- # +{ event_name => [{from:, to:, guard:}, ...] }+
26
- # @param entry_actions [Hash{Symbol => Array<#call>}]
27
- # +{ state_name => [callable, ...] }+
28
- # @param action_timeouts [Hash{Symbol => Numeric}]
29
- # +{ state_name => seconds }+
30
- # @param auto_transitions [Array<Hash>]
31
- # +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
32
- # @param exit_actions [Hash{Symbol => Array<#call>}]
33
- # +{ state_name => [callable, ...] }+
34
- # @api private
35
15
  def initialize(
36
16
  entry_point:,
37
17
  declared_states:,
38
18
  wait_state_names:,
39
19
  external_events:,
40
20
  entry_actions:,
41
- action_timeouts:,
42
21
  auto_transitions:,
43
22
  exit_actions:
44
23
  )
@@ -47,215 +26,179 @@ module Phronomy
47
26
  @wait_state_names = wait_state_names
48
27
  @external_events = external_events
49
28
  @entry_actions = entry_actions
50
- @action_timeouts = action_timeouts
51
29
  @auto_transitions = auto_transitions
52
30
  @exit_actions = exit_actions
53
31
  end
54
32
 
55
- # Constructs and returns the anonymous phase-machine Class.
56
- #
57
- # @return [Class] an anonymous class with a +state_machine :phase+ definition
58
- # @raise [ArgumentError] if state_machines raises during class construction
59
- # @api private
60
33
  def build
61
34
  entry = @entry_point
62
35
  all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
63
- auto_trans = @auto_transitions
64
- ext_events = @external_events
65
- entry_acts = @entry_actions
66
- exit_acts = @exit_actions
67
- act_timeouts = @action_timeouts
68
- build_cb = method(:build_entry_callback)
36
+ auto_transitions = @auto_transitions
37
+ external_events = @external_events
38
+ entry_actions = @entry_actions
39
+ exit_actions = @exit_actions
40
+ condition_builder = method(:build_transition_condition)
41
+ entry_callback_builder = method(:build_entry_callback)
42
+ exit_callback_builder = method(:build_exit_callback)
43
+ transition_callback = build_transition_action_callback
69
44
 
70
45
  Class.new do
71
- # Holds the current WorkflowContext so guards and callbacks can read it.
72
- attr_accessor :context
73
-
74
- # Set to true by an entry action that returned an awaitable Task.
75
- # When true, FSMSession skips the automatic advance_or_halt step and
76
- # waits for the async worker thread to post a state_completed event back.
77
- attr_accessor :async_pending
78
-
79
- # Invocation-local routing. FSMSession sets these on each machine
80
- # instance; the compiled Class captures no Runtime-specific object.
81
- attr_accessor :event_loop, :timer_queue_provider, :session_id
46
+ attr_accessor(
47
+ :context,
48
+ :current_event,
49
+ :selected_transition_action,
50
+ :selected_transition_metadata
51
+ )
82
52
 
83
53
  state_machine :phase, initial: entry do
84
- all_states.each { |s| state s }
54
+ all_states.each { |state_name| state state_name }
85
55
 
86
- # Auto-fire transitions: all auto transitions unified under :state_completed.
87
- # Includes unguarded (unconditional) and guarded (conditional) transitions.
88
- # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
89
56
  event :state_completed do
90
- auto_trans.each do |t|
91
- if t[:guard]
92
- guard_proc = t[:guard]
93
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
94
- else
95
- transition t[:from] => t[:to]
96
- end
57
+ auto_transitions.each do |transition_definition|
58
+ transition(
59
+ transition_definition[:from] => transition_definition[:to],
60
+ :if => condition_builder.call(transition_definition)
61
+ )
97
62
  end
98
63
  end
99
64
 
100
- # External events: human-in-the-loop triggers from wait states.
101
- ext_events.each do |ev_name, transitions|
102
- event ev_name do
103
- transitions.each do |t|
104
- if t[:guard]
105
- guard_proc = t[:guard]
106
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
107
- else
108
- transition t[:from] => t[:to]
109
- end
65
+ external_events.each do |event_name, transitions|
66
+ event event_name do
67
+ transitions.each do |transition_definition|
68
+ transition(
69
+ transition_definition[:from] => transition_definition[:to],
70
+ :if => condition_builder.call(transition_definition)
71
+ )
110
72
  end
111
73
  end
112
74
  end
113
75
 
114
- # Entry callbacks: fire after_transition into each state.
115
- # Each callable is registered as a separate callback; state_machines
116
- # accumulates them and fires in declaration order.
117
- # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
118
- # the returned context replaces the current one on the tracker.
119
- entry_acts.each do |state_name, callables|
76
+ entry_actions.each do |state_name, callables|
120
77
  callables.each do |callable|
121
- cb = build_cb.call(callable, state_name, act_timeouts[state_name])
122
- after_transition to: state_name, &cb
78
+ after_transition(
79
+ to: state_name,
80
+ &entry_callback_builder.call(callable, state_name)
81
+ )
123
82
  end
124
83
  end
125
84
 
126
- # Exit callbacks: fire before_transition out of each state.
127
- # Each callable is registered as a separate callback; state_machines
128
- # accumulates them and fires in declaration order.
129
- exit_acts.each do |state_name, callables|
85
+ # Both source exit callbacks and the transition action are
86
+ # before_transition callbacks. Register exits first to preserve the
87
+ # required exit -> transition action -> entry ordering.
88
+ exit_actions.each do |state_name, callables|
130
89
  callables.each do |callable|
131
- before_transition from: state_name do |machine|
132
- callable.call(machine.context)
133
- end
90
+ before_transition(
91
+ from: state_name,
92
+ &exit_callback_builder.call(callable, state_name)
93
+ )
134
94
  end
135
95
  end
96
+
97
+ before_transition(&transition_callback)
136
98
  end
137
99
  end
138
- rescue => e
139
- raise ArgumentError, "Failed to build phase machine: #{e.message}"
100
+ rescue => error
101
+ raise ArgumentError, "Failed to build phase machine: #{error.message}"
140
102
  end
141
103
 
142
104
  private
143
105
 
144
- # Returns a proc suitable for use as an +after_transition+ callback.
145
- #
146
- # The returned proc accepts a single argument (the phase machine instance),
147
- # invokes the entry action callable with the current context, then delegates
148
- # the result to {#handle_entry_action_result}. Capturing this in the
149
- # builder's scope lets the anonymous +Class.new+ block stay slim.
150
- #
151
- # @param callable [#call] the entry action
152
- # @param state_name [Symbol] name of the target state (for error messages)
153
- # @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
154
- # @return [Proc]
155
- # @api private
156
- def build_entry_callback(callable, state_name, timeout_secs)
157
- handle = method(:handle_entry_action_result)
106
+ def build_transition_condition(transition_definition)
107
+ guard = transition_definition[:guard]
108
+ action = transition_definition[:action]
109
+ metadata = {
110
+ from: transition_definition[:from],
111
+ event: transition_definition[:event],
112
+ to: public_destination(transition_definition[:to])
113
+ }.freeze
114
+
158
115
  ->(machine) {
159
- result = callable.call(machine.context)
160
- handle.call(machine, result, state_name, timeout_secs)
116
+ matched =
117
+ guard.nil? ||
118
+ call_with_optional_event(
119
+ guard,
120
+ machine.context,
121
+ machine.current_event
122
+ )
123
+
124
+ if matched
125
+ machine.selected_transition_action = action
126
+ machine.selected_transition_metadata = metadata
127
+ end
128
+ matched
161
129
  }
162
130
  end
163
131
 
164
- # Dispatches the return value of an entry action callable.
165
- #
166
- # - +Phronomy::Task+ → async or blocking task handling
167
- # - +Phronomy::WorkflowContext+ → replaces the machine's context directly
168
- # - anything else → ignored
169
- #
170
- # @param machine [Object] phase machine instance
171
- # @param result [Object] return value of the entry callable
172
- # @param state_name [Symbol] name of the entered state
173
- # @param timeout_secs [Numeric, nil] optional timeout in seconds
174
- # @api private
175
- def handle_entry_action_result(machine, result, state_name, timeout_secs)
176
- if result.is_a?(Phronomy::Task)
177
- dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
178
- elsif result.is_a?(Phronomy::WorkflowContext)
179
- machine.context = result
180
- end
132
+ def build_transition_action_callback
133
+ ->(machine) {
134
+ callable = machine.selected_transition_action
135
+ unless callable.nil?
136
+ metadata = machine.selected_transition_metadata || {}
137
+ result = call_with_optional_event(
138
+ callable,
139
+ machine.context,
140
+ machine.current_event
141
+ )
142
+ if result.is_a?(Phronomy::Task)
143
+ raise Phronomy::InvalidAsyncTransitionActionError,
144
+ transition_task_error_message(metadata)
145
+ end
146
+ machine.context = result if workflow_context_result?(result)
147
+ end
148
+ }
181
149
  end
182
150
 
183
- # Handles a +Phronomy::Task+ return value in EventLoop mode.
184
- #
185
- # Marks the machine as async-pending and spawns a cooperative background
186
- # task that awaits the result, then posts the appropriate event back to
187
- # the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
188
- # step while +async_pending+ is true.
189
- #
190
- # @param machine [Object] phase machine instance
191
- # @param result [Phronomy::Task]
192
- # @param state_name [Symbol]
193
- # @param timeout_secs [Numeric, nil]
194
- # @api private
195
- def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
196
- machine.async_pending = true
197
- session_id = machine.session_id || machine.context.thread_id
198
- if timeout_secs
199
- machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
200
- next if result.done?
201
-
202
- machine.event_loop.post(
203
- Phronomy::Event.new(
204
- type: :error,
205
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
206
- payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
207
- "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
208
- )}
209
- )
210
- )
151
+ def build_entry_callback(callable, state_name)
152
+ ->(machine) {
153
+ result = callable.call(machine.context)
154
+ if result.is_a?(Phronomy::Task)
155
+ raise Phronomy::InvalidAsyncEntryActionError,
156
+ "Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
157
+ "Start the asynchronous operation, register its callback/listener, " \
158
+ "and return the WorkflowContext or nil."
211
159
  end
212
- end
213
- result.on_complete do |task_result, error|
214
- if error
215
- machine.event_loop.post(
216
- Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
217
- )
218
- next
160
+ machine.context = result if workflow_context_result?(result)
161
+ }
162
+ end
163
+
164
+ def build_exit_callback(callable, state_name)
165
+ ->(machine) {
166
+ result = callable.call(machine.context)
167
+ if result.is_a?(Phronomy::Task)
168
+ raise Phronomy::InvalidAsyncEntryActionError,
169
+ "Exit action for state #{state_name.inspect} returned Phronomy::Task. " \
170
+ "Exit actions are synchronous Run-to-Completion callbacks."
219
171
  end
220
- ev = if task_result.is_a?(Phronomy::WorkflowContext)
221
- Phronomy::Event.new(type: :action_completed, target_id: session_id, payload: task_result)
172
+ }
173
+ end
174
+
175
+ def call_with_optional_event(callable, context, event)
176
+ parameters =
177
+ if callable.respond_to?(:parameters)
178
+ callable.parameters
222
179
  else
223
- Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
180
+ callable.method(:call).parameters
224
181
  end
225
- machine.event_loop.post(ev)
226
- end
182
+ accepts_event = parameters.length >= 2
183
+ accepts_event ? callable.call(context, event) : callable.call(context)
227
184
  end
228
185
 
229
- # Handles a +Phronomy::Task+ return value in non-EventLoop mode.
230
- #
231
- # Blocks the current execution context until the task completes or the
232
- # optional timeout elapses.
233
- #
234
- # @param machine [Object] phase machine instance
235
- # @param result [Phronomy::Task]
236
- # @param state_name [Symbol]
237
- # @param timeout_secs [Numeric, nil]
238
- # @api private
239
- def await_task_blocking(machine, result, state_name, timeout_secs)
240
- enforce_timeout!(result, state_name, timeout_secs)
241
- task_result = result.wait_result
242
- machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
186
+ def workflow_context_result?(result)
187
+ result.respond_to?(:set_graph_metadata)
243
188
  end
244
189
 
245
- # Raises +ActionTimeoutError+ if the task does not complete within
246
- # +timeout_secs+. No-op when +timeout_secs+ is +nil+.
247
- #
248
- # @param result [Phronomy::Task]
249
- # @param state_name [Symbol]
250
- # @param timeout_secs [Numeric, nil]
251
- # @api private
252
- def enforce_timeout!(result, state_name, timeout_secs)
253
- return unless timeout_secs
254
- return unless result.join(timeout_secs).nil?
190
+ def public_destination(destination)
191
+ return :__finish__ if destination == Phronomy::WorkflowRunner::FINISH
192
+
193
+ destination
194
+ end
255
195
 
256
- result.cancel!
257
- raise Phronomy::ActionTimeoutError,
258
- "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
196
+ def transition_task_error_message(metadata)
197
+ "Transition action " \
198
+ "#{metadata[:from].inspect} --#{metadata[:event].inspect}--> " \
199
+ "#{metadata[:to].inspect} returned Phronomy::Task. " \
200
+ "Start the asynchronous operation, register its callback/listener, " \
201
+ "and return the WorkflowContext or nil."
259
202
  end
260
203
  end
261
204
  end