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.
- checksums.yaml +4 -4
- data/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +192 -78
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/retryable.rb +6 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +337 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/generator_verifier.rb +20 -11
- data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +17 -9
- metadata +35 -35
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /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
|
|
@@ -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
|
|
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
|
-
|
|
79
|
+
wait_result
|
|
68
80
|
else
|
|
69
81
|
begin
|
|
70
|
-
Timeout.timeout(limit) {
|
|
82
|
+
Timeout.timeout(limit) { wait_result }
|
|
71
83
|
rescue Timeout::Error
|
|
72
84
|
nil
|
|
73
85
|
end
|
|
@@ -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.
|
|
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
|
|
165
|
-
@backend.
|
|
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.
|
|
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 {
|
|
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) }.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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(&:
|
|
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
|
-
# #
|
|
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].
|
|
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
|
-
).
|
|
145
|
+
).wait_result
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
end
|
data/lib/phronomy/tools/mcp.rb
CHANGED
data/lib/phronomy/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
237
|
+
task_result = result.wait_result
|
|
227
238
|
machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
228
239
|
end
|
|
229
240
|
|
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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::
|
|
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.
|
|
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
|
-
|
|
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
|