phronomy 0.11.0 → 0.11.1

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +192 -78
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +337 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +1 -1
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Phronomy
6
+ class Task
7
+ # Backend for externally-completed Tasks.
8
+ #
9
+ # DeferredBackend never starts a thread. The owner transitions it to a
10
+ # terminal state by calling Task#transition! and then #unblock.
11
+ # @api private
12
+ class DeferredBackend < Backend
13
+ def initialize(task:, &)
14
+ super
15
+ @done_queue = Queue.new
16
+ task.transition!(:running)
17
+ end
18
+
19
+ # Unblocks await/join after the task reaches a terminal state.
20
+ # @api private
21
+ def unblock(value, error)
22
+ @done_queue.push([value, error])
23
+ end
24
+
25
+ # Blocks until externally completed.
26
+ # @api private
27
+ def wait_result
28
+ scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
29
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
30
+ if scheduler && in_managed_fiber && !@task.done?
31
+ scheduler.track_blocking_await
32
+ waiting_fiber = Fiber.current
33
+ @task.on_complete do |_value, _error|
34
+ scheduler.complete_blocking_await
35
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
36
+ end
37
+ Fiber.yield(:cooperative_suspend)
38
+ end
39
+
40
+ value, error = @done_queue.pop
41
+ raise error if error
42
+
43
+ value
44
+ end
45
+
46
+ # Deferred tasks have no execution thread of their own.
47
+ # @api private
48
+ def alive?
49
+ !@task.done?
50
+ end
51
+
52
+ # Marks the task cancelled without interrupting external work.
53
+ # @api private
54
+ def cancel!
55
+ self
56
+ end
57
+
58
+ # Blocks until externally completed, with optional timeout.
59
+ # @api private
60
+ def join(limit = nil)
61
+ if limit.nil?
62
+ wait_result
63
+ else
64
+ begin
65
+ Timeout.timeout(limit) { wait_result }
66
+ rescue Timeout::Error
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -100,7 +100,7 @@ module Phronomy
100
100
  # @return [Object]
101
101
  # @raise [Exception]
102
102
  # @api private
103
- def await
103
+ def wait_result
104
104
  unless @fiber.alive?
105
105
  raise @error if @error
106
106
  return @value
@@ -44,7 +44,7 @@ module Phronomy
44
44
  # @return [Object]
45
45
  # @raise [Exception]
46
46
  # @api private
47
- def await
47
+ def wait_result
48
48
  raise @error if @error
49
49
 
50
50
  @value
@@ -37,7 +37,19 @@ module Phronomy
37
37
  # @return [Object] the mapped value
38
38
  # @raise [Exception] if the source task or the map block raised an error
39
39
  # @api private
40
- def await
40
+ def wait_result
41
+ scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
42
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
43
+ if scheduler && in_managed_fiber && !@task.done?
44
+ scheduler.track_blocking_await
45
+ waiting_fiber = Fiber.current
46
+ @task.on_complete do |_value, _error|
47
+ scheduler.complete_blocking_await
48
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
49
+ end
50
+ Fiber.yield(:cooperative_suspend)
51
+ end
52
+
41
53
  value, error = @done_queue.pop
42
54
  raise error if error
43
55
 
@@ -64,10 +76,10 @@ module Phronomy
64
76
  # @api private
65
77
  def join(limit = nil)
66
78
  if limit.nil?
67
- await
79
+ wait_result
68
80
  else
69
81
  begin
70
- Timeout.timeout(limit) { await }
82
+ Timeout.timeout(limit) { wait_result }
71
83
  rescue Timeout::Error
72
84
  nil
73
85
  end
@@ -41,7 +41,7 @@ module Phronomy
41
41
  # @return [Object]
42
42
  # @raise [Exception]
43
43
  # @api private
44
- def await
44
+ def wait_result
45
45
  @thread.join
46
46
  raise @error if @error
47
47
 
@@ -5,6 +5,7 @@ require_relative "task/thread_backend"
5
5
  require_relative "task/immediate_backend"
6
6
  require_relative "task/fiber_backend"
7
7
  require_relative "task/mapped_backend"
8
+ require_relative "task/deferred_backend"
8
9
 
