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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. 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