phronomy 0.11.0 → 0.12.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.
- checksums.yaml +4 -4
- data/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +197 -83
- 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 +346 -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 +6 -6
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +17 -10
- 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
|
|
@@ -252,9 +252,9 @@ module Phronomy
|
|
|
252
252
|
effective_name = prepared.new.name
|
|
253
253
|
Class.new(prepared) do
|
|
254
254
|
tool_name effective_name
|
|
255
|
-
define_method(:call) do |args|
|
|
255
|
+
define_method(:call) do |args, **kwargs|
|
|
256
256
|
self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
|
|
257
|
-
super(args)
|
|
257
|
+
super(args, **kwargs)
|
|
258
258
|
end
|
|
259
259
|
end
|
|
260
260
|
end
|
|
@@ -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
|
@@ -35,39 +35,43 @@ module Phronomy
|
|
|
35
35
|
# - "stdio://<command>" — spawn a child process
|
|
36
36
|
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
37
37
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
38
|
+
# @param headers [Hash] additional HTTP request headers forwarded to every
|
|
39
|
+
# request (tool discovery and tool execution). Ignored for stdio transports.
|
|
40
|
+
# Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
|
|
38
41
|
# @return [Mcp] a configured subclass instance ready for use with an Agent
|
|
39
42
|
# @api public
|
|
40
|
-
def from_server(server_uri, tool_name:)
|
|
43
|
+
def from_server(server_uri, tool_name:, headers: {})
|
|
41
44
|
# Use a short-lived transport only to query the tool definition,
|
|
42
45
|
# then close it. Each Mcp instance creates its own transport
|
|
43
46
|
# so that concurrent callers never share IO streams.
|
|
44
|
-
transport = build_transport(server_uri)
|
|
47
|
+
transport = build_transport(server_uri, headers: headers)
|
|
45
48
|
begin
|
|
46
49
|
tool_def = transport.fetch_tool(tool_name)
|
|
47
50
|
ensure
|
|
48
51
|
transport.close
|
|
49
52
|
end
|
|
50
|
-
build_tool_class(tool_name, server_uri, tool_def).new
|
|
53
|
+
build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
private
|
|
54
57
|
|
|
55
|
-
def build_transport(uri)
|
|
58
|
+
def build_transport(uri, headers: {})
|
|
56
59
|
scheme, path = uri.split("://", 2)
|
|
57
60
|
case scheme
|
|
58
61
|
when "stdio"
|
|
59
62
|
StdioTransport.new(path)
|
|
60
63
|
when "http", "https"
|
|
61
|
-
HttpTransport.new(uri)
|
|
64
|
+
HttpTransport.new(uri, headers: headers)
|
|
62
65
|
else
|
|
63
66
|
raise ArgumentError, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
def build_tool_class(tool_name, server_uri, tool_def)
|
|
70
|
+
def build_tool_class(tool_name, server_uri, tool_def, headers: {})
|
|
68
71
|
klass = Class.new(Mcp)
|
|
69
72
|
klass.tool_name(tool_name)
|
|
70
73
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
74
|
+
klass.instance_variable_set(:@mcp_headers, headers)
|
|
71
75
|
|
|
72
76
|
# Register description and params from the MCP tool definition.
|
|
73
77
|
klass.description(tool_def[:description] || tool_name)
|
|
@@ -82,7 +86,8 @@ module Phronomy
|
|
|
82
86
|
# never share IO streams, eliminating the need for synchronisation.
|
|
83
87
|
klass.define_method(:initialize) do
|
|
84
88
|
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
85
|
-
|
|
89
|
+
hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
|
|
90
|
+
@mcp_transport = self.class.send(:build_transport, uri, headers: hdrs)
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
klass.define_method(:execute) do |**args|
|
|
@@ -158,7 +163,7 @@ module Phronomy
|
|
|
158
163
|
@wait_thr = nil
|
|
159
164
|
stderr_thread&.join(1)
|
|
160
165
|
begin
|
|
161
|
-
stderr_op&.
|
|
166
|
+
stderr_op&.blocking_wait(timeout: 1.0)
|
|
162
167
|
rescue
|
|
163
168
|
nil
|
|
164
169
|
end
|
|
@@ -249,8 +254,10 @@ module Phronomy
|
|
|
249
254
|
raise Phronomy::ToolError,
|
|
250
255
|
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
251
256
|
end
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
# Do NOT call @stdout.gets here: gets() blocks until a newline arrives,
|
|
258
|
+
# which hangs indefinitely when the server emits no startup line or a
|
|
259
|
+
# partial line without '\n'. IO.select already confirmed the server is
|
|
260
|
+
# alive and responsive; the first rpc_call will consume actual output.
|
|
254
261
|
end
|
|
255
262
|
end
|
|
256
263
|
|
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,
|