phronomy 0.16.0 → 0.18.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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -1,335 +1,194 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "task/backend"
4
- require_relative "task/thread_backend"
5
- require_relative "task/immediate_backend"
6
- require_relative "task/fiber_backend"
7
- require_relative "task/mapped_backend"
8
- require_relative "task/deferred_backend"
9
-
10
3
  module Phronomy
11
- # A single unit of concurrent work.
12
- #
13
- # Decouples task semantics from the underlying execution primitive via a
14
- # pluggable {Backend}. The default backend is {ThreadBackend}; a cooperative
15
- # or test-double backend can be substituted via {.default_backend_class=} or
16
- # by passing +backend_class:+ to {.spawn}.
17
- #
18
- # +Task.spawn+ is an **internal API** used by schedulers and the framework
19
- # itself. Application code and framework components should use
20
- # +Runtime.instance.spawn+ instead, which routes through the configured
21
- # scheduler and respects the concurrency model.
4
+ # A thread-free asynchronous completion handle.
22
5
  #
23
- # @example Basic usage (framework/test code only prefer Runtime.instance.spawn in app code)
24
- # task = Phronomy::Task.spawn { expensive_io() }
25
- # result = task.wait_result # blocks until done, re-raises errors
26
- #
27
- # @example Cancel a running task
28
- # task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
29
- # task.cancel!
30
- #
31
- # @example Task tree — cancel parent cancels children
32
- # parent = Phronomy::Task.spawn { sleep 10 }
33
- # child = Phronomy::Task.spawn(parent: parent) { sleep 10 }
34
- # parent.cancel! # child is also cancelled
6
+ # Task no longer executes work. Execution belongs to EventLoop/FSMSession or
7
+ # OffloadPool. Task only represents completion, failure, cancellation,
8
+ # callbacks and a blocking wait for external callers.
35
9
  class Task
36
- # Valid task lifecycle states.
37
- STATES = %i[pending running completed failed cancelled].freeze
38
-
39
- # Returns the process-wide default backend class.
40
- # Defaults to {ThreadBackend}.
41
- # Override in tests or to enable a cooperative scheduler backend.
42
- # @return [Class<Backend>]
43
- # @api private
44
- def self.default_backend_class
45
- @default_backend_class || ThreadBackend
46
- end
47
-
48
- # Sets the process-wide default backend class.
49
- # @param klass [Class<Backend>]
50
- # @api private
51
- def self.default_backend_class=(klass)
52
- @default_backend_class = klass
53
- end
54
-
55
- # Returns the {Task} currently executing on this thread, or +nil+.
56
- # Returns +nil+ when called from outside a task-managed execution context.
57
- # @return [Task, nil]
58
- # @api private
59
- def self.current
60
- Thread.current[:phronomy_current_task]
61
- end
62
-
63
- # Returns the monotonic clock value (ms) when the current task last recorded
64
- # a yield (or when the task started), or +nil+ when not inside a task context.
65
- # Used by {Runtime#yield} for CPU-bound detection without placing
66
- # +Thread.current+ in files outside the allowlist.
67
- # @return [Integer, nil]
68
- # @api private
69
- def self.current_cpu_slice_start_ms
70
- Thread.current[:phronomy_task_cpu_slice_start_ms]
71
- end
72
-
73
- # Resets the CPU slice start clock for the current task to +now+.
74
- # Call this immediately after the cooperative yield has been performed so
75
- # that the next yield correctly measures only the time since the last yield.
76
- # @api private
77
- def self.record_yield!
78
- Thread.current[:phronomy_task_cpu_slice_start_ms] =
79
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
80
- end
81
-
82
- # Returns and increments a per-thread yield-if-needed counter.
83
- # Used by {Runtime#yield_if_needed} so that the counter is thread-local
84
- # without putting +Thread.current+ in runtime.rb (which is outside the
85
- # Thread.current allowlist).
86
- # @return [Integer] the new counter value
87
- # @api private
88
- def self.increment_yield_counter!
89
- count = (Thread.current[:phronomy_yield_if_needed_counter] || 0) + 1
90
- Thread.current[:phronomy_yield_if_needed_counter] = count
91
- count
92
- end
93
-
94
- # Cooperative cancellation checkpoint.
95
- #
96
- # Raises {CancellationError} if the current task's status is +:cancelled+.
97
- # On {ThreadBackend}, cancellation is delivered via +Thread#raise+ so this
98
- # is a no-op in practice; on future cooperative backends this will be the
99
- # primary cancellation mechanism.
100
- #
101
- # Safe to call from outside a task context (no-op when no current task).
102
- # @return [void]
103
- # @raise [CancellationError] if the current task has been cancelled
104
- # @api private
105
- def self.checkpoint!
106
- ct = current
107
- return unless ct
108
-
109
- raise CancellationError, "Task cancelled" if ct.status == :cancelled
110
- end
10
+ STATES = %i[pending completed failed cancelled].freeze
11
+ TERMINAL_STATES = %i[completed failed cancelled].freeze
12
+ private_constant :TERMINAL_STATES
111
13
 
