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
@@ -1,214 +1,275 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "state_machines"
5
4
 
6
5
  module Phronomy
7
- # Execution engine for compiled workflows.
8
- # Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
9
- # Instantiated by Phronomy::Workflow and used internally.
6
+ # Execution boundary for compiled Workflows.
10
7
  #
11
- # == Design principle
8
+ # WorkflowRunner prepares WorkflowContext instances, registers FSMSession
9
+ # objects with the Runtime-owned EventLoop, observes completion, and persists
10
+ # serializable Workflow snapshots. All Workflow execution APIs share this
11
+ # path; their only differences are blocking and observation semantics.
12
12
  #
13
- # State transitions are driven entirely by state_machines. The PhaseTracker
14
- # holds a reference to the current WorkflowContext via +attr_accessor :context+,
15
- # and guard lambdas evaluate +m.context+ (the WorkflowContext) rather than
16
- # the PhaseTracker itself. This ensures that "what happens next" is always
17
- # determined by the declared state machine topology, never by Phronomy internals.
18
- #
19
- # Entry and exit actions are registered as state_machines +after_transition to:+
20
- # and +before_transition from:+ callbacks respectively. Entry actions may either
21
- # mutate the context in place or return a new context (e.g. via +s.merge(...)+).
22
- # When an entry action returns a Phronomy::WorkflowContext, that value replaces
23
- # the current context; otherwise the return value is ignored.
24
- # Exit actions are always mutation-in-place; their return value is ignored.
25
- #
26
- # The sole exception is the initial state: state_machines does not fire transition
27
- # callbacks on initialization, so the entry action for the entry point is invoked
28
- # directly by WorkflowRunner before the main execution loop begins.
29
- #
30
- # == Two transition categories registered in PhaseTracker
31
- #
32
- # 1. state_completed — all auto-fire transitions (with or without guards).
33
- # Fired when an action state's action completes.
34
- # Guards are evaluated in declaration order; first match wins.
35
- # (declared with +transition from: :foo, to: :bar+ or
36
- # +transition from: :foo, guard: ..., to: :bar+)
37
- #
38
- # 2. <event_name> — external events triggered by human input, originating
39
- # from wait states
40
- # (declared with +transition from: :awaiting, on: :approve, to: :run+)
41
13
  # @api private
42
14
  class WorkflowRunner
43
15
  include Phronomy::Runnable
44
16
 
45
- # Sentinel value for the terminal state of a workflow.
46
17
  FINISH = :__end__
47
18
 
48
- def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil, action_timeouts: {})
19
+ Execution = Data.define(
20
+ :context,
21
+ :thread_id,
22
+ :recursion_limit,
23
+ :store,
24
+ :persist
25
+ )
26
+
27
+ def initialize(
28
+ state_class:,
29
+ entry_actions:,
30
+ declared_states:,
31
+ auto_transitions:,
32
+ external_events:,
33
+ entry_point:,
34
+ exit_actions: {},
35
+ wait_state_names: [],
36
+ state_store: nil
37
+ )
49
38
  @state_class = state_class
50
- @entry_actions = entry_actions # { state_name => [callable, ...] }
39
+ @entry_actions = entry_actions
51
40
  @declared_states = declared_states
52
- # Lookup set: states with at least one auto-fire transition declared.
53
- @auto_state_set = auto_transitions.each_with_object({}) { |t, h| h[t[:from]] = true }
54
- @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
41
+ @auto_state_set = auto_transitions.each_with_object({}) do |transition, set|
42
+ set[transition[:from]] = true
43
+ end
44
+ @external_events = external_events
55
45
  @entry_point = entry_point
56
46
  @wait_state_names = wait_state_names
57
47
  @state_store = state_store
58
- @action_timeouts = action_timeouts # { state_name => seconds }
59
48
  @phase_machine_class = Workflow::PhaseMachineBuilder.new(
60
49
  entry_point: @entry_point,
61
50
  declared_states: @declared_states,
62
51
  wait_state_names: @wait_state_names,
63
52
  external_events: @external_events,
64
53
  entry_actions: @entry_actions,
65
- action_timeouts: @action_timeouts,
66
54
  auto_transitions: auto_transitions,
67
55
  exit_actions: exit_actions
68
56
  ).build
69
57
  end
70
58
 
71
- # Executes the workflow from the initial state.
72
- # @param input [Hash] initial context field values
73
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
74
- # @return [Object] final context (includes Phronomy::WorkflowContext)
75
- # @api private
76
59
  def invoke(input, config: {})
