phronomy 0.7.0 → 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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/scheduler"
4
+ require_relative "runtime/thread_scheduler"
5
+ require_relative "runtime/fake_scheduler"
6
+ require_relative "runtime/deterministic_scheduler"
7
+ require_relative "runtime/timer_queue"
8
+ require_relative "runtime/scheduler_timer_adapter"
9
+ require_relative "runtime/task_registry"
10
+ require_relative "runtime/runtime_metrics"
11
+ require_relative "runtime/timer_service"
12
+
13
+ module Phronomy
14
+ # Central authority for concurrent primitives.
15
+ #
16
+ # +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
17
+ # manages the lifecycle of all concurrency in Phronomy. It owns:
18
+ #
19
+ # * a pluggable {Scheduler} (default: {ThreadScheduler})
20
+ # * a task registry for graceful shutdown
21
+ # * the shared {BlockingAdapterPool}
22
+ #
23
+ # In production, use the process-wide singleton via {.instance}.
24
+ # In tests, construct a Runtime with a {FakeScheduler} to run tasks
25
+ # synchronously without spawning additional threads:
26
+ #
27
+ # @example Production usage
28
+ # group = Phronomy::Runtime.instance.task_group(limit: 4)
29
+ # tools.each { |t| group.spawn { t.call } }
30
+ # results = group.await_all
31
+ #
32
+ # @example Test usage — no extra threads
33
+ # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
34
+ # task = runtime.spawn { 42 }
35
+ # expect(task.await).to eq(42)
36
+ class Runtime
37
+ # Returns the process-wide default Runtime.
38
+ #
39
+ # Auto-creates an instance using the scheduler backend specified by
40
+ # +Phronomy.configuration.runtime_backend+:
41
+ # - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
42
+ # - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
43
+ # - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
44
+ # Fiber-based synchronous execution; not yet suitable for production
45
+ # because it uses virtual time rather than real wall-clock timers)
46
+ # - +:cooperative+ — deprecated alias for +:immediate+
47
+ #
48
+ # @return [Runtime]
49
+ # @api private
50
+ def self.instance
51
+ @instance ||= begin
52
+ scheduler = case Phronomy.configuration.runtime_backend
53
+ when :cooperative
54
+ Phronomy.configuration.logger&.warn(
55
+ "[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
56
+ "Use :immediate for synchronous/test execution. " \
57
+ ":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
58
+ )
59
+ FakeScheduler.new
60
+ when :immediate
61
+ FakeScheduler.new
62
+ when :fiber
63
+ Phronomy.configuration.logger&.warn(
64
+ "[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
65
+ "This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
66
+ "Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
67
+ "Not recommended for production use."
68
+ )
69
+ DeterministicScheduler.new(autorun: true)
70
+ else
71
+ ThreadScheduler.new
72
+ end
73
+ new(scheduler: scheduler)
74
+ end
75
+ end
76
+
77
+ # Replaces the process-wide default Runtime. Useful in tests.
78
+ # @param runtime [Runtime]
79
+ # @return [Runtime]
80
+ # @api private
81
+ def self.instance=(runtime)
82
+ @instance = runtime
83
+ end
84
+
85
+ # Returns +true+ when the calling thread is executing inside an active
86
+ # scheduler task (i.e. {Task.current} is non-nil). Code running inside
87
+ # a {Runtime#spawn} block is always in a scheduler context.
88
+ #
89
+ # Use this to detect potential scheduler-blocking calls:
90
+ # if Phronomy::Runtime.in_scheduler_context?
91
+ # Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
92
+ # end
93
+ #
94
+ # @return [Boolean]
95
+ # @api private
96
+ def self.in_scheduler_context?
97
+ !Task.current.nil?
98
+ end
99
+
100
+ # Executes +block+ and returns +[result, elapsed_ms]+ where +elapsed_ms+
101
+ # is the wall-clock duration in milliseconds (Integer, rounded).
102
+ #
103
+ # Isolates all direct references to +Process.clock_gettime+ /
104
+ # +Process::CLOCK_MONOTONIC+ in one place so that callers stay at the
105
+ # framework abstraction level.
106
+ #
107
+ # @yield block to time
108
+ # @return [Array(Object, Integer)] +[block_return_value, elapsed_ms]+
109
+ # @api private
110
+ def self.measure_ms
111
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ result = yield
113
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
114
+ [result, elapsed_ms]
115
+ end
116
+
117
+ # The scheduler backing this runtime instance.
118
+ # @return [Scheduler]
119
+ attr_reader :scheduler
120
+
121
+ # @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
122
+ # @api private
123
+ def initialize(scheduler: ThreadScheduler.new)
124
+ @scheduler = scheduler
125
+ @task_registry = TaskRegistry.new
126
+ @metrics = RuntimeMetrics.new
127
+ @gate_registry = Phronomy::Concurrency::GateRegistry.new
128
+ @pool_registry = Phronomy::Concurrency::PoolRegistry.new
129
+ @timer_service = TimerService.new(scheduler)
130
+ end
131
+
132
+ # Returns (or lazily creates) the {ConcurrencyGate} for the named resource.
133
+ #
134
+ # Gate caps are read from the global {Phronomy::Configuration} when the gate
135
+ # is first accessed; subsequent calls return the cached gate. To change the
136
+ # cap at runtime, call {#reset_gate} first.
137
+ #
138
+ # @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
139
+ # @return [ConcurrencyGate]
140
+ # @api private
141
+ def gate(name)
142
+ @gate_registry.get(name.to_sym)
143
+ end
144
+
145
+ # Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
146
+ # it from the current configuration. Useful in tests.
147
+ #
148
+ # @param name [Symbol]
149
+ # @return [void]
150
+ # @api private
151
+ def reset_gate(name)
152
+ @gate_registry.reset(name.to_sym)
153
+ end
154
+
155
+ # Cooperative yield point.
156
+ #
157
+ # Signals the scheduler that the current task is willing to give up CPU time
158
+ # so that other ready tasks can run. On the default {ThreadScheduler} this
159
+ # calls +Thread.pass+. On a future fiber-based scheduler this would switch
160
+ # to the next runnable fiber.
161
+ #
162
+ # When +blocking_detect_threshold_ms+ is configured, checks whether the
163
+ # current task has exceeded that threshold without yielding; if so, emits a
164
+ # warning via the configured logger and increments
165
+ # +non_yield_threshold_violation_count+.
166
+ #
167
+ # Call this inside tight loops or CPU-intensive sections of tool +execute+
168
+ # methods and Workflow actions to keep the scheduler responsive.
169
+ #
170
+ # @return [void]
171
+ # @api private
172
+ def yield
173
+ if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
174
+ slice_start = Task.current_cpu_slice_start_ms
175
+ if slice_start
176
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
177
+ if elapsed > threshold
178
+ name = Task.current&.name || "unknown"
179
+ Phronomy.configuration.logger&.warn(
180
+ "[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
181
+ "without yielding (threshold: #{threshold}ms)"
182
+ )
183
+ @metrics.increment_starvation
184
+ end
185
+ end
186
+ end
187
+ Task.record_yield!
188
+ @scheduler.yield
189
+ end
190
+
191
+ # Number of times a task has exceeded the CPU-bound detection threshold
192
+ # (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
193
+ # Resets to 0 when the Runtime is recreated.
194
+ # @return [Integer]
195
+ # @api private
196
+ def non_yield_threshold_violation_count
197
+ @metrics.starvation_count
198
+ end
199
+
200
+ # Cooperative yield point with a call-count gate.
201
+ #
202
+ # Increments a per-thread counter and calls {#yield} when the counter
203
+ # reaches a multiple of +every+. The counter is thread-local so concurrent
204
+ # tasks each maintain their own independent loop counter without requiring
205
+ # a mutex.
206
+ #
207
+ # @example
208
+ # data.each_with_index do |row, i|
209
+ # process(row)
210
+ # Phronomy::Runtime.instance.yield_if_needed(every: 500)
211
+ # end
212
+ #
213
+ # @param every [Integer] yield once every N calls (default: 1000)
214
+ # @return [void]
215
+ # @api private
216
+ def yield_if_needed(every: 1000)
217
+ # Delegate Thread.current access to Task so that runtime.rb stays outside
218
+ # the Thread.current allowlist (Issue #302).
219
+ self.yield if (Task.increment_yield_counter! % every).zero?
220
+ end
221
+
222
+ # Creates a new {TaskGroup} with an optional concurrency cap.
223
+ #
224
+ # @param limit [Integer, Float::INFINITY] max simultaneous tasks
225
+ # @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
226
+ # @return [TaskGroup]
227
+ # @api private
228
+ def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
229
+ TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
230
+ end
231
+
232
+ # Spawns a single {Task} using the runtime's scheduler.
233
+ #
234
+ # The spawned task is registered in the task registry so {#shutdown}
235
+ # can wait for it to complete. The task is automatically deregistered
236
+ # from the registry when it finishes (success, failure, or cancellation)
237
+ # so long-lived runtimes do not accumulate stale references.
238
+ #
239
+ # Task names beginning with a recognised type prefix are counted in the
240
+ # task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
241
+ # +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
242
+ #
243
+ # @param name [String, nil] optional label for debugging
244
+ # @yield block to execute (concurrently or synchronously, depending on
245
+ # the configured scheduler)
246
+ # @return [Task]
247
+ # @api private
248
+ def spawn(name: nil, &block)
249
+ type = _task_type(name)
250
+ spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
251
+ @metrics.record_start(type)
252
+
253
+ task = @scheduler.spawn(name: name, parent: Task.current) do
254
+ run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
255
+ @metrics.record_wait(run_start - spawn_at)
256
+ begin
257
+ result = block.call
258
+ @metrics.record_end(type, :completed, run_start)
259
+ result
260
+ rescue CancellationError
261
+ @metrics.record_end(type, :cancelled, run_start)
262
+ raise
263
+ rescue => e
264
+ @metrics.record_end(type, :failed, run_start)
265
+ raise e
266
+ ensure
267
+ current = Task.current
268
+ @task_registry.deregister(current) if current
269
+ end
270
+ end
271
+ @task_registry.register(task)
272
+ task
273
+ end
274
+
275
+ # Returns a snapshot of task-centric metrics for the current Runtime.
276
+ #
277
+ # | Key | Description |
278
+ # |-----|-------------|
279
+ # | `active_agent_tasks` | currently running agent spawns |
280
+ # | `active_tool_tasks` | currently running tool spawns |
281
+ # | `active_workflow_tasks` | currently running workflow spawns |
282
+ # | `active_rag_tasks` | currently running RAG fetches |
283
+ # | `active_llm_tasks` | currently running LLM calls |
284
+ # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
285
+ # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
286
+ # | `task_run_time_p50_ms` | p50 execution duration (ms) |
287
+ # | `task_run_time_p95_ms` | p95 execution duration (ms) |
288
+ # | `cancelled_tasks` | total cancelled task count |
289
+ # | `failed_tasks` | total failed task count |
290
+ # | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
291
+ #
292
+ # @return [Hash{Symbol => Numeric}]
293
+ # @api private
294
+ def task_snapshot
295
+ @metrics.snapshot
296
+ end
297
+
298
+ # Returns the shared {BlockingAdapterPool} for this Runtime.
299
+ # All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
300
+ # submitted through this pool.
301
+ #
302
+ # Pool settings default to 10 workers / 100-deep queue. Override by
303
+ # constructing a Runtime with custom pool options or by replacing the
304
+ # shared Runtime via {.instance=} in tests.
305
+ #
306
+ # @param pool_size [Integer] worker thread count (default: 10)
307
+ # @param queue_size [Integer] max pending operations (default: 100)
308
+ # @return [BlockingAdapterPool]
309
+ # @api private
310
+ def blocking_io(pool_size: 10, queue_size: 100)
311
+ @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
312
+ end
313
+
314
+ # Returns (or lazily creates) a named {BlockingAdapterPool}.
315
+ #
316
+ # Named pools allow per-subsystem thread-budget control and observability.
317
+ # Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
318
+ # Each pool gets its own dedicated worker threads labelled with the pool name.
319
+ #
320
+ # @example
321
+ # runtime.pool(:llm) # default size (10 workers)
322
+ # runtime.pool(:db, size: 20) # custom size
323
+ #
324
+ # @param name [Symbol, String] pool identifier
325
+ # @param size [Integer] worker thread count (default: 10)
326
+ # @param queue_size [Integer] max pending operations (default: 100)
327
+ # @return [BlockingAdapterPool]
328
+ # @api private
329
+ def pool(name, size: 10, queue_size: 100)
330
+ @pool_registry.named_pool(name, size: size, queue_size: queue_size)
331
+ end
332
+
333
+ # Returns the shared timer queue for this Runtime.
334
+ #
335
+ # When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
336
+ # runtime backend), returns a {SchedulerTimerAdapter} that integrates with
337
+ # the scheduler's tick cycle instead of spawning a background OS thread.
338
+ # This is the first concrete step of the TimerQueue scheduler-tick integration
339
+ # described in ADR-010 (Issue #331).
340
+ #
341
+ # For all other schedulers, returns a {TimerQueue} backed by a single
342
+ # background thread.
343
+ #
344
+ # All deadline-based cancellation should be registered here instead of
345
+ # spawning one-off sleep threads. Lazily created on first access.
346
+ #
347
+ # @return [TimerQueue, SchedulerTimerAdapter]
348
+ # @api private
349
+ def timer_queue
350
+ @timer_service.timer_queue
351
+ end
352
+
353
+ # Waits for all registered tasks to finish, then shuts down the
354
+ # EventLoop (if active), blocking adapter pool, named pools, and timer queue
355
+ # (if they were started).
356
+ #
357
+ # When EventLoop mode is enabled, all pending Workflow and Agent FSM events
358
+ # are drained before pools are shut down, ensuring in-flight sessions
359
+ # complete cleanly.
360
+ #
361
+ # Call this before process exit to avoid leaving orphaned threads or
362
+ # pending work items.
363
+ #
364
+ # @return [void]
365
+ # @api private
366
+ def shutdown
367
+ @task_registry.drain
368
+ # Drain EventLoop events before stopping pools so that in-flight
369
+ # Workflow / Agent FSM sessions can complete their final LLM calls.
370
+ if Phronomy.configuration.event_loop
371
+ Phronomy::EventLoop.instance.stop(drain: true)
372
+ end
373
+ @pool_registry.shutdown
374
+ @timer_service.shutdown
375
+ end
376
+
377
+ private
378
+
379
+ TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
380
+ private_constant :TASK_TYPE_PREFIXES
381
+
382
+ def _task_type(name)
383
+ return :other if name.nil?
384
+
385
+ prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
386
+ prefix ? prefix.to_sym : :other
387
+ end
388
+ end
389
+ end
@@ -4,9 +4,9 @@ module Phronomy
4
4
  # Text splitter implementations for chunking documents before embedding.