112
- # Spawns a new task executing +block+ concurrently.
113
- #
114
- # @param name [String, nil] optional human-readable label
115
- # @param parent [Task, nil] parent task; cancelling the parent
116
- # also cancels this task (default: currently running task)
117
- # @param backend_class [Class<Backend>] backend to use
118
- # @yieldreturn [Object] the task result
119
- # @return [Task]
120
- # @api private
121
- def self.spawn(name: nil, parent: current, backend_class: default_backend_class, &block)
122
- new(name: name, parent: parent, backend_class: backend_class, &block)
123
- end
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) {}
14
+ def self.deferred(name: nil, parent: nil)
15
+ new(name: name, parent: parent)
132
16
  end
133
17
 
134
- # @return [String, nil] optional human-readable label
135
- attr_reader :name
18
+ attr_reader :name, :parent
136
19
 
137
- # @return [Task, nil] parent task in the task tree, if any
138
- attr_reader :parent
139
-
140
- # @return [Backend] the execution backend for this task
141
- # @api private
142
- attr_reader :backend
143
-
144
- # @param name [String, nil]
145
- # @param parent [Task, nil]
146
- # @param backend_class [Class<Backend>]
147
- # @api private use {.spawn} instead
148
- def initialize(name: nil, parent: nil, backend_class: self.class.default_backend_class, &block)
20
+ def initialize(name: nil, parent: nil)
149
21
  @name = name
150
22
  @parent = parent
151
23
  @status = :pending
24
+ @value = nil
25
+ @error = nil
152
26
  @mutex = Mutex.new
27
+ @cond = ConditionVariable.new
153
28
  @children = []
154
29
  @on_complete_callbacks = []
155
- @completed_value = nil
156
- @completed_error = nil
157
30
  parent&.register_child(self)
158
- @backend = backend_class.new(task: self, &block)
159
31
  end
160
32
 
161
- # Returns the current lifecycle state.
162
- # @return [Symbol] one of {STATES}
163
- # @api private
164
33
  def status
165
34
  @mutex.synchronize { @status }
166
35
  end
167
36
 
168
- # Blocks until the task completes and returns its value.
169
- # Re-raises any exception raised inside the block.
170
- #
171
- # @return [Object] the result produced by the block
172
- # @raise [Exception] if the block raised an error
173
- # @api private
174
- def wait_result
175
- @backend.wait_result
37
+ def done?
38
+ @mutex.synchronize { TERMINAL_STATES.include?(@status) }
176
39
  end
177
40
 