60
+ ensure_blocking_call_allowed!(:invoke, :invoke_async)
77
61
  caller_meta = {}
78
62
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
79
63
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
80
64
 
81
65
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
82
- state, thread_id, recursion_limit, store = _build_initial_context(input, config)
83
- result = run_via_event_loop(state, recursion_limit: recursion_limit)
84
- store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
66
+ execution = prepare_new_execution(input, config)
67
+ result = start_execution(execution).wait_result
85
68
  [result, nil]
86
69
  end
87
70
  end
88
71
 
89
- # Registers the workflow with the EventLoop and returns a {Phronomy::Task}
90
- # immediately without blocking the caller. The task resolves with the final
91
- # context when the workflow finishes.
92
- #
93
- # This is the EventLoop-driven equivalent of spawning a thread around
94
- # {#invoke}. No extra OS thread is created; the EventLoop's existing thread
95
- # drives the execution.
96
- #
97
- # @param input [Hash] initial context field values
98
- # @param config [Hash]
99
- # @return [Phronomy::Task]
100
- # @api private
101
72
  def invoke_deferred(input, config: {})
102
- runtime = Phronomy::Runtime.instance
103
- event_loop = runtime.event_loop
104
- state, thread_id, recursion_limit, store = _build_initial_context(input, config)
105
- result_task = Phronomy::Task.deferred(name: "workflow-async:#{thread_id}")
106
- session = build_session_for(
107
- context: state,
108
- recursion_limit: recursion_limit,
109
- runtime: runtime
110
- )
111
- if store && config[:thread_id]
112
- # Wrap so that state is persisted when the task resolves.
113
- persist_task = Phronomy::Task.deferred(name: "workflow-async-persist:#{thread_id}")
114
- event_loop.register(session, completion: persist_task)
115
- persist_task.on_complete do |result, error|
116
- store.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) unless error
117
- if error
118
- result_task.backend.unblock(nil, error)
119
- result_task.transition!(:failed, error: error)
120
- else
121
- result_task.backend.unblock(result, nil)
122
- result_task.transition!(:completed, value: result)
123
- end
124
- end
125
- else
126
- event_loop.register(session, completion: result_task)
127
- end
128
- result_task
73
+ execution = prepare_new_execution(input, config)
74
+ start_execution(execution)
75
+ rescue => error
76
+ failed_task("workflow-async:preparation", error)
77
+ end
78
+
79
+ def stream(input, config: {}, &observer)
80
+ ensure_blocking_call_allowed!(:stream, :invoke_async)
81
+ raise ArgumentError, "stream requires a block" unless observer
82
+
83
+ execution = prepare_new_execution(input, config)
84
+ start_execution(
85
+ execution,
86
+ stable_observer: observer
87
+ ).wait_result
129
88
  end
130
89
 
131
- # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
132
- # @param state [Object] halted context
133
- # @param input [Hash, nil] optional field updates to merge before resuming
134
- # @return [Object] final context
135
- # @api private
136
90
  def resume(state:, input: nil)
137
91
  send_event(state: state, event: :resume, input: input)
138
92
  end
139
93
 
140
- # Fires a named event to advance a halted workflow.
141
- #
142
- # The special event +:resume+ selects the first external event registered
143
- # for the current wait state and fires it.
144
- #
145
- # @param state [Object] halted context
146
- # @param event [Symbol] named event or +:resume+ for generic resumption
147
- # @param input [Hash, nil] optional field updates to merge before resuming
148
- # @return [Object] final context
149
- # @api private
150
94
  def send_event(state:, event:, input: nil)
151
- state = state.merge(input) if input
152
- event = event.to_sym
153
- current_phase = state.phase
154
-
155
- ev_to_fire = if event == :resume
156
- # Find the first external event that can originate from the current wait state.
157
- name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
158
- unless name
159
- raise ArgumentError,
160
- "No external event registered for wait state #{current_phase.inspect}"
161
- end
162
- name
163
- else
164
- unless @external_events.key?(event)
165
- raise ArgumentError,
166
- "Unknown event #{event.inspect}. Valid events: #{@external_events.keys.inspect}"
167
- end
168
- event
95
+ ensure_blocking_call_allowed!(:send_event, :signal)
96
+ context = input ? state.merge(input) : state
97
+ current_phase = context.phase.to_sym
98
+ event_name = resolve_resume_event(current_phase, event)
99
+ thread_id = context.thread_id
100
+ unless thread_id
101
+ raise ArgumentError, "Halted WorkflowContext has no thread_id"
169
102
  end
