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,167 +4,75 @@ require_relative "workflow_runner"
4
4
  require_relative "runnable"
5
5
 
6
6
  module Phronomy
7
- # StateChart-style workflow definition DSL.
8
- #
9
- # Defines agent workflows in terms of *states* and *events* backed by
10
- # Phronomy::WorkflowRunner. This is the primary high-level API
11
- # for workflow-based execution in phronomy.
12
- #
13
- # == Basic usage
14
- #
15
- # app = Phronomy::Workflow.define(MyContext) do
16
- # initial :fetch
17
- #
18
- # state :fetch
19
- # state :process
20
- #
21
- # entry :fetch, FETCH_NODE
22
- # entry :process, PROCESS_NODE
23
- #
24
- # transition from: :fetch, to: :process
25
- # transition from: :process, to: :__finish__
26
- # end
27
- #
28
- # result = app.invoke({ url: "https://example.com" })
29
- #
30
- # == Wait states
31
- #
32
- # app = Phronomy::Workflow.define(MyContext) do
33
- # initial :propose
34
- #
35
- # state :propose
36
- # wait_state :awaiting_approval
37
- # state :execute
38
- #
39
- # entry :propose, PROPOSE_NODE
40
- # entry :execute, EXECUTE_NODE
41
- #
42
- # transition from: :propose, to: :awaiting_approval
43
- # transition from: :execute, to: :__finish__
44
- #
45
- # transition from: :awaiting_approval, on: :approve, to: :execute
46
- # transition from: :awaiting_approval, on: :reject, to: :propose
47
- # end
48
- #
49
- # halted = app.invoke({ ... })
50
- # final = app.send_event(state: halted, event: :approve)
51
- #
52
- # == Conditional transitions
53
- #
54
- # transition from: :decide, guard: ->(s) { s.score > 5 }, to: :high
55
- # transition from: :decide, to: :low # fallback (no guard)
56
- #
7
+ # StateChart-style Workflow definition DSL.
57
8
  class Workflow
58
9
  include Phronomy::Runnable
59
10
 
60
- # Defines a new Workflow.
61
- # @param context_class [Class] class that includes Phronomy::WorkflowContext
62
- # @param state_store [Phronomy::StateStore::Base, nil] optional per-workflow state store.
63
- # Takes precedence over the global +Phronomy.configuration.state_store+.
64
- # @yield block evaluated in DSL context
65
- # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
66
- # @raise [ArgumentError] if no states are declared (empty workflow)
67
- # @raise [ArgumentError] if any transition references an undeclared +to:+ or +from:+ state
68
- # @api public
69
11
  def self.define(context_class, state_store: nil, &block)
70
12
  builder = Builder.new(context_class, state_store: state_store)
71
13
  builder.instance_eval(&block)
72
14
  builder.build
73
15
  end
74
16
 
75
- # @param runner [Phronomy::WorkflowRunner]
76
- # @api public
77
17
  def initialize(runner)
78
18
  @runner = runner
79
19
  end
80
20
 
81
- # Executes the workflow from the initial state.
82
- # @param input [Hash] initial context field values
83
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
84
- # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
85
- # object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
86
- # derived from it (existing +config:+ keys take precedence). The object is also
87
- # stored in +config[:invocation_context]+ for downstream tracing.
88
- # @return [Object] final context
89
- # @api public
90
21
  def invoke(input, config: {}, invocation_context: nil)
91
- if invocation_context
92
- config = _apply_invocation_context(config, invocation_context)
93
- end
22
+ config = _apply_invocation_context(config, invocation_context) if invocation_context
94
23
  @runner.invoke(input, config: config)
95
24
  end
96
25
 
97
- # Invokes this workflow asynchronously and returns a {Phronomy::Task}.
98
- #
99
- # Unlike {#invoke}, this method registers the workflow session with the
100
- # EventLoop and returns immediately without spawning an extra OS thread.
101
- # The returned Task resolves with the final context when the workflow
102
- # finishes.
103
- #
104
- # @param input [Hash]
105
- # @param config [Hash]
106
- # @param invocation_context [Phronomy::InvocationContext, nil]
107
- # @return [Phronomy::Task]
108
- # @api public
109
26
  def invoke_async(input, config: {}, invocation_context: nil)
