phronomy 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Manages a bounded set of concurrent {Task}s with structured concurrency.
|
|
5
|
+
#
|
|
6
|
+
# Enforces an upper bound on simultaneously running tasks (+limit+).
|
|
7
|
+
# When the limit is reached, {#spawn} blocks the caller until a slot
|
|
8
|
+
# becomes available. Results are always returned in the order tasks
|
|
9
|
+
# were spawned, regardless of completion order.
|
|
10
|
+
#
|
|
11
|
+
# A configurable +failure_policy+ controls how errors propagate:
|
|
12
|
+
# - +:fail_fast+ (default) — cancels all remaining tasks on the first error
|
|
13
|
+
# - +:collect_all+ — waits for every task to complete, then raises the first error
|
|
14
|
+
# - +:skip_failed+ — ignores failed tasks and returns only successful results
|
|
15
|
+
#
|
|
16
|
+
# {#cancel_all!} cancels every task in the group and joins them, guaranteeing
|
|
17
|
+
# that the active child task count reaches zero before returning.
|
|
18
|
+
#
|
|
19
|
+
# @example Parallel tool calls with a concurrency cap
|
|
20
|
+
# group = Phronomy::TaskGroup.new(limit: 5)
|
|
21
|
+
# tasks = items.map { |item| group.spawn { process(item) } }
|
|
22
|
+
# results = group.await_all # Array in spawn order
|
|
23
|
+
#
|
|
24
|
+
# @example Collect-all failure policy
|
|
25
|
+
# group = Phronomy::TaskGroup.new(failure_policy: :collect_all)
|
|
26
|
+
# …
|
|
27
|
+
class TaskGroup
|
|
28
|
+
# Valid failure policies.
|
|
29
|
+
FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
|
|
30
|
+
|
|
31
|
+
# @param limit [Integer, Float::INFINITY] maximum simultaneous active tasks
|
|
32
|
+
# @param failure_policy [Symbol] one of {FAILURE_POLICIES} (default +:fail_fast+)
|
|
33
|
+
# @param runtime [Runtime, nil] runtime used to spawn tasks via {Runtime#spawn};
|
|
34
|
+
# when +nil+, tasks are created directly via +Task.new+ (backward-compatible mode).
|
|
35
|
+
# Pass +runtime: self+ from {Runtime#task_group} to keep task execution consistent
|
|
36
|
+
# with the configured scheduler backend.
|
|
37
|
+
# @api private
|
|
38
|
+
def initialize(limit: Float::INFINITY, failure_policy: :fail_fast, runtime: nil)
|
|
39
|
+
raise ArgumentError, "unknown failure_policy: #{failure_policy}" unless FAILURE_POLICIES.include?(failure_policy)
|
|
40
|
+
|
|
41
|
+
@limit = limit
|
|
42
|
+
@failure_policy = failure_policy
|
|
43
|
+
@runtime = runtime
|
|
44
|
+
@tasks = []
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
@cond = ConditionVariable.new
|
|
47
|
+
@active = 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Spawns a new task within the group.
|
|
51
|
+
# Blocks if the number of currently active tasks equals +limit+.
|
|
52
|
+
#
|
|
53
|
+
# @yield block to execute concurrently
|
|
54
|
+
# @return [Task] the spawned task
|
|
55
|
+
# @api private
|
|
56
|
+
def spawn(&block)
|
|
57
|
+
wait_for_slot!
|
|
58
|
+
|
|
59
|
+
task = if @runtime
|
|
60
|
+
@runtime.spawn(name: "task-group-worker") do
|
|
61
|
+
block.call
|
|
62
|
+
ensure
|
|
63
|
+
release_slot!
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
Task.new do
|
|
67
|
+
block.call
|
|
68
|
+
ensure
|
|
69
|
+
release_slot!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@mutex.synchronize { @tasks << task }
|
|
74
|
+
task
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Waits for all spawned tasks to complete.
|
|
78
|
+
# Returns results in spawn order.
|
|
79
|
+
#
|
|
80
|
+
# Failure behaviour is controlled by the +failure_policy+ set at
|
|
81
|
+
# construction time:
|
|
82
|
+
# - +:fail_fast+ — raises the first error after cancelling unfinished tasks
|
|
83
|
+
# - +:collect_all+ — waits for all tasks, then raises the first error
|
|
84
|
+
# - +:skip_failed+ — returns only the values of successful tasks
|
|
85
|
+
#
|
|
86
|
+
# @return [Array] results in spawn order (or successful-only for :skip_failed)
|
|
87
|
+
# @raise [Exception] when any task failed (except :skip_failed)
|
|
88
|
+
# @api private
|
|
89
|
+
def await_all
|
|
90
|
+
tasks = @mutex.synchronize { @tasks.dup }
|
|
91
|
+
return [] if tasks.empty?
|
|
92
|
+
|
|
93
|
+
if Phronomy::Runtime::Scheduler.current
|
|
94
|
+
_await_all_cooperative(tasks)
|
|
95
|
+
else
|
|
96
|
+
_await_all_threaded(tasks)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Cooperative await_all for DeterministicScheduler context.
|
|
103
|
+
# Uses on_complete callbacks + AsyncQueue to observe task completions in
|
|
104
|
+
# arrival order (not spawn order), matching the fail-fast semantics of the
|
|
105
|
+
# threaded path. AsyncQueue#pop suspends the current Fiber cooperatively
|
|
106
|
+
# rather than blocking the OS thread.
|
|
107
|
+
# @api private
|
|
108
|
+
# @param tasks [Array<Task>]
|
|
109
|
+
# @return [Array]
|
|
110
|
+
def _await_all_cooperative(tasks)
|
|
111
|
+
completion_q = AsyncQueue.new
|
|
112
|
+
tasks.each_with_index do |task, idx|
|
|
113
|
+
task.on_complete do |value, error|
|
|
114
|
+
completion_q.push({index: idx, value: value, error: error})
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
entries = Array.new(tasks.length)
|
|
119
|
+
cancelled = false
|
|
120
|
+
fail_fast_error = nil
|
|
121
|
+
|
|
122
|
+
tasks.length.times do
|
|
123
|
+
entry = completion_q.pop # cooperative suspend via scheduler signal
|
|
124
|
+
entries[entry[:index]] = entry
|
|
125
|
+
|
|
126
|
+
if entry[:error] && @failure_policy == :fail_fast && !cancelled
|
|
127
|
+
cancelled = true
|
|
128
|
+
fail_fast_error = entry[:error]
|
|
129
|
+
tasks.each { |t| t.cancel! unless t.done? }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
case @failure_policy
|
|
134
|
+
when :fail_fast
|
|
135
|
+
raise fail_fast_error if fail_fast_error
|
|
136
|
+
entries.map { |r| r[:value] }
|
|
137
|
+
when :skip_failed
|
|
138
|
+
entries.filter_map { |r| r[:value] unless r[:error] }
|
|
139
|
+
else # :collect_all
|
|
140
|
+
errors = entries.filter_map { |r| r[:error] }
|
|
141
|
+
raise errors.first if errors.any?
|
|
142
|
+
entries.map { |r| r[:value] }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Thread-blocking await_all for ThreadBackend / ImmediateBackend context.
|
|
147
|
+
# Uses Task#on_complete callbacks instead of spawning N additional watcher
|
|
148
|
+
# tasks (Issue #328). on_complete receives the task's value and error
|
|
149
|
+
# directly — no await call is needed, eliminating the risk of a self-join
|
|
150
|
+
# when the callback fires inside the task's own execution thread.
|
|
151
|
+
def _await_all_threaded(tasks)
|
|
152
|
+
completion_q = Queue.new
|
|
153
|
+
tasks.each_with_index do |task, idx|
|
|
154
|
+
task.on_complete do |value, error|
|
|
155
|
+
completion_q.push({index: idx, value: value, error: error})
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
entries = Array.new(tasks.length)
|
|
160
|
+
cancelled = false
|
|
161
|
+
# The error that triggered fail_fast cancellation (tracked separately so
|
|
162
|
+
# we raise it rather than a secondary CancellationError from cancelled tasks).
|
|
163
|
+
fail_fast_error = nil
|
|
164
|
+
|
|
165
|
+
tasks.length.times do
|
|
166
|
+
entry = completion_q.pop
|
|
167
|
+
entries[entry[:index]] = entry
|
|
168
|
+
|
|
169
|
+
if entry[:error] && @failure_policy == :fail_fast && !cancelled
|
|
170
|
+
cancelled = true
|
|
171
|
+
fail_fast_error = entry[:error]
|
|
172
|
+
tasks.each { |t| t.cancel! unless t.done? }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
case @failure_policy
|
|
177
|
+
when :fail_fast
|
|
178
|
+
raise fail_fast_error if fail_fast_error
|
|
179
|
+
entries.map { |r| r[:value] }
|
|
180
|
+
when :skip_failed
|
|
181
|
+
entries.filter_map { |r| r[:value] unless r[:error] }
|
|
182
|
+
else # :collect_all
|
|
183
|
+
errors = entries.filter_map { |r| r[:error] }
|
|
184
|
+
raise errors.first if errors.any?
|
|
185
|
+
entries.map { |r| r[:value] }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
public
|
|
190
|
+
|
|
191
|
+
# Cancels all tasks currently in the group and waits for each to finish.
|
|
192
|
+
# After this method returns, the active child task count is guaranteed to
|
|
193
|
+
# be zero.
|
|
194
|
+
#
|
|
195
|
+
# Note: if a task is cancelled before its block has started executing, the
|
|
196
|
+
# internal +ensure+ clause inside the block may not run, so @active is
|
|
197
|
+
# reset explicitly after all tasks are joined.
|
|
198
|
+
#
|
|
199
|
+
# @return [self]
|
|
200
|
+
# @api private
|
|
201
|
+
def cancel_all!
|
|
202
|
+
tasks = @mutex.synchronize { @tasks.dup }
|
|
203
|
+
tasks.each(&:cancel!)
|
|
204
|
+
tasks.each do |t|
|
|
205
|
+
t.join
|
|
206
|
+
rescue
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
# Force @active to zero: tasks cancelled before block execution starts
|
|
210
|
+
# may not decrement @active via their ensure clause.
|
|
211
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
212
|
+
if scheduler && @coop_signal
|
|
213
|
+
@active = 0
|
|
214
|
+
scheduler.raise_signal_all(@coop_signal)
|
|
215
|
+
else
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
@active = 0
|
|
218
|
+
@cond.broadcast
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
self
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns the number of currently executing child tasks.
|
|
225
|
+
# @return [Integer]
|
|
226
|
+
# @api private
|
|
227
|
+
def active_task_count
|
|
228
|
+
@mutex.synchronize { @active }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def wait_for_slot!
|
|
234
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
235
|
+
if scheduler
|
|
236
|
+
@coop_signal ||= scheduler.new_signal
|
|
237
|
+
loop do
|
|
238
|
+
if @active < @limit
|
|
239
|
+
@active += 1
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
243
|
+
end
|
|
244
|
+
else
|
|
245
|
+
@mutex.synchronize do
|
|
246
|
+
@cond.wait(@mutex) while @active >= @limit
|
|
247
|
+
@active += 1
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def release_slot!
|
|
253
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
254
|
+
if scheduler && @coop_signal
|
|
255
|
+
@active -= 1
|
|
256
|
+
scheduler.raise_signal(@coop_signal)
|
|
257
|
+
else
|
|
258
|
+
@mutex.synchronize do
|
|
259
|
+
@active -= 1
|
|
260
|
+
@cond.signal
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
# A deterministic, manually-advanced clock for use in tests.
|
|
6
|
+
#
|
|
7
|
+
# Replaces real +Process.clock_gettime+ calls so that time-sensitive code
|
|
8
|
+
# can be tested without relying on wall-clock sleeps.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# clock = Phronomy::Testing::FakeClock.new
|
|
12
|
+
# clock.now # => 0.0
|
|
13
|
+
# clock.advance(5) # advance by 5 seconds
|
|
14
|
+
# clock.now # => 5.0
|
|
15
|
+
class FakeClock
|
|
16
|
+
# @return [Float] the current logical time in seconds since the epoch (t=0)
|
|
17
|
+
attr_reader :now
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@now = 0.0
|
|
21
|
+
@callbacks = [] # [[fire_at, block], ...]
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Advance the clock by +seconds+ and fire any registered callbacks whose
|
|
26
|
+
# deadline has passed.
|
|
27
|
+
#
|
|
28
|
+
# @param seconds [Numeric]
|
|
29
|
+
# @return [self]
|
|
30
|
+
# @api private
|
|
31
|
+
def advance(seconds)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@now += seconds.to_f
|
|
34
|
+
fire_expired_callbacks!
|
|
35
|
+
end
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Register a one-shot callback that fires when the clock reaches +at+.
|
|
40
|
+
#
|
|
41
|
+
# @param at [Numeric] logical time to fire
|
|
42
|
+
# @yield called with no arguments when the clock reaches +at+
|
|
43
|
+
# @return [self]
|
|
44
|
+
# @api private
|
|
45
|
+
def at(at, &block)
|
|
46
|
+
@mutex.synchronize { @callbacks << [at.to_f, block] }
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Schedule a one-shot callback to fire after +seconds+ from the current
|
|
51
|
+
# logical time. This is the same interface as {Runtime::TimerQueue#schedule}
|
|
52
|
+
# so that a +FakeClock+ can be passed as a +timer_queue:+ argument in tests.
|
|
53
|
+
#
|
|
54
|
+
# @param seconds [Numeric] delay in logical seconds
|
|
55
|
+
# @yield called when the clock reaches the scheduled time
|
|
56
|
+
# @return [self]
|
|
57
|
+
# @api private
|
|
58
|
+
def schedule(seconds:, &block)
|
|
59
|
+
at(@now + seconds.to_f, &block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the number of pending (un-fired) callbacks.
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
# @api private
|
|
65
|
+
def pending_callbacks
|
|
66
|
+
@mutex.synchronize { @callbacks.size }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns the logical time of the next pending callback, or +nil+ if
|
|
70
|
+
# there are no pending callbacks.
|
|
71
|
+
#
|
|
72
|
+
# @return [Float, nil]
|
|
73
|
+
# @api private
|
|
74
|
+
def next_timer_at
|
|
75
|
+
@mutex.synchronize { @callbacks.min_by { |(t, _)| t }&.first }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Advance the clock exactly to the next pending callback and fire it.
|
|
79
|
+
# Raises +RuntimeError+ when there are no pending callbacks.
|
|
80
|
+
#
|
|
81
|
+
# @return [self]
|
|
82
|
+
# @api private
|
|
83
|
+
def advance_to_next_timer
|
|
84
|
+
target = next_timer_at
|
|
85
|
+
raise "No pending timers to advance to" unless target
|
|
86
|
+
|
|
87
|
+
advance(target - @now)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns descriptive entries for all pending callbacks.
|
|
91
|
+
# Used by {Phronomy::Runtime::FakeScheduler#pending_timers}.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Hash>] each entry: +{ fire_at:, description: nil }+
|
|
94
|
+
# @api private
|
|
95
|
+
def pending_timer_entries
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@callbacks.sort_by { |(t, _)| t }.map { |(t, _)| {fire_at: t, description: nil} }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def fire_expired_callbacks!
|
|
104
|
+
fired, @callbacks = @callbacks.partition { |(t, _)| t <= @now }
|
|
105
|
+
fired.sort_by { |(t, _)| t }.each { |(_, cb)| cb.call }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
# A deterministic event dispatcher for use in tests.
|
|
6
|
+
#
|
|
7
|
+
# Wraps a {Thread::Queue} and dispatches events one at a time via {#tick}
|
|
8
|
+
# or drains all pending events via {#tick_until_idle}. Tests can inspect
|
|
9
|
+
# queue depth and verify event ordering without wall-clock sleeps.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# scheduler = Phronomy::Testing::FakeScheduler.new
|
|
13
|
+
# scheduler.post(:a)
|
|
14
|
+
# scheduler.post(:b)
|
|
15
|
+
# scheduler.queue_depth # => 2
|
|
16
|
+
# scheduler.tick # dispatches :a
|
|
17
|
+
# scheduler.queue_depth # => 1
|
|
18
|
+
# scheduler.tick_until_idle
|
|
19
|
+
# scheduler.dispatched # => [:a, :b]
|
|
20
|
+
class FakeScheduler
|
|
21
|
+
# @return [Array] all events dispatched so far (in order)
|
|
22
|
+
attr_reader :dispatched
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@queue = Thread::Queue.new
|
|
26
|
+
@dispatched = []
|
|
27
|
+
@handlers = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Enqueue an event for later dispatch.
|
|
31
|
+
#
|
|
32
|
+
# @param event [Object]
|
|
33
|
+
# @return [self]
|
|
34
|
+
# @api private
|
|
35
|
+
def post(event)
|
|
36
|
+
@queue.push(event)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Dispatch the next queued event.
|
|
41
|
+
# Calls the registered handler (if any) and records the event.
|
|
42
|
+
# Returns the dispatched event, or +nil+ if the queue is empty.
|
|
43
|
+
#
|
|
44
|
+
# @return [Object, nil]
|
|
45
|
+
# @api private
|
|
46
|
+
def tick
|
|
47
|
+
return nil if @queue.empty?
|
|
48
|
+
|
|
49
|
+
event = begin
|
|
50
|
+
@queue.pop(true)
|
|
51
|
+
rescue
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
return nil unless event
|
|
55
|
+
|
|
56
|
+
@dispatched << event
|
|
57
|
+
handler = @handlers[event.class] || @handlers[:any]
|
|
58
|
+
handler&.call(event)
|
|
59
|
+
event
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Dispatch events until the queue is empty.
|
|
63
|
+
# Bounded by +max_ticks+ to prevent infinite loops.
|
|
64
|
+
#
|
|
65
|
+
# @param max_ticks [Integer]
|
|
66
|
+
# @return [Integer] number of events dispatched
|
|
67
|
+
# @api private
|
|
68
|
+
def tick_until_idle(max_ticks: 1000)
|
|
69
|
+
count = 0
|
|
70
|
+
while !@queue.empty? && count < max_ticks
|
|
71
|
+
tick
|
|
72
|
+
count += 1
|
|
73
|
+
end
|
|
74
|
+
count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the number of events waiting to be dispatched.
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
# @api private
|
|
80
|
+
def queue_depth
|
|
81
|
+
@queue.size
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Register a handler block for events of the given class.
|
|
85
|
+
# Use +:any+ to handle all event types.
|
|
86
|
+
#
|
|
87
|
+
# @param klass [Class, :any]
|
|
88
|
+
# @yield [event]
|
|
89
|
+
# @return [self]
|
|
90
|
+
# @api private
|
|
91
|
+
def on(klass, &block)
|
|
92
|
+
@handlers[klass] = block
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns true when the queue is empty.
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
# @api private
|
|
99
|
+
def idle?
|
|
100
|
+
@queue.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
# RSpec helper module that provides a deterministic {Runtime} backed by
|
|
6
|
+
# {Phronomy::Runtime::FakeScheduler}.
|
|
7
|
+
#
|
|
8
|
+
# Include this module in your RSpec describe/context blocks and call
|
|
9
|
+
# {#with_fake_scheduler} to run a block of code inside a fully
|
|
10
|
+
# synchronous, event-logged runtime.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage (no clock)
|
|
13
|
+
# include Phronomy::Testing::SchedulerHelpers
|
|
14
|
+
#
|
|
15
|
+
# it "records completed events" do
|
|
16
|
+
# with_fake_scheduler do |sched|
|
|
17
|
+
# Phronomy::Runtime.instance.spawn(name: "my-task") { 42 }
|
|
18
|
+
# expect(sched.event_log.map { |e| e[:type] }).to include(:completed)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example With a FakeClock
|
|
23
|
+
# include Phronomy::Testing::SchedulerHelpers
|
|
24
|
+
#
|
|
25
|
+
# it "surfaces pending timers" do
|
|
26
|
+
# clock = Phronomy::Testing::FakeClock.new
|
|
27
|
+
# with_fake_scheduler(clock: clock) do |sched|
|
|
28
|
+
# clock.schedule(seconds: 5) { :fired }
|
|
29
|
+
# expect(sched.pending_timers.first[:fire_at]).to eq(5.0)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
module SchedulerHelpers
|
|
33
|
+
# Run +block+ with a {Phronomy::Runtime} that uses
|
|
34
|
+
# {Phronomy::Runtime::FakeScheduler}.
|
|
35
|
+
#
|
|
36
|
+
# The global runtime is replaced for the duration of the block and
|
|
37
|
+
# restored afterwards, whether the block raises or not.
|
|
38
|
+
#
|
|
39
|
+
# @param clock [Phronomy::Testing::FakeClock, nil]
|
|
40
|
+
# Optional fake clock to inject into the scheduler for timer support
|
|
41
|
+
# and event timestamping.
|
|
42
|
+
# @yield [scheduler, clock] the {Runtime::FakeScheduler} and the clock
|
|
43
|
+
# @return [Object] the return value of the block
|
|
44
|
+
# @api private
|
|
45
|
+
def with_fake_scheduler(clock: nil)
|
|
46
|
+
scheduler = Phronomy::Runtime::FakeScheduler.new
|
|
47
|
+
scheduler.clock = clock if clock
|
|
48
|
+
runtime = Phronomy::Runtime.new(scheduler: scheduler)
|
|
49
|
+
original = Phronomy::Runtime.instance
|
|
50
|
+
Phronomy::Runtime.instance = runtime
|
|
51
|
+
begin
|
|
52
|
+
yield scheduler, clock
|
|
53
|
+
ensure
|
|
54
|
+
Phronomy::Runtime.instance = original
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Test helpers for deterministic, timer-independent testing.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# require "phronomy/testing"
|
|
8
|
+
# clock = Phronomy::Testing::FakeClock.new
|
|
9
|
+
# scheduler = Phronomy::Testing::FakeScheduler.new
|
|
10
|
+
module Testing
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -35,6 +35,7 @@ module Phronomy
|
|
|
35
35
|
# @param description [String, nil] description exposed to the LLM;
|
|
36
36
|
# defaults to "Delegates to <AgentClassName>"
|
|
37
37
|
# @return [Class] an anonymous Phronomy::Tool::AgentTool subclass
|
|
38
|
+
# @api public
|
|
38
39
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
39
40
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
40
41
|
|