9
10
  module Phronomy
10
11
  # A single unit of concurrent work.
@@ -21,7 +22,7 @@ module Phronomy
21
22
  #
22
23
  # @example Basic usage (framework/test code only — prefer Runtime.instance.spawn in app code)
23
24
  # task = Phronomy::Task.spawn { expensive_io() }
24
- # result = task.await # blocks until done, re-raises errors
25
+ # result = task.wait_result # blocks until done, re-raises errors
25
26
  #
26
27
  # @example Cancel a running task
27
28
  # task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
@@ -121,6 +122,15 @@ module Phronomy
121
122
  new(name: name, parent: parent, backend_class: backend_class, &block)
122
123
  end
123
124
 
125
+ # Creates a task whose lifecycle is completed externally.
126
+ # @param name [String, nil]
127
+ # @param parent [Task, nil]
128
+ # @return [Task]
129
+ # @api private
130
+ def self.deferred(name: nil, parent: current)
131
+ new(name: name, parent: parent, backend_class: DeferredBackend) {}
132
+ end
133
+
124
134
  # @return [String, nil] optional human-readable label
125
135
  attr_reader :name
126
136
 
@@ -161,8 +171,8 @@ module Phronomy
161
171
  # @return [Object] the result produced by the block
162
172
  # @raise [Exception] if the block raised an error
163
173
  # @api private
164
- def await
165
- @backend.await
174
+ def wait_result
175
+ @backend.wait_result
166
176
  end
167
177
 
168
178
  # Registers a callback to be invoked when the task reaches a terminal state
@@ -170,7 +180,7 @@ module Phronomy
170
180
  #
171
181
  # The callback receives two arguments: +value+ (the task's return value,
172
182
  # or +nil+) and +error+ (the exception, or +nil+). These are provided
173
- # directly so the callback does not need to call +task.await+, which would
183
+ # directly so the callback does not need to call +task.wait_result+, which would
174
184
  # risk a self-join error when the callback runs inside the task's own thread.
175
185
  #
176
186
  # If the task is already done when this method is called, the callback is
@@ -204,7 +214,7 @@ module Phronomy
204
214
  #
205
215
  # The primary use-case is transforming an agent result into a
206
216
  # {WorkflowContext} so that a Workflow entry action can return a Task
207
- # whose value is picked up by {Workflow::FSMSession} via the existing
217
+ # whose value is picked up by {FSMSession} via the existing
208
218
  # +:action_completed+ path:
209
219
  #
210
220
  # @example Returning agent output into a Workflow state field
@@ -237,13 +247,15 @@ module Phronomy
237
247
  mapped_error = e
238
248
  end
239
249
  end
250
+ # Unblock mapped.wait_result / mapped.join before the terminal transition.
251
+ # on_complete callbacks may resume a waiting Fiber immediately; the
252
+ # queue must already contain the result at that point.
253
+ mapped.backend.unblock(mapped_value, mapped_error)
240
254
  if mapped_error
241
255
  mapped.transition!(:failed, error: mapped_error)
242
256
  else
243
257
  mapped.transition!(:completed, value: mapped_value)
244
258
  end
245
- # Unblock mapped.await / mapped.join after the terminal transition.
246
- mapped.backend.unblock(mapped_value, mapped_error)
247
259
  end
248
260
  mapped
249
261
  end
@@ -58,7 +58,7 @@ module Phronomy
58
58
  # still returns 0.0 — warn is a side-effect not tested by value assertions
59
59
  def score(actual:, expected:, input: nil)
60
60
  prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
61
- response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.await
61
+ response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.blocking_wait
62
62
  response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
63
63
  rescue => e
64
64
  raise if @raise_on_error
