phronomy 0.10.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/CHANGELOG.md +41 -1
- data/README.md +35 -24
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +223 -95
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/filterable.rb +184 -0
- data/lib/phronomy/agent/concerns/retryable.rb +9 -5
- data/lib/phronomy/agent/context/capability/base.rb +2 -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/filter/base.rb +56 -0
- data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
- data/lib/phronomy/filter.rb +5 -0
- data/lib/phronomy/generator_verifier.rb +21 -13
- data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
- 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 +24 -13
- data/scripts/api_snapshot.rb +2 -2
- metadata +42 -44
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
- 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/guardrail/base.rb +0 -45
- data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
- data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
- data/lib/phronomy/guardrail.rb +0 -7
- 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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Filter
|
|
5
|
+
# Abstract base class for value filters.
|
|
6
|
+
#
|
|
7
|
+
# A filter may either transform a value (return the new value) or block it
|
|
8
|
+
# (raise {Phronomy::FilterBlockError}). The same filter instance can be
|
|
9
|
+
# registered at multiple call sites — input, output, and tool result.
|
|
10
|
+
#
|
|
11
|
+
# @example PII masking filter
|
|
12
|
+
# class PiiMaskFilter < Phronomy::Filter::Base
|
|
13
|
+
# def call(value, **_context)
|
|
14
|
+
# value.to_s
|
|
15
|
+
# .gsub(/\b\d{2,4}-\d{2,4}-\d{4}\b/, "[PHONE]")
|
|
16
|
+
# .gsub(/\b(?:\d{4}[- ]?){3}\d{4}\b/, "[CARD]")
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Blocking filter
|
|
21
|
+
# class NoBadWordFilter < Phronomy::Filter::Base
|
|
22
|
+
# def call(value, **_context)
|
|
23
|
+
# block!("Forbidden content detected") if value.to_s.include?("badword")
|
|
24
|
+
# value
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @api public
|
|
29
|
+
class Base
|
|
30
|
+
# Process +value+ and return the (possibly transformed) result.
|
|
31
|
+
#
|
|
32
|
+
# The +context+ keyword arguments vary by call site:
|
|
33
|
+
# - Tool result: +{ tool_name: String, args: Hash }+
|
|
34
|
+
# - Input / output: +(empty)+
|
|
35
|
+
#
|
|
36
|
+
# @param value [Object] the value being filtered
|
|
37
|
+
# @param context [Hash] optional call-site metadata
|
|
38
|
+
# @return [Object] the transformed value (or the original if unchanged)
|
|
39
|
+
# @raise [Phronomy::FilterBlockError] to reject the value
|
|
40
|
+
# @api public
|
|
41
|
+
def call(value, **_context)
|
|
42
|
+
raise NotImplementedError, "#{self.class}#call is not implemented"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
# Reject the value with a human-readable reason.
|
|
48
|
+
# @param reason [String]
|
|
49
|
+
# @raise [Phronomy::FilterBlockError]
|
|
50
|
+
# @api public
|
|
51
|
+
def block!(reason)
|
|
52
|
+
raise Phronomy::FilterBlockError.new(reason, filter: self)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb}
RENAMED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Filter
|
|
5
5
|
# Detects potential prompt injection attempts in the agent input.
|
|
6
6
|
#
|
|
7
7
|
# Prompt injection is an attack where an adversary embeds LLM instructions
|
|
8
8
|
# inside data sources (e.g. RAG chunks, tool results, user input) to override
|
|
9
9
|
# the agent's intended behaviour.
|
|
10
10
|
#
|
|
11
|
-
# This
|
|
12
|
-
# calls {#
|
|
13
|
-
# an input
|
|
11
|
+
# This filter scans the input string for common injection patterns and
|
|
12
|
+
# calls {#block!} when a match is found. It is intended to be registered as
|
|
13
|
+
# an input filter on agents that consume untrusted external content.
|
|
14
14
|
#
|
|
15
15
|
# @example
|
|
16
16
|
# class MyAgent < Phronomy::Agent::Base
|
|
17
17
|
# model "gpt-4o"
|
|
18
|
-
#
|
|
18
|
+
# input_filter Phronomy::Filter::PromptInjectionFilter
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
21
|
# @example Custom patterns
|
|
22
|
-
#
|
|
22
|
+
# filter = Phronomy::Filter::PromptInjectionFilter.new(
|
|
23
23
|
# extra_patterns: [/exfiltrate/i]
|
|
24
24
|
# )
|
|
25
|
-
|
|
25
|
+
# agent.add_input_filter(filter)
|
|
26
|
+
#
|
|
27
|
+
# @api public
|
|
28
|
+
class PromptInjectionFilter < Base
|
|
26
29
|
# Common prompt injection / jailbreak patterns.
|
|
27
30
|
DEFAULT_PATTERNS = [
|
|
28
31
|
/ignore\s+(previous|prior|all)\s+instructions?/i,
|
|
@@ -38,20 +41,24 @@ module Phronomy
|
|
|
38
41
|
].freeze
|
|
39
42
|
|
|
40
43
|
# @param extra_patterns [Array<Regexp>] additional patterns to scan for
|
|
41
|
-
# @api
|
|
44
|
+
# @api public
|
|
42
45
|
def initialize(extra_patterns: [])
|
|
43
46
|
super()
|
|
44
47
|
@patterns = DEFAULT_PATTERNS + extra_patterns
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
# Scans the input string for injection patterns.
|
|
48
|
-
# @param
|
|
49
|
-
# @
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
# @param value [String, Hash]
|
|
52
|
+
# @param context [Hash]
|
|
53
|
+
# @return [String, Hash] the original value when no injection is detected
|
|
54
|
+
# @raise [Phronomy::FilterBlockError] when a pattern matches
|
|
55
|
+
# @api public
|
|
56
|
+
def call(value, **_context)
|
|
57
|
+
text = value.is_a?(Hash) ? value.values.join(" ") : value.to_s
|
|
52
58
|
@patterns.each do |pattern|
|
|
53
|
-
|
|
59
|
+
block!("Potential prompt injection detected") if text.match?(pattern)
|
|
54
60
|
end
|
|
61
|
+
value
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
end
|
|
@@ -66,8 +66,7 @@ module Phronomy
|
|
|
66
66
|
# @!attribute [r] trusted
|
|
67
67
|
# @return [Boolean] true when confidence >= threshold
|
|
68
68
|
Result = Struct.new(
|
|
69
|
-
:output, :confidence, :citations, :iterations, :review_notes, :trusted
|
|
70
|
-
keyword_init: true
|
|
69
|
+
:output, :confidence, :citations, :iterations, :review_notes, :trusted
|
|
71
70
|
) do
|
|
72
71
|
# @return [Boolean] true when confidence >= threshold
|
|
73
72
|
alias_method :trusted?, :trusted
|
|
@@ -193,21 +192,30 @@ module Phronomy
|
|
|
193
192
|
entry :draft, ->(state) {
|
|
194
193
|
feedback = state.review_notes.last
|
|
195
194
|
prompt = dpb.call(state.input, feedback)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
entry :review, ->(state) {
|
|
205
209
|
prompt = rpb.call(state.input, state.draft, state.citations)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
219
|
}
|
|
212
220
|
|
|
213
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
|
|
@@ -47,8 +47,7 @@ module Phronomy
|
|
|
47
47
|
:index, # Integer — 0-based worker index
|
|
48
48
|
:agent, # Agent::Base instance
|
|
49
49
|
:messages, # Array — accumulated conversation history
|
|
50
|
-
:status
|
|
51
|
-
keyword_init: true
|
|
50
|
+
:status # Symbol — :idle | :available | :done
|
|
52
51
|
) do
|
|
53
52
|
# Returns true when this worker is ready to accept the next task.
|
|
54
53
|
def available? = [:idle, :available].include?(status)
|
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,
|