110
- if invocation_context
111
- config = _apply_invocation_context(config, invocation_context)
112
- end
27
+ config = _apply_invocation_context(config, invocation_context) if invocation_context
113
28
  @runner.invoke_deferred(input, config: config)
114
29
  end
115
30
 
116
- # Resumes a halted workflow. Generic resume that works for all halt types.
117
- # @param state [Object] halted context
118
- # @param input [Hash, nil] optional field updates to merge before resuming
119
- # @return [Object] final context
120
- # @api public
31
+ def stream(input, config: {}, invocation_context: nil, &block)
32
+ config = _apply_invocation_context(config, invocation_context) if invocation_context
33
+ @runner.stream(input, config: config, &block)
34
+ end
35
+
121
36
  def resume(state:, input: nil)
122
37
  @runner.resume(state: state, input: input)
123
38
  end
124
39
 
125
- # Fires a named event to advance a halted workflow.
126
- # @param state [Object] halted context
127
- # @param event [Symbol] event name (e.g. :approve, :reject, :resume)
128
- # @param input [Hash, nil] optional field updates to merge before resuming
129
- # @return [Object] final context
130
- # @api public
131
40
  def send_event(state:, event:, input: nil)
132
41
  @runner.send_event(state: state, event: event, input: input)
133
42
  end
134
43
 
135
- # Streaming execution. Yields { state: Symbol, context: Object } after each state action.
136
- # @param input [Hash]
137
- # @param config [Hash]
138
- # @yield [Hash]
139
- # @return [Object] final context
44
+ # Sends an event to an active Workflow session without blocking.
45
+ #
46
+ # This method is safe to call from an Agent/Tool listener running on the
47
+ # EventLoop thread because it only enqueues a later dispatch.
48
+ #
49
+ # @return [Boolean] true when admitted; false when the session is not live
50
+ # or Runtime shutdown has begun
140
51
  # @api public
141
- def stream(input, config: {}, &block)
142
- @runner.stream(input, config: config, &block)
52
+ def signal(thread_id:, event:, payload: nil)
53
+ @runner.signal(
54
+ thread_id: thread_id,
55
+ event: event,
56
+ payload: payload
57
+ )
143
58
  end
144
59
 
145
60
  private
146
61
 
147
- # Merges an {InvocationContext} into the config hash.
148
- # Existing +config+ keys take precedence (backward-compat).
149
- def _apply_invocation_context(config, ic)
150
- effective = config.merge(invocation_context: ic)
151
- effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
62
+ def _apply_invocation_context(config, invocation_context)
63
+ effective = config.merge(invocation_context: invocation_context)
64
+ if effective[:thread_id].nil? && invocation_context.thread_id
65
+ effective = effective.merge(thread_id: invocation_context.thread_id)
66
+ end
152
67
  if effective[:cancellation_token].nil?
153
- if (tok = ic.effective_timeout_token)
154
- effective = effective.merge(cancellation_token: tok)
155
- end
68
+ token = invocation_context.effective_timeout_token
69
+ effective = effective.merge(cancellation_token: token) if token
156
70
  end
157
71
  effective
158
72
  end
159
73
 
160
74
  public
161
75
 
162
- # ---------------------------------------------------------------------------
163
- # Internal DSL builder
164
- # ---------------------------------------------------------------------------
165
-
166
- # DSL builder for Phronomy::Workflow.define.
167
- # Collects state/event/transition declarations and produces a WorkflowRunner.
168
76
  class Builder
169
77
  FINISH = Phronomy::WorkflowRunner::FINISH
170
78
 
@@ -172,197 +80,150 @@ module Phronomy
172
80
  @context_class = context_class