@@ -192,21 +192,30 @@ module Phronomy
192
192
  entry :draft, ->(state) {
193
193
  feedback = state.review_notes.last
194
194
  prompt = dpb.call(state.input, feedback)
195
- result = draft_agent.invoke(prompt)
196
- parsed = drp.call(result[:output])
197
- state.draft = parsed[:answer].to_s
198
- state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
199
- state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
200
- state.iteration = state.iteration + 1
195
+ # Return a Task so WorkflowRunner awaits it off the EventLoop thread,
196
+ # then posts :action_completed with the new context back to EventLoop.
197
+ draft_agent.invoke_async(prompt).map do |result|
198
+ parsed = drp.call(result[:output])
199
+ state.merge(
200
+ draft: parsed[:answer].to_s,
201
+ self_score: pipeline.__send__(:clamp, parsed[:confidence]),
202
+ citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
203
+ iteration: state.iteration + 1
204
+ )
205
+ end
201
206
  }
202
207
 
203
208
  entry :review, ->(state) {
204
209
  prompt = rpb.call(state.input, state.draft, state.citations)
205
- result = review_agent.invoke(prompt)
206
- parsed = rrp.call(result[:output])
207
- state.review_score = pipeline.__send__(:clamp, parsed[:score])
208
- state.approved = parsed[:approved] == true
209
- state.review_notes << parsed[:feedback].to_s
210
+ # Return a Task so WorkflowRunner awaits it off the EventLoop thread.
211
+ review_agent.invoke_async(prompt).map do |result|
212
+ parsed = rrp.call(result[:output])
213
+ state.merge(
214
+ review_score: pipeline.__send__(:clamp, parsed[:score]),
215
+ approved: parsed[:approved] == true,
216
+ review_notes: [parsed[:feedback].to_s]
217
+ )
218
+ end
210
219
  }
211
220
 
212
221
  entry :finalize, ->(state) { state.output = state.draft }
@@ -84,7 +84,7 @@ module Phronomy
84
84
  input,
85
85
  thread_id: ctx[:thread_id] || parent_ic&.thread_id,
86
86
  config: task_config
87
- ).await
87
+ ).wait_result
88
88
  result[:output]
89
89
  rescue
90
90
  raise if on_error == :raise
@@ -217,7 +217,7 @@ module Phronomy
217
217
  input,
218
218
  config: effective_config,
219
219
  thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
220
- ).await
220
+ ).wait_result
221
221
  end
222
222
 
223
223
  private
@@ -306,7 +306,7 @@ module Phronomy
306
306
  task[:input],
307
307
  config: task_config,
308
308
  thread_id: task[:thread_id] || invocation_context&.thread_id
309
- ).await
309
+ ).wait_result
310
310
  rescue => e
311
311
  errors[i] = e unless on_error == :skip
312
312
  end
@@ -324,7 +324,7 @@ module Phronomy
324
324
  "(#{alive.length} of #{spawned.length} tasks still running)"
325
325
  end
326
326
  else
327
- spawned.each(&:await)
327
+ spawned.each(&:wait_result)
328
328
  end
329
329
 
330
330
  first_error = errors.compact.first
@@ -77,7 +77,7 @@ module Phronomy
77
77
  # Thread that previously wrapped each pool operation.
78
78
  #
79
79
  # Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
80
- # #await, so results are collected uniformly below.
80
+ # #wait_result, so results are collected uniformly below.
81
81
  ct = @cancellation_token
82
82
  max = @max_parallel_tools
83
83
  tool_results = tool_calls.each_slice(max).flat_map do |batch|
@@ -105,7 +105,7 @@ module Phronomy
105
105
 
106
106
  # Await all dispatched operations in original order.
107
107
  dispatched.map do |item|
108
- result = item[:awaitable] ? item[:awaitable].await : item[:result]
108
+ result = item[:awaitable] ? item[:awaitable].wait_result : item[:result]
109
109
  {tool_call: item[:tool_call], result: result}
110
110
  end
111
111
  end
@@ -142,7 +142,7 @@ module Phronomy
142
142
  tool: tool,
143
143
  args: tool_call.arguments,
144
144
  cancellation_token: @cancellation_token
145
- ).await
145
+ ).wait_result
146
146
  end
147
147
  end
148
148
  end
@@ -158,7 +158,7 @@ module Phronomy
158
158
  @wait_thr = nil
159
159
  stderr_thread&.join(1)
160
160
  begin
161
- stderr_op&.await(timeout: 1.0)
161
+ stderr_op&.blocking_wait(timeout: 1.0)
162
162
  rescue