178
- # Registers a callback to be invoked when the task reaches a terminal state
179
- # (+:completed+, +:failed+, or +:cancelled+).
180
- #
181
- # The callback receives two arguments: +value+ (the task's return value,
182
- # or +nil+) and +error+ (the exception, or +nil+). These are provided
183
- # directly so the callback does not need to call +task.wait_result+, which would
184
- # risk a self-join error when the callback runs inside the task's own thread.
185
- #
186
- # If the task is already done when this method is called, the callback is
187
- # invoked immediately (synchronously, on the calling thread).
41
+ def alive?
42
+ !done?
43
+ end
44
+
45
+ # Blocks the calling thread until settlement. EventLoop is never allowed to
46
+ # wait for a Task; it must continue through explicit events instead.
47
+ def wait_result(timeout: nil)
48
+ if Phronomy::Runtime.in_event_loop_context? && !done?
49
+ raise Phronomy::EventLoopReentrancyError,
50
+ "Task#wait_result cannot block the EventLoop thread; continue via an event"
51
+ end
52
+
53
+ deadline = timeout && monotonic_now + timeout.to_f
54
+ value, error = @mutex.synchronize do
55
+ until TERMINAL_STATES.include?(@status)
56
+ if deadline
57
+ remaining = deadline - monotonic_now
58
+ if remaining <= 0
59
+ raise Phronomy::TimeoutError,
60
+ "timed out waiting for Task #{@name || "(unnamed)"}"
61
+ end
62
+ @cond.wait(@mutex, remaining)
63
+ else
64
+ @cond.wait(@mutex)
65
+ end
66
+ end
67
+ [@value, @error]
68
+ end
69
+
70
+ raise error if error
71
+ value
72
+ end
73
+
74
+ # Compatibility wait that does not re-raise the task error.
75
+ # Returns self when settled, nil on timeout.
76
+ def join(limit = nil)
77
+ if Phronomy::Runtime.in_event_loop_context? && !done?
78
+ raise Phronomy::EventLoopReentrancyError,
79
+ "Task#join cannot block the EventLoop thread; continue via an event"
80
+ end
81
+
82
+ deadline = limit && monotonic_now + limit.to_f
83
+ @mutex.synchronize do
84
+ until TERMINAL_STATES.include?(@status)
85
+ if deadline
86
+ remaining = deadline - monotonic_now
87
+ return nil if remaining <= 0
88
+ @cond.wait(@mutex, remaining)
89
+ else
90
+ @cond.wait(@mutex)
91
+ end
92
+ end
93
+ end
94
+ self
95
+ end
96
+
97
+ # Registers an independent completion notification.
188
98
  #
189
- # @yield [value, error] called when the task finishes
190
- # @return [self]
191
- # @api private
99
+ # A callback failure is logged and does not suppress delivery to other
100
+ # completion callbacks or change the Task's already-settled result.
192
101
  def on_complete(&callback)
193
- fire_now = false
102
+ raise ArgumentError, "on_complete requires a block" unless callback
103
+
194
104
  fire_args = nil
195
105
  @mutex.synchronize do
196
- # Check @status directly to avoid re-entering the mutex (done? calls
197
- # status, which also takes @mutex).
198
- if %i[completed failed cancelled].include?(@status)
199
- fire_now = true
200
- fire_args = [@completed_value, @completed_error]
106
+ if TERMINAL_STATES.include?(@status)
107
+ fire_args = [@value, @error]
201
108
  else
202
109
  @on_complete_callbacks << callback
203
110
  end
204
111
  end
205
- callback.call(*fire_args) if fire_now
112
+ deliver_completion_callback(callback, *fire_args) if fire_args
113
+ self
114
+ end
115
+
116
+ def complete(value = nil)
117
+ settle!(:completed, value: value)
118
+ end
119
+
120
+ def fail(error)
121
+ raise ArgumentError, "error is required" unless error
122
+ settle!(:failed, error: error)
123
+ end
124
+
125
+ def cancel!(error = Phronomy::CancellationError.new("Task cancelled"))
126
+ changed = settle!(:cancelled, error: error)
127
+ if changed
128
+ children = @mutex.synchronize { @children.dup }
129
+ children.each(&:cancel!)
130
+ end
206
131
  self