170
103
 
171
- run_via_event_loop(state,
104
+ execution = Execution.new(
105
+ context: context,
106
+ thread_id: thread_id.to_s,
172
107
  recursion_limit: Phronomy.configuration.recursion_limit,
173
- resume_event: ev_to_fire, resume_phase: current_phase)
108
+ store: configured_store,
109
+ persist: true
110
+ )
111
+ start_execution(
112
+ execution,
113
+ resume_event: event_name,
114
+ resume_phase: current_phase
115
+ ).wait_result
174
116
  end
175
117
 
176
- # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
177
- # @param input [Hash]
178
- # @param config [Hash]
179
- # @yield [Hash]
180
- # @return [Object] final context
181
- # @api private
182
- def stream(input, config: {}, &block)
183
- thread_id = config[:thread_id] || SecureRandom.uuid
184
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
185
- state = @state_class.new(**input)
186
- state.set_graph_metadata(thread_id: thread_id)
187
- run_workflow(state, recursion_limit: recursion_limit, &block)
118
+ # Posts an application-defined event to a currently live Workflow session.
119
+ #
120
+ # Admission is asynchronous. A true result means the EventLoop accepted the
121
+ # event for an admitted session; it does not mean that a transition matched.
122
+ # A false result means that the Runtime is stopping or the session is no
123
+ # longer admitted.
124
+ def signal(thread_id:, event:, payload: nil)
125
+ if thread_id.nil?
126
+ raise ArgumentError, "thread_id is required"
127
+ end
128
+
129
+ event_name = event.to_sym
130
+ unless @external_events.key?(event_name)
131
+ raise ArgumentError,
132
+ "Unknown event #{event_name.inspect}. " \
133
+ "Valid events: #{@external_events.keys.inspect}"
134
+ end
135
+
136
+ Phronomy::Runtime.instance.event_loop.post_to_session(
137
+ Phronomy::Event.new(
138
+ type: event_name,
139
+ target_id: thread_id.to_s,
140
+ payload: payload
141
+ )
142
+ )
188
143
  end
189
144
 
190
145
  private
191
146
 
192
- # Builds the initial WorkflowContext from input and config.
193
- # Returns [state, thread_id, recursion_limit, store].
194
- def _build_initial_context(input, config)
195
- thread_id = config[:thread_id] || SecureRandom.uuid
196
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
197
- store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
198
- snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
199
- initial_fields = if snapshot && snapshot[:fields]
200
- snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
147
+ def ensure_blocking_call_allowed!(method_name, async_alternative)
148
+ return unless Phronomy::Runtime.instance.event_loop.current?
149
+
150
+ raise Phronomy::Error,
151
+ "Cannot call Workflow##{method_name} from the EventLoop thread. " \
152
+ "Use #{async_alternative} instead."
153
+ end
154
+
155
+ def prepare_new_execution(input, config)
156
+ thread_id = (config[:thread_id] || SecureRandom.uuid).to_s
157
+ recursion_limit = config.fetch(
158
+ :recursion_limit,
159
+ Phronomy.configuration.recursion_limit
160
+ )
161
+ store = configured_store(config)
162
+ snapshot = store&.load(thread_id) if config[:thread_id]
163
+
164
+ stored_fields = snapshot && snapshot[:fields]
165
+ initial_fields = if stored_fields
166
+ stored_fields
167
+ .transform_keys(&:to_sym)
168
+ .merge(input.transform_keys(&:to_sym))
201
169
  else
202
170
  input
203
171
  end
