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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +6 -7
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -2,219 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Concurrency
|
|
5
|
-
#
|
|
5
|
+
# Thread-safe FIFO queue used at explicit thread boundaries.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# backing primitive without changing call sites.
|
|
10
|
-
#
|
|
11
|
-
# @example Producer / consumer
|
|
12
|
-
# queue = Phronomy::Concurrency::AsyncQueue.new
|
|
13
|
-
# Runtime.instance.spawn { queue.push(expensive_io()) }
|
|
14
|
-
# value = queue.pop # blocks until the producer pushes
|
|
15
|
-
# @api private
|
|
7
|
+
# It intentionally contains no scheduler/Fiber semantics. Cooperative
|
|
8
|
+
# application execution is represented by FSMSession state and EventLoop events.
|
|
16
9
|
class AsyncQueue
|
|
17
|
-
# @param max_size [Integer, nil] optional upper bound on queue depth.
|
|
18
|
-
# When set, {#push} blocks the caller until a slot is available.
|
|
19
|
-
# @api private
|
|
20
10
|
def initialize(max_size: nil)
|
|
21
11
|
@queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
|
|
22
|
-
@max_size = max_size
|
|
23
|
-
@waiter_mutex = Mutex.new
|
|
24
|
-
@cross_thread_waiter = nil # [fiber, scheduler] set by _pop_cooperative; consumed by push
|
|
25
|
-
@cross_thread_scheduler = nil # set by expect_cross_thread_push
|
|
26
12
|
end
|
|
27
13
|
|
|
28
|
-
# Enqueues +item+.
|
|
29
|
-
# In a cooperative scheduler context with a bounded queue (max_size:), suspends
|
|
30
|
-
# the current Fiber via a scheduler signal when the queue is full rather than
|
|
31
|
-
# blocking the OS thread. Without a scheduler, falls back to the standard
|
|
32
|
-
# SizedQueue blocking behaviour.
|
|
33
|
-
# @param item [Object] value to enqueue
|
|
34
|
-
# @return [self]
|
|
35
|
-
# @api private
|
|
36
14
|
def push(item)
|
|
37
|
-
|
|
38
|
-
if scheduler && @max_size
|
|
39
|
-
_push_cooperative(scheduler, item)
|
|
40
|
-
else
|
|
41
|
-
@queue.push(item)
|
|
42
|
-
scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
|
|
43
|
-
# Wake a cross-thread waiter if one is registered.
|
|
44
|
-
# Handles the case where a DeterministicScheduler Fiber is suspended
|
|
45
|
-
# in _pop_cooperative waiting for a push from a non-scheduler thread
|
|
46
|
-
# (e.g. EventLoop thread where Scheduler.current is nil).
|
|
47
|
-
# enqueue_fiber is thread-safe; complete_blocking_await decrements
|
|
48
|
-
# @pending_awaits so run_until_idle can eventually exit.
|
|
49
|
-
if @cross_thread_scheduler
|
|
50
|
-
waiter = @waiter_mutex.synchronize do
|
|
51
|
-
w = @cross_thread_waiter
|
|
52
|
-
@cross_thread_waiter = nil
|
|
53
|
-
w
|
|
54
|
-
end
|
|
55
|
-
if waiter
|
|
56
|
-
fiber, sched = waiter
|
|
57
|
-
sched.complete_blocking_await
|
|
58
|
-
sched.enqueue_fiber(-> { fiber.resume })
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
15
|
+
@queue.push(item)
|
|
62
16
|
self
|
|
63
17
|
end
|
|
64
18
|
|
|
65
|
-
# Dequeues and returns the next item.
|
|
66
|
-
# In a cooperative scheduler context, suspends the current Fiber (yielding
|
|
67
|
-
# control back to the scheduler) rather than blocking the OS thread.
|
|
68
|
-
#
|
|
69
|
-
# When +timeout+ is given the semantics depend on the active backend:
|
|
70
|
-
#
|
|
71
|
-
# * **Thread backend** (`:thread`) — uses real wall-clock time via
|
|
72
|
-
# +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
|
|
73
|
-
# Returns +nil+ if no item arrives within the specified number of real seconds.
|
|
74
|
-
# * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
|
|
75
|
-
# *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
|
|
76
|
-
# the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
|
|
77
|
-
# In tests this means the timeout is fully deterministic and does not depend on
|
|
78
|
-
# actual elapsed wall time. However, in production `:fiber` mode the timeout
|
|
79
|
-
# may never expire unless the scheduler explicitly advances virtual time.
|
|
80
|
-
#
|
|
81
|
-
# @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
|
|
82
|
-
# in production workloads is not guaranteed and may differ from wall-clock
|
|
83
|
-
# expectations.
|
|
84
|
-
# @note **Cooperative timeout limitation**: on the cooperative path, the
|
|
85
|
-
# deadline is re-checked *after* a wake-up signal arrives. If virtual time
|
|
86
|
-
# has already passed the deadline when the consumer is woken by a producer
|
|
87
|
-
# push, the consumer returns +nil+ rather than the pushed item. Without any
|
|
88
|
-
# wake-up signal the waiting Fiber remains suspended even after
|
|
89
|
-
# +scheduler.advance+ — the timeout does not self-fire.
|
|
90
|
-
# @param timeout [Numeric, nil] seconds to wait before returning +nil+.
|
|
91
|
-
# Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
|
|
92
|
-
# @return [Object, nil] the next item, or +nil+ when timeout expires
|
|
93
|
-
# @api private
|
|
94
19
|
def pop(timeout: nil)
|
|
95
|
-
|
|
96
|
-
if scheduler
|
|
97
|
-
_pop_cooperative(scheduler, timeout: timeout)
|
|
98
|
-
elsif timeout
|
|
99
|
-
@queue.pop(timeout: timeout)
|
|
100
|
-
else
|
|
101
|
-
@queue.pop
|
|
102
|
-
end
|
|
20
|
+
timeout ? @queue.pop(timeout: timeout) : @queue.pop
|
|
103
21
|
end
|
|
104
22
|
|
|
105
|
-
# Returns the current number of items in the queue.
|
|
106
|
-
# @return [Integer]
|
|
107
|
-
# @api private
|
|
108
23
|
def size
|
|
109
24
|
@queue.size
|
|
110
25
|
end
|
|
111
26
|
|
|
112
|
-
# Returns +true+ when the queue contains no items.
|
|
113
|
-
# @return [Boolean]
|
|
114
|
-
# @api private
|
|
115
27
|
def empty?
|
|
116
28
|
@queue.empty?
|
|
117
29
|
end
|
|
118
30
|
|
|
119
|
-
# Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
|
|
120
|
-
# @return [self]
|
|
121
|
-
# @api private
|
|
122
31
|
def close
|
|
123
32
|
@queue.close
|
|
124
33
|
self
|
|
125
34
|
end
|
|
126
|
-
|
|
127
|
-
# Marks this queue as expecting pushes from a non-scheduler OS thread.
|
|
128
|
-
# When set, {#pop} in cooperative mode uses +track_blocking_await+ so that
|
|
129
|
-
# {Runtime::DeterministicScheduler#run_until_idle} does not exit while
|
|
130
|
-
# waiting for the cross-thread push. Called by {EventLoop#register} when
|
|
131
|
-
# a cooperative scheduler is active on the calling thread.
|
|
132
|
-
# @param scheduler [Runtime::Scheduler]
|
|
133
|
-
# @return [self]
|
|
134
|
-
# @api private
|
|
135
|
-
def expect_cross_thread_push(scheduler)
|
|
136
|
-
@cross_thread_scheduler = scheduler
|
|
137
|
-
self
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
private
|
|
141
|
-
|
|
142
|
-
# Cooperative pop for DeterministicScheduler context.
|
|
143
|
-
# Suspends the current Fiber via the scheduler's signal mechanism rather than
|
|
144
|
-
# blocking the OS thread.
|
|
145
|
-
#
|
|
146
|
-
# Two suspension paths:
|
|
147
|
-
# * **Same-scheduler** (default): uses {CoopSignal} — the producer is another
|
|
148
|
-
# Fiber on the same DeterministicScheduler. run_until_idle is allowed to
|
|
149
|
-
# exit; the producer's push will enqueue the consumer Fiber.
|
|
150
|
-
# * **Cross-thread** ({#expect_cross_thread_push} was called): uses
|
|
151
|
-
# +track_blocking_await+ so that run_until_idle does not exit while waiting
|
|
152
|
-
# for a push from a non-scheduler OS thread (e.g. EventLoop). The push
|
|
153
|
-
# side calls +complete_blocking_await+ + +enqueue_fiber+ to resume.
|
|
154
|
-
#
|
|
155
|
-
# The empty?/register pair for the cross-thread path is wrapped in
|
|
156
|
-
# @waiter_mutex to eliminate the race between the empty check and the
|
|
157
|
-
# registration of the waker.
|
|
158
|
-
# @api private
|
|
159
|
-
# @param scheduler [Runtime::Scheduler]
|
|
160
|
-
# @param timeout [Numeric, nil]
|
|
161
|
-
# @return [Object, nil]
|
|
162
|
-
def _pop_cooperative(scheduler, timeout:)
|
|
163
|
-
@coop_signal ||= scheduler.new_signal
|
|
164
|
-
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
165
|
-
|
|
166
|
-
loop do
|
|
167
|
-
unless @queue.empty?
|
|
168
|
-
item = @queue.pop(timeout: 0)
|
|
169
|
-
# Notify a push-waiter (bounded queue) that a slot opened up.
|
|
170
|
-
scheduler.raise_signal(@push_signal) if @push_signal
|
|
171
|
-
return item
|
|
172
|
-
end
|
|
173
|
-
return nil if deadline && scheduler.virtual_time >= deadline
|
|
174
|
-
|
|
175
|
-
if @cross_thread_scheduler
|
|
176
|
-
# Cross-thread path: atomically check the queue and register a waker
|
|
177
|
-
# so that a concurrent push cannot slip between the empty? check above
|
|
178
|
-
# and the registration below.
|
|
179
|
-
will_yield = false
|
|
180
|
-
@waiter_mutex.synchronize do
|
|
181
|
-
if @queue.empty?
|
|
182
|
-
@cross_thread_waiter = [Fiber.current, scheduler]
|
|
183
|
-
scheduler.track_blocking_await
|
|
184
|
-
will_yield = true
|
|
185
|
-
end
|
|
186
|
-
# else: push arrived between the loop's empty? check and here;
|
|
187
|
-
# will_yield stays false and the next loop iteration dequeues it.
|
|
188
|
-
end
|
|
189
|
-
Fiber.yield(:cooperative_suspend) if will_yield
|
|
190
|
-
else
|
|
191
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
return nil if deadline && scheduler.virtual_time >= deadline
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Cooperative push for DeterministicScheduler context with a bounded queue.
|
|
199
|
-
# Suspends the current Fiber via a scheduler signal when the queue is full,
|
|
200
|
-
# rather than blocking the OS thread.
|
|
201
|
-
# @api private
|
|
202
|
-
# @param scheduler [Runtime::Scheduler]
|
|
203
|
-
# @param item [Object]
|
|
204
|
-
# @return [void]
|
|
205
|
-
def _push_cooperative(scheduler, item)
|
|
206
|
-
@push_signal ||= scheduler.new_signal
|
|
207
|
-
|
|
208
|
-
loop do
|
|
209
|
-
unless @queue.size >= @max_size
|
|
210
|
-
@queue.push(item)
|
|
211
|
-
# Notify any pop-waiter that an item is now available.
|
|
212
|
-
scheduler.raise_signal(@coop_signal) if @coop_signal
|
|
213
|
-
return
|
|
214
|
-
end
|
|
215
|
-
scheduler.wait_for_signal(@push_signal)
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
35
|
end
|
|
219
36
|
end
|
|
220
37
|
end
|
|
@@ -6,7 +6,7 @@ module Phronomy
|
|
|
6
6
|
# optionally a {Deadline}.
|
|
7
7
|
#
|
|
8
8
|
# +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
|
|
9
|
-
# tool code.
|
|
9
|
+
# tool code. All work performed within a scope should observe the scope's
|
|
10
10
|
# token; when the scope is cancelled (explicitly or by deadline expiry) the
|
|
11
11
|
# token is cancelled and all child tasks that check it will stop.
|
|
12
12
|
#
|
|
@@ -18,10 +18,9 @@ module Phronomy
|
|
|
18
18
|
#
|
|
19
19
|
# @example Explicit cancellation
|
|
20
20
|
# scope = Phronomy::Concurrency::CancellationScope.new
|
|
21
|
-
# Phronomy::Runtime.instance.
|
|
22
|
-
# scope.token
|
|
23
|
-
#
|
|
24
|
-
# end
|
|
21
|
+
# operation = Phronomy::Runtime.instance.offload.submit(
|
|
22
|
+
# cancellation_token: scope.token
|
|
23
|
+
# ) { synchronous_operation }
|
|
25
24
|
# scope.cancel! if some_condition
|
|
26
25
|
class CancellationScope
|
|
27
26
|
# @return [CancellationToken] the token owned by this scope
|
|
@@ -33,7 +32,7 @@ module Phronomy
|
|
|
33
32
|
# @param parent_token [CancellationToken, nil] when provided, cancellation of
|
|
34
33
|
# the parent token is propagated to this scope's token via a callback
|
|
35
34
|
# (for explicit cancel) and/or the Runtime timer queue (for monotonic
|
|
36
|
-
# deadline expiry).
|
|
35
|
+
# deadline expiry). No polling thread is spawned.
|
|
37
36
|
# @api private
|
|
38
37
|
def initialize(parent_token: nil)
|
|
39
38
|
@token = Phronomy::Concurrency::CancellationToken.new
|
|
@@ -92,7 +91,7 @@ module Phronomy
|
|
|
92
91
|
end
|
|
93
92
|
|
|
94
93
|
# Pops from +queue+ with a timeout derived from the attached deadline (or
|
|
95
|
-
# +fallback_timeout+ seconds when no deadline is set).
|
|
94
|
+
# +fallback_timeout+ seconds when no deadline is set). If the pop times out,
|
|
96
95
|
# the scope is cancelled and the block is called (or a {TimeoutError} raised).
|
|
97
96
|
#
|
|
98
97
|
# @param queue [Phronomy::Concurrency::AsyncQueue] the queue to pop from
|
|
@@ -2,91 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Concurrency
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# Pass a token to an agent via +config: { cancellation_token: token }+.
|
|
8
|
-
# The agent checks the token before each LLM call and raises
|
|
9
|
-
# {Phronomy::CancellationError} when the token is cancelled or the
|
|
10
|
-
# optional deadline has passed.
|
|
11
|
-
#
|
|
12
|
-
# A token may be shared across multiple agent invocations and across threads;
|
|
13
|
-
# all access to internal state is protected by a Mutex.
|
|
14
|
-
#
|
|
15
|
-
# @example Explicit cancel from another thread
|
|
16
|
-
# token = Phronomy::Concurrency::CancellationToken.new
|
|
17
|
-
# Thread.new { sleep 5; token.cancel! }
|
|
18
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
19
|
-
#
|
|
20
|
-
# @example Hard deadline via monotonic clock (recommended)
|
|
21
|
-
# token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
22
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
23
|
-
#
|
|
24
|
-
# @example Hard deadline via wall-clock (legacy)
|
|
25
|
-
# token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
|
|
26
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
27
|
-
#
|
|
28
|
-
# @example Propagate to parallel workers
|
|
29
|
-
# token = Phronomy::Concurrency::CancellationToken.new
|
|
30
|
-
# orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
|
|
5
|
+
# Cooperative cancellation token for Agent/Tool work.
|
|
31
6
|
class CancellationToken
|
|
32
|
-
#
|
|
33
|
-
# with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
|
|
34
|
-
# a token with +deadline: Time.now + seconds+, this factory is immune to NTP
|
|
35
|
-
# adjustments and DST transitions.
|
|
36
|
-
#
|
|
37
|
-
# @param seconds [Numeric] duration in seconds until the token expires.
|
|
38
|
-
# @return [CancellationToken]
|
|
7
|
+
# Creates a token that expires after +seconds+ measured with the monotonic clock.
|
|
39
8
|
# @api public
|
|
40
9
|
def self.timeout_after(seconds)
|
|
41
10
|
monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
42
11
|
new(monotonic_deadline: monotonic_deadline)
|
|
43
12
|
end
|
|
44
13
|
|
|
45
|
-
# @param
|
|
46
|
-
# +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
|
|
47
|
-
# {.timeout_after} for duration-based cancellation.
|
|
48
|
-
# @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
|
|
49
|
-
# {.timeout_after}; prefer that factory method over passing this directly.
|
|
14
|
+
# @param monotonic_deadline [Float, nil] internal monotonic timestamp.
|
|
50
15
|
# @api public
|
|
51
|
-
|
|
52
|
-
def initialize(deadline: nil, monotonic_deadline: nil)
|
|
16
|
+
def initialize(monotonic_deadline: nil)
|
|
53
17
|
@cancelled = false
|
|
54
|
-
@deadline = deadline
|
|
55
18
|
@monotonic_deadline = monotonic_deadline
|
|
56
19
|
@mutex = Mutex.new
|
|
57
20
|
@cancel_callbacks = []
|
|
58
21
|
end
|
|
59
22
|
|
|
60
|
-
# @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
|
|
61
|
-
attr_reader :deadline
|
|
62
|
-
|
|
63
|
-
# Returns the remaining seconds until the monotonic deadline fires, or +nil+
|
|
64
|
-
# when no monotonic deadline is set. Returns 0.0 if already past.
|
|
65
|
-
# @return [Float, nil]
|
|
66
23
|
# @api public
|
|
67
24
|
def remaining_monotonic_seconds
|
|
68
25
|
return nil if @monotonic_deadline.nil?
|
|
26
|
+
|
|
69
27
|
remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
28
|
[remaining, 0.0].max
|
|
71
29
|
end
|
|
72
30
|
|
|
73
|
-
# Registers a
|
|
74
|
-
# cancelled via {#cancel!}. If the token is already cancelled, the block
|
|
75
|
-
# is called immediately (still within the caller's thread).
|
|
31
|
+
# Registers a callback for explicit cancellation.
|
|
76
32
|
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
# NOT trigger callbacks on expiry.
|
|
33
|
+
# Deadline expiry by itself only changes {#cancelled?}. Components that need
|
|
34
|
+
# callback delivery for a monotonic deadline must promote that deadline to
|
|
35
|
+
# +cancel!+ through the Runtime timer queue.
|
|
36
|
+
#
|
|
37
|
+
# Cancellation callbacks are independent notifications. Failure of one
|
|
38
|
+
# callback is logged and does not suppress delivery to other callbacks.
|
|
84
39
|
#
|
|
85
|
-
# @yield called with no arguments when (or if) the token is cancelled
|
|
86
40
|
# @return [self]
|
|
87
41
|
# @api public
|
|
88
|
-
# mutant:disable - mutex removal mutation is GVL-safe equivalent under MRI
|
|
89
42
|
def on_cancel(&block)
|
|
43
|
+
raise ArgumentError, "on_cancel requires a block" unless block
|
|
44
|
+
|
|
90
45
|
already_cancelled = @mutex.synchronize do
|
|
91
46
|
if @cancelled
|
|
92
47
|
true
|
|
@@ -95,50 +50,67 @@ module Phronomy
|
|
|
95
50
|
false
|
|
96
51
|
end
|
|
97
52
|
end
|
|
98
|
-
block
|
|
53
|
+
deliver_cancel_callback(block) if already_cancelled
|
|
99
54
|
self
|
|
100
55
|
end
|
|
101
56
|
|
|
102
|
-
#
|
|
103
|
-
#
|
|
57
|
+
# Explicitly cancels the token and invokes each currently registered callback
|
|
58
|
+
# once. The callback registry is cleared before callbacks run so completed
|
|
59
|
+
# registrations are not retained for the lifetime of a long-lived token.
|
|
60
|
+
#
|
|
61
|
+
# Cancellation callbacks are isolated from one another: a StandardError raised
|
|
62
|
+
# by one callback is logged and the remaining callbacks are still delivered.
|
|
63
|
+
#
|
|
104
64
|
# @return [self]
|
|
105
65
|
# @api public
|
|
106
|
-
# mutant:disable - mutex removal and dup-vs-ref mutations are GVL-safe equivalents
|
|
107
66
|
def cancel!
|
|
108
67
|
callbacks = @mutex.synchronize do
|
|
109
68
|
return self if @cancelled
|
|
69
|
+
|
|
110
70
|
@cancelled = true
|
|
111
|
-
@cancel_callbacks
|
|
71
|
+
callbacks = @cancel_callbacks
|
|
72
|
+
@cancel_callbacks = []
|
|
73
|
+
callbacks
|
|
112
74
|
end
|
|
113
|
-
callbacks.each(
|
|
75
|
+
callbacks.each { |callback| deliver_cancel_callback(callback) }
|
|
114
76
|
self
|
|
115
77
|
end
|
|
116
78
|
|
|
117
|
-
# Returns +true+ when the token has been explicitly cancelled via {#cancel!},
|
|
118
|
-
# when the wall-clock deadline has passed, or when the monotonic deadline
|
|
119
|
-
# (set by {.timeout_after}) has elapsed. Thread-safe.
|
|
120
|
-
# @return [Boolean]
|
|
121
79
|
# @api public
|
|
122
|
-
# mutant:disable - mutex removal on @cancelled read is GVL-safe equivalent under MRI
|
|
123
80
|
def cancelled?
|
|
124
81
|
return true if @mutex.synchronize { @cancelled }
|
|
125
|
-
|
|
82
|
+
|
|
126
83
|
!@monotonic_deadline.nil? &&
|
|
127
84
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
|
|
128
85
|
end
|
|
129
86
|
|
|
130
|
-
# Raises {Phronomy::CancellationError} if the token is cancelled.
|
|
131
|
-
# A convenience method for cooperative cancellation checks inside tools,
|
|
132
|
-
# RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
|
|
133
|
-
#
|
|
134
|
-
# @param message [String] optional error message
|
|
135
|
-
# @return [nil] when the token is not cancelled
|
|
136
|
-
# @raise [Phronomy::CancellationError] when the token is cancelled
|
|
137
87
|
# @api public
|
|
138
|
-
# mutant:disable - raise(CancellationError) resolves to raise(Phronomy::CancellationError) in this namespace
|
|
139
88
|
def raise_if_cancelled!(message = "invocation cancelled")
|
|
140
89
|
raise Phronomy::CancellationError, message if cancelled?
|
|
141
90
|
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def deliver_cancel_callback(callback)
|
|
95
|
+
callback.call
|
|
96
|
+
rescue => error
|
|
97
|
+
Phronomy.configuration.logger&.error do
|
|
98
|
+
"[CancellationToken] on_cancel callback raised #{error.class}: #{error.message}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Removes an explicit-cancellation callback that is no longer needed.
|
|
103
|
+
#
|
|
104
|
+
# This exists so framework operation registrations do not keep their captured
|
|
105
|
+
# state alive when the operation completes before the token is cancelled. It is
|
|
106
|
+
# intentionally not part of the public cancellation API.
|
|
107
|
+
#
|
|
108
|
+
# A concurrent +cancel!+ may already have taken the callback for delivery;
|
|
109
|
+
# callers must therefore make their callback idempotent.
|
|
110
|
+
def unregister_cancel_callback(callback)
|
|
111
|
+
@mutex.synchronize { @cancel_callbacks.delete(callback) }
|
|
112
|
+
self
|
|
113
|
+
end
|
|
142
114
|
end
|
|
143
115
|
end
|
|
144
116
|
end
|
|
@@ -44,9 +44,8 @@ module Phronomy
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Attaches this deadline to a {CancellationToken} by cancelling the token
|
|
47
|
-
# when the deadline expires.
|
|
48
|
-
#
|
|
49
|
-
# per deadline.
|
|
47
|
+
# when the deadline expires. The Runtime timer queue is driven by EventLoop;
|
|
48
|
+
# no timer-specific OS thread is created.
|
|
50
49
|
#
|
|
51
50
|
# @param token [CancellationToken]
|
|
52
51
|
# @param timer_queue [Runtime::TimerQueue, nil] queue to register with;
|