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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -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 +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- 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/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -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
|
data/lib/phronomy/splitter.rb
CHANGED
|
@@ -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
|