204
- state = @state_class.new(**initial_fields)
205
- state.set_graph_metadata(thread_id: thread_id)
206
- [state, thread_id, recursion_limit, store]
172
+
173
+ context = @state_class.new(**initial_fields)
174
+ context.set_graph_metadata(thread_id: thread_id)
175
+
176
+ Execution.new(
177
+ context: context,
178
+ thread_id: thread_id,
179
+ recursion_limit: recursion_limit,
180
+ store: store,
181
+ persist: !config[:thread_id].nil?
182
+ )
183
+ end
184
+
185
+ def configured_store(config = {})
186
+ config.fetch(:state_store, @state_store) ||
187
+ Phronomy.configuration.state_store
188
+ end
189
+
190
+ def start_execution(
191
+ execution,
192
+ resume_event: nil,
193
+ resume_phase: nil,
194
+ stable_observer: nil
195
+ )
196
+ runtime = Phronomy::Runtime.instance
197
+ result_task = Phronomy::Task.deferred(
198
+ name: "workflow:#{execution.thread_id}"
199
+ )
200
+ source_task = Phronomy::Task.deferred(
201
+ name: "workflow-source:#{execution.thread_id}"
202
+ )
203
+
204
+ source_task.on_complete do |result, error|
205
+ finalize_execution(
206
+ result_task: result_task,
207
+ result: result,
208
+ error: error,
209
+ store: execution.store,
210
+ thread_id: execution.thread_id,
211
+ persist: execution.persist
212
+ )
213
+ end
214
+
215
+ session = build_session_for(
216
+ context: execution.context,
217
+ recursion_limit: execution.recursion_limit,
218
+ runtime: runtime,
219
+ resume_event: resume_event,
220
+ resume_phase: resume_phase,
221
+ stable_observer: stable_observer
222
+ )
223
+ runtime.event_loop.register(session, completion: source_task)
224
+ result_task
225
+ rescue => error
226
+ fail_task(result_task, error) if result_task
227
+ result_task || failed_task("workflow:registration", error)
228
+ end
229
+
230
+ def finalize_execution(
231
+ result_task:,
232
+ result:,
233
+ error:,
234
+ store:,
235
+ thread_id:,
236
+ persist:
237
+ )
238
+ if error
239
+ fail_task(result_task, error)
240
+ return
241
+ end
242
+
243
+ begin
244
+ persist_snapshot(store, thread_id, result, persist: persist)
245
+ rescue => persistence_error
246
+ fail_task(result_task, persistence_error)
247
+ return
248
+ end
249
+
250
+ complete_task(result_task, result)
251
+ end
252
+
253
+ def persist_snapshot(store, thread_id, context, persist:)
254
+ return unless store && persist
255
+
256
+ store.save(
257
+ thread_id,
258
+ {
259
+ fields: context.to_h,
260
+ phase: context.phase.to_s
261
+ }
262
+ )
207
263
  end
208
264
 
209
- # Builds an FSMSession for the given context. Used in EventLoop mode.
210
- def build_session_for(context:, recursion_limit:, runtime: Phronomy::Runtime.instance,
211
- resume_event: nil, resume_phase: nil)
265
+ def build_session_for(
266
+ context:,
267
+ recursion_limit:,
268
+ runtime:,
269
+ resume_event: nil,
270
+ resume_phase: nil,
271
+ stable_observer: nil
272
+ )
212
273
  Phronomy::FSMSession.new(
213
274
  id: context.thread_id,
214
275
  context: context,
@@ -221,171 +282,48 @@ module Phronomy
221
282
  phase_machine_class: @phase_machine_class,
222
283
  recursion_limit: recursion_limit,
223
284
  event_loop: runtime.event_loop,
224
- timer_queue_provider: -> { runtime.timer_queue },
225
- action_timeouts: @action_timeouts,
226
285
  resume_event: resume_event,
227
- resume_phase: resume_phase
286
+ resume_phase: resume_phase,
287
+ stable_observer: stable_observer
228
288
  )
229
289
  end
230
290
 
