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
data/lib/phronomy/runnable.rb
CHANGED
|
@@ -25,13 +25,30 @@ module Phronomy
|
|
|
25
25
|
# Yields a span; the block must return [result, usage] where usage is a
|
|
26
26
|
# Phronomy::TokenUsage or nil. Returns only the result value.
|
|
27
27
|
#
|
|
28
|
+
# When +trace_pii+ is disabled, both the input and the output (LLM response,
|
|
29
|
+
# tool result) are replaced with the literal string "[REDACTED]" before being
|
|
30
|
+
# forwarded to the tracing backend. The actual result is still returned to
|
|
31
|
+
# the caller — only the copy sent to the tracer is redacted.
|
|
32
|
+
#
|
|
28
33
|
# @example
|
|
29
34
|
# trace("my_chain", input: input) { [invoke(input), nil] }
|
|
35
|
+
# @api public
|
|
30
36
|
def trace(name, input: nil, **meta, &block)
|
|
31
|
-
# Redact user input from spans when trace_pii is disabled to prevent
|
|
32
|
-
# accidental PII transmission to external tracing backends.
|
|
33
37
|
traced_input = Phronomy.configuration.trace_pii ? input : "[REDACTED]"
|
|
34
|
-
|
|
38
|
+
|
|
39
|
+
if Phronomy.configuration.trace_pii
|
|
40
|
+
# PII recording is allowed: pass through unchanged.
|
|
41
|
+
Phronomy.configuration.tracer.trace(name, input: traced_input, **meta, &block)
|
|
42
|
+
else
|
|
43
|
+
# Redact both input (above) and output before forwarding to the tracer.
|
|
44
|
+
# Capture the real result so callers receive the unredacted value.
|
|
45
|
+
real_result = nil
|
|
46
|
+
Phronomy.configuration.tracer.trace(name, input: traced_input, **meta) do |span|
|
|
47
|
+
real_result, usage = block.call(span)
|
|
48
|
+
["[REDACTED]", usage]
|
|
49
|
+
end
|
|
50
|
+
real_result
|
|
51
|
+
end
|
|
35
52
|
end
|
|
36
53
|
end
|
|
37
54
|
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Tick-based deterministic cooperative scheduler for testing.
|
|
6
|
+
#
|
|
7
|
+
# Unlike {FakeScheduler} (which runs every task synchronously to completion
|
|
8
|
+
# before +spawn+ returns), +DeterministicScheduler+ pushes each task to a
|
|
9
|
+
# ready queue and only advances execution one step at a time via {#tick}.
|
|
10
|
+
# This makes it possible to test:
|
|
11
|
+
#
|
|
12
|
+
# - Task interleaving (two tasks yielding control back and forth)
|
|
13
|
+
# - Virtual-time timer firing order
|
|
14
|
+
# - +await+ suspension and resumption
|
|
15
|
+
# - Cancellation while a task is suspended
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# sched = Phronomy::Runtime::DeterministicScheduler.new
|
|
19
|
+
# rt = Phronomy::Runtime.new(scheduler: sched)
|
|
20
|
+
#
|
|
21
|
+
# rt.spawn { Fiber.yield; :done } # not started yet
|
|
22
|
+
# sched.tick # runs until first Fiber.yield
|
|
23
|
+
# sched.tick # runs to completion
|
|
24
|
+
# sched.run_until_idle # same as calling tick until empty
|
|
25
|
+
#
|
|
26
|
+
# @example Virtual clock
|
|
27
|
+
# sched.schedule_after(1.0) { puts "fired at T=1" }
|
|
28
|
+
# sched.advance(1.0) # moves virtual clock forward, fires the timer
|
|
29
|
+
# sched.run_until_idle # dispatches the timer callback
|
|
30
|
+
# EXPERIMENTAL Fiber-based cooperative scheduler.
|
|
31
|
+
#
|
|
32
|
+
# Uses {Task::FiberBackend} to run tasks cooperatively without OS threads.
|
|
33
|
+
# Intended for deterministic testing and, in future, as a production
|
|
34
|
+
# cooperative scheduler. Not recommended for production use.
|
|
35
|
+
#
|
|
36
|
+
# Activated via +runtime_backend: :fiber+ in {Phronomy.configure}.
|
|
37
|
+
# @api private
|
|
38
|
+
class DeterministicScheduler < Scheduler
|
|
39
|
+
# Scheduler-aware signal for cooperative suspension.
|
|
40
|
+
#
|
|
41
|
+
# Used by {ConcurrencyGate} and {TaskGroup} to suspend a Fiber until a
|
|
42
|
+
# slot or condition becomes available, without blocking the OS thread.
|
|
43
|
+
# All methods must be called from within a {DeterministicScheduler} tick.
|
|
44
|
+
# @api private
|
|
45
|
+
class CoopSignal
|
|
46
|
+
def initialize(scheduler)
|
|
47
|
+
@scheduler = scheduler
|
|
48
|
+
@waiters = [] # Array of Fiber
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Suspends the current Fiber until {#notify_one} or {#notify_all} fires.
|
|
52
|
+
# @api private
|
|
53
|
+
# @return [void]
|
|
54
|
+
def wait
|
|
55
|
+
@waiters << Fiber.current
|
|
56
|
+
# Yield with :cooperative_suspend so step_callable knows not to
|
|
57
|
+
# automatically re-enqueue this Fiber — only an explicit notify call
|
|
58
|
+
# should resume it.
|
|
59
|
+
Fiber.yield(:cooperative_suspend)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wakes up one waiting Fiber.
|
|
63
|
+
# @api private
|
|
64
|
+
# @return [void]
|
|
65
|
+
def notify_one
|
|
66
|
+
waiter = @waiters.shift
|
|
67
|
+
@scheduler.enqueue_fiber(-> { waiter.resume }) if waiter
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wakes up all waiting Fibers.
|
|
71
|
+
# @api private
|
|
72
|
+
# @return [void]
|
|
73
|
+
def notify_all
|
|
74
|
+
waiters, @waiters = @waiters, []
|
|
75
|
+
waiters.each { |w| @scheduler.enqueue_fiber(-> { w.resume }) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Float] current virtual clock time (seconds since scheduler creation)
|
|
80
|
+
attr_reader :virtual_time
|
|
81
|
+
|
|
82
|
+
# @param autorun [Boolean] when +true+, each call to {#spawn} automatically
|
|
83
|
+
# drains the ready queue via {#run_until_idle} before returning the task.
|
|
84
|
+
# This makes +DeterministicScheduler+ behave like {FakeScheduler} (tasks
|
|
85
|
+
# complete synchronously) while still executing them on real Fibers.
|
|
86
|
+
# Used internally by the +:fiber+ runtime backend.
|
|
87
|
+
# @api private
|
|
88
|
+
def initialize(autorun: false)
|
|
89
|
+
@autorun = autorun
|
|
90
|
+
@ready = [] # Array of callables ({ fiber.resume } or timer callbacks)
|
|
91
|
+
@mutex = Mutex.new
|
|
92
|
+
@virtual_time = 0.0
|
|
93
|
+
@timer_heap = [] # Array of { fire_at:, callback: }
|
|
94
|
+
@real_timer_heap = [] # Array of [fire_at_monotonic, callback] for wall-clock timers
|
|
95
|
+
@clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
96
|
+
# Tracks Fibers suspended in BlockingAdapterPool#await so that
|
|
97
|
+
# run_until_idle knows to keep looping until worker threads complete.
|
|
98
|
+
# Protected by @await_mutex (separate from @mutex to avoid contention).
|
|
99
|
+
@pending_awaits = 0
|
|
100
|
+
@await_mutex = Mutex.new
|
|
101
|
+
@await_cond = ConditionVariable.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns +true+ when this scheduler is in autorun mode.
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
# @api private
|
|
107
|
+
def autorun?
|
|
108
|
+
@autorun
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Spawns a new {Task} backed by {Task::FiberBackend} and enqueues it.
|
|
112
|
+
# The task does NOT start executing until {#tick} is called.
|
|
113
|
+
#
|
|
114
|
+
# @param name [String, nil]
|
|
115
|
+
# @param parent [Task, nil]
|
|
116
|
+
# @return [Task]
|
|
117
|
+
# @api private
|
|
118
|
+
def spawn(name:, parent:, &block)
|
|
119
|
+
task = Task.spawn(name: name, parent: parent, backend_class: Task::FiberBackend, &block)
|
|
120
|
+
backend = task.backend
|
|
121
|
+
# Build a self-rescheduling step: after each step, re-enqueue if the
|
|
122
|
+
# Fiber yielded cooperatively and is still alive.
|
|
123
|
+
step_callable = nil
|
|
124
|
+
step_callable = lambda do
|
|
125
|
+
backend.step
|
|
126
|
+
enqueue_fiber(step_callable) if backend.alive? && !backend.cooperative_suspend?
|
|
127
|
+
end
|
|
128
|
+
enqueue_fiber(step_callable)
|
|
129
|
+
# Auto-run only when called from outside a running scheduler tick.
|
|
130
|
+
# When SCHEDULER_KEY is set, the calling code is already inside a managed
|
|
131
|
+
# Fiber; the outer run_until_idle loop will pick up the new task on the
|
|
132
|
+
# next iteration without a recursive re-entry.
|
|
133
|
+
run_until_idle if @autorun && Thread.current.thread_variable_get(SCHEDULER_KEY).nil?
|
|
134
|
+
task
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Creates a new cooperative signal backed by {CoopSignal}.
|
|
138
|
+
# @return [CoopSignal]
|
|
139
|
+
# @api private
|
|
140
|
+
def new_signal
|
|
141
|
+
CoopSignal.new(self)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Suspends the current Fiber until +signal+ is notified.
|
|
145
|
+
# @param signal [CoopSignal]
|
|
146
|
+
# @return [void]
|
|
147
|
+
# @api private
|
|
148
|
+
def wait_for_signal(signal)
|
|
149
|
+
signal.wait
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Wakes up one Fiber waiting on +signal+.
|
|
153
|
+
# @param signal [CoopSignal]
|
|
154
|
+
# @return [void]
|
|
155
|
+
# @api private
|
|
156
|
+
def raise_signal(signal)
|
|
157
|
+
signal.notify_one
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Wakes up all Fibers waiting on +signal+.
|
|
161
|
+
# @param signal [CoopSignal]
|
|
162
|
+
# @return [void]
|
|
163
|
+
# @api private
|
|
164
|
+
def raise_signal_all(signal)
|
|
165
|
+
signal.notify_all
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Executes one ready entry (a fiber step or a timer callback).
|
|
169
|
+
# Sets the thread-local scheduler reference so that +FiberBackend#await+
|
|
170
|
+
# can suspend cooperatively.
|
|
171
|
+
#
|
|
172
|
+
# @return [self]
|
|
173
|
+
# @api private
|
|
174
|
+
def tick
|
|
175
|
+
callable = @mutex.synchronize { @ready.shift }
|
|
176
|
+
return self unless callable
|
|
177
|
+
|
|
178
|
+
# Use thread_variable_set (not Thread#[]) so the value is accessible from
|
|
179
|
+
# any Fiber running on this OS thread, not just the current Fiber.
|
|
180
|
+
prev = Thread.current.thread_variable_get(SCHEDULER_KEY)
|
|
181
|
+
Thread.current.thread_variable_set(SCHEDULER_KEY, self)
|
|
182
|
+
callable.call
|
|
183
|
+
ensure
|
|
184
|
+
Thread.current.thread_variable_set(SCHEDULER_KEY, prev)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Drains the ready queue by calling {#tick} until it is empty.
|
|
188
|
+
#
|
|
189
|
+
# In autorun mode ({#autorun?} is +true+), also handles wall-clock timers
|
|
190
|
+
# and cooperative blocking-I/O awaits:
|
|
191
|
+
# - Fires any timers whose deadline has already passed on each iteration.
|
|
192
|
+
# - When all ready tasks are done but future timers remain pending, sleeps
|
|
193
|
+
# until the next deadline and fires them.
|
|
194
|
+
# - When Fibers are suspended in {BlockingAdapterPool::PendingOperation#await}
|
|
195
|
+
# (tracked via {#track_blocking_await}), waits on a condition variable
|
|
196
|
+
# that is broadcast by {#enqueue_fiber} when the worker thread completes
|
|
197
|
+
# (Issue #338). This ensures run_until_idle does not exit while blocking
|
|
198
|
+
# I/O operations are still in flight.
|
|
199
|
+
#
|
|
200
|
+
# Does not fire pending virtual timers — call {#advance} for those.
|
|
201
|
+
#
|
|
202
|
+
# @return [self]
|
|
203
|
+
# @api private
|
|
204
|
+
def run_until_idle
|
|
205
|
+
if @autorun
|
|
206
|
+
loop do
|
|
207
|
+
fire_real_timers
|
|
208
|
+
tick until idle?
|
|
209
|
+
|
|
210
|
+
# Atomically check all exit conditions.
|
|
211
|
+
should_break = @await_mutex.synchronize do
|
|
212
|
+
idle? && pending_real_timer_count.zero? && @pending_awaits.zero?
|
|
213
|
+
end
|
|
214
|
+
break if should_break
|
|
215
|
+
|
|
216
|
+
if idle?
|
|
217
|
+
if pending_real_timer_count > 0 &&
|
|
218
|
+
@await_mutex.synchronize { @pending_awaits.zero? }
|
|
219
|
+
# Only real timers pending — sleep until the next deadline.
|
|
220
|
+
sleep_until_next_real_timer
|
|
221
|
+
else
|
|
222
|
+
# Pending blocking awaits (pool workers still running).
|
|
223
|
+
# Wait for the completion signal broadcast by enqueue_fiber /
|
|
224
|
+
# complete_blocking_await (30-second safety cap).
|
|
225
|
+
@await_mutex.synchronize { @await_cond.wait(@await_mutex, 30) }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
tick until idle?
|
|
231
|
+
end
|
|
232
|
+
self
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Advances the virtual clock by +seconds+ and enqueues any timer
|
|
236
|
+
# callbacks that are now due.
|
|
237
|
+
#
|
|
238
|
+
# @param seconds [Numeric]
|
|
239
|
+
# @return [self]
|
|
240
|
+
# @api private
|
|
241
|
+
def advance(seconds)
|
|
242
|
+
@virtual_time += seconds
|
|
243
|
+
fire_due_timers
|
|
244
|
+
self
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Schedules +callback+ to fire at the given absolute virtual time.
|
|
248
|
+
#
|
|
249
|
+
# @param absolute_time [Float]
|
|
250
|
+
# @yield callback to invoke when the virtual clock reaches +absolute_time+
|
|
251
|
+
# @return [self]
|
|
252
|
+
# @api private
|
|
253
|
+
def schedule_at(absolute_time, &callback)
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
@timer_heap << {fire_at: absolute_time, callback: callback}
|
|
256
|
+
@timer_heap.sort_by! { |e| e[:fire_at] }
|
|
257
|
+
end
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Schedules +callback+ to fire +delay+ seconds from now (virtual time).
|
|
262
|
+
#
|
|
263
|
+
# @param delay [Numeric]
|
|
264
|
+
# @yield callback
|
|
265
|
+
# @return [self]
|
|
266
|
+
# @api private
|
|
267
|
+
def schedule_after(delay, &callback)
|
|
268
|
+
schedule_at(@virtual_time + delay, &callback)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Enqueues a callable (Fiber step or arbitrary block) onto the ready queue.
|
|
272
|
+
# Called by {Task::FiberBackend#await} to resume a waiting Fiber.
|
|
273
|
+
# Also wakes any thread blocked in {#run_until_idle} waiting for external
|
|
274
|
+
# completion signals (e.g. from {BlockingAdapterPool} worker threads).
|
|
275
|
+
#
|
|
276
|
+
# @param callable [#call]
|
|
277
|
+
# @return [self]
|
|
278
|
+
# @api private
|
|
279
|
+
def enqueue_fiber(callable)
|
|
280
|
+
@mutex.synchronize { @ready << callable }
|
|
281
|
+
# Broadcast to wake run_until_idle if it is sleeping on @await_cond.
|
|
282
|
+
# @await_mutex is always acquired AFTER releasing @mutex (never nested)
|
|
283
|
+
# to guarantee consistent lock ordering and avoid deadlocks.
|
|
284
|
+
@await_mutex.synchronize { @await_cond.broadcast }
|
|
285
|
+
self
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns +true+ when there are no ready entries to dispatch.
|
|
289
|
+
# @return [Boolean]
|
|
290
|
+
# @api private
|
|
291
|
+
def idle?
|
|
292
|
+
@mutex.synchronize { @ready.empty? }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Returns the number of entries currently in the ready queue.
|
|
296
|
+
# @return [Integer]
|
|
297
|
+
# @api private
|
|
298
|
+
def ready_count
|
|
299
|
+
@mutex.synchronize { @ready.size }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Returns a list of pending timer entries (not yet fired).
|
|
303
|
+
# Each entry has +:fire_at+ and +:description+ (if set) keys.
|
|
304
|
+
# @return [Array<Hash>]
|
|
305
|
+
# @api private
|
|
306
|
+
def pending_timers
|
|
307
|
+
@mutex.synchronize { @timer_heap.dup }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Schedules +callback+ to fire +seconds+ from now (wall-clock time).
|
|
311
|
+
#
|
|
312
|
+
# Unlike {#schedule_after} (which uses virtual time), this method uses
|
|
313
|
+
# the real monotonic clock. Callbacks are fired during {#run_until_idle}
|
|
314
|
+
# when {#autorun?} is +true+, or explicitly via {#fire_real_timers}.
|
|
315
|
+
#
|
|
316
|
+
# This is the integration point for {TimerQueue} replacement: when a
|
|
317
|
+
# {Runtime} is backed by a +DeterministicScheduler+, its {Runtime#timer_queue}
|
|
318
|
+
# returns a {SchedulerTimerAdapter} that delegates here instead of spawning
|
|
319
|
+
# a background OS thread.
|
|
320
|
+
#
|
|
321
|
+
# @param seconds [Numeric] delay before the callback fires
|
|
322
|
+
# @yield called when the deadline is reached
|
|
323
|
+
# @return [self]
|
|
324
|
+
# @api private
|
|
325
|
+
def schedule_real_after(seconds, &callback)
|
|
326
|
+
fire_at = @clock.call + seconds.to_f
|
|
327
|
+
@mutex.synchronize do
|
|
328
|
+
@real_timer_heap << [fire_at, callback]
|
|
329
|
+
@real_timer_heap.sort_by! { |(t, _)| t }
|
|
330
|
+
end
|
|
331
|
+
self
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Fires all wall-clock timer callbacks whose deadline has passed.
|
|
335
|
+
# Enqueues each fired callback onto the ready queue for scheduler dispatch.
|
|
336
|
+
#
|
|
337
|
+
# @return [self]
|
|
338
|
+
# @api private
|
|
339
|
+
def fire_real_timers
|
|
340
|
+
now = @clock.call
|
|
341
|
+
due = @mutex.synchronize do
|
|
342
|
+
ready, pending = @real_timer_heap.partition { |(t, _)| t <= now }
|
|
343
|
+
@real_timer_heap.replace(pending)
|
|
344
|
+
ready
|
|
345
|
+
end
|
|
346
|
+
due.each { |(_, cb)| enqueue_fiber(cb) }
|
|
347
|
+
self
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Returns the number of pending wall-clock timer entries (not yet fired).
|
|
351
|
+
# @return [Integer]
|
|
352
|
+
# @api private
|
|
353
|
+
def pending_real_timer_count
|
|
354
|
+
@mutex.synchronize { @real_timer_heap.size }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Registers one pending cooperative blocking-I/O await.
|
|
358
|
+
# Called by {BlockingAdapterPool::PendingOperation#await} before
|
|
359
|
+
# +Fiber.yield+ so that {#run_until_idle} knows not to exit yet.
|
|
360
|
+
# Each call must be balanced by a {#complete_blocking_await} call.
|
|
361
|
+
# @return [self]
|
|
362
|
+
# @api private
|
|
363
|
+
def track_blocking_await
|
|
364
|
+
@await_mutex.synchronize { @pending_awaits += 1 }
|
|
365
|
+
self
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Marks one pending cooperative blocking-I/O await as complete.
|
|
369
|
+
# Called from the {BlockingAdapterPool::PendingOperation#on_complete}
|
|
370
|
+
# callback (on the pool worker thread) after the result is ready.
|
|
371
|
+
# Decrements the counter and broadcasts to wake {#run_until_idle}.
|
|
372
|
+
# @return [self]
|
|
373
|
+
# @api private
|
|
374
|
+
def complete_blocking_await
|
|
375
|
+
@await_mutex.synchronize do
|
|
376
|
+
@pending_awaits -= 1
|
|
377
|
+
@await_cond.broadcast
|
|
378
|
+
end
|
|
379
|
+
self
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
def real_timers_due?
|
|
385
|
+
now = @clock.call
|
|
386
|
+
@mutex.synchronize { @real_timer_heap.any? { |(t, _)| t <= now } }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Sleeps until the nearest pending real-timer deadline, then returns.
|
|
390
|
+
# Called only from run_until_idle when the ready queue is empty and at
|
|
391
|
+
# least one future real-timer is pending. Ensures we never overshoot:
|
|
392
|
+
# sleep is bounded to the exact remaining time to the next deadline.
|
|
393
|
+
# @api private
|
|
394
|
+
def sleep_until_next_real_timer
|
|
395
|
+
next_at = @mutex.synchronize { @real_timer_heap.first&.first }
|
|
396
|
+
return unless next_at
|
|
397
|
+
|
|
398
|
+
wait_duration = [next_at - @clock.call, 0.0].max
|
|
399
|
+
sleep(wait_duration) if wait_duration > 0
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def fire_due_timers
|
|
403
|
+
due = @mutex.synchronize do
|
|
404
|
+
ready, pending = @timer_heap.partition { |e| e[:fire_at] <= @virtual_time }
|
|
405
|
+
@timer_heap.replace(pending)
|
|
406
|
+
ready
|
|
407
|
+
end
|
|
408
|
+
due.each { |e| enqueue_fiber(e[:callback]) }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Synchronous scheduler for use in tests.
|
|
6
|
+
#
|
|
7
|
+
# Each spawned task is executed immediately on the calling thread using
|
|
8
|
+
# {Task::ImmediateBackend}. No new threads are created, so a
|
|
9
|
+
# {Runtime} that uses +FakeScheduler+ does not increase the process
|
|
10
|
+
# Thread count on {Runtime#spawn}.
|
|
11
|
+
#
|
|
12
|
+
# In addition to the basic synchronous execution, +FakeScheduler+ records
|
|
13
|
+
# all task lifecycle events in {#event_log} and all spawned tasks in
|
|
14
|
+
# {#tasks}. This allows specs to assert event ordering and task state
|
|
15
|
+
# without relying on wall-clock sleeps.
|
|
16
|
+
#
|
|
17
|
+
# === tick / tick_until
|
|
18
|
+
#
|
|
19
|
+
# Because +FakeScheduler+ uses {Task::ImmediateBackend}, every task runs
|
|
20
|
+
# to completion before {Runtime#spawn} returns. Consequently {#tick} is
|
|
21
|
+
# semantically a no-op -- the "ready task" has already executed. It is
|
|
22
|
+
# provided so that test code written against the cooperative scheduler
|
|
23
|
+
# interface compiles and documents intent (e.g. "advance by one step").
|
|
24
|
+
#
|
|
25
|
+
# === pending_timers
|
|
26
|
+
#
|
|
27
|
+
# If a {Phronomy::Testing::FakeClock} is injected via {#clock=}, its
|
|
28
|
+
# pending callbacks are surfaced as +pending_timers+.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
|
|
32
|
+
# task = runtime.spawn(name: "agent-test") { 42 }
|
|
33
|
+
# expect(task.await).to eq(42)
|
|
34
|
+
# expect(task.status).to eq(:completed)
|
|
35
|
+
# @api private
|
|
36
|
+
class FakeScheduler < Scheduler
|
|
37
|
+
# @return [Array<Hash>] ordered list of task lifecycle events.
|
|
38
|
+
# Each entry is +{ type:, task_name:, at: }+ where +type+ is one of
|
|
39
|
+
# +:spawned+, +:started+, +:completed+, +:cancelled+, +:failed+ and
|
|
40
|
+
# +at+ is a Float monotonic timestamp (seconds).
|
|
41
|
+
attr_reader :event_log
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>] all tasks spawned by this scheduler.
|
|
44
|
+
# Each entry is +{ task:, name:, status: }+.
|
|
45
|
+
attr_reader :tasks
|
|
46
|
+
|
|
47
|
+
# Optional {Phronomy::Testing::FakeClock} used to timestamp events and
|
|
48
|
+
# surface pending timers. When +nil+, a real monotonic clock is used.
|
|
49
|
+
# @return [Phronomy::Testing::FakeClock, nil]
|
|
50
|
+
attr_accessor :clock
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@event_log = []
|
|
54
|
+
@tasks = []
|
|
55
|
+
@clock = nil
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Spawns +block+ as a {Task} backed by {Task::ImmediateBackend}.
|
|
60
|
+
# The block executes synchronously before this method returns.
|
|
61
|
+
# Lifecycle events are recorded in {#event_log}.
|
|
62
|
+
#
|
|
63
|
+
# @param name [String, nil]
|
|
64
|
+
# @param parent [Task, nil]
|
|
65
|
+
# @return [Task]
|
|
66
|
+
# @api private
|
|
67
|
+
def spawn(name:, parent:, &block)
|
|
68
|
+
_log_event(:spawned, name)
|
|
69
|
+
task = Task.spawn(name: name, parent: parent, backend_class: Task::ImmediateBackend) do
|
|
70
|
+
_log_event(:started, name)
|
|
71
|
+
begin
|
|
72
|
+
result = block.call
|
|
73
|
+
_log_event(:completed, name)
|
|
74
|
+
result
|
|
75
|
+
rescue CancellationError
|
|
76
|
+
_log_event(:cancelled, name)
|
|
77
|
+
raise
|
|
78
|
+
rescue => e
|
|
79
|
+
_log_event(:failed, name)
|
|
80
|
+
raise e
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
@mutex.synchronize { @tasks << {task: task, name: name, status: task.status} }
|
|
84
|
+
task
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Execute one ready task.
|
|
88
|
+
#
|
|
89
|
+
# Because {Task::ImmediateBackend} runs tasks synchronously inside
|
|
90
|
+
# {#spawn}, all ready tasks have already executed by the time this
|
|
91
|
+
# method is called. This method is a no-op provided for API
|
|
92
|
+
# compatibility with cooperative scheduler interfaces.
|
|
93
|
+
#
|
|
94
|
+
# @return [self]
|
|
95
|
+
# @api private
|
|
96
|
+
def tick
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Run +block+ repeatedly until it returns truthy or +max_ticks+ is
|
|
101
|
+
# reached. Because tasks execute synchronously, the condition is
|
|
102
|
+
# evaluated once; if it is already met this method returns immediately.
|
|
103
|
+
#
|
|
104
|
+
# @param max_ticks [Integer] safety bound (default: 1000)
|
|
105
|
+
# @yield condition evaluated after each tick
|
|
106
|
+
# @return [Boolean] +true+ if condition was satisfied
|
|
107
|
+
# @api private
|
|
108
|
+
def tick_until(max_ticks: 1000)
|
|
109
|
+
max_ticks.times do
|
|
110
|
+
return true if yield
|
|
111
|
+
tick
|
|
112
|
+
end
|
|
113
|
+
yield ? true : false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a list of pending timer entries surfaced from the injected
|
|
117
|
+
# {clock}. Returns an empty array when no clock is set.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array<Hash>] each entry: +{ fire_at:, description: }+
|
|
120
|
+
# @api private
|
|
121
|
+
def pending_timers
|
|
122
|
+
return [] unless @clock
|
|
123
|
+
|
|
124
|
+
@clock.pending_timer_entries
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Assert that the named tasks completed in the given order.
|
|
128
|
+
# Raises +RSpec::Expectations::ExpectationNotMetError+ if order is wrong.
|
|
129
|
+
# Intended for use inside RSpec examples.
|
|
130
|
+
#
|
|
131
|
+
# @param names [Array<String, nil>] task names in expected order
|
|
132
|
+
# @return [void]
|
|
133
|
+
# @api private
|
|
134
|
+
def assert_order(*names)
|
|
135
|
+
completed = @event_log.select { |e| e[:type] == :completed }.map { |e| e[:task_name] }
|
|
136
|
+
indices = names.map { |n| completed.index(n) }
|
|
137
|
+
unless indices.none?(&:nil?) && indices == indices.sort
|
|
138
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
139
|
+
"Expected tasks to complete in order #{names.inspect} " + "but completed order was #{completed.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Assert that the named tasks reached +:cancelled+ state.
|
|
144
|
+
#
|
|
145
|
+
# @param names [Array<String, nil>] task names expected to be cancelled
|
|
146
|
+
# @return [void]
|
|
147
|
+
# @api private
|
|
148
|
+
def assert_cancelled(*names)
|
|
149
|
+
cancelled = @event_log.select { |e| e[:type] == :cancelled }.map { |e| e[:task_name] }
|
|
150
|
+
missing = names.reject { |n| cancelled.include?(n) }
|
|
151
|
+
return if missing.empty?
|
|
152
|
+
|
|
153
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
154
|
+
"Expected tasks #{missing.inspect} to be cancelled " + "but cancelled tasks were #{cancelled.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def _log_event(type, task_name)
|
|
160
|
+
at = @clock ? @clock.now : Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
@mutex.synchronize { @event_log << {type: type, task_name: task_name, at: at} }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Lazy cache of {ConcurrencyGate} instances, keyed by resource name.
|
|
6
|
+
#
|
|
7
|
+
# Gate concurrency caps are read from {Phronomy::Configuration} when a gate
|
|
8
|
+
# is first accessed; subsequent calls return the cached instance. Call
|
|
9
|
+
# {#reset} to drop the cache and force a rebuild on the next access.
|
|
10
|
+
# @api private
|
|
11
|
+
class GateRegistry
|
|
12
|
+
GATE_CONFIG_MAP = {
|
|
13
|
+
agent: :max_concurrent_agent_tasks,
|
|
14
|
+
tool: :max_concurrent_tool_tasks,
|
|
15
|
+
workflow: :max_concurrent_workflow_tasks,
|
|
16
|
+
llm: :max_concurrent_llm_calls,
|
|
17
|
+
rag: :max_concurrent_rag_fetches,
|
|
18
|
+
vector: :max_concurrent_vector_searches
|
|
19
|
+
}.freeze
|
|
20
|
+
private_constant :GATE_CONFIG_MAP
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@gates = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns (or lazily creates) the gate for +name+.
|
|
28
|
+
# @param name [Symbol]
|
|
29
|
+
# @return [ConcurrencyGate]
|
|
30
|
+
# @api private
|
|
31
|
+
def get(name)
|
|
32
|
+
@mutex.synchronize { @gates[name] ||= _build(name) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Drops the cached gate for +name+ so the next {#get} rebuilds it.
|
|
36
|
+
# @param name [Symbol]
|
|
37
|
+
# @return [void]
|
|
38
|
+
# @api private
|
|
39
|
+
def reset(name)
|
|
40
|
+
@mutex.synchronize { @gates.delete(name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def _build(name)
|
|
46
|
+
config_key = GATE_CONFIG_MAP[name]
|
|
47
|
+
max = config_key ? Phronomy.configuration.public_send(config_key) : nil
|
|
48
|
+
ConcurrencyGate.new(max_concurrent: max, name: name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|