207
132
  end
208
133
 
209
- # Returns a new {Task} whose completed value is the result of applying
210
- # +block+ to this task's completed value.
211
- #
212
- # If this task fails or is cancelled, the mapped task also fails/is
213
- # cancelled with the same error. The block is never called in error cases.
214
- #
215
- # The transformation runs from the source Task's completion callback.
216
- # It is a generic value-composition API; Workflow entry actions do not await
217
- # either the source Task or the mapped Task.
218
- #
219
- # @example Transforming an Agent result outside a Workflow entry action
220
- # output_task = agent.invoke_async("hello").map { |result| result[:output] }
221
- #
222
- # @yield [value] the completed value of this task
223
- # @yieldreturn [Object] the value for the mapped task
224
- # @return [Task] a new task that completes when this task does
225
- # @api public
226
134
  def map(&block)
227
- # MappedBackend drives the task lifecycle entirely via on_complete;
228
- # it never spawns a thread of its own.
229
- mapped = self.class.spawn(
230
- name: "#{@name}-mapped",
231
- parent: @parent,
232
- backend_class: MappedBackend
233
- ) {}
135
+ raise ArgumentError, "map requires a block" unless block
234
136
 
137
+ mapped = self.class.deferred(name: "#{@name}-mapped", parent: @parent)
235
138
  on_complete do |value, error|
236
- mapped_value = nil
237
- mapped_error = error
238
- unless error
239
- begin
240
- mapped_value = block.call(value)
241
- rescue => e
242
- mapped_error = e
243
- end
139
+ if error
140
+ mapped.fail(error)
141
+ next
244
142
  end
245
- # Unblock mapped.wait_result / mapped.join before the terminal transition.
246
- # on_complete callbacks may resume a waiting Fiber immediately; the
247
- # queue must already contain the result at that point.
248
- mapped.backend.unblock(mapped_value, mapped_error)
249
- if mapped_error
250
- mapped.transition!(:failed, error: mapped_error)
251
- else
252
- mapped.transition!(:completed, value: mapped_value)
143
+
144
+ begin
145
+ mapped.complete(block.call(value))
146
+ rescue => mapped_error
147
+ mapped.fail(mapped_error)
253
148
  end
254
149
  end
255
150
  mapped
256
151
  end
257
152
 
258
- # Returns +true+ once the task has finished (success, error, or cancellation).
259
- # @return [Boolean]
260
- # @api private
261
- def done?
262
- %i[completed failed cancelled].include?(status)
263
- end
153
+ protected
264
154
 
265
- # Requests cancellation. Propagates to all registered child tasks.
266
- # Sets status to :cancelled immediately so that even tasks that have not
267
- # started executing yet are correctly marked as cancelled after join.
268
- # Passes a CancellationError to on_complete callbacks so callers do not
269
- # need to call await to discover the error.
270
- # @return [self]
271
- # @api private
272
- def cancel!
273
- transition!(:cancelled, error: CancellationError.new("Task cancelled"))
274
- # @backend may be nil if cancel! is called while ImmediateBackend is still
275
- # initializing (the block runs synchronously inside .new, so register_child
276
- # fires before @backend is assigned). Safe-navigate to avoid NoMethodError.
277
- @backend&.cancel!
278
- children = @mutex.synchronize { @children.dup }
279
- children.each(&:cancel!)
280
- self
155
+ def register_child(child)
156
+ @mutex.synchronize { @children << child }
281
157
  end
282
158
 
283
- # Joins the underlying execution context, optionally with a timeout.
284
- # Returns +nil+ when the timeout expires before completion.
285
- #
286
- # @param limit [Numeric, nil] seconds to wait; nil waits indefinitely
287
- # @return [Object, nil]
288
- # @api private
289
- def join(limit = nil)
290
- @backend.join(limit)
291
- end
159
+ private
292
160
 
