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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +35 -24
  4. data/benchmark/bench_agent_invoke.rb +1 -0
  5. data/lib/phronomy/agent/base.rb +223 -95
  6. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  7. data/lib/phronomy/agent/concerns/filterable.rb +184 -0
  8. data/lib/phronomy/agent/concerns/retryable.rb +9 -5
  9. data/lib/phronomy/agent/context/capability/base.rb +2 -2
  10. data/lib/phronomy/agent/invocation_context.rb +171 -0
  11. data/lib/phronomy/agent/invocation_session.rb +337 -0
  12. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  13. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  14. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  15. data/lib/phronomy/configuration.rb +0 -6
  16. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  17. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  18. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  19. data/lib/phronomy/engine/fsm_session.rb +248 -0
  20. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  21. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  22. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  23. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  24. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  25. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  26. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  27. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  28. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  29. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  30. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  31. data/lib/phronomy/filter/base.rb +56 -0
  32. data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
  33. data/lib/phronomy/filter.rb +5 -0
  34. data/lib/phronomy/generator_verifier.rb +21 -13
  35. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  37. data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
  38. data/lib/phronomy/tools/mcp.rb +1 -1
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  41. data/lib/phronomy/workflow.rb +6 -3
  42. data/lib/phronomy/workflow_context.rb +7 -5
  43. data/lib/phronomy/workflow_runner.rb +77 -27
  44. data/lib/phronomy.rb +24 -13
  45. data/scripts/api_snapshot.rb +2 -2
  46. metadata +42 -44
  47. data/lib/phronomy/agent/checkpoint.rb +0 -208
  48. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  49. data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
  50. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  51. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  52. data/lib/phronomy/context.rb +0 -12
  53. data/lib/phronomy/guardrail/base.rb +0 -45
  54. data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
  55. data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
  56. data/lib/phronomy/guardrail.rb +0 -7
  57. data/lib/phronomy/tool.rb +0 -9
  58. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  59. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  60. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  61. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  62. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  63. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  64. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  65. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  66. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  67. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  68. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  69. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  70. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  71. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  72. /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
@@ -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
@@ -1,28 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Guardrail
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 guardrail scans the input string for common injection patterns and
12
- # calls {#fail!} when a match is found. It is intended to be registered as
13
- # an input guardrail on agents that consume untrusted external content.
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
- # input_guardrails Phronomy::Guardrail::PromptInjectionGuardrail.new
18
+ # input_filter Phronomy::Filter::PromptInjectionFilter
19
19
  # end
20
20
  #
21
21
  # @example Custom patterns
22
- # guard = Phronomy::Guardrail::PromptInjectionGuardrail.new(
22
+ # filter = Phronomy::Filter::PromptInjectionFilter.new(
23
23
  # extra_patterns: [/exfiltrate/i]
24
24
  # )
25
- class PromptInjectionGuardrail < InputGuardrail
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 private
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 input [String, Hash]
49
- # @api private
50
- def check(input)
51
- text = input.is_a?(Hash) ? input.values.join(" ") : input.to_s
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
- fail!("Potential prompt injection detected") if text.match?(pattern)
59
+ block!("Potential prompt injection detected") if text.match?(pattern)
54
60
  end
61
+ value
55
62
  end
56
63
  end
57
64
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience require for Filter sub-classes.
4
+ require_relative "filter/base"
5
+ require_relative "filter/prompt_injection_filter"
@@ -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
- result = draft_agent.invoke(prompt)
197
- parsed = drp.call(result[:output])
198
- state.draft = parsed[:answer].to_s
199
- state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
200
- state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
201
- 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
202
206
  }
203
207
 
204
208
  entry :review, ->(state) {
205
209
  prompt = rpb.call(state.input, state.draft, state.citations)
206
- result = review_agent.invoke(prompt)
207
- parsed = rrp.call(result[:output])
208
- state.review_score = pipeline.__send__(:clamp, parsed[:score])
209
- state.approved = parsed[:approved] == true
210
- 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
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
- ).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
@@ -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, # Symbol — :idle | :available | :done
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)
@@ -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.10.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,