173
81
  @state_store = state_store
174
82
  @initial = nil
175
- # Ordered list of declared state names (action states only, not wait states).
176
83
  @declared_states = []
177
- # { state_name => [callable, ...] } — entry actions registered via entry()
178
84
  @entry_actions = {}
179
- # { state_name => [callable, ...] } — exit actions registered via exit()
180
85
  @exit_actions = {}
181
- # Array of { from:, to:, guard:, on: } — all transitions in declaration order
182
86
  @transitions = []
183
- # Set of wait state names
184
87
  @wait_state_names = []
185
- # { state_name => Numeric } — per-state action timeout in seconds
186
- @action_timeouts = {}
187
88
  end
188
89
 
189
- # Declares the initial (entry) state.
190
- # @param state_name [Symbol]
191
- # rubocop:disable Style/TrivialAccessors
192
- # @api public
193
- def initial(state_name)
90
+ def initial(state_name) # rubocop:disable Style/TrivialAccessors
194
91
  @initial = state_name
195
92
  end
196
- # rubocop:enable Style/TrivialAccessors
197
-
198
- # Declares an action state.
199
- # @param name [Symbol] state name
200
- # @param action [#call, nil] optional entry action shorthand.
201
- # +state :generate, action: MY_PROC+ is equivalent to
202
- # +state :generate; entry :generate, MY_PROC+.
203
- # @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
204
- # entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
205
- # Only applies when the action returns a {Task} or {PendingOperation}.
206
- # @api public
207
- def state(name, action: nil, action_timeout: nil)
93
+
94
+ # Declares a Workflow state.
95
+ #
96
+ # Entry actions are synchronous Run-to-Completion callbacks. To start
97
+ # asynchronous work, register its listener/callback inside the action and
98
+ # return the context or nil. Returning Phronomy::Task is an error.
99
+ def state(name, action: nil)
208
100
  @declared_states << name
209
- @action_timeouts[name] = action_timeout if action_timeout
210
101
  entry(name, action) if action
211
102
  end
212
103
 
213
- # Declares an entry action for a state.
214
- # The callable is invoked when the workflow enters +name+.
215
- # It receives the current context. Two styles are supported:
216
- # - Mutation-in-place: mutate context fields directly (+s.field = value+);
217
- # the return value is ignored.
218
- # - Immutable update: return a new context via +s.merge(field: value)+;
219
- # the returned context replaces the current one.
220
- # Multiple calls for the same state are allowed; callables fire in declaration order.
221
- # @param name [Symbol] state name
222
- # @param callable [#call] receives context; may return a new WorkflowContext
223
- # @api public
224
104
  def entry(name, callable)
225
105
  (@entry_actions[name] ||= []) << callable
226
106
  end
227
107
 
228
- # Declares an exit action for a state.
229
- # The callable is invoked when the workflow leaves +name+.
230
- # It receives the current context and should mutate it in place.
231
- # Return value is ignored.
232
- # Multiple calls for the same state are allowed; callables fire in declaration order.
233
- # @param name [Symbol] state name
234
- # @param callable [#call] receives context, mutates it in place
235
- # @api public
236
108
  def exit(name, callable)
237
109
  (@exit_actions[name] ||= []) << callable
238
110
  end
239
111
 
240
- # Declares a wait state that automatically halts execution when reached.
241
- # No entry action is registered; the workflow pauses here until an event resumes it.
242
- # @param name [Symbol] wait state name (conventionally :awaiting_something)
243
- # @api public
244
112
  def wait_state(name)
245
113
  @wait_state_names << name
246
114
  end
247
115
 