293
- # Returns +true+ while the task's block is still executing.
294
- # @return [Boolean]
295
- # @api private
296
- def alive?
297
- @backend.alive?
298
- end
299
-
300
- # Updates the task lifecycle state.
301
- # Called by backends during execution transitions.
302
- # Terminal states (completed/failed/cancelled) are never overwritten.
303
- # When a terminal state is reached, fires on_complete callbacks (outside
304
- # the mutex) passing the result value and error directly.
305
- #
306
- # @param new_status [Symbol]
307
- # @param value [Object, nil] task return value (terminal states only)
308
- # @param error [Exception, nil] exception raised by the block, if any
309
- # @api private
310
- def transition!(new_status, value: nil, error: nil)
161
+ def settle!(new_status, value: nil, error: nil)
311
162
  callbacks = nil
312
- @mutex.synchronize do
313
- # Check @status directly (not via #done?) to avoid re-entering the mutex.
314
- return if %i[completed failed cancelled].include?(@status)
163
+ changed = @mutex.synchronize do
164
+ next false if TERMINAL_STATES.include?(@status)
315
165
 
316
166
  @status = new_status
317
- if %i[completed failed cancelled].include?(new_status)
318
- @completed_value = value
319
- @completed_error = error
320
- callbacks = @on_complete_callbacks.dup
321
- @on_complete_callbacks.clear
167
+ @value = value
168
+ @error = error
169
+ callbacks = @on_complete_callbacks.dup
170
+ @on_complete_callbacks.clear
171
+ @cond.broadcast
172
+ true
173
+ end
174
+ if changed
175
+ callbacks.each do |callback|
176
+ deliver_completion_callback(callback, value, error)
322
177
  end
323
178
  end
324
- callbacks&.each { |cb| cb.call(value, error) }
179
+ changed
325
180
  end
326
181
 
327
- # Registers +child+ as a child task for cancellation propagation.
328
- # Called automatically during child task initialization.
329
- # @param child [Task]
330
- # @api private
331
- def register_child(child)
332
- @mutex.synchronize { @children << child }
182
+ def deliver_completion_callback(callback, value, error)
183
+ callback.call(value, error)
184
+ rescue => callback_error
185
+ Phronomy.configuration.logger&.error do
186
+ "[Task] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
187
+ end
188
+ end
189
+
190
+ def monotonic_now
191
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
333
192
  end
334
193
  end
335
194
  end
@@ -6,11 +6,11 @@ module Phronomy
6
6
  #
7
7
  # Subclasses must implement {#complete} and {#stream}. The adapter or the
8
8
  # underlying provider client owns transport timeout, retry, backoff, and
9
- # rate-limit behavior. Phronomy only supplies cooperative cancellation and
10
- # isolates blocking calls in {BlockingAdapterPool}.
9
+ # rate-limit behavior. Phronomy supplies cooperative cancellation and
10
+ # isolates synchronous provider calls in {OffloadPool}.
11
11
  #
12
12
  # The agent pipeline calls {#complete_async} / {#stream_async} which wrap
13
- # those methods in a {BlockingAdapterPool} submission.
13
+ # those methods in an {OffloadPool} submission.
14
14
  class Base
15
15
  # Performs a blocking (non-streaming) LLM completion.
16
16
  # Implementors must call +chat.ask(message)+ (or equivalent) and
@@ -41,8 +41,8 @@ module Phronomy
41
41
  raise NotImplementedError, "#{self.class}#stream is not implemented"
42
42
  end
43
43
 
44
- # Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
45
- # a {BlockingAdapterPool::PendingOperation}.
44
+ # Submits a non-streaming LLM call to {OffloadPool} and returns
45
+ # an {OffloadPool::PendingOperation}.
46
46
  #
47
47
  # Transport timeout and retry remain the responsibility of the adapter or
48
48
  # provider client; Phronomy does not attach an additional operation timeout.
@@ -50,20 +50,20 @@ module Phronomy
50
50
  # @param chat [Object] configured chat session