231
- # Executes the workflow via the default Runtime-owned EventLoop.
232
- # Blocks the calling thread on a completion queue until the workflow
233
- # finishes, halts at a wait state, or raises an error.
234
- def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
235
- runtime = Phronomy::Runtime.instance
236
- event_loop = runtime.event_loop
237
- session = build_session_for(
238
- context: context,
239
- recursion_limit: recursion_limit,
240
- runtime: runtime,
241
- resume_event: resume_event,
242
- resume_phase: resume_phase
243
- )
244
- completion_queue = event_loop.register(session)
245
- result = completion_queue.pop
246
- raise result if result.is_a?(Exception)
247
- result
248
- end
249
-
250
- def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
251
- # Mark the current thread as a synchronous execution context.
252
- # This allows WorkflowContext field mutations via setters without raising
253
- # WorkflowContextOwnershipError (which would otherwise fire since EventLoop
254
- # is always active and run_workflow runs on the caller's thread).
255
- Thread.current[:phronomy_sync_execution] = Thread.current[:phronomy_sync_execution].to_i + 1
256
- _run_workflow_body(ctx, resume_event: resume_event, resume_phase: resume_phase,
257
- recursion_limit: recursion_limit, &event_block)
258
- ensure
259
- depth = Thread.current[:phronomy_sync_execution].to_i - 1
260
- Thread.current[:phronomy_sync_execution] = (depth > 0) ? depth : nil
261
- end
262
-
263
- def _run_workflow_body(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
264
- if resume_event
265
- # -- Resume from a wait state -------------------------------------------
266
- # Fire the external event on a tracker positioned at the wait state.
267
- # state_machines will invoke before_transition (exit) and after_transition
268
- # (entry) callbacks as part of the transition, so both actions fire here.
269
- current_state = resume_phase
270
- tracker = new_phase_machine(current_state)
271
- tracker.context = ctx
272
- fire_event!(tracker, resume_event, current_state)
273
- ctx = tracker.context
274
- next_phase = tracker.phase.to_sym
275
- current_state = (next_phase == current_state) ? FINISH : next_phase
276
- else
277
- # -- Fresh start --------------------------------------------------------
278
- current_state = @entry_point
279
- tracker = new_phase_machine(current_state)
280
- tracker.context = ctx
281
- # state_machines only fires after_transition callbacks on transitions.
282
- # The entry point has no prior transition, so we invoke its entry actions directly.
283
- @entry_actions[current_state]&.each do |c|
284
- result = c.call(ctx)
285
- if result.is_a?(Phronomy::Task)
286
- timeout_secs = @action_timeouts[current_state]
287
- if timeout_secs
288
- if result.join(timeout_secs).nil?
289
- result.cancel!
290
- raise Phronomy::ActionTimeoutError,
291
- "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
292
- end
293
- end
294
- task_result = result.wait_result
295
- ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
296
- elsif result.is_a?(Phronomy::WorkflowContext)
297
- ctx = result
298
- end
291
+ def resolve_resume_event(current_phase, event)
292
+ event_name = event.to_sym
293
+ unless event_name == :resume
294
+ unless @external_events.key?(event_name)
295
+ raise ArgumentError,
296
+ "Unknown event #{event_name.inspect}. " \
297
+ "Valid events: #{@external_events.keys.inspect}"
299
298
  end
300
- tracker.context = ctx
299
+ return event_name
301
300
  end
302
301
 
303
- # Event queue: decouple action execution from transition firing.
304
- # Events are enqueued after visiting a state and processed at the top
305
- # of the next iteration so that guards always see the freshest context.
306
- event_queue = []
307
- step = 0
308
-
309
- loop do
310
- break if current_state == FINISH
311
-
312
- # -- Process next pending event -----------------------------------------
313
- # Dequeue one event and fire it against the state machine. Guards are
314
- # evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
315
- if (event = event_queue.shift)
316
- if step >= recursion_limit
317
- raise Phronomy::RecursionLimitError,
318
- "Recursion limit (#{recursion_limit}) exceeded"
319
- end
320
-
321
- fire_event!(tracker, event, current_state)
322
- ctx = tracker.context
323
- next_phase = tracker.phase.to_sym
324
- # When next_phase == current_state no transition matched → terminal state.
325
- current_state = (next_phase == current_state) ? FINISH : next_phase
326
- step += 1
327
- next
328
- end
329
-
330
- # -- Queue empty: check for halt -----------------------------------------
331
- # Auto-halt at wait states: persist phase in context and return to caller.
332
- # The caller resumes via send_event.
333
- if @wait_state_names.include?(current_state)
334
- ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
335
- return ctx
336
- end
337
-
338
- # -- Validate state is known --------------------------------------------
339
- unless @declared_states.include?(current_state)
340
- raise ArgumentError, "State #{current_state.inspect} is not defined"
341
- end
342
-
343
- # -- Emit stream event and enqueue transition ---------------------------
344
- # Entry action for current_state has already been invoked (either by the
345
- # initial manual call above, or by the after_transition callback fired
346
- # inside fire_event! on the previous iteration).
347
- event_block&.call({state: current_state, context: ctx})
348
-
349
- # state_completed: unified event for all auto-fire transitions.
350
- # No enqueue: terminal state — next iteration exits via FINISH check.
351
- if @auto_state_set.key?(current_state)
352
- event_queue << :state_completed
353
- else
354
- current_state = FINISH
302
+ name, = @external_events.find do |_candidate, transitions|
303
+ Array(transitions).any? do |transition|
304
+ transition[:from] == current_phase
355
305
  end
356
306
  end
307
+ return name if name
357
308
 
358
- ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: :__end__)
359
- ctx
309
+ raise ArgumentError,
310
+ "No external event registered for state #{current_phase.inspect}"
360
311
  end