248
- # Declares a transition between states.
249
- # Auto-fire transitions (no +on:+) fire automatically when an action state's
250
- # action completes. External transitions (+on: :event_name+) are triggered
251
- # manually via +send_event+.
252
- # When +guard:+ is provided the transition is taken only if the guard returns
253
- # truthy for the current context. Multiple transitions from the same source are
254
- # evaluated in declaration order; the first passing guard wins.
255
- # @param from [Symbol] source state
256
- # @param to [Symbol] destination state or :__finish__
257
- # @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
258
- # @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
259
- # @api public
260
- def transition(from:, to:, guard: nil, on: nil)
261
- dest = (to == :__finish__) ? FINISH : to
262
- @transitions << {from: from, to: dest, guard: guard, on: on}
116
+ # Declares a transition.
117
+ #
118
+ # Guards and actions may accept either (context) or (context, event).
119
+ # Transition actions are synchronous Run-to-Completion callbacks executed
120
+ # after the source exit callbacks and before the target entry callbacks.
121
+ # They may start asynchronous work and register listeners, but returning
122
+ # Phronomy::Task is an error; completion must arrive as a later event.
123
+ def transition(from:, to:, guard: nil, on: nil, action: nil)
124
+ destination = (to == :__finish__) ? FINISH : to
125
+ @transitions << {
126
+ from: from,
127
+ to: destination,
128
+ guard: guard,
129
+ on: on,
130
+ action: action
131
+ }
132
+ end
133
+
134
+ def build
135
+ validate_graph!
136
+
137
+ auto_transitions = []
138
+ external_events = {}
139
+ @transitions.each do |transition|
140
+ if transition[:on]
141
+ event_name = transition[:on].to_sym
142
+ external_events[event_name] ||= []
143
+ external_events[event_name] << {
144
+ from: transition[:from],
145
+ to: transition[:to],
146
+ guard: transition[:guard],
147
+ action: transition[:action],
148
+ event: event_name
149
+ }
150
+ else
151
+ auto_transitions << {
152
+ from: transition[:from],
153
+ to: transition[:to],
154
+ guard: transition[:guard],
155
+ action: transition[:action],
156
+ event: :state_completed
157
+ }
158
+ end
159
+ end
160
+
161
+ runner = Phronomy::WorkflowRunner.new(
162
+ state_class: @context_class,
163
+ entry_actions: @entry_actions.dup,
164
+ exit_actions: @exit_actions.dup,
165
+ declared_states: @declared_states.dup,
166
+ auto_transitions: auto_transitions,
167
+ external_events: external_events,
168
+ entry_point: @initial || @declared_states.first,
169
+ wait_state_names: @wait_state_names.dup,
170
+ state_store: @state_store
171
+ )
172
+ Workflow.new(runner)
263
173
  end
264
174
 
265
175
  private
266
176
 
267
- # Performs build-time structural validation of the workflow graph.
268
- # Raises ArgumentError for hard errors; warns for unreachable states.
269
177
  def validate_graph!
270
178
  all_states = (@declared_states + @wait_state_names).uniq
271
179
  entry_point = @initial || @declared_states.first
272
180
 
273
- if entry_point.nil?
274
- raise ArgumentError, "Workflow has no states declared — call state(...) or wait_state(...) at least once"
181
+ unless entry_point
182
+ raise ArgumentError,
183
+ "Workflow has no states declared — call state(...) or " \
184
+ "wait_state(...) at least once"
275
185
  end
276
186
 
277
- # Collect all reachable state names from transitions (excluding :__finish__ sentinel).
278
- referenced_targets = @transitions.map { |t| t[:to] }.reject { |t| t == FINISH }
279
- undefined = referenced_targets - all_states
280
- unless undefined.empty?
187
+ undefined_targets = @transitions
188
+ .map { |transition| transition[:to] }
189
+ .reject { |target| target == FINISH } - all_states
190
+ unless undefined_targets.empty?
281
191
  raise ArgumentError,
282
- "Workflow transition(s) reference undefined state(s): #{undefined.sort.inspect}. " \
283
- "Declare each with state(...) or wait_state(...)."
192
+ "Workflow transition(s) reference undefined state(s): " \
193
+ "#{undefined_targets.sort.inspect}"
284
194
  end
285
195
 