163
163
  nil
164
164
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.11.0"
4
+ VERSION = "0.11.1"
5
5
  end
@@ -170,11 +170,7 @@ module Phronomy
170
170
  # @api private
171
171
  def handle_entry_action_result(machine, result, state_name, timeout_secs)
172
172
  if result.is_a?(Phronomy::Task)
173
- if Phronomy.configuration.event_loop
174
- dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
175
- else
176
- await_task_blocking(machine, result, state_name, timeout_secs)
177
- end
173
+ dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
178
174
  elsif result.is_a?(Phronomy::WorkflowContext)
179
175
  machine.context = result
180
176
  end
@@ -195,19 +191,34 @@ module Phronomy
195
191
  def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
196
192
  machine.async_pending = true
197
193
  thread_id = machine.context.thread_id
198
- Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
199
- enforce_timeout!(result, state_name, timeout_secs)
200
- task_result = result.await
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
+ )
207
+ 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
215
+ end
201
216
  ev = if task_result.is_a?(Phronomy::WorkflowContext)
202
217
  Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
203
218
  else
204
219
  Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
205
220
  end
206
221
  Phronomy::EventLoop.instance.post(ev)
207
- rescue => e
208
- Phronomy::EventLoop.instance.post(
209
- Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
210
- )
211
222
  end
212
223
  end
213
224
 
@@ -223,7 +234,7 @@ module Phronomy
223
234
  # @api private
224
235
  def await_task_blocking(machine, result, state_name, timeout_secs)
225
236
  enforce_timeout!(result, state_name, timeout_secs)
226
- task_result = result.await
237
+ task_result = result.wait_result
227
238
  machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
228
239
  end
229
240
 
@@ -96,6 +96,11 @@ module Phronomy
96
96
 
97
97
  # Invokes this workflow asynchronously and returns a {Phronomy::Task}.
98
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
+ #
99
104
  # @param input [Hash]
100
105
  # @param config [Hash]
101
106
  # @param invocation_context [Phronomy::InvocationContext, nil]
@@ -105,9 +110,7 @@ module Phronomy
105
110
  if invocation_context
106
111
  config = _apply_invocation_context(config, invocation_context)
107
112
  end
108
- Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
109
- invoke(input, config: config)
110
- end
113
+ @runner.invoke_deferred(input, config: config)
111
114
  end
112
115
 
113
116
  # Resumes a halted workflow. Generic resume that works for all halt types.
@@ -160,14 +160,16 @@ module Phronomy
160
160
  private
161
161
 
162
162
  # Asserts that the calling thread is allowed to mutate this context.
163
- # No-op when EventLoop mode is disabled.
164
- # @raise [Phronomy::WorkflowContextOwnershipError] when called from a
165
- # non-EventLoop thread in EventLoop mode.
163
+ # Raises WorkflowContextOwnershipError when called from outside the EventLoop
164
+ # dispatch thread, unless we are inside a synchronous execution context
165
+ # (e.g. Workflow#stream using the run_workflow sync path).
166
+ # @raise [Phronomy::WorkflowContextOwnershipError]
166
167
  # @api private
167
168
  # mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
168
169
  def _assert_write_permitted!
169
- return unless defined?(Phronomy::EventLoop) &&
170
- Phronomy.configuration.event_loop
170
+ return unless defined?(Phronomy::EventLoop)
171
+ # Allow mutations when executing synchronously (e.g. Workflow#stream via run_workflow).
172
+ return if Thread.current[:phronomy_sync_execution]
171
173
  return if Phronomy::EventLoop.current?
172
174
 
173
175
  raise Phronomy::WorkflowContextOwnershipError,
@@ -79,29 +79,50 @@ module Phronomy
79
79
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
80
80
 
81
81
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
82
- thread_id = config[:thread_id] || SecureRandom.uuid
83
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
84
-
85
- store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
86
- snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
87
- initial_fields = if snapshot && snapshot[:fields]
88
- snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
89
- else
90
- input
91
- end
92
-
93
- state = @state_class.new(**initial_fields)
94
- state.set_graph_metadata(thread_id: thread_id)
95
- result = if Phronomy.configuration.event_loop
96
- run_via_event_loop(state, recursion_limit: recursion_limit)
97
- else
98
- run_workflow(state, recursion_limit: recursion_limit)
99
- end
82
+ state, thread_id, recursion_limit, store = _build_initial_context(input, config)
83
+ result = run_via_event_loop(state, recursion_limit: recursion_limit)
100
84
  store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