361
312
 
362
- # Fires +event_name+ on +tracker+, raising a descriptive error if no
363
- # transition matches. state_machines event methods return false when no
364
- # transition can be taken (invalid state or all guards fail).
365
- def fire_event!(tracker, event_name, from_state)
366
- return if tracker.send(event_name)
313
+ def complete_task(task, value)
314
+ task.backend.unblock(value, nil)
315
+ task.transition!(:completed, value: value)
316
+ end
367
317
 
368
- raise ArgumentError,
369
- "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
370
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
318
+ def fail_task(task, error)
319
+ task.backend.unblock(nil, error)
320
+ task.transition!(:failed, error: error)
371
321
  end
372
322
 
373
- # Builds the PhaseTracker class backed by state_machines.
374
- #
375
- # Four event/callback types are registered:
376
- # state_completed — all auto-fire transitions (guarded and unguarded)
377
- # <external_name> — external events originating from wait states
378
- # after_transition to — entry callbacks (invoked when entering a state)
379
- # before_transition from — exit callbacks (invoked when leaving a state)
380
- #
381
- # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
382
- # Creates a PhaseTracker instance initialized to +from_state+.
383
- def new_phase_machine(from_state)
384
- machine = @phase_machine_class.new
385
- # Override the initial state set by state_machine's initializer so we can
386
- # resume from an arbitrary state (e.g. after a wait state).
387
- machine.instance_variable_set(:@phase, from_state.to_s)
388
- machine
323
+ def failed_task(name, error)
324
+ task = Phronomy::Task.deferred(name: name)
325
+ fail_task(task, error)
326
+ task
389
327
  end
390
328
  end
391
329
  end
data/lib/phronomy.rb CHANGED
@@ -12,10 +12,12 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
12
12
  loader.inflector.inflect("rag" => "RAG")
13
13
  # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
14
14
  # Phronomy::FSMSession is the top-level cooperative execution engine shared by
15
- # WorkflowRunner and Agent::InvocationSession.
15
+ # WorkflowRunner, AgentInvocationSessionBuilder, and ToolInvocationSessionBuilder.
16
16
  loader.inflector.inflect("fsm_session" => "FSMSession")
17
17
  # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
18
18
  loader.inflector.inflect("llm_adapter" => "LLMAdapter")
19
+ # LLMOperationResult: preserve the LLM acronym for the Agent result carrier.
20
+ loader.inflector.inflect("llm_operation_result" => "LLMOperationResult")
19
21
  # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
20
22
  loader.inflector.inflect("ruby_llm" => "RubyLLM")
21
23
  # Collapse engine/ so that its contents autoload directly under Phronomy::
@@ -34,7 +36,7 @@ module Phronomy
34
36
  class ParseError < Error; end
35
37
  class RecursionLimitError < Error; end
36
38
  class ToolError < Error; end
37
- # Raised when an agent invocation exceeds the timeout set via +invoke_timeout+.
39
+ # Base error for Phronomy-owned timed boundaries and generic timeout primitives.
38
40
  class TimeoutError < Error; end
39
41
 
40
42
  class ConfigurationError < Error; end
@@ -125,8 +127,8 @@ module Phronomy
125
127
  # result is available. Extends {TimeoutError} for backwards compatibility.
126
128
  class ScopeTimeoutError < TimeoutError; end
127
129
 
128
- # Raised when a Workflow entry/exit action task exceeds the +action_timeout:+
129
- # configured for its state. Extends {TimeoutError}.
130
+ # Deprecated compatibility constant. Workflow entry/exit actions are
131
+ # synchronous and the Workflow DSL no longer accepts +action_timeout:+.
130
132
  class ActionTimeoutError < TimeoutError; end
131
133
 
132
134
  # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
@@ -29,11 +29,14 @@ PREAMBLE = <<~RUBY
29
29
  # Patch invoke methods to return canned responses instead of calling the LLM.
30
30
  module Phronomy
31
31
  module Agent
32
- class Base
32
+ # Prepend overrides AsyncEventApi (which is also prepended) so invoke
33
+ # returns a canned response without triggering the EventLoop.
34
+ module CiInvokeStub
33
35
  def invoke(input = nil, **)
34
36
  {output: "ci-stub-output", messages: []}
35
37
  end
36
38
  end
39
+ Base.prepend(CiInvokeStub)
37
40
 
38
41
  class Runner
39
42
  def invoke(input = nil, **)