phronomy 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  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/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -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,211 +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
46
+ attr_accessor(
47
+ :context,
48
+ :current_event,
49
+ :selected_transition_action,
50
+ :selected_transition_metadata
51
+ )
78
52
 
79
53
  state_machine :phase, initial: entry do
80
- all_states.each { |s| state s }
54
+ all_states.each { |state_name| state state_name }
81
55
 
82
- # Auto-fire transitions: all auto transitions unified under :state_completed.
83
- # Includes unguarded (unconditional) and guarded (conditional) transitions.
84
- # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
85
56
  event :state_completed do
86
- auto_trans.each do |t|
87
- if t[:guard]
88
- guard_proc = t[:guard]
89
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
90
- else
91
- transition t[:from] => t[:to]
92
- 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
+ )
93
62
  end
94
63
  end
95
64
 
96
- # External events: human-in-the-loop triggers from wait states.
97
- ext_events.each do |ev_name, transitions|
98
- event ev_name do
99
- transitions.each do |t|
100
- if t[:guard]
101
- guard_proc = t[:guard]
102
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
103
- else
104
- transition t[:from] => t[:to]
105
- 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
+ )
106
72
  end
107
73
  end
108
74
  end
109
75
 
110
- # Entry callbacks: fire after_transition into each state.
111
- # Each callable is registered as a separate callback; state_machines
112
- # accumulates them and fires in declaration order.
113
- # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
114
- # the returned context replaces the current one on the tracker.
115
- entry_acts.each do |state_name, callables|
76
+ entry_actions.each do |state_name, callables|
116
77
  callables.each do |callable|
117
- cb = build_cb.call(callable, state_name, act_timeouts[state_name])
118
- after_transition to: state_name, &cb
78
+ after_transition(
79
+ to: state_name,
80
+ &entry_callback_builder.call(callable, state_name)
81
+ )
119
82
  end
120
83
  end
121
84
 
122
- # Exit callbacks: fire before_transition out of each state.
123
- # Each callable is registered as a separate callback; state_machines
124
- # accumulates them and fires in declaration order.
125
- 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|
126
89
  callables.each do |callable|
127
- before_transition from: state_name do |machine|
128
- callable.call(machine.context)
129
- end
90
+ before_transition(
91
+ from: state_name,
92
+ &exit_callback_builder.call(callable, state_name)
93
+ )
130
94
  end
131
95
  end
96
+
97
+ before_transition(&transition_callback)
132
98
  end
133
99
  end
134
- rescue => e
135
- raise ArgumentError, "Failed to build phase machine: #{e.message}"
100
+ rescue => error
101
+ raise ArgumentError, "Failed to build phase machine: #{error.message}"
136
102
  end
137
103
 
138
104
  private
139
105
 
140
- # Returns a proc suitable for use as an +after_transition+ callback.
141
- #
142
- # The returned proc accepts a single argument (the phase machine instance),
143
- # invokes the entry action callable with the current context, then delegates
144
- # the result to {#handle_entry_action_result}. Capturing this in the
145
- # builder's scope lets the anonymous +Class.new+ block stay slim.
146
- #
147
- # @param callable [#call] the entry action
148
- # @param state_name [Symbol] name of the target state (for error messages)
149
- # @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
150
- # @return [Proc]
151
- # @api private
152
- def build_entry_callback(callable, state_name, timeout_secs)
153
- 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
+
154
115
  ->(machine) {
155
- result = callable.call(machine.context)
156
- 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
157
129
  }
158
130
  end
159
131
 
160
- # Dispatches the return value of an entry action callable.
161
- #
162
- # - +Phronomy::Task+ → async or blocking task handling
163
- # - +Phronomy::WorkflowContext+ → replaces the machine's context directly
164
- # - anything else → ignored
165
- #
166
- # @param machine [Object] phase machine instance
167
- # @param result [Object] return value of the entry callable
168
- # @param state_name [Symbol] name of the entered state
169
- # @param timeout_secs [Numeric, nil] optional timeout in seconds
170
- # @api private
171
- def handle_entry_action_result(machine, result, state_name, timeout_secs)
172
- if result.is_a?(Phronomy::Task)
173
- dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
174
- elsif result.is_a?(Phronomy::WorkflowContext)
175
- machine.context = result
176
- 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
+ }
177
149
  end
178
150
 
179
- # Handles a +Phronomy::Task+ return value in EventLoop mode.
180
- #
181
- # Marks the machine as async-pending and spawns a cooperative background
182
- # task that awaits the result, then posts the appropriate event back to
183
- # the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
184
- # step while +async_pending+ is true.
185
- #
186
- # @param machine [Object] phase machine instance
187
- # @param result [Phronomy::Task]
188
- # @param state_name [Symbol]
189
- # @param timeout_secs [Numeric, nil]
190
- # @api private
191
- def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
192
- machine.async_pending = true
193
- thread_id = machine.context.thread_id
194
- if timeout_secs
195
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
196
- next if result.done?
197
-
198
- Phronomy::EventLoop.instance.post(
199
- Phronomy::Event.new(
200
- type: :error,
201
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
202
- payload: {session_id: thread_id, result: Phronomy::ActionTimeoutError.new(
203
- "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
204
- )}
205
- )
206
- )
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."
207
159
  end
208
- end
209
- result.on_complete do |task_result, error|
210
- if error
211
- Phronomy::EventLoop.instance.post(
212
- Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: thread_id, result: error})
213
- )
214
- 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."
215
171
  end
216
- ev = if task_result.is_a?(Phronomy::WorkflowContext)
217
- Phronomy::Event.new(type: :action_completed, target_id: thread_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
218
179
  else
219
- Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
180
+ callable.method(:call).parameters
220
181
  end
221
- Phronomy::EventLoop.instance.post(ev)
222
- end
182
+ accepts_event = parameters.length >= 2
183
+ accepts_event ? callable.call(context, event) : callable.call(context)
223
184
  end
224
185
 
225
- # Handles a +Phronomy::Task+ return value in non-EventLoop mode.
226
- #
227
- # Blocks the current execution context until the task completes or the
228
- # optional timeout elapses.
229
- #
230
- # @param machine [Object] phase machine instance
231
- # @param result [Phronomy::Task]
232
- # @param state_name [Symbol]
233
- # @param timeout_secs [Numeric, nil]
234
- # @api private
235
- def await_task_blocking(machine, result, state_name, timeout_secs)
236
- enforce_timeout!(result, state_name, timeout_secs)
237
- task_result = result.wait_result
238
- machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
186
+ def workflow_context_result?(result)
187
+ result.respond_to?(:set_graph_metadata)
239
188
  end
240
189
 
241
- # Raises +ActionTimeoutError+ if the task does not complete within
242
- # +timeout_secs+. No-op when +timeout_secs+ is +nil+.
243
- #
244
- # @param result [Phronomy::Task]
245
- # @param state_name [Symbol]
246
- # @param timeout_secs [Numeric, nil]
247
- # @api private
248
- def enforce_timeout!(result, state_name, timeout_secs)
249
- return unless timeout_secs
250
- 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
251
195
 
252
- result.cancel!
253
- raise Phronomy::ActionTimeoutError,
254
- "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."
255
202
  end
256
203
  end
257
204
  end