51
51
  # @param message [String] user message
52
52
  # @param config [Hash] invocation config
53
- # @param pool [BlockingAdapterPool] pool to submit to
54
- # @return [BlockingAdapterPool::PendingOperation]
53
+ # @param pool [OffloadPool] pool to submit to
54
+ # @return [OffloadPool::PendingOperation]
55
55
  # @api private
56
56
  def complete_async(chat, message, config: {}, pool: default_pool)
57
57
  token = config[:cancellation_token]
58
- pool.submit(cancellation_token: token) do
58
+ pool.submit(cancellation_token: token, on_full: :raise) do
59
59
  complete(chat, message, config: config)
60
60
  end
61
61
  end
62
62
 
63
- # Submits a streaming LLM call to {BlockingAdapterPool} and returns
64
- # a {BlockingAdapterPool::PendingOperation}.
63
+ # Submits a streaming LLM call to {OffloadPool} and returns
64
+ # an {OffloadPool::PendingOperation}.
65
65
  #
66
- # The block is invoked on the blocking-pool worker thread. Agent code must
66
+ # The block is invoked on an OffloadPool worker thread. Agent code must
67
67
  # pass only a lightweight internal sink that posts a value to EventLoop;
68
68
  # Application callbacks must never be passed directly to this method.
69
69
  #
@@ -71,13 +71,13 @@ module Phronomy
71
71
  # provider client; Phronomy does not attach an additional operation timeout.
72
72
  #
73
73
  # @yield [chunk] streaming chunk on the worker thread
74
- # @return [BlockingAdapterPool::PendingOperation]
74
+ # @return [OffloadPool::PendingOperation]
75
75
  # @api private
76
76
  def stream_async(chat, message, config: {}, pool: default_pool, &block)
77
77
  raise ArgumentError, "stream_async requires a block" unless block
78
78
 
79
79
  token = config[:cancellation_token]
80
- pool.submit(cancellation_token: token) do
80
+ pool.submit(cancellation_token: token, on_full: :raise) do
81
81
  stream(chat, message, config: config) do |chunk|
82
82
  token&.raise_if_cancelled!("invocation cancelled during streaming")
83
83
  block.call(chunk)
@@ -88,7 +88,7 @@ module Phronomy
88
88
  private
89
89
 
90
90
  def default_pool
91
- Phronomy::Runtime.instance.blocking_io
91
+ Phronomy::Runtime.instance.offload
92
92
  end
93
93
  end
94
94
  end
@@ -4,10 +4,9 @@ module Phronomy
4
4
  module LLMAdapter
5
5
  # LLM adapter that delegates to the RubyLLM blocking client.
6
6
  #
7
- # This is the default adapter used by Phronomy agents. It wraps
8
- # +chat.ask+ (and its streaming variant) so that the blocking HTTP
9
- # call runs inside {BlockingAdapterPool} rather than on the EventLoop
10
- # thread or the caller's thread directly.
7
+ # This is the default adapter used by Phronomy agents. It wraps
8
+ # +chat.ask+ (and its streaming variant) so that the synchronous provider
9
+ # call runs inside {OffloadPool} rather than on the EventLoop thread.
11
10
  #
12
11
  # @example Explicitly configuring this adapter
13
12
  # Phronomy.configure do |c|
@@ -7,8 +7,8 @@ module Phronomy
7
7
  # dependency on the RubyLLM blocking client. All LLM calls in
8
8
  # {Agent::Base} are routed through the adapter so that:
9
9
  #
10
- # - Blocking HTTP can be submitted to {BlockingAdapterPool} for bounded
11
- # concurrency and per-operation timeouts.
10
+ # - Synchronous provider work can be submitted to {OffloadPool} for bounded
11
+ # off-EventLoop execution.
12
12
  # - Alternative LLM clients can be swapped in without changing agent code.
13
13
  #
14
14
  # @example Configuring a custom adapter