phronomy 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
|
@@ -0,0 +1,374 @@
|
|
|
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/gate_registry"
|
|
12
|
+
require_relative "runtime/pool_registry"
|
|
13
|
+
require_relative "runtime/timer_service"
|
|
14
|
+
|
|
15
|
+
module Phronomy
|
|
16
|
+
# Central authority for concurrent primitives.
|
|
17
|
+
#
|
|
18
|
+
# +Runtime+ is the single place that creates {Task}s, {TaskGroup}s, and
|
|
19
|
+
# manages the lifecycle of all concurrency in Phronomy. It owns:
|
|
20
|
+
#
|
|
21
|
+
# * a pluggable {Scheduler} (default: {ThreadScheduler})
|
|
22
|
+
# * a task registry for graceful shutdown
|
|
23
|
+
# * the shared {BlockingAdapterPool}
|
|
24
|
+
#
|
|
25
|
+
# In production, use the process-wide singleton via {.instance}.
|
|
26
|
+
# In tests, construct a Runtime with a {FakeScheduler} to run tasks
|
|
27
|
+
# synchronously without spawning additional threads:
|
|
28
|
+
#
|
|
29
|
+
# @example Production usage
|
|
30
|
+
# group = Phronomy::Runtime.instance.task_group(limit: 4)
|
|
31
|
+
# tools.each { |t| group.spawn { t.call } }
|
|
32
|
+
# results = group.await_all
|
|
33
|
+
#
|
|
34
|
+
# @example Test usage — no extra threads
|
|
35
|
+
# runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
|
|
36
|
+
# task = runtime.spawn { 42 }
|
|
37
|
+
# expect(task.await).to eq(42)
|
|
38
|
+
class Runtime
|
|
39
|
+
# Returns the process-wide default Runtime.
|
|
40
|
+
#
|
|
41
|
+
# Auto-creates an instance using the scheduler backend specified by
|
|
42
|
+
# +Phronomy.configuration.runtime_backend+:
|
|
43
|
+
# - +:thread+ (default) — {ThreadScheduler} (one OS thread per task)
|
|
44
|
+
# - +:immediate+ — {FakeScheduler} (synchronous, no extra threads)
|
|
45
|
+
# - +:fiber+ — {DeterministicScheduler} in autorun mode (EXPERIMENTAL;
|
|
46
|
+
# Fiber-based synchronous execution; not yet suitable for production
|
|
47
|
+
# because it uses virtual time rather than real wall-clock timers)
|
|
48
|
+
# - +:cooperative+ — deprecated alias for +:immediate+
|
|
49
|
+
#
|
|
50
|
+
# @return [Runtime]
|
|
51
|
+
# @api private
|
|
52
|
+
def self.instance
|
|
53
|
+
@instance ||= begin
|
|
54
|
+
scheduler = case Phronomy.configuration.runtime_backend
|
|
55
|
+
when :cooperative
|
|
56
|
+
Phronomy.configuration.logger&.warn(
|
|
57
|
+
"[phronomy] runtime_backend: :cooperative is a deprecated alias for :immediate. " \
|
|
58
|
+
"Use :immediate for synchronous/test execution. " \
|
|
59
|
+
":cooperative will be reassigned when a real cooperative Fiber-based scheduler is available."
|
|
60
|
+
)
|
|
61
|
+
FakeScheduler.new
|
|
62
|
+
when :immediate
|
|
63
|
+
FakeScheduler.new
|
|
64
|
+
when :fiber
|
|
65
|
+
Phronomy.configuration.logger&.warn(
|
|
66
|
+
"[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
|
|
67
|
+
"This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
|
|
68
|
+
"Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
|
|
69
|
+
"Not recommended for production use."
|
|
70
|
+
)
|
|
71
|
+
DeterministicScheduler.new(autorun: true)
|
|
72
|
+
else
|
|
73
|
+
ThreadScheduler.new
|
|
74
|
+
end
|
|
75
|
+
new(scheduler: scheduler)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Replaces the process-wide default Runtime. Useful in tests.
|
|
80
|
+
# @param runtime [Runtime]
|
|
81
|
+
# @return [Runtime]
|
|
82
|
+
# @api private
|
|
83
|
+
def self.instance=(runtime)
|
|
84
|
+
@instance = runtime
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns +true+ when the calling thread is executing inside an active
|
|
88
|
+
# scheduler task (i.e. {Task.current} is non-nil). Code running inside
|
|
89
|
+
# a {Runtime#spawn} block is always in a scheduler context.
|
|
90
|
+
#
|
|
91
|
+
# Use this to detect potential scheduler-blocking calls:
|
|
92
|
+
# if Phronomy::Runtime.in_scheduler_context?
|
|
93
|
+
# Phronomy.configuration.logger&.warn("blocking call inside scheduler task")
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
# @api private
|
|
98
|
+
def self.in_scheduler_context?
|
|
99
|
+
!Task.current.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The scheduler backing this runtime instance.
|
|
103
|
+
# @return [Scheduler]
|
|
104
|
+
attr_reader :scheduler
|
|
105
|
+
|
|
106
|
+
# @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
|
|
107
|
+
# @api private
|
|
108
|
+
def initialize(scheduler: ThreadScheduler.new)
|
|
109
|
+
@scheduler = scheduler
|
|
110
|
+
@task_registry = TaskRegistry.new
|
|
111
|
+
@metrics = RuntimeMetrics.new
|
|
112
|
+
@gate_registry = GateRegistry.new
|
|
113
|
+
@pool_registry = PoolRegistry.new
|
|
114
|
+
@timer_service = TimerService.new(scheduler)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns (or lazily creates) the {ConcurrencyGate} for the named resource.
|
|
118
|
+
#
|
|
119
|
+
# Gate caps are read from the global {Phronomy::Configuration} when the gate
|
|
120
|
+
# is first accessed; subsequent calls return the cached gate. To change the
|
|
121
|
+
# cap at runtime, call {#reset_gate} first.
|
|
122
|
+
#
|
|
123
|
+
# @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
|
|
124
|
+
# @return [ConcurrencyGate]
|
|
125
|
+
# @api private
|
|
126
|
+
def gate(name)
|
|
127
|
+
@gate_registry.get(name.to_sym)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
|
|
131
|
+
# it from the current configuration. Useful in tests.
|
|
132
|
+
#
|
|
133
|
+
# @param name [Symbol]
|
|
134
|
+
# @return [void]
|
|
135
|
+
# @api private
|
|
136
|
+
def reset_gate(name)
|
|
137
|
+
@gate_registry.reset(name.to_sym)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Cooperative yield point.
|
|
141
|
+
#
|
|
142
|
+
# Signals the scheduler that the current task is willing to give up CPU time
|
|
143
|
+
# so that other ready tasks can run. On the default {ThreadScheduler} this
|
|
144
|
+
# calls +Thread.pass+. On a future fiber-based scheduler this would switch
|
|
145
|
+
# to the next runnable fiber.
|
|
146
|
+
#
|
|
147
|
+
# When +blocking_detect_threshold_ms+ is configured, checks whether the
|
|
148
|
+
# current task has exceeded that threshold without yielding; if so, emits a
|
|
149
|
+
# warning via the configured logger and increments
|
|
150
|
+
# +non_yield_threshold_violation_count+.
|
|
151
|
+
#
|
|
152
|
+
# Call this inside tight loops or CPU-intensive sections of tool +execute+
|
|
153
|
+
# methods and Workflow actions to keep the scheduler responsive.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
# @api private
|
|
157
|
+
def yield
|
|
158
|
+
if (threshold = Phronomy.configuration.blocking_detect_threshold_ms)
|
|
159
|
+
slice_start = Task.current_cpu_slice_start_ms
|
|
160
|
+
if slice_start
|
|
161
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
|
|
162
|
+
if elapsed > threshold
|
|
163
|
+
name = Task.current&.name || "unknown"
|
|
164
|
+
Phronomy.configuration.logger&.warn(
|
|
165
|
+
"[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
|
|
166
|
+
"without yielding (threshold: #{threshold}ms)"
|
|
167
|
+
)
|
|
168
|
+
@metrics.increment_starvation
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
Task.record_yield!
|
|
173
|
+
@scheduler.yield
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Number of times a task has exceeded the CPU-bound detection threshold
|
|
177
|
+
# (i.e. ran longer than +blocking_detect_threshold_ms+ without yielding).
|
|
178
|
+
# Resets to 0 when the Runtime is recreated.
|
|
179
|
+
# @return [Integer]
|
|
180
|
+
# @api private
|
|
181
|
+
def non_yield_threshold_violation_count
|
|
182
|
+
@metrics.starvation_count
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Cooperative yield point with a call-count gate.
|
|
186
|
+
#
|
|
187
|
+
# Increments a per-thread counter and calls {#yield} when the counter
|
|
188
|
+
# reaches a multiple of +every+. The counter is thread-local so concurrent
|
|
189
|
+
# tasks each maintain their own independent loop counter without requiring
|
|
190
|
+
# a mutex.
|
|
191
|
+
#
|
|
192
|
+
# @example
|
|
193
|
+
# data.each_with_index do |row, i|
|
|
194
|
+
# process(row)
|
|
195
|
+
# Phronomy::Runtime.instance.yield_if_needed(every: 500)
|
|
196
|
+
# end
|
|
197
|
+
#
|
|
198
|
+
# @param every [Integer] yield once every N calls (default: 1000)
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @api private
|
|
201
|
+
def yield_if_needed(every: 1000)
|
|
202
|
+
# Delegate Thread.current access to Task so that runtime.rb stays outside
|
|
203
|
+
# the Thread.current allowlist (Issue #302).
|
|
204
|
+
self.yield if (Task.increment_yield_counter! % every).zero?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Creates a new {TaskGroup} with an optional concurrency cap.
|
|
208
|
+
#
|
|
209
|
+
# @param limit [Integer, Float::INFINITY] max simultaneous tasks
|
|
210
|
+
# @param failure_policy [Symbol] one of :fail_fast, :collect_all, :skip_failed (default :fail_fast)
|
|
211
|
+
# @return [TaskGroup]
|
|
212
|
+
# @api private
|
|
213
|
+
def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
|
|
214
|
+
TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Spawns a single {Task} using the runtime's scheduler.
|
|
218
|
+
#
|
|
219
|
+
# The spawned task is registered in the task registry so {#shutdown}
|
|
220
|
+
# can wait for it to complete. The task is automatically deregistered
|
|
221
|
+
# from the registry when it finishes (success, failure, or cancellation)
|
|
222
|
+
# so long-lived runtimes do not accumulate stale references.
|
|
223
|
+
#
|
|
224
|
+
# Task names beginning with a recognised type prefix are counted in the
|
|
225
|
+
# task-centric metrics returned by {#task_snapshot}. Recognised prefixes:
|
|
226
|
+
# +agent-+, +tool-+, +workflow-+, +rag-+, +llm-+, +vector-+.
|
|
227
|
+
#
|
|
228
|
+
# @param name [String, nil] optional label for debugging
|
|
229
|
+
# @yield block to execute (concurrently or synchronously, depending on
|
|
230
|
+
# the configured scheduler)
|
|
231
|
+
# @return [Task]
|
|
232
|
+
# @api private
|
|
233
|
+
def spawn(name: nil, &block)
|
|
234
|
+
type = _task_type(name)
|
|
235
|
+
spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
236
|
+
@metrics.record_start(type)
|
|
237
|
+
|
|
238
|
+
task = @scheduler.spawn(name: name, parent: Task.current) do
|
|
239
|
+
run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
240
|
+
@metrics.record_wait(run_start - spawn_at)
|
|
241
|
+
begin
|
|
242
|
+
result = block.call
|
|
243
|
+
@metrics.record_end(type, :completed, run_start)
|
|
244
|
+
result
|
|
245
|
+
rescue CancellationError
|
|
246
|
+
@metrics.record_end(type, :cancelled, run_start)
|
|
247
|
+
raise
|
|
248
|
+
rescue => e
|
|
249
|
+
@metrics.record_end(type, :failed, run_start)
|
|
250
|
+
raise e
|
|
251
|
+
ensure
|
|
252
|
+
current = Task.current
|
|
253
|
+
@task_registry.deregister(current) if current
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
@task_registry.register(task)
|
|
257
|
+
task
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Returns a snapshot of task-centric metrics for the current Runtime.
|
|
261
|
+
#
|
|
262
|
+
# | Key | Description |
|
|
263
|
+
# |-----|-------------|
|
|
264
|
+
# | `active_agent_tasks` | currently running agent spawns |
|
|
265
|
+
# | `active_tool_tasks` | currently running tool spawns |
|
|
266
|
+
# | `active_workflow_tasks` | currently running workflow spawns |
|
|
267
|
+
# | `active_rag_tasks` | currently running RAG fetches |
|
|
268
|
+
# | `active_llm_tasks` | currently running LLM calls |
|
|
269
|
+
# | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
|
|
270
|
+
# | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
|
|
271
|
+
# | `task_run_time_p50_ms` | p50 execution duration (ms) |
|
|
272
|
+
# | `task_run_time_p95_ms` | p95 execution duration (ms) |
|
|
273
|
+
# | `cancelled_tasks` | total cancelled task count |
|
|
274
|
+
# | `failed_tasks` | total failed task count |
|
|
275
|
+
# | `non_yield_threshold_violation_count` | cumulative count of tasks that ran past `blocking_detect_threshold_ms` without yielding |
|
|
276
|
+
#
|
|
277
|
+
# @return [Hash{Symbol => Numeric}]
|
|
278
|
+
# @api private
|
|
279
|
+
def task_snapshot
|
|
280
|
+
@metrics.snapshot
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns the shared {BlockingAdapterPool} for this Runtime.
|
|
284
|
+
# All blocking I/O (LLM HTTP, MCP, ActiveRecord, Redis) should be
|
|
285
|
+
# submitted through this pool.
|
|
286
|
+
#
|
|
287
|
+
# Pool settings default to 10 workers / 100-deep queue. Override by
|
|
288
|
+
# constructing a Runtime with custom pool options or by replacing the
|
|
289
|
+
# shared Runtime via {.instance=} in tests.
|
|
290
|
+
#
|
|
291
|
+
# @param pool_size [Integer] worker thread count (default: 10)
|
|
292
|
+
# @param queue_size [Integer] max pending operations (default: 100)
|
|
293
|
+
# @return [BlockingAdapterPool]
|
|
294
|
+
# @api private
|
|
295
|
+
def blocking_io(pool_size: 10, queue_size: 100)
|
|
296
|
+
@pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Returns (or lazily creates) a named {BlockingAdapterPool}.
|
|
300
|
+
#
|
|
301
|
+
# Named pools allow per-subsystem thread-budget control and observability.
|
|
302
|
+
# Recommended pool names: +:llm+, +:mcp+, +:db+, +:redis+, +:tool+.
|
|
303
|
+
# Each pool gets its own dedicated worker threads labelled with the pool name.
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# runtime.pool(:llm) # default size (10 workers)
|
|
307
|
+
# runtime.pool(:db, size: 20) # custom size
|
|
308
|
+
#
|
|
309
|
+
# @param name [Symbol, String] pool identifier
|
|
310
|
+
# @param size [Integer] worker thread count (default: 10)
|
|
311
|
+
# @param queue_size [Integer] max pending operations (default: 100)
|
|
312
|
+
# @return [BlockingAdapterPool]
|
|
313
|
+
# @api private
|
|
314
|
+
def pool(name, size: 10, queue_size: 100)
|
|
315
|
+
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Returns the shared timer queue for this Runtime.
|
|
319
|
+
#
|
|
320
|
+
# When the scheduler is a {DeterministicScheduler} (e.g. the +:fiber+
|
|
321
|
+
# runtime backend), returns a {SchedulerTimerAdapter} that integrates with
|
|
322
|
+
# the scheduler's tick cycle instead of spawning a background OS thread.
|
|
323
|
+
# This is the first concrete step of the TimerQueue scheduler-tick integration
|
|
324
|
+
# described in ADR-010 (Issue #331).
|
|
325
|
+
#
|
|
326
|
+
# For all other schedulers, returns a {TimerQueue} backed by a single
|
|
327
|
+
# background thread.
|
|
328
|
+
#
|
|
329
|
+
# All deadline-based cancellation should be registered here instead of
|
|
330
|
+
# spawning one-off sleep threads. Lazily created on first access.
|
|
331
|
+
#
|
|
332
|
+
# @return [TimerQueue, SchedulerTimerAdapter]
|
|
333
|
+
# @api private
|
|
334
|
+
def timer_queue
|
|
335
|
+
@timer_service.timer_queue
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Waits for all registered tasks to finish, then shuts down the
|
|
339
|
+
# EventLoop (if active), blocking adapter pool, named pools, and timer queue
|
|
340
|
+
# (if they were started).
|
|
341
|
+
#
|
|
342
|
+
# When EventLoop mode is enabled, all pending Workflow and Agent FSM events
|
|
343
|
+
# are drained before pools are shut down, ensuring in-flight sessions
|
|
344
|
+
# complete cleanly.
|
|
345
|
+
#
|
|
346
|
+
# Call this before process exit to avoid leaving orphaned threads or
|
|
347
|
+
# pending work items.
|
|
348
|
+
#
|
|
349
|
+
# @return [void]
|
|
350
|
+
# @api private
|
|
351
|
+
def shutdown
|
|
352
|
+
@task_registry.drain
|
|
353
|
+
# Drain EventLoop events before stopping pools so that in-flight
|
|
354
|
+
# Workflow / Agent FSM sessions can complete their final LLM calls.
|
|
355
|
+
if Phronomy.configuration.event_loop
|
|
356
|
+
Phronomy::EventLoop.instance.stop(drain: true)
|
|
357
|
+
end
|
|
358
|
+
@pool_registry.shutdown
|
|
359
|
+
@timer_service.shutdown
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private
|
|
363
|
+
|
|
364
|
+
TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
|
|
365
|
+
private_constant :TASK_TYPE_PREFIXES
|
|
366
|
+
|
|
367
|
+
def _task_type(name)
|
|
368
|
+
return :other if name.nil?
|
|
369
|
+
|
|
370
|
+
prefix = TASK_TYPE_PREFIXES.find { |p| name.to_s.start_with?("#{p}-") }
|
|
371
|
+
prefix ? prefix.to_sym : :other
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -18,6 +18,7 @@ module Phronomy
|
|
|
18
18
|
# returned by a Loader, or a plain String.
|
|
19
19
|
# @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
|
|
20
20
|
# @raise [NotImplementedError] when not overridden by a subclass
|
|
21
|
+
# @api public
|
|
21
22
|
def split(document)
|
|
22
23
|
raise NotImplementedError, "#{self.class}#split is not implemented"
|
|
23
24
|
end
|
|
@@ -26,6 +27,7 @@ module Phronomy
|
|
|
26
27
|
#
|
|
27
28
|
# @param documents [Array<Hash, String>]
|
|
28
29
|
# @return [Array<Hash>]
|
|
30
|
+
# @api public
|
|
29
31
|
def split_all(documents)
|
|
30
32
|
documents.flat_map { |doc| split(doc) }
|
|
31
33
|
end
|
|
@@ -15,6 +15,7 @@ module Phronomy
|
|
|
15
15
|
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
16
16
|
# @param chunk_overlap [Integer] characters to repeat at the start of each
|
|
17
17
|
# subsequent chunk (default: 200); must be less than chunk_size
|
|
18
|
+
# @api public
|
|
18
19
|
def initialize(chunk_size: 1000, chunk_overlap: 200)
|
|
19
20
|
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
20
21
|
|
|
@@ -24,6 +25,7 @@ module Phronomy
|
|
|
24
25
|
|
|
25
26
|
# @param document [Hash, String]
|
|
26
27
|
# @return [Array<Hash>]
|
|
28
|
+
# @api public
|
|
27
29
|
def split(document)
|
|
28
30
|
doc = normalise(document)
|
|
29
31
|
text = doc[:text]
|
|
@@ -25,6 +25,7 @@ module Phronomy
|
|
|
25
25
|
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
26
26
|
# @param chunk_overlap [Integer] overlap characters (default: 200)
|
|
27
27
|
# @param separators [Array<String>] separator list in priority order
|
|
28
|
+
# @api public
|
|
28
29
|
def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
|
|
29
30
|
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ module Phronomy
|
|
|
35
36
|
|
|
36
37
|
# @param document [Hash, String]
|
|
37
38
|
# @return [Array<Hash>]
|
|
39
|
+
# @api public
|
|
38
40
|
def split(document)
|
|
39
41
|
doc = normalise(document)
|
|
40
42
|
texts = recursive_split(doc[:text], @separators)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module StateStore
|
|
5
|
+
# Abstract base class for workflow state persistence backends.
|
|
6
|
+
#
|
|
7
|
+
# Subclasses must implement {#load}, {#save}, and {#delete}.
|
|
8
|
+
# A snapshot is a plain +Hash+ with two keys:
|
|
9
|
+
# +:fields+ — output of +context.to_h+
|
|
10
|
+
# +:phase+ — +context.phase.to_s+
|
|
11
|
+
#
|
|
12
|
+
# @example Implementing a custom backend
|
|
13
|
+
# class MyStore < Phronomy::StateStore::Base
|
|
14
|
+
# def load(thread_id) = MyRecord.find_by(thread_id:)&.to_h
|
|
15
|
+
# def save(thread_id, snapshot) = MyRecord.upsert(thread_id:, data: snapshot)
|
|
16
|
+
# def delete(thread_id) = MyRecord.where(thread_id:).delete_all
|
|
17
|
+
# end
|
|
18
|
+
class Base
|
|
19
|
+
# Load the stored snapshot for +thread_id+.
|
|
20
|
+
#
|
|
21
|
+
# @param thread_id [String]
|
|
22
|
+
# @return [Hash, nil] stored snapshot hash, or +nil+ if absent
|
|
23
|
+
# @api public
|
|
24
|
+
def load(thread_id)
|
|
25
|
+
raise NotImplementedError, "#{self.class}#load is not implemented"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Persist +snapshot+ for +thread_id+. Overwrites any existing snapshot.
|
|
29
|
+
#
|
|
30
|
+
# @param thread_id [String]
|
|
31
|
+
# @param snapshot [Hash] serialisable hash of workflow state
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @api public
|
|
34
|
+
def save(thread_id, snapshot)
|
|
35
|
+
raise NotImplementedError, "#{self.class}#save is not implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Delete the stored snapshot for +thread_id+. No-op if absent.
|
|
39
|
+
#
|
|
40
|
+
# @param thread_id [String]
|
|
41
|
+
# @return [void]
|
|
42
|
+
# @api public
|
|
43
|
+
def delete(thread_id)
|
|
44
|
+
raise NotImplementedError, "#{self.class}#delete is not implemented"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module StateStore
|
|
5
|
+
# Thread-safe in-process state store backed by a plain Ruby Hash.
|
|
6
|
+
#
|
|
7
|
+
# Used as the recommended default for single-process applications and tests.
|
|
8
|
+
# State does not survive process restart.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# store = Phronomy::StateStore::InMemory.new
|
|
12
|
+
# store.save("t1", { fields: { count: 1 }, phase: "__end__" })
|
|
13
|
+
# store.load("t1") # => { fields: { count: 1 }, phase: "__end__" }
|
|
14
|
+
# store.delete("t1")
|
|
15
|
+
# store.load("t1") # => nil
|
|
16
|
+
class InMemory < Base
|
|
17
|
+
def initialize
|
|
18
|
+
@data = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param thread_id [String]
|
|
23
|
+
# @return [Hash, nil]
|
|
24
|
+
# @api public
|
|
25
|
+
def load(thread_id)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
snap = @data[thread_id]
|
|
28
|
+
snap ? deep_dup(snap) : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param thread_id [String]
|
|
33
|
+
# @param snapshot [Hash]
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @api public
|
|
36
|
+
def save(thread_id, snapshot)
|
|
37
|
+
@mutex.synchronize { @data[thread_id] = deep_dup(snapshot) }
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param thread_id [String]
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @api public
|
|
44
|
+
def delete(thread_id)
|
|
45
|
+
@mutex.synchronize { @data.delete(thread_id) }
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Recursively deep-duplicates a plain-data value (Hash, Array, or scalar).
|
|
52
|
+
# Sufficient for snapshot data which consists of JSON-compatible types.
|
|
53
|
+
def deep_dup(val)
|
|
54
|
+
case val
|
|
55
|
+
when Hash then val.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
56
|
+
when Array then val.map { |v| deep_dup(v) }
|
|
57
|
+
else val.frozen? ? val : (val.dup rescue val) # rubocop:disable Style/RescueModifier
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
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
|