286
- # Check that all from: states in transitions are declared.
287
- referenced_sources = @transitions.map { |t| t[:from] }
288
- undefined_sources = referenced_sources - all_states
196
+ undefined_sources = @transitions
197
+ .map { |transition| transition[:from] } - all_states
289
198
  unless undefined_sources.empty?
290
199
  raise ArgumentError,
291
- "Workflow transition(s) originate from undefined state(s): #{undefined_sources.sort.inspect}. " \
292
- "Declare each with state(...) or wait_state(...)."
200
+ "Workflow transition(s) originate from undefined state(s): " \
201
+ "#{undefined_sources.sort.inspect}"
293
202
  end
294
203
 
295
- # Reachability check: warn about declared states that cannot be reached
296
- # from the initial state (transition target not referenced by any transition).
297
204
  reachable = Set.new([entry_point])
298
205
  queue = [entry_point]
299
206
  until queue.empty?
300
207
  current = queue.shift
301
- @transitions.each do |t|
302
- next if t[:from] != current
303
- next if t[:to] == FINISH
304
- unless reachable.include?(t[:to])
305
- reachable.add(t[:to])
306
- queue << t[:to]
307
- end
308
- end
309
- end
208
+ @transitions.each do |transition|
209
+ next unless transition[:from] == current
210
+ next if transition[:to] == FINISH
211
+ next if reachable.include?(transition[:to])
310
212
 
311
- unreachable = all_states - reachable.to_a
312
- unless unreachable.empty?
313
- msg = "[Phronomy] Workflow has unreachable state(s): #{unreachable.sort.inspect}. " \
314
- "These states can never be entered from the initial state '#{entry_point}'."
315
- if Phronomy.configuration.logger
316
- Phronomy.configuration.logger.warn(msg)
317
- else
318
- warn msg
213
+ reachable.add(transition[:to])
214
+ queue << transition[:to]
319
215
  end
320
216
  end
321
- end
322
-
323
- public
324
-
325
- # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
326
- # Performs build-time validation of the graph structure:
327
- # - raises ArgumentError when no initial state is declared and no states have been defined
328
- # - raises ArgumentError when a transition references an undeclared target state
329
- # - warns when declared states are unreachable from the initial state
330
- # @raise [ArgumentError] on structural errors
331
- # @api public
332
- def build
333
- entry_actions = @entry_actions.dup
334
- exit_actions = @exit_actions.dup
335
-
336
- validate_graph!
337
217
 
338
- # Auto-fire transitions (no :on): fire automatically when action completes.
339
- # External events (with :on): triggered manually via send_event.
340
- auto_transitions = []
341
- external_events = {}
342
-
343
- @transitions.each do |t|
344
- if t[:on]
345
- external_events[t[:on]] ||= []
346
- external_events[t[:on]] << {from: t[:from], to: t[:to], guard: t[:guard]}
347
- else
348
- auto_transitions << {from: t[:from], to: t[:to], guard: t[:guard]}
349
- end
350
- end
351
-
352
- runner = Phronomy::WorkflowRunner.new(
353
- state_class: @context_class,
354
- entry_actions: entry_actions,
355
- exit_actions: exit_actions,
356
- declared_states: @declared_states.dup,
357
- auto_transitions: auto_transitions,
358
- external_events: external_events,
359
- entry_point: @initial || @declared_states.first,
360
- wait_state_names: @wait_state_names,
361
- state_store: @state_store,
362
- action_timeouts: @action_timeouts.dup
363
- )
364
-
365
- Workflow.new(runner)
218
+ unreachable = all_states - reachable.to_a
219
+ return if unreachable.empty?
220
+
221
+ message =
222
+ "[Phronomy] Workflow has unreachable state(s): " \
223
+ "#{unreachable.sort.inspect}. These states can never be entered " \
224
+ "from the initial state #{entry_point.inspect}."
225
+ logger = Phronomy.configuration.logger
226
+ logger ? logger.warn(message) : Kernel.warn(message)
366
227
  end
367
228
  end
368
229
  end