phronomy 0.7.1 → 0.8.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 +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +19 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +1 -0
- metadata +44 -42
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# A bounded, observable thread pool for blocking I/O operations.
|
|
5
|
-
#
|
|
6
|
-
# ## Architectural boundary
|
|
7
|
-
#
|
|
8
|
-
# `BlockingAdapterPool` is the *only* place in Phronomy that uses raw OS threads
|
|
9
|
-
# for I/O. All third-party gem calls whose internal I/O Phronomy cannot control
|
|
10
|
-
# — including RubyLLM, ActiveRecord, Redis, Faraday, and MCP stdio transport —
|
|
11
|
-
# **must** route through this pool (or a named pool obtained via
|
|
12
|
-
# {Runtime#pool}). Custom non-blocking HTTP/selector runtimes are intentionally
|
|
13
|
-
# out of scope; the pool + cooperative scheduler combination satisfies all
|
|
14
|
-
# current concurrency requirements without that complexity. (See ADR-010.)
|
|
15
|
-
#
|
|
16
|
-
# All blocking calls (LLM HTTP, MCP stdio, ActiveRecord, Redis, etc.) must be
|
|
17
|
-
# submitted through this pool so that:
|
|
18
|
-
#
|
|
19
|
-
# 1. The total number of OS threads is capped.
|
|
20
|
-
# 2. Queue depth is bounded (backpressure when the pool is saturated).
|
|
21
|
-
# 3. Per-operation timeouts are enforced consistently.
|
|
22
|
-
# 4. Abandoned (timed-out) operations are tracked and logged.
|
|
23
|
-
# 5. Metrics (active count, queue depth, abandoned count, avg wait time) are
|
|
24
|
-
# observable at runtime.
|
|
25
|
-
#
|
|
26
|
-
# @example Submitting a blocking LLM call
|
|
27
|
-
# op = runtime.blocking_io.submit(timeout: 30) { chat.ask(message) }
|
|
28
|
-
# result = op.await # blocks the calling thread until done
|
|
29
|
-
#
|
|
30
|
-
# @example With cancellation
|
|
31
|
-
# token = Phronomy::CancellationToken.timeout_after(60)
|
|
32
|
-
# op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
|
|
33
|
-
# result = op.await
|
|
34
|
-
class BlockingAdapterPool
|
|
35
|
-
# Represents the pending result of a submitted blocking operation.
|
|
36
|
-
# Returned immediately by {BlockingAdapterPool#submit}; call {#await} to
|
|
37
|
-
# wait for the result.
|
|
38
|
-
class PendingOperation
|
|
39
|
-
# @return [Boolean] true when the operation has finished (success or error)
|
|
40
|
-
# @api private
|
|
41
|
-
def done?
|
|
42
|
-
@mutex.synchronize { @done }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# @return [Boolean] true when the operation was abandoned due to timeout
|
|
46
|
-
# @api private
|
|
47
|
-
def abandoned?
|
|
48
|
-
@abandoned
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @return [Float] seconds spent in the queue before execution started
|
|
52
|
-
# @api private
|
|
53
|
-
def wait_time
|
|
54
|
-
@wait_time || 0.0
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Blocks until the operation completes and returns its value.
|
|
58
|
-
#
|
|
59
|
-
# An optional +timeout+ (in seconds) may be passed here; it is measured
|
|
60
|
-
# from the moment +await+ is called. If both a submit-time timeout and an
|
|
61
|
-
# await-time timeout are present, the earlier deadline wins. The worker
|
|
62
|
-
# thread is NOT interrupted — it runs to completion on its own.
|
|
63
|
-
#
|
|
64
|
-
# An optional +cancellation_token+ may be passed here (or at submit time).
|
|
65
|
-
# If the token is cancelled while waiting, {Phronomy::CancellationError} is
|
|
66
|
-
# raised immediately without interrupting the worker.
|
|
67
|
-
#
|
|
68
|
-
# **Cooperative path (`:fiber` / `DeterministicScheduler`):**
|
|
69
|
-
# When called from a Fiber managed by {DeterministicScheduler} (i.e. under
|
|
70
|
-
# the +:fiber+ runtime backend), the calling Fiber suspends cooperatively
|
|
71
|
-
# via +Fiber.yield+ rather than blocking the OS thread. The Fiber is
|
|
72
|
-
# resumed on the scheduler's ready queue once the worker thread completes
|
|
73
|
-
# the operation.
|
|
74
|
-
#
|
|
75
|
-
# @note **Cooperative cancellation semantics** (ADR-010):
|
|
76
|
-
# Phronomy uses a non-preemptive, cooperative-first concurrency model.
|
|
77
|
-
# Cancellation is *cooperative*, not preemptive:
|
|
78
|
-
# - When a +cancellation_token+ is cancelled, +CancellationError+ is
|
|
79
|
-
# raised to the +await+ caller immediately; when the timeout fires,
|
|
80
|
-
# +TimeoutError+ is raised instead. In both cases, the underlying
|
|
81
|
-
# worker thread is **not** forcibly stopped.
|
|
82
|
-
# - The worker thread will complete its submitted block naturally.
|
|
83
|
-
# Code inside the block must call +token.check!+ at suitable
|
|
84
|
-
# checkpoints to observe the cancelled state and exit early.
|
|
85
|
-
# - There is no +Thread#kill+ or +Thread#raise+ involved. The framework
|
|
86
|
-
# never forcibly terminates worker threads.
|
|
87
|
-
#
|
|
88
|
-
# @note **Cooperative timeout limitation**: the +timeout:+ parameter passed
|
|
89
|
-
# to +await+ is *not* enforced on the cooperative path. The calling Fiber
|
|
90
|
-
# remains suspended until the worker thread finishes regardless of how many
|
|
91
|
-
# seconds elapse. This is because the cooperative scheduler cannot
|
|
92
|
-
# preempt a running OS thread. If a time bound is required, set
|
|
93
|
-
# +timeout:+ at {BlockingAdapterPool#submit submit} time instead; the pool
|
|
94
|
-
# will then abandon the operation on the worker side and mark it as
|
|
95
|
-
# {#abandoned?}.
|
|
96
|
-
#
|
|
97
|
-
# @param timeout [Numeric, nil] seconds from now before raising TimeoutError
|
|
98
|
-
# (thread path only; ignored on the cooperative/fiber path)
|
|
99
|
-
# @param cancellation_token [CancellationToken, nil]
|
|
100
|
-
# @return [Object]
|
|
101
|
-
# @raise [Phronomy::TimeoutError]
|
|
102
|
-
# @raise [Phronomy::CancellationError]
|
|
103
|
-
# @raise [Exception] error raised inside the submitted block
|
|
104
|
-
# @api private
|
|
105
|
-
def await(timeout: nil, cancellation_token: nil)
|
|
106
|
-
effective_timeout = [timeout, @timeout].compact.min
|
|
107
|
-
effective_token = cancellation_token || @cancellation_token
|
|
108
|
-
|
|
109
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
110
|
-
|
|
111
|
-
# Cooperative context: suspend the calling Fiber rather than blocking
|
|
112
|
-
# the OS thread so that DeterministicScheduler can continue dispatching
|
|
113
|
-
# other tasks while waiting for the blocking worker to finish.
|
|
114
|
-
# (Issue #338, ADR-010 Rule 3)
|
|
115
|
-
# Uses the same thread-local key as Task::FiberBackend::SCHEDULER_KEY
|
|
116
|
-
# (:phronomy_deterministic_scheduler) to avoid a cross-file constant
|
|
117
|
-
# dependency at load time.
|
|
118
|
-
scheduler = Thread.current.thread_variable_get(:phronomy_deterministic_scheduler)
|
|
119
|
-
in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
|
|
120
|
-
if scheduler && in_managed_fiber
|
|
121
|
-
unless @done
|
|
122
|
-
# Register this await with the scheduler so run_until_idle knows
|
|
123
|
-
# not to exit until the worker thread completes (Issue #338).
|
|
124
|
-
scheduler.track_blocking_await
|
|
125
|
-
waiting_fiber = Fiber.current
|
|
126
|
-
on_complete do |_result, _error|
|
|
127
|
-
# Decrement the counter and wake run_until_idle, then re-enqueue
|
|
128
|
-
# the suspended Fiber for cooperative resumption.
|
|
129
|
-
scheduler.complete_blocking_await
|
|
130
|
-
scheduler.enqueue_fiber(-> { waiting_fiber.resume })
|
|
131
|
-
end
|
|
132
|
-
Fiber.yield(:cooperative_suspend)
|
|
133
|
-
end
|
|
134
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
135
|
-
raise @error if @error
|
|
136
|
-
|
|
137
|
-
return @value
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Wake up the waiting thread whenever the token is cancelled so we can
|
|
141
|
-
# propagate cancellation without sleeping until the timeout expires.
|
|
142
|
-
effective_token&.on_cancel { @mutex.synchronize { @cond.broadcast } }
|
|
143
|
-
|
|
144
|
-
if effective_timeout
|
|
145
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_timeout
|
|
146
|
-
@mutex.synchronize do
|
|
147
|
-
until @done
|
|
148
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
149
|
-
|
|
150
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
151
|
-
if remaining <= 0
|
|
152
|
-
# Guard against double-counting when await is called multiple times.
|
|
153
|
-
unless @abandoned
|
|
154
|
-
@abandoned = true
|
|
155
|
-
@on_abandoned&.call
|
|
156
|
-
end
|
|
157
|
-
raise Phronomy::TimeoutError, "blocking operation timed out after #{effective_timeout}s"
|
|
158
|
-
end
|
|
159
|
-
@cond.wait(@mutex, remaining)
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
else
|
|
163
|
-
@mutex.synchronize do
|
|
164
|
-
until @done
|
|
165
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
166
|
-
|
|
167
|
-
@cond.wait(@mutex)
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
raise @error if @error
|
|
172
|
-
|
|
173
|
-
@value
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Registers a callback to be called when the operation finishes.
|
|
177
|
-
# If the operation has already finished the callback is invoked immediately
|
|
178
|
-
# on the calling thread. Otherwise it is invoked on the worker thread that
|
|
179
|
-
# completes the operation.
|
|
180
|
-
#
|
|
181
|
-
# The callback receives +result+ and +error+ (one of them will be +nil+).
|
|
182
|
-
#
|
|
183
|
-
# @yield [result, error]
|
|
184
|
-
# @return [self]
|
|
185
|
-
# @api private
|
|
186
|
-
def on_complete(&callback)
|
|
187
|
-
fire_args = nil
|
|
188
|
-
@mutex.synchronize do
|
|
189
|
-
if @done
|
|
190
|
-
fire_args = [@value, @error]
|
|
191
|
-
else
|
|
192
|
-
@callbacks ||= []
|
|
193
|
-
@callbacks << callback
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
callback.call(*fire_args) if fire_args
|
|
197
|
-
self
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# @api private
|
|
201
|
-
def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil)
|
|
202
|
-
@block = block
|
|
203
|
-
@timeout = timeout
|
|
204
|
-
@cancellation_token = cancellation_token
|
|
205
|
-
@on_abandoned = on_abandoned
|
|
206
|
-
@value = nil
|
|
207
|
-
@error = nil
|
|
208
|
-
@done = false
|
|
209
|
-
@abandoned = false
|
|
210
|
-
@wait_time = nil
|
|
211
|
-
@submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
212
|
-
@mutex = Mutex.new
|
|
213
|
-
@cond = ConditionVariable.new
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# @api private
|
|
217
|
-
def execute!
|
|
218
|
-
@wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
|
|
219
|
-
|
|
220
|
-
if @cancellation_token&.cancelled?
|
|
221
|
-
complete_with_error!(CancellationError.new("operation cancelled before execution"))
|
|
222
|
-
return
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Do NOT use Timeout.timeout here — it delivers an async Thread#raise
|
|
226
|
-
# that can corrupt external library state (mutexes, C extensions, etc.).
|
|
227
|
-
# Timeout enforcement is handled cooperatively in #await instead.
|
|
228
|
-
# Each blocking library (Net::HTTP, pg, redis, etc.) should set its
|
|
229
|
-
# own native connection/read timeouts.
|
|
230
|
-
begin
|
|
231
|
-
complete_with_value!(@block.call)
|
|
232
|
-
rescue => e
|
|
233
|
-
complete_with_error!(e)
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
private
|
|
238
|
-
|
|
239
|
-
def complete_with_value!(value)
|
|
240
|
-
cbs = nil
|
|
241
|
-
@mutex.synchronize do
|
|
242
|
-
@value = value
|
|
243
|
-
@done = true
|
|
244
|
-
@cond.broadcast
|
|
245
|
-
cbs = @callbacks
|
|
246
|
-
@callbacks = nil
|
|
247
|
-
end
|
|
248
|
-
cbs&.each { |cb| cb.call(value, nil) }
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def complete_with_error!(error)
|
|
252
|
-
cbs = nil
|
|
253
|
-
@mutex.synchronize do
|
|
254
|
-
@error = error
|
|
255
|
-
@done = true
|
|
256
|
-
@cond.broadcast
|
|
257
|
-
cbs = @callbacks
|
|
258
|
-
@callbacks = nil
|
|
259
|
-
end
|
|
260
|
-
cbs&.each { |cb| cb.call(nil, error) }
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# @param pool_size [Integer] maximum number of worker threads
|
|
265
|
-
# @param queue_size [Integer] maximum pending operations waiting for a worker
|
|
266
|
-
# @param name [String, Symbol, nil] optional pool name used in thread labels
|
|
267
|
-
# @param logger [Logger, nil] optional logger for warnings
|
|
268
|
-
# @api private
|
|
269
|
-
def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil)
|
|
270
|
-
@pool_size = pool_size
|
|
271
|
-
@queue_size = queue_size
|
|
272
|
-
@name = name
|
|
273
|
-
@logger = logger
|
|
274
|
-
@queue = SizedQueue.new(queue_size)
|
|
275
|
-
@active_count = 0
|
|
276
|
-
@abandoned_count = 0
|
|
277
|
-
@total_wait_ns = 0
|
|
278
|
-
@completed_count = 0
|
|
279
|
-
@mutex = Mutex.new
|
|
280
|
-
@shutdown = false
|
|
281
|
-
@workers = Array.new(pool_size) { |i| spawn_worker(i) }
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Submits a blocking operation to the pool.
|
|
285
|
-
# Returns a {PendingOperation} immediately; the block runs on a worker thread.
|
|
286
|
-
#
|
|
287
|
-
# @note **Cooperative callers**: if you are running under the `:fiber` backend
|
|
288
|
-
# (i.e. inside a {DeterministicScheduler} Fiber), set +timeout:+ here
|
|
289
|
-
# rather than on {PendingOperation#await}. The await-time timeout is not
|
|
290
|
-
# enforced on the cooperative path (the Fiber cannot preempt a running
|
|
291
|
-
# worker thread). A submit-time timeout triggers on the worker side and
|
|
292
|
-
# marks the operation {PendingOperation#abandoned? abandoned}, which
|
|
293
|
-
# unblocks the waiting Fiber via the normal on-complete callback.
|
|
294
|
-
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
295
|
-
# @param cancellation_token [CancellationToken, nil]
|
|
296
|
-
# @yield block containing the blocking call
|
|
297
|
-
# @return [PendingOperation]
|
|
298
|
-
# @raise [Phronomy::PoolShutdownError] when the pool has been shut down
|
|
299
|
-
# @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
|
|
300
|
-
# @raise [Phronomy::TimeoutError] when +on_full: :timeout+ and wait exceeds +full_timeout+
|
|
301
|
-
# @api private
|
|
302
|
-
def submit(timeout: nil, cancellation_token: nil, on_full: :wait, full_timeout: nil, &block)
|
|
303
|
-
raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
|
|
304
|
-
|
|
305
|
-
op = PendingOperation.new(block, timeout: timeout, cancellation_token: cancellation_token,
|
|
306
|
-
on_abandoned: timeout ? -> { @mutex.synchronize { @abandoned_count += 1 } } : nil)
|
|
307
|
-
begin
|
|
308
|
-
case on_full
|
|
309
|
-
when :raise
|
|
310
|
-
begin
|
|
311
|
-
@queue.push(op, true)
|
|
312
|
-
rescue ThreadError
|
|
313
|
-
raise Phronomy::BackpressureError, "BlockingAdapterPool queue is full (depth: #{@queue_size})"
|
|
314
|
-
end
|
|
315
|
-
when :timeout
|
|
316
|
-
deadline = full_timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) : nil
|
|
317
|
-
loop do
|
|
318
|
-
@queue.push(op, true)
|
|
319
|
-
break
|
|
320
|
-
rescue ThreadError
|
|
321
|
-
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
322
|
-
raise Phronomy::TimeoutError, "timed out waiting for a free slot in BlockingAdapterPool"
|
|
323
|
-
end
|
|
324
|
-
sleep(0.005)
|
|
325
|
-
end
|
|
326
|
-
else # :wait (default)
|
|
327
|
-
@queue.push(op)
|
|
328
|
-
end
|
|
329
|
-
rescue ClosedQueueError
|
|
330
|
-
# Shutdown raced with this submit — treat as if @shutdown was already set.
|
|
331
|
-
raise Phronomy::PoolShutdownError, "pool has been shut down"
|
|
332
|
-
end
|
|
333
|
-
op
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
# Gracefully drains the pool and terminates all worker threads.
|
|
337
|
-
# Waits up to +drain_timeout+ seconds for in-flight operations to finish.
|
|
338
|
-
#
|
|
339
|
-
# Closing the underlying SizedQueue signals workers to exit after draining
|
|
340
|
-
# remaining items, without blocking on a full-queue push.
|
|
341
|
-
#
|
|
342
|
-
# @param drain_timeout [Numeric] seconds to wait for workers to finish
|
|
343
|
-
# @return [self]
|
|
344
|
-
# @api private
|
|
345
|
-
def shutdown(drain_timeout: 30)
|
|
346
|
-
@shutdown = true
|
|
347
|
-
@queue.close
|
|
348
|
-
@workers.each { |t| t.join(drain_timeout) }
|
|
349
|
-
self
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
# --- Metrics ----------------------------------------------------------
|
|
353
|
-
|
|
354
|
-
# @return [Integer] number of operations currently executing on workers
|
|
355
|
-
# @api private
|
|
356
|
-
def active_count
|
|
357
|
-
@mutex.synchronize { @active_count }
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
# @return [Integer] number of operations waiting in the queue
|
|
361
|
-
# @api private
|
|
362
|
-
def queue_depth
|
|
363
|
-
@queue.size
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
# @return [Integer] number of operations that were abandoned due to timeout
|
|
367
|
-
# @api private
|
|
368
|
-
def abandoned_count
|
|
369
|
-
@mutex.synchronize { @abandoned_count }
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# Average time (in seconds) that completed operations spent in the queue
|
|
373
|
-
# waiting for a worker. Returns 0.0 when no operations have completed yet.
|
|
374
|
-
# @return [Float]
|
|
375
|
-
# @api private
|
|
376
|
-
def average_wait_seconds
|
|
377
|
-
@mutex.synchronize do
|
|
378
|
-
return 0.0 if @completed_count.zero?
|
|
379
|
-
|
|
380
|
-
@total_wait_ns / @completed_count.to_f / 1_000_000_000.0
|
|
381
|
-
end
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# @return [Integer] configured maximum number of worker threads
|
|
385
|
-
attr_reader :pool_size
|
|
386
|
-
|
|
387
|
-
# @return [Integer] configured maximum queue depth
|
|
388
|
-
attr_reader :queue_size
|
|
389
|
-
|
|
390
|
-
# @return [String, Symbol, nil] pool name used in thread labels
|
|
391
|
-
attr_reader :name
|
|
392
|
-
|
|
393
|
-
private
|
|
394
|
-
|
|
395
|
-
SENTINEL = :shutdown
|
|
396
|
-
private_constant :SENTINEL
|
|
397
|
-
|
|
398
|
-
def spawn_worker(index = nil)
|
|
399
|
-
label = ["phronomy", "blocking-pool", @name, index].compact.join("-")
|
|
400
|
-
Thread.new do
|
|
401
|
-
Thread.current.name = label
|
|
402
|
-
loop do
|
|
403
|
-
op = begin
|
|
404
|
-
@queue.pop
|
|
405
|
-
rescue ClosedQueueError
|
|
406
|
-
break
|
|
407
|
-
end
|
|
408
|
-
# nil is returned by a closed, empty Queue on some Ruby versions
|
|
409
|
-
break if op.nil? || op == SENTINEL
|
|
410
|
-
|
|
411
|
-
run_operation(op)
|
|
412
|
-
end
|
|
413
|
-
end
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
def run_operation(op)
|
|
417
|
-
@mutex.synchronize { @active_count += 1 }
|
|
418
|
-
|
|
419
|
-
begin
|
|
420
|
-
op.execute!
|
|
421
|
-
ensure
|
|
422
|
-
@mutex.synchronize do
|
|
423
|
-
@active_count -= 1
|
|
424
|
-
|
|
425
|
-
if op.abandoned?
|
|
426
|
-
@logger&.warn { "BlockingAdapterPool: worker finished operation after caller timed out" }
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
@total_wait_ns += (op.wait_time * 1_000_000_000).to_i
|
|
430
|
-
@completed_count += 1
|
|
431
|
-
end
|
|
432
|
-
end
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
end
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Represents a bounded execution scope that owns a {CancellationToken} and
|
|
5
|
-
# optionally a {Deadline}.
|
|
6
|
-
#
|
|
7
|
-
# +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
|
|
8
|
-
# tool code. All work performed within a scope should observe the scope's
|
|
9
|
-
# token; when the scope is cancelled (explicitly or by deadline expiry) the
|
|
10
|
-
# token is cancelled and all child tasks that check it will stop.
|
|
11
|
-
#
|
|
12
|
-
# @example Time-bounded invocation
|
|
13
|
-
# scope = Phronomy::CancellationScope.new.deadline_in(30)
|
|
14
|
-
# result = scope.pop_queue(completion_queue) do
|
|
15
|
-
# raise Phronomy::TimeoutError, "timed out"
|
|
16
|
-
# end
|
|
17
|
-
#
|
|
18
|
-
# @example Explicit cancellation
|
|
19
|
-
# scope = Phronomy::CancellationScope.new
|
|
20
|
-
# Phronomy::Runtime.instance.spawn(name: "worker") do
|
|
21
|
-
# scope.token.raise_if_cancelled!
|
|
22
|
-
# # ... do work ...
|
|
23
|
-
# end
|
|
24
|
-
# scope.cancel! if some_condition
|
|
25
|
-
class CancellationScope
|
|
26
|
-
# @return [CancellationToken] the token owned by this scope
|
|
27
|
-
attr_reader :token
|
|
28
|
-
|
|
29
|
-
# @return [Deadline, nil] the deadline attached to this scope, if any
|
|
30
|
-
attr_reader :deadline
|
|
31
|
-
|
|
32
|
-
# @param parent_token [CancellationToken, nil] when provided, cancellation of
|
|
33
|
-
# the parent token is propagated to this scope's token via a callback
|
|
34
|
-
# (for explicit cancel) and/or the Runtime timer queue (for monotonic
|
|
35
|
-
# deadline expiry). No polling thread is spawned.
|
|
36
|
-
# @api private
|
|
37
|
-
def initialize(parent_token: nil)
|
|
38
|
-
@token = Phronomy::CancellationToken.new
|
|
39
|
-
@deadline = nil
|
|
40
|
-
|
|
41
|
-
if parent_token
|
|
42
|
-
# Propagate explicit cancel() from parent to child via callback.
|
|
43
|
-
parent_token.on_cancel { @token.cancel! }
|
|
44
|
-
|
|
45
|
-
# Propagate monotonic-deadline expiry from parent to child via the
|
|
46
|
-
# timer queue (avoids a polling thread).
|
|
47
|
-
remaining = parent_token.remaining_monotonic_seconds
|
|
48
|
-
if !remaining.nil?
|
|
49
|
-
if remaining <= 0
|
|
50
|
-
@token.cancel!
|
|
51
|
-
else
|
|
52
|
-
Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
|
|
53
|
-
@token.cancel!
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Attaches a deadline that will cancel this scope after +seconds+.
|
|
61
|
-
#
|
|
62
|
-
# @param seconds [Numeric] timeout duration
|
|
63
|
-
# @return [self]
|
|
64
|
-
# @api private
|
|
65
|
-
def deadline_in(seconds)
|
|
66
|
-
@deadline = Phronomy::Deadline.in(seconds)
|
|
67
|
-
@deadline.attach_to(@token)
|
|
68
|
-
self
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Cancels this scope immediately.
|
|
72
|
-
# @return [void]
|
|
73
|
-
# @api private
|
|
74
|
-
def cancel!
|
|
75
|
-
@token.cancel!
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Returns +true+ if this scope has been cancelled.
|
|
79
|
-
# @return [Boolean]
|
|
80
|
-
# @api private
|
|
81
|
-
def cancelled?
|
|
82
|
-
@token.cancelled?
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Returns the remaining time in seconds before the deadline expires,
|
|
86
|
-
# or +nil+ when no deadline is set.
|
|
87
|
-
# @return [Float, nil]
|
|
88
|
-
# @api private
|
|
89
|
-
def remaining_seconds
|
|
90
|
-
@deadline&.remaining_seconds
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Pops from +queue+ with a timeout derived from the attached deadline (or
|
|
94
|
-
# +fallback_timeout+ seconds when no deadline is set). If the pop times out,
|
|
95
|
-
# the scope is cancelled and the block is called (or a {TimeoutError} raised).
|
|
96
|
-
#
|
|
97
|
-
# @param queue [Phronomy::AsyncQueue] the queue to pop from
|
|
98
|
-
# @param fallback_timeout [Numeric, nil] used when no deadline is attached
|
|
99
|
-
# @yield called when the operation times out
|
|
100
|
-
# @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
|
|
101
|
-
# @return [Object] the popped value
|
|
102
|
-
# @api private
|
|
103
|
-
def pop_queue(queue, fallback_timeout: nil)
|
|
104
|
-
timeout = @deadline&.remaining_seconds || fallback_timeout
|
|
105
|
-
result = if timeout
|
|
106
|
-
queue.pop(timeout: timeout)
|
|
107
|
-
else
|
|
108
|
-
queue.pop
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
if result.nil?
|
|
112
|
-
cancel!
|
|
113
|
-
if block_given?
|
|
114
|
-
yield
|
|
115
|
-
else
|
|
116
|
-
raise Phronomy::TimeoutError, "CancellationScope timed out"
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
result
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Provides cooperative cancellation for agent invocations.
|
|
5
|
-
#
|
|
6
|
-
# Pass a token to an agent via +config: { cancellation_token: token }+.
|
|
7
|
-
# The agent checks the token before each LLM call and raises
|
|
8
|
-
# {Phronomy::CancellationError} when the token is cancelled or the
|
|
9
|
-
# optional deadline has passed.
|
|
10
|
-
#
|
|
11
|
-
# A token may be shared across multiple agent invocations and across threads;
|
|
12
|
-
# all access to internal state is protected by a Mutex.
|
|
13
|
-
#
|
|
14
|
-
# @example Explicit cancel from another thread
|
|
15
|
-
# token = Phronomy::CancellationToken.new
|
|
16
|
-
# Thread.new { sleep 5; token.cancel! }
|
|
17
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
18
|
-
#
|
|
19
|
-
# @example Hard deadline via monotonic clock (recommended)
|
|
20
|
-
# token = Phronomy::CancellationToken.timeout_after(30)
|
|
21
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
22
|
-
#
|
|
23
|
-
# @example Hard deadline via wall-clock (legacy)
|
|
24
|
-
# token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
|
|
25
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
26
|
-
#
|
|
27
|
-
# @example Propagate to parallel workers
|
|
28
|
-
# token = Phronomy::CancellationToken.new
|
|
29
|
-
# orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
|
|
30
|
-
class CancellationToken
|
|
31
|
-
# Returns a new token that will expire after +seconds+ seconds, measured
|
|
32
|
-
# with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
|
|
33
|
-
# a token with +deadline: Time.now + seconds+, this factory is immune to NTP
|
|
34
|
-
# adjustments and DST transitions.
|
|
35
|
-
#
|
|
36
|
-
# @param seconds [Numeric] duration in seconds until the token expires.
|
|
37
|
-
# @return [CancellationToken]
|
|
38
|
-
# @api public
|
|
39
|
-
def self.timeout_after(seconds)
|
|
40
|
-
monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
41
|
-
new(monotonic_deadline: monotonic_deadline)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# @param deadline [Time, nil] optional wall-clock deadline; the token reports
|
|
45
|
-
# +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
|
|
46
|
-
# {.timeout_after} for duration-based cancellation.
|
|
47
|
-
# @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
|
|
48
|
-
# {.timeout_after}; prefer that factory method over passing this directly.
|
|
49
|
-
# @api public
|
|
50
|
-
def initialize(deadline: nil, monotonic_deadline: nil)
|
|
51
|
-
@cancelled = false
|
|
52
|
-
@deadline = deadline
|
|
53
|
-
@monotonic_deadline = monotonic_deadline
|
|
54
|
-
@mutex = Mutex.new
|
|
55
|
-
@cancel_callbacks = []
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
|
|
59
|
-
attr_reader :deadline
|
|
60
|
-
|
|
61
|
-
# Returns the remaining seconds until the monotonic deadline fires, or +nil+
|
|
62
|
-
# when no monotonic deadline is set. Returns 0.0 if already past.
|
|
63
|
-
# @return [Float, nil]
|
|
64
|
-
# @api public
|
|
65
|
-
def remaining_monotonic_seconds
|
|
66
|
-
return nil if @monotonic_deadline.nil?
|
|
67
|
-
remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
68
|
-
[remaining, 0.0].max
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Registers a one-shot callback invoked when this token is explicitly
|
|
72
|
-
# cancelled via {#cancel!}. If the token is already cancelled, the block
|
|
73
|
-
# is called immediately (still within the caller's thread).
|
|
74
|
-
#
|
|
75
|
-
# Callbacks are NOT fired for deadline-based cancellation (i.e. when
|
|
76
|
-
# {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
|
|
77
|
-
# {Runtime#timer_queue} to schedule deadline callbacks.
|
|
78
|
-
#
|
|
79
|
-
# @yield called with no arguments when (or if) the token is cancelled
|
|
80
|
-
# @return [self]
|
|
81
|
-
# @api public
|
|
82
|
-
def on_cancel(&block)
|
|
83
|
-
already_cancelled = @mutex.synchronize do
|
|
84
|
-
if @cancelled
|
|
85
|
-
true
|
|
86
|
-
else
|
|
87
|
-
@cancel_callbacks << block
|
|
88
|
-
false
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
block.call if already_cancelled
|
|
92
|
-
self
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
|
|
96
|
-
# Thread-safe; idempotent — calling multiple times has no additional effect.
|
|
97
|
-
# @return [self]
|
|
98
|
-
# @api public
|
|
99
|
-
def cancel!
|
|
100
|
-
callbacks = @mutex.synchronize do
|
|
101
|
-
return self if @cancelled
|
|
102
|
-
@cancelled = true
|
|
103
|
-
@cancel_callbacks.dup
|
|
104
|
-
end
|
|
105
|
-
callbacks.each(&:call)
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Returns +true+ when the token has been explicitly cancelled via {#cancel!},
|
|
110
|
-
# when the wall-clock deadline has passed, or when the monotonic deadline
|
|
111
|
-
# (set by {.timeout_after}) has elapsed. Thread-safe.
|
|
112
|
-
# @return [Boolean]
|
|
113
|
-
# @api public
|
|
114
|
-
def cancelled?
|
|
115
|
-
return true if @mutex.synchronize { @cancelled }
|
|
116
|
-
return true if !@deadline.nil? && Time.now >= @deadline
|
|
117
|
-
!@monotonic_deadline.nil? &&
|
|
118
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Raises {Phronomy::CancellationError} if the token is cancelled.
|
|
122
|
-
# A convenience method for cooperative cancellation checks inside tools,
|
|
123
|
-
# RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
|
|
124
|
-
#
|
|
125
|
-
# @param message [String] optional error message
|
|
126
|
-
# @return [nil] when the token is not cancelled
|
|
127
|
-
# @raise [Phronomy::CancellationError] when the token is cancelled
|
|
128
|
-
# @api public
|
|
129
|
-
def raise_if_cancelled!(message = "invocation cancelled")
|
|
130
|
-
raise Phronomy::CancellationError, message if cancelled?
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|