101
85
  [result, nil]
102
86
  end
103
87
  end
104
88
 
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
+ def invoke_deferred(input, config: {})
102
+ state, thread_id, recursion_limit, store = _build_initial_context(input, config)
103
+ result_task = Phronomy::Task.deferred(name: "workflow-async:#{thread_id}")
104
+ Phronomy::EventLoop.instance.start
105
+ session = build_session_for(context: state, recursion_limit: recursion_limit)
106
+ if store && config[:thread_id]
107
+ # Wrap so that state is persisted when the task resolves.
108
+ persist_task = Phronomy::Task.deferred(name: "workflow-async-persist:#{thread_id}")
109
+ Phronomy::EventLoop.instance.register(session, completion: persist_task)
110
+ persist_task.on_complete do |result, error|
111
+ store.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) unless error
112
+ if error
113
+ result_task.backend.unblock(nil, error)
114
+ result_task.transition!(:failed, error: error)
115
+ else
116
+ result_task.backend.unblock(result, nil)
117
+ result_task.transition!(:completed, value: result)
118
+ end
119
+ end
120
+ else
121
+ Phronomy::EventLoop.instance.register(session, completion: result_task)
122
+ end
123
+ result_task
124
+ end
125
+
105
126
  # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
106
127
  # @param state [Object] halted context
107
128
  # @param input [Hash, nil] optional field updates to merge before resuming
@@ -142,13 +163,9 @@ module Phronomy
142
163
  event
143
164
  end
144
165
 
145
- if Phronomy.configuration.event_loop
146
- run_via_event_loop(state,
147
- recursion_limit: Phronomy.configuration.recursion_limit,
148
- resume_event: ev_to_fire, resume_phase: current_phase)
149
- else
150
- run_workflow(state, resume_event: ev_to_fire, resume_phase: current_phase)
151
- end
166
+ run_via_event_loop(state,
167
+ recursion_limit: Phronomy.configuration.recursion_limit,
168
+ resume_event: ev_to_fire, resume_phase: current_phase)
152
169
  end
153
170
 
154
171
  # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
@@ -167,9 +184,26 @@ module Phronomy
167
184
 
168
185
  private
169
186
 
187
+ # Builds the initial WorkflowContext from input and config.
188
+ # Returns [state, thread_id, recursion_limit, store].
189
+ def _build_initial_context(input, config)
190
+ thread_id = config[:thread_id] || SecureRandom.uuid
191
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
192
+ store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
193
+ snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
194
+ initial_fields = if snapshot && snapshot[:fields]
195
+ snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
196
+ else
197
+ input
198
+ end
199
+ state = @state_class.new(**initial_fields)
200
+ state.set_graph_metadata(thread_id: thread_id)
201
+ [state, thread_id, recursion_limit, store]
202
+ end
203
+
170
204
  # Builds an FSMSession for the given context. Used in EventLoop mode.
171
205
  def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
172
- Phronomy::Workflow::FSMSession.new(
206
+ Phronomy::FSMSession.new(
173
207
  id: context.thread_id,
174
208
  context: context,
175
209
  entry_point: @entry_point,
@@ -190,6 +224,9 @@ module Phronomy
190
224
  # Blocks the calling thread on a completion queue until the workflow
191
225
  # finishes, halts at a wait state, or raises an error.
192
226
  def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
227
+ # Ensure EventLoop is running. In tests, reset_runtime! resets the
228
+ # singleton without restarting it; start is idempotent when already alive.
229
+ Phronomy::EventLoop.instance.start
193
230
  session = build_session_for(
194
231
  context: context, recursion_limit: recursion_limit,
195
232
  resume_event: resume_event, resume_phase: resume_phase
@@ -201,6 +238,19 @@ module Phronomy
201
238
  end
202
239
 
203
240
  def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
241
+ # Mark the current thread as a synchronous execution context.
242
+ # This allows WorkflowContext field mutations via setters without raising
243
+ # WorkflowContextOwnershipError (which would otherwise fire since EventLoop
244
+ # is always active and run_workflow runs on the caller's thread).
245
+ Thread.current[:phronomy_sync_execution] = Thread.current[:phronomy_sync_execution].to_i + 1
246
+ _run_workflow_body(ctx, resume_event: resume_event, resume_phase: resume_phase,
247
+ recursion_limit: recursion_limit, &event_block)
248
+ ensure
249
+ depth = Thread.current[:phronomy_sync_execution].to_i - 1
250
+ Thread.current[:phronomy_sync_execution] = (depth > 0) ? depth : nil
251
+ end
252
+
253
+ def _run_workflow_body(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
204
254
  if resume_event
205
255
  # -- Resume from a wait state -------------------------------------------
206
256
  # Fire the external event on a tracker positioned at the wait state.
@@ -231,7 +281,7 @@ module Phronomy
231
281
  "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
232
282
  end
233
283
  end
234
- task_result = result.await
284
+ task_result = result.wait_result
235
285
  ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
236
286
  elsif result.is_a?(Phronomy::WorkflowContext)
237
287
  ctx = result
data/lib/phronomy.rb CHANGED
@@ -11,11 +11,18 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
11
11
  # RAG: Zeitwerk would infer "Rag" — override to "RAG".
12
12
  loader.inflector.inflect("rag" => "RAG")
13
13
  # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
14
+ # Phronomy::FSMSession is the top-level cooperative execution engine shared by
15
+ # WorkflowRunner and Agent::InvocationSession.
14
16
  loader.inflector.inflect("fsm_session" => "FSMSession")
15
17
  # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
16
18
  loader.inflector.inflect("llm_adapter" => "LLMAdapter")
17
19
  # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
18
20
  loader.inflector.inflect("ruby_llm" => "RubyLLM")
21
+ # Collapse engine/ so that its contents autoload directly under Phronomy::
22
+ # (no Engine:: prefix). e.g. engine/event_loop.rb => Phronomy::EventLoop.
23
+ # This allows the execution engine to be organised in its own subdirectory
24
+ # without changing any class names or callers.
25
+ loader.collapse("#{__dir__}/phronomy/engine")
19
26
  loader.setup
20
27
 
21
28
  require_relative "phronomy/version"
@@ -99,14 +106,6 @@ module Phronomy
99
106
  end
100
107
  end
101
108
 
102
- # Raised when {Agent::Base#resume} (or the class-level equivalent) is called
103
- # with a {Agent::Checkpoint} whose +checkpoint_id+ has already been consumed
104
- # by a previous +resume+ call on the same store.
105
- #
106
- # This protects against duplicate resume executions caused by webhook retries
107
- # or queue message redelivery.
108
- class CheckpointAlreadyResumedError < Error; end
109
-
110
109
  # Raised when an operation is submitted to a {BlockingAdapterPool} that has
111
110
  # already been shut down via {BlockingAdapterPool#shutdown}.
112
111
  class PoolShutdownError < Error; end
@@ -187,8 +186,17 @@ module Phronomy
187
186
  # config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
188
187
  # @api public
189
188
  def reset_runtime!
190
- Phronomy::EventLoop.reset!
189
+ # Do NOT stop the EventLoop here. Since Phase 2, all Agent#invoke calls
190
+ # go through FSMSession + EventLoop. Stopping and restarting the EventLoop
191
+ # on every after(:each) hook would add thread creation overhead per test.
192
+ # run_via_event_loop and _invoke_via_fsm restart it lazily if it was reset.
193
+ #
194
+ # Preserve event_loop_stop_grace_seconds from the current configuration
195
+ # so that test suites that set it to 0 (for fast teardown) keep that value
196
+ # across configuration resets.
197
+ prev_grace = @configuration&.event_loop_stop_grace_seconds
191
198
  @configuration = Configuration.new
199
+ @configuration.event_loop_stop_grace_seconds = prev_grace if prev_grace
192
200
  end
193
201
  end
194
202
  end