5
5
  #
6
6
  # Sub-classes are auto-loaded by Zeitwerk:
7
- # Phronomy::Splitter::Base
8
- # Phronomy::Splitter::FixedSizeSplitter
9
- # Phronomy::Splitter::RecursiveSplitter
7
+ # Phronomy::Agent::Context::Knowledge::Splitter::Base
8
+ # Phronomy::Agent::Context::Knowledge::Splitter::FixedSizeSplitter
9
+ # Phronomy::Agent::Context::Knowledge::Splitter::RecursiveSplitter
10
10
  module Splitter
11
11
  end
12
12
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Task
5
+ # Abstract base class for Task execution backends.
6
+ #
7
+ # A backend encapsulates the execution primitive (Thread, Fiber, etc.) and
8
+ # the lifecycle transitions it drives. Concrete backends must implement all
9
+ # abstract methods. The default concrete implementation is {ThreadBackend}.
10
+ #
11
+ # Backends receive a reference to the owning {Task} so they can call
12
+ # {Task#transition!} at the appropriate lifecycle points.
13
+ class Backend
14
+ # @param task [Task] the owning Task (used for status callbacks)
15
+ # @param block [Proc] the work to execute
16
+ # @api private
17
+ def initialize(task:, &block)
18
+ @task = task
19
+ @block = block
20
+ end
21
+
22
+ # Blocks until the task completes and returns its value.
23
+ # Re-raises errors from the block.
24
+ # @return [Object]
25
+ # @raise [Exception]
26
+ # @api private
27
+ def await
28
+ raise NotImplementedError, "#{self.class}#await not implemented"
29
+ end
30
+
31
+ # Returns +true+ while execution is still ongoing.
32
+ # @return [Boolean]
33
+ # @api private
34
+ def alive?
35
+ raise NotImplementedError, "#{self.class}#alive? not implemented"
36
+ end
37
+
38
+ # Requests cancellation.
39
+ # Thread-based backends may use +Thread#raise+; cooperative backends
40
+ # should mark the task cancelled and rely on {Task.checkpoint!}.
41
+ # @return [self]
42
+ # @api private
43
+ def cancel!
44
+ raise NotImplementedError, "#{self.class}#cancel! not implemented"
45
+ end
46
+
47
+ # Joins the execution context with an optional timeout.
48
+ # Returns +nil+ when a non-nil +limit+ expires before completion,
49
+ # matching +Thread#join+ semantics.
50
+ # @param limit [Numeric, nil]
51
+ # @return [Object, nil]
52
+ # @api private
53
+ def join(limit = nil)
54
+ raise NotImplementedError, "#{self.class}#join not implemented"
55
+ end
56
+
57
+ # Returns the task's result value once it has reached a terminal state.
58
+ # Only valid to call after the task is done.
59
+ # Subclasses should override if they store the result.
60
+ # @return [Object, nil]
61
+ # @api private
62
+ def completed_value
63
+ nil
64
+ end
65
+
66
+ # Returns the exception raised by the task, or +nil+ on success/cancellation.
67
+ # Only valid to call after the task is done.
68
+ # Subclasses should override if they store errors.
69
+ # @return [Exception, nil]
70
+ # @api private
71
+ def completed_error
72
+ nil
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :task, :block
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Task
5
+ # Cooperative task backend using Ruby Fibers.
6
+ #
7
+ # Unlike {ImmediateBackend} (which runs the block to completion on the
8
+ # calling thread) or {ThreadBackend} (which runs the block on a new OS
9
+ # thread), +FiberBackend+ wraps the block in a +Fiber+ that is NOT started
10
+ # immediately. The owning scheduler calls {#step} to advance execution one
11
+ # cooperative step at a time.
12
+ #
13
+ # This backend is used exclusively by {Runtime::DeterministicScheduler} to
14
+ # enable deterministic, wall-clock-free testing of concurrent logic.
15
+ #
16
+ # Thread-local key under which the currently active {DeterministicScheduler}
17
+ # is stored so that {#await} can suspend cooperatively.
18
+ SCHEDULER_KEY = :phronomy_deterministic_scheduler
19
+
20
+ # @api private
21
+ class FiberBackend < Backend
22
+ def initialize(task:, &block)
23
+ super
24
+ @value = nil
25
+ @error = nil
26
+ @cancel_error = nil
27
+ @cancel_requested = false
28
+ @started = false
29
+ @cooperative_suspend = false
30
+
31
+ # Capture `self` (the FiberBackend instance) in the closure so that
32
+ # instance-variable writes from inside the Fiber update this object.
33
+ @fiber = Fiber.new do
34
+ task.transition!(:running)
35
+ begin
36
+ # If cancel! was called before the first step, raise immediately.
37
+ raise @cancel_error if @cancel_error
38
+
39
+ @value = block.call
40
+ task.transition!(:completed, value: @value)
41
+ rescue CancellationError => e
42
+ task.transition!(:cancelled, error: e)
43
+ @error = e
44
+ rescue => e
45
+ task.transition!(:failed, error: e)
46
+ @error = e
47
+ ensure
48
+ task.transition!(:cancelled) unless task.done?
49
+ end
50
+ end
51
+ end
52
+
53
+ # Advances execution by one scheduler step.
54
+ # Resumes the Fiber until it yields (via +Fiber.yield+) or finishes.
55
+ # Cooperative cancellation is checked at the start of each step: if
56
+ # +cancel!+ has been called, +CancellationError+ is raised inside the
57
+ # Fiber at this controlled checkpoint rather than injected at an
58
+ # arbitrary suspension point via +Fiber#raise+.
59
+ # @return [self]
60
+ # @api private
61
+ def step
62
+ return self unless @fiber.alive?
63
+
64
+ @started = true
65
+ # Deliver pending cancellation at this scheduler checkpoint rather than
66
+ # injecting it mid-Fiber via Fiber#raise (which would be preemptive).
67
+ if @cancel_requested && @cancel_error
68
+ begin
69
+ @fiber.raise(@cancel_error)
70
+ rescue FiberError
71
+ nil # Fiber completed between the check and raise — safe to ignore.
72
+ end
73
+ @cancel_requested = false
74
+ return self
75
+ end
76
+ yield_value = @fiber.resume
77
+ # A yield value of :cooperative_suspend signals that the Fiber deliberately
78
+ # suspended itself (e.g. inside CoopSignal#wait) and must NOT be
79
+ # re-enqueued by step_callable — it will be resumed by an explicit signal.
80
+ @cooperative_suspend = (yield_value == :cooperative_suspend)
81
+ self
82
+ end
83
+
84
+ # Returns +true+ if the Fiber yielded cooperatively (via a signal wait)
85
+ # and should not be automatically re-enqueued by the scheduler.
86
+ # @return [Boolean]
87
+ # @api private
88
+ def cooperative_suspend?
89
+ @cooperative_suspend
90
+ end
91
+
92
+ # Blocks until the task completes.
93
+ #
94
+ # When called from within a {DeterministicScheduler}-managed Fiber,
95
+ # suspends the current Fiber cooperatively and schedules it to resume
96
+ # when this task completes. When called from outside a managed Fiber
97
+ # (e.g. the main fiber or a regular thread), drives execution by calling
98
+ # {#step} in a loop.
99
+ #
100
+ # @return [Object]
101
+ # @raise [Exception]
102
+ # @api private
103
+ def await
104
+ unless @fiber.alive?
105
+ raise @error if @error
106
+ return @value
107
+ end
108
+
109
+ scheduler = Thread.current.thread_variable_get(SCHEDULER_KEY)
110
+ # Fiber.main was added in Ruby 3.2.4+; fall back to true (assume we are
111
+ # inside a managed Fiber whenever a scheduler is active).
112
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
113
+ if scheduler && in_managed_fiber
114
+ # Cooperative context: suspend current Fiber until task is done.
115
+ waiting_fiber = Fiber.current
116
+ @task.on_complete { scheduler.enqueue_fiber(-> { waiting_fiber.resume }) }
117
+ Fiber.yield(:cooperative_suspend)
118
+ else
119
+ # Non-cooperative context: drive the fiber to completion.
120
+ step while @fiber.alive?
121
+ end
122
+
123
+ raise @error if @error
124
+ @value
125
+ end
126
+
127
+ # @return [Boolean] +true+ while the Fiber has not yet finished
128
+ # @api private
129
+ def alive?
130
+ @fiber.alive?
131
+ end
132
+
133
+ # Requests cancellation using a cooperative checkpoint mechanism.
134
+ # Sets a cancellation flag; the error is raised inside the Fiber at the
135
+ # next +step+ call (i.e. when the scheduler next dispatches this task),
136
+ # not injected at an arbitrary suspension point via +Fiber#raise+.
137
+ # If the Fiber has not yet started, the error is recorded so it is raised
138
+ # on the first {#step}.
139
+ # @return [self]
140
+ # @api private
141
+ def cancel!
142
+ @cancel_error = CancellationError.new("Task cancelled")
143
+ @cancel_requested = true
144
+ self
145
+ end
146
+
147
+ # Joins execution by stepping until the Fiber is no longer alive.
148
+ # @param limit [Numeric, nil] ignored
149
+ # @return [self]
150
+ # @api private
151
+ def join(_limit = nil)
152
+ step while @fiber.alive?
153
+ self
154
+ end
155
+ end
156
+ end
157
+ end