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,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# Provides cooperative cancellation for agent invocations.
|
|
6
|
+
#
|
|
7
|
+
# Pass a token to an agent via +config: { cancellation_token: token }+.
|
|
8
|
+
# The agent checks the token before each LLM call and raises
|
|
9
|
+
# {Phronomy::CancellationError} when the token is cancelled or the
|
|
10
|
+
# optional deadline has passed.
|
|
11
|
+
#
|
|
12
|
+
# A token may be shared across multiple agent invocations and across threads;
|
|
13
|
+
# all access to internal state is protected by a Mutex.
|
|
14
|
+
#
|
|
15
|
+
# @example Explicit cancel from another thread
|
|
16
|
+
# token = Phronomy::Concurrency::CancellationToken.new
|
|
17
|
+
# Thread.new { sleep 5; token.cancel! }
|
|
18
|
+
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
19
|
+
#
|
|
20
|
+
# @example Hard deadline via monotonic clock (recommended)
|
|
21
|
+
# token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
22
|
+
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
23
|
+
#
|
|
24
|
+
# @example Hard deadline via wall-clock (legacy)
|
|
25
|
+
# token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
|
|
26
|
+
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
27
|
+
#
|
|
28
|
+
# @example Propagate to parallel workers
|
|
29
|
+
# token = Phronomy::Concurrency::CancellationToken.new
|
|
30
|
+
# orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
|
|
31
|
+
class CancellationToken
|
|
32
|
+
# Returns a new token that will expire after +seconds+ seconds, measured
|
|
33
|
+
# with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
|
|
34
|
+
# a token with +deadline: Time.now + seconds+, this factory is immune to NTP
|
|
35
|
+
# adjustments and DST transitions.
|
|
36
|
+
#
|
|
37
|
+
# @param seconds [Numeric] duration in seconds until the token expires.
|
|
38
|
+
# @return [CancellationToken]
|
|
39
|
+
# @api public
|
|
40
|
+
def self.timeout_after(seconds)
|
|
41
|
+
monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
42
|
+
new(monotonic_deadline: monotonic_deadline)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param deadline [Time, nil] optional wall-clock deadline; the token reports
|
|
46
|
+
# +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
|
|
47
|
+
# {.timeout_after} for duration-based cancellation.
|
|
48
|
+
# @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
|
|
49
|
+
# {.timeout_after}; prefer that factory method over passing this directly.
|
|
50
|
+
# @api public
|
|
51
|
+
# mutant:disable - removing @cancelled = false is equivalent because nil is falsey
|
|
52
|
+
def initialize(deadline: nil, monotonic_deadline: nil)
|
|
53
|
+
@cancelled = false
|
|
54
|
+
@deadline = deadline
|
|
55
|
+
@monotonic_deadline = monotonic_deadline
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
@cancel_callbacks = []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
|
|
61
|
+
attr_reader :deadline
|
|
62
|
+
|
|
63
|
+
# Returns the remaining seconds until the monotonic deadline fires, or +nil+
|
|
64
|
+
# when no monotonic deadline is set. Returns 0.0 if already past.
|
|
65
|
+
# @return [Float, nil]
|
|
66
|
+
# @api public
|
|
67
|
+
def remaining_monotonic_seconds
|
|
68
|
+
return nil if @monotonic_deadline.nil?
|
|
69
|
+
remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
[remaining, 0.0].max
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Registers a one-shot callback invoked when this token is explicitly
|
|
74
|
+
# cancelled via {#cancel!}. If the token is already cancelled, the block
|
|
75
|
+
# is called immediately (still within the caller's thread).
|
|
76
|
+
#
|
|
77
|
+
# Callbacks are NOT fired for deadline-based cancellation (i.e. when
|
|
78
|
+
# {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
|
|
79
|
+
# {Runtime#timer_queue} to schedule deadline callbacks.
|
|
80
|
+
#
|
|
81
|
+
# @yield called with no arguments when (or if) the token is cancelled
|
|
82
|
+
# @return [self]
|
|
83
|
+
# @api public
|
|
84
|
+
# mutant:disable - mutex removal mutation is GVL-safe equivalent under MRI
|
|
85
|
+
def on_cancel(&block)
|
|
86
|
+
already_cancelled = @mutex.synchronize do
|
|
87
|
+
if @cancelled
|
|
88
|
+
true
|
|
89
|
+
else
|
|
90
|
+
@cancel_callbacks << block
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
block.call if already_cancelled
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
|
|
99
|
+
# Thread-safe; idempotent — calling multiple times has no additional effect.
|
|
100
|
+
# @return [self]
|
|
101
|
+
# @api public
|
|
102
|
+
# mutant:disable - mutex removal and dup-vs-ref mutations are GVL-safe equivalents
|
|
103
|
+
def cancel!
|
|
104
|
+
callbacks = @mutex.synchronize do
|
|
105
|
+
return self if @cancelled
|
|
106
|
+
@cancelled = true
|
|
107
|
+
@cancel_callbacks.dup
|
|
108
|
+
end
|
|
109
|
+
callbacks.each(&:call)
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns +true+ when the token has been explicitly cancelled via {#cancel!},
|
|
114
|
+
# when the wall-clock deadline has passed, or when the monotonic deadline
|
|
115
|
+
# (set by {.timeout_after}) has elapsed. Thread-safe.
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
# @api public
|
|
118
|
+
# mutant:disable - mutex removal on @cancelled read is GVL-safe equivalent under MRI
|
|
119
|
+
def cancelled?
|
|
120
|
+
return true if @mutex.synchronize { @cancelled }
|
|
121
|
+
return true if !@deadline.nil? && Time.now >= @deadline
|
|
122
|
+
!@monotonic_deadline.nil? &&
|
|
123
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Raises {Phronomy::CancellationError} if the token is cancelled.
|
|
127
|
+
# A convenience method for cooperative cancellation checks inside tools,
|
|
128
|
+
# RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
|
|
129
|
+
#
|
|
130
|
+
# @param message [String] optional error message
|
|
131
|
+
# @return [nil] when the token is not cancelled
|
|
132
|
+
# @raise [Phronomy::CancellationError] when the token is cancelled
|
|
133
|
+
# @api public
|
|
134
|
+
# mutant:disable - raise(CancellationError) resolves to raise(Phronomy::CancellationError) in this namespace
|
|
135
|
+
def raise_if_cancelled!(message = "invocation cancelled")
|
|
136
|
+
raise Phronomy::CancellationError, message if cancelled?
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# A counting semaphore that enforces a concurrency cap across a named
|
|
6
|
+
# resource category (e.g. agent tasks, tool tasks, LLM calls).
|
|
7
|
+
#
|
|
8
|
+
# When +max_concurrent+ is +nil+ the gate is a no-op and all callers
|
|
9
|
+
# pass through immediately without acquiring a slot.
|
|
10
|
+
#
|
|
11
|
+
# Backpressure behaviour when the gate is full is controlled by the
|
|
12
|
+
# +on_full:+ keyword:
|
|
13
|
+
# +:reject+ — raise {Phronomy::BackpressureError} immediately
|
|
14
|
+
# +:wait+ — block the calling fiber/thread until a slot is free
|
|
15
|
+
# +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
|
|
16
|
+
# after +timeout:+ seconds if no slot becomes available
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# gate = Phronomy::Concurrency::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
|
|
20
|
+
# gate.acquire(on_full: :reject) do
|
|
21
|
+
# run_agent_task
|
|
22
|
+
# end
|
|
23
|
+
class ConcurrencyGate
|
|
24
|
+
# @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
|
|
25
|
+
# @param name [Symbol, String, nil] human-readable label used in error messages
|
|
26
|
+
# @api private
|
|
27
|
+
def initialize(max_concurrent:, name: nil)
|
|
28
|
+
@max = max_concurrent
|
|
29
|
+
@name = name
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@cond = ConditionVariable.new
|
|
32
|
+
@count = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the configured cap (or nil when unlimited).
|
|
36
|
+
attr_reader :max
|
|
37
|
+
|
|
38
|
+
# Returns the name label.
|
|
39
|
+
attr_reader :name
|
|
40
|
+
|
|
41
|
+
# Returns the number of slots currently in use.
|
|
42
|
+
def current_count
|
|
43
|
+
@mutex.synchronize { @count }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Acquires a slot, executes +block+, then releases the slot.
|
|
47
|
+
# When the gate is unlimited (max is nil) the block runs directly.
|
|
48
|
+
#
|
|
49
|
+
# @param on_full [:reject, :wait, :timeout] backpressure strategy
|
|
50
|
+
# @param timeout [Numeric, nil] seconds before +:timeout+ gives up
|
|
51
|
+
# @yield
|
|
52
|
+
# @return block return value
|
|
53
|
+
# @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
|
|
54
|
+
# @api private
|
|
55
|
+
def acquire(on_full: :wait, timeout: nil, &block)
|
|
56
|
+
return block.call if @max.nil?
|
|
57
|
+
|
|
58
|
+
_acquire_slot(on_full: on_full, timeout: timeout)
|
|
59
|
+
begin
|
|
60
|
+
block.call
|
|
61
|
+
ensure
|
|
62
|
+
_release_slot
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def _acquire_slot(on_full:, timeout:)
|
|
69
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
70
|
+
if scheduler
|
|
71
|
+
_acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
|
|
72
|
+
else
|
|
73
|
+
_acquire_slot_threaded(on_full: on_full, timeout: timeout)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def _acquire_slot_coop(scheduler, on_full:, timeout:)
|
|
78
|
+
# In cooperative mode all tasks run on the same thread, so no mutex needed.
|
|
79
|
+
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
80
|
+
@coop_signal ||= scheduler.new_signal
|
|
81
|
+
|
|
82
|
+
loop do
|
|
83
|
+
if @count < @max
|
|
84
|
+
@count += 1
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
case on_full
|
|
89
|
+
when :reject
|
|
90
|
+
raise Phronomy::BackpressureError,
|
|
91
|
+
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
92
|
+
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
93
|
+
when :timeout
|
|
94
|
+
if deadline && scheduler.virtual_time >= deadline
|
|
95
|
+
raise Phronomy::BackpressureError,
|
|
96
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
97
|
+
end
|
|
98
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
99
|
+
if deadline && scheduler.virtual_time >= deadline
|
|
100
|
+
raise Phronomy::BackpressureError,
|
|
101
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
102
|
+
end
|
|
103
|
+
else # :wait
|
|
104
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def _acquire_slot_threaded(on_full:, timeout:)
|
|
110
|
+
deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
|
|
111
|
+
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
loop do
|
|
114
|
+
if @count < @max
|
|
115
|
+
@count += 1
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
case on_full
|
|
120
|
+
when :reject
|
|
121
|
+
raise Phronomy::BackpressureError,
|
|
122
|
+
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
123
|
+
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
124
|
+
when :timeout
|
|
125
|
+
remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
|
|
126
|
+
if remaining && remaining <= 0
|
|
127
|
+
raise Phronomy::BackpressureError,
|
|
128
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
129
|
+
end
|
|
130
|
+
@cond.wait(@mutex, remaining || nil)
|
|
131
|
+
# re-check deadline after wakeup
|
|
132
|
+
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
133
|
+
raise Phronomy::BackpressureError,
|
|
134
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
135
|
+
end
|
|
136
|
+
else # :wait
|
|
137
|
+
@cond.wait(@mutex)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def _release_slot
|
|
144
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
145
|
+
if scheduler && @coop_signal
|
|
146
|
+
@count -= 1
|
|
147
|
+
scheduler.raise_signal(@coop_signal)
|
|
148
|
+
else
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
@count -= 1
|
|
151
|
+
@cond.signal
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# A point in time used as an upper bound for an operation.
|
|
6
|
+
#
|
|
7
|
+
# Uses the monotonic clock (+Process::CLOCK_MONOTONIC+) internally to avoid
|
|
8
|
+
# skew from NTP adjustments or DST transitions.
|
|
9
|
+
#
|
|
10
|
+
# @example Create a 30-second deadline and check remaining time
|
|
11
|
+
# deadline = Phronomy::Concurrency::Deadline.in(30)
|
|
12
|
+
# sleep 1
|
|
13
|
+
# deadline.remaining_seconds # => ~29.0
|
|
14
|
+
# deadline.expired? # => false
|
|
15
|
+
class Deadline
|
|
16
|
+
# Creates a deadline that expires +seconds+ from now.
|
|
17
|
+
#
|
|
18
|
+
# @param seconds [Numeric] seconds from now until expiry
|
|
19
|
+
# @return [Deadline]
|
|
20
|
+
# @api private
|
|
21
|
+
def self.in(seconds)
|
|
22
|
+
new(Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param monotonic_at [Float] absolute monotonic timestamp of expiry
|
|
26
|
+
# @api private
|
|
27
|
+
def initialize(monotonic_at)
|
|
28
|
+
@monotonic_at = monotonic_at
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns +true+ when the deadline has passed.
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
# @api private
|
|
34
|
+
def expired?
|
|
35
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_at
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Seconds remaining until expiry. Returns 0 when already expired.
|
|
39
|
+
# @return [Float]
|
|
40
|
+
# @api private
|
|
41
|
+
def remaining_seconds
|
|
42
|
+
remaining = @monotonic_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
[remaining, 0.0].max
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Attaches this deadline to a {CancellationToken} by cancelling the token
|
|
47
|
+
# when the deadline expires. Uses the Runtime timer queue (a single
|
|
48
|
+
# background thread shared by all deadlines) instead of spawning one thread
|
|
49
|
+
# per deadline.
|
|
50
|
+
#
|
|
51
|
+
# @param token [CancellationToken]
|
|
52
|
+
# @param timer_queue [Runtime::TimerQueue, nil] queue to register with;
|
|
53
|
+
# defaults to +Phronomy::Runtime.instance.timer_queue+
|
|
54
|
+
# @return [self]
|
|
55
|
+
# @api private
|
|
56
|
+
def attach_to(token, timer_queue: Phronomy::Runtime.instance.timer_queue)
|
|
57
|
+
seconds = remaining_seconds
|
|
58
|
+
return self if seconds <= 0
|
|
59
|
+
|
|
60
|
+
timer_queue.schedule(seconds: seconds) { token.cancel! }
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# Registry and lifecycle manager for {BlockingAdapterPool} instances.
|
|
6
|
+
#
|
|
7
|
+
# Maintains one unnamed "default" pool (accessed via {#default_pool}) and
|
|
8
|
+
# an arbitrary number of named pools (accessed via {#named_pool}).
|
|
9
|
+
# All pools are shut down together by {#shutdown}.
|
|
10
|
+
# @api private
|
|
11
|
+
class PoolRegistry
|
|
12
|
+
def initialize
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@pools = {}
|
|
15
|
+
@default = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns (or lazily creates) the unnamed default pool.
|
|
19
|
+
# @param pool_size [Integer]
|
|
20
|
+
# @param queue_size [Integer]
|
|
21
|
+
# @return [BlockingAdapterPool]
|
|
22
|
+
# @api private
|
|
23
|
+
def default_pool(pool_size: 10, queue_size: 100)
|
|
24
|
+
@default ||= BlockingAdapterPool.new(
|
|
25
|
+
name: :default,
|
|
26
|
+
pool_size: pool_size,
|
|
27
|
+
queue_size: queue_size
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns (or lazily creates) a named pool.
|
|
32
|
+
# @param name [Symbol, String]
|
|
33
|
+
# @param size [Integer]
|
|
34
|
+
# @param queue_size [Integer]
|
|
35
|
+
# @return [BlockingAdapterPool]
|
|
36
|
+
# @api private
|
|
37
|
+
def named_pool(name, size: 10, queue_size: 100)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@pools[name.to_sym] ||= BlockingAdapterPool.new(
|
|
40
|
+
name: name,
|
|
41
|
+
pool_size: size,
|
|
42
|
+
queue_size: queue_size
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Shuts down the default pool and all named pools.
|
|
48
|
+
# @return [void]
|
|
49
|
+
# @api private
|
|
50
|
+
def shutdown
|
|
51
|
+
@default&.shutdown
|
|
52
|
+
pools = @mutex.synchronize { @pools.values.dup }
|
|
53
|
+
pools.each(&:shutdown)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -61,12 +61,154 @@ module Phronomy
|
|
|
61
61
|
# Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
|
|
62
62
|
attr_accessor :state_store
|
|
63
63
|
|
|
64
|
+
# Maximum byte length of a tool result returned to the LLM.
|
|
65
|
+
# When a tool returns a String longer than this limit, the string is truncated
|
|
66
|
+
# and a warning is logged. Set to +nil+ (default) to disable truncation.
|
|
67
|
+
# @example
|
|
68
|
+
# Phronomy.configure { |c| c.tool_result_max_size = 8192 }
|
|
69
|
+
attr_accessor :tool_result_max_size
|
|
70
|
+
|
|
71
|
+
# LLM adapter used by Agent::Base to perform LLM calls.
|
|
72
|
+
# Must be an instance of a class that inherits from
|
|
73
|
+
# {Phronomy::LLMAdapter::Base}. Defaults to
|
|
74
|
+
# {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
|
|
75
|
+
# {BlockingAdapterPool}.
|
|
76
|
+
# Set to a custom adapter to swap in an alternative LLM client without
|
|
77
|
+
# changing any agent code.
|
|
78
|
+
# @example
|
|
79
|
+
# Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
|
|
80
|
+
attr_accessor :llm_adapter
|
|
81
|
+
|
|
82
|
+
# Default backpressure strategy for {BlockingAdapterPool#submit} when the
|
|
83
|
+
# queue is full. One of +:wait+ (block until a slot is available),
|
|
84
|
+
# +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
|
|
85
|
+
# {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
|
|
86
|
+
# @return [:wait, :raise, :timeout]
|
|
87
|
+
attr_accessor :backpressure
|
|
88
|
+
|
|
89
|
+
# Seconds to wait before raising {Phronomy::TimeoutError} when
|
|
90
|
+
# +backpressure+ is +:timeout+.
|
|
91
|
+
# @return [Numeric, nil]
|
|
92
|
+
attr_accessor :backpressure_timeout
|
|
93
|
+
|
|
94
|
+
# Warn when an event spends longer than this many seconds waiting in the
|
|
95
|
+
# EventLoop queue before being dispatched (starvation detection).
|
|
96
|
+
# Set to +nil+ to disable the warning.
|
|
97
|
+
# @return [Numeric, nil]
|
|
98
|
+
attr_accessor :event_loop_starvation_threshold_seconds
|
|
99
|
+
|
|
100
|
+
# Warn when processing a single event on the EventLoop thread takes longer
|
|
101
|
+
# than this many seconds (long-running task / blocking-on-loop detection).
|
|
102
|
+
# Set to +nil+ to disable the warning.
|
|
103
|
+
# @return [Numeric, nil]
|
|
104
|
+
attr_accessor :event_loop_dispatch_threshold_seconds
|
|
105
|
+
|
|
106
|
+
# When true, enables all blocking operation diagnostics (Issue #279).
|
|
107
|
+
# Equivalent to setting all diagnostic thresholds to their defaults.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
attr_accessor :scheduler_debug
|
|
110
|
+
|
|
111
|
+
# Wall-clock threshold (milliseconds) after which a task that has not
|
|
112
|
+
# yielded the scheduler emits a warning log. nil disables the check.
|
|
113
|
+
# @return [Float, nil]
|
|
114
|
+
attr_accessor :blocking_detect_threshold_ms
|
|
115
|
+
|
|
116
|
+
# Maximum number of concurrent agent tasks (invoke_async calls in-flight).
|
|
117
|
+
# nil = unlimited (default). When at capacity, behaviour is controlled by
|
|
118
|
+
# +backpressure+ (:wait, :raise/:reject, :timeout).
|
|
119
|
+
# @return [Integer, nil]
|
|
120
|
+
attr_accessor :max_concurrent_agent_tasks
|
|
121
|
+
|
|
122
|
+
# Maximum number of concurrent tool tasks (parallel tool calls in-flight).
|
|
123
|
+
# nil = unlimited (default).
|
|
124
|
+
# @return [Integer, nil]
|
|
125
|
+
attr_accessor :max_concurrent_tool_tasks
|
|
126
|
+
|
|
127
|
+
# Maximum number of concurrent workflow tasks.
|
|
128
|
+
# nil = unlimited (default).
|
|
129
|
+
# @return [Integer, nil]
|
|
130
|
+
attr_accessor :max_concurrent_workflow_tasks
|
|
131
|
+
|
|
132
|
+
# Maximum number of concurrent LLM calls in-flight.
|
|
133
|
+
# nil = unlimited (default).
|
|
134
|
+
# @return [Integer, nil]
|
|
135
|
+
attr_accessor :max_concurrent_llm_calls
|
|
136
|
+
|
|
137
|
+
# Upper bound on the number of streaming token chunks that may be buffered
|
|
138
|
+
# in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
|
|
139
|
+
# throttled. When nil (default), the queue is unbounded.
|
|
140
|
+
# @return [Integer, nil]
|
|
141
|
+
attr_accessor :stream_queue_max_size
|
|
142
|
+
|
|
143
|
+
# Maximum number of concurrent RAG knowledge-source fetches in-flight.
|
|
144
|
+
# nil = unlimited (default).
|
|
145
|
+
# @return [Integer, nil]
|
|
146
|
+
attr_accessor :max_concurrent_rag_fetches
|
|
147
|
+
|
|
148
|
+
# Maximum number of concurrent vector-store searches in-flight.
|
|
149
|
+
# nil = unlimited (default).
|
|
150
|
+
# @return [Integer, nil]
|
|
151
|
+
attr_accessor :max_concurrent_vector_searches
|
|
152
|
+
|
|
153
|
+
# Scheduler starvation threshold (milliseconds).
|
|
154
|
+
# When a task waits more than this many milliseconds after calling
|
|
155
|
+
# +runtime.yield+ before being resumed, the wait is counted as a starvation
|
|
156
|
+
# event. Used by the fairness regression test and by the
|
|
157
|
+
# +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
|
|
158
|
+
# Default: 50ms.
|
|
159
|
+
# @return [Numeric]
|
|
160
|
+
attr_accessor :starvation_threshold_ms
|
|
161
|
+
|
|
162
|
+
# Scheduler backend to use for new {Phronomy::Runtime} instances.
|
|
163
|
+
#
|
|
164
|
+
# | Value | Scheduler | Typical use |
|
|
165
|
+
# |-------|-----------|-------------|
|
|
166
|
+
# | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
|
|
167
|
+
# | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
|
|
168
|
+
# | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
|
|
169
|
+
# | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
|
|
170
|
+
#
|
|
171
|
+
# The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
|
|
172
|
+
# it will not become the default until integration test coverage is production grade
|
|
173
|
+
# and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
|
|
174
|
+
#
|
|
175
|
+
# When this setting is changed, the change only takes effect on the NEXT
|
|
176
|
+
# call to {Runtime.instance} that auto-creates a new instance (i.e. after the
|
|
177
|
+
# previous instance has been replaced or reset). To replace the current
|
|
178
|
+
# instance immediately call +Phronomy::Runtime.instance = nil+ first.
|
|
179
|
+
#
|
|
180
|
+
# @return [:thread, :immediate, :fiber]
|
|
181
|
+
attr_accessor :runtime_backend
|
|
182
|
+
|
|
183
|
+
# When +true+, calling {Agent#invoke} from inside a scheduler task
|
|
184
|
+
# raises {SchedulerReentrancyError}. When +false+ (default), a warning
|
|
185
|
+
# is logged instead so that existing callers have time to migrate.
|
|
186
|
+
# @return [Boolean]
|
|
187
|
+
attr_accessor :strict_runtime_guards
|
|
188
|
+
|
|
64
189
|
def initialize
|
|
65
190
|
@recursion_limit = 25
|
|
66
191
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
67
192
|
@trace_pii = false
|
|
68
193
|
@event_loop = false
|
|
69
194
|
@event_loop_stop_grace_seconds = 5
|
|
195
|
+
@llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
196
|
+
@backpressure = :wait
|
|
197
|
+
@backpressure_timeout = nil
|
|
198
|
+
@event_loop_starvation_threshold_seconds = nil
|
|
199
|
+
@event_loop_dispatch_threshold_seconds = nil
|
|
200
|
+
@scheduler_debug = false
|
|
201
|
+
@blocking_detect_threshold_ms = nil
|
|
202
|
+
@max_concurrent_agent_tasks = nil
|
|
203
|
+
@max_concurrent_tool_tasks = nil
|
|
204
|
+
@max_concurrent_workflow_tasks = nil
|
|
205
|
+
@max_concurrent_llm_calls = nil
|
|
206
|
+
@stream_queue_max_size = nil
|
|
207
|
+
@max_concurrent_rag_fetches = nil
|
|
208
|
+
@max_concurrent_vector_searches = nil
|
|
209
|
+
@starvation_threshold_ms = 50
|
|
210
|
+
@runtime_backend = :thread
|
|
211
|
+
@strict_runtime_guards = false
|
|
70
212
|
end
|
|
71
213
|
end
|
|
72
214
|
end
|
data/lib/phronomy/context.rb
CHANGED
|
@@ -5,14 +5,8 @@ module Phronomy
|
|
|
5
5
|
# context assembly.
|
|
6
6
|
#
|
|
7
7
|
# Sub-modules are auto-loaded by Zeitwerk:
|
|
8
|
-
# Phronomy::
|
|
9
|
-
# Phronomy::
|
|
8
|
+
# Phronomy::LlmContextWindow::TokenEstimator
|
|
9
|
+
# Phronomy::LlmContextWindow::TokenBudget
|
|
10
10
|
module Context
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
|
-
|
|
14
|
-
require_relative "context/assembler"
|
|
15
|
-
require_relative "context/context_version_cache"
|
|
16
|
-
require_relative "context/trim_context"
|
|
17
|
-
require_relative "context/trigger_context"
|
|
18
|
-
require_relative "context/compaction_context"
|