phronomy 0.7.1 → 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/README.md +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -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/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -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 +1 -1
- 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 +19 -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/{runtime → concurrency}/gate_registry.rb +1 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- 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/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +1 -0
- metadata +44 -42
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- 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/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- 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/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- 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
|
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"
|
data/lib/phronomy/embeddings.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Phronomy
|
|
|
4
4
|
# Embeddings adapters for converting text into vector representations.
|
|
5
5
|
#
|
|
6
6
|
# Sub-classes are auto-loaded by Zeitwerk:
|
|
7
|
-
# Phronomy::Embeddings::Base
|
|
8
|
-
# Phronomy::Embeddings::RubyLLMEmbeddings
|
|
7
|
+
# Phronomy::Agent::Context::Knowledge::Embeddings::Base
|
|
8
|
+
# Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings
|
|
9
9
|
module Embeddings
|
|
10
10
|
end
|
|
11
11
|
end
|
data/lib/phronomy/eval/runner.rb
CHANGED
|
@@ -28,6 +28,7 @@ module Phronomy
|
|
|
28
28
|
# @param concurrency [Integer] number of parallel threads (default: 1, sequential)
|
|
29
29
|
# @return [Array<EvalResult>]
|
|
30
30
|
# @api public
|
|
31
|
+
# mutant:disable - concurrency default value mutations (0/2) are genuine equivalent because sequential and concurrent paths produce identical results; if concurrency<=1 boundary mutations (==1 / <1 / <=0 / .eql? / .equal? / false / nil / <=2) are genuine equivalent because the concurrent path with concurrency=1 still produces the same Array<EvalResult> via each_slice(1); spawn name: mutations are genuine equivalent (name is only used for logging)
|
|
31
32
|
def run(dataset, callable, concurrency: 1)
|
|
32
33
|
cases = dataset.to_a
|
|
33
34
|
return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
|
|
@@ -59,6 +60,7 @@ module Phronomy
|
|
|
59
60
|
private
|
|
60
61
|
|
|
61
62
|
# Evaluate a single EvalCase with the given callable and return an EvalResult.
|
|
63
|
+
# mutant:disable - multiple genuine equivalent mutations: latency_ms=+t0 or =t0 are genuine because :millisecond makes all values Integer so be_a(Integer) passes; (actual,usage)=result is genuine because Ruby multi-assign of a String yields usage=nil identical to extract(); score_safely input: nil/eval_case/absent are genuine because ExactMatch and IncludesScorer ignore the :input kwarg; EvalResult error: nil/absent and usage: nil are genuine because on a successful score run score_error and usage are already nil
|
|
62
64
|
def run_one(eval_case, callable)
|
|
63
65
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
64
66
|
result = callable.call(eval_case.input)
|
|
@@ -71,6 +73,7 @@ module Phronomy
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
# Normalises the callable's return value into [actual_string, usage_or_nil].
|
|
76
|
+
# mutant:disable - multiple genuine equivalent mutations: is_a?(Hash) vs instance_of?(Hash) (no Hash subclass in practice); to_s vs to_str (String only); result[:output]/[:usage] vs .fetch(:output)/[:usage] (keys always present when is_a?(Hash)); [result.to_s, nil] vs [result.to_s] because actual,usage=[val] → usage=nil via Ruby multi-assign; result.to_s vs result.to_str for String-only values
|
|
74
77
|
def extract(result)
|
|
75
78
|
if result.is_a?(Hash)
|
|
76
79
|
[result[:output].to_s, result[:usage]]
|
|
@@ -80,6 +83,7 @@ module Phronomy
|
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# Calls the scorer and returns [score, error]. On failure, returns [0.0, exception].
|
|
86
|
+
# mutant:disable - [scorer.score(**kwargs), nil] vs [scorer.score(**kwargs)]: because score,error=[val] → error=nil via Ruby multi-assign; both produce the same destructuring in the caller
|
|
83
87
|
def score_safely(scorer, **kwargs)
|
|
84
88
|
[scorer.score(**kwargs), nil]
|
|
85
89
|
rescue => e
|
|
@@ -45,9 +45,20 @@ module Phronomy
|
|
|
45
45
|
|
|
46
46
|
# @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
|
|
47
47
|
# @api public
|
|
48
|
+
# mutant:disable - multiple genuine equivalent mutations:
|
|
49
|
+
# actual.to_str / actual: (shorthand) are genuine (callers pass String);
|
|
50
|
+
# expected.to_str / expected: are genuine (String);
|
|
51
|
+
# response.content.strip (no to_s) is genuine (content is String);
|
|
52
|
+
# lstrip/rstrip/no-strip are genuine (whitespace doesn't affect number scanning);
|
|
53
|
+
# scan(/-?\d\.?\d*/) is genuine (for [0,1] range responses, single-digit-before-decimal
|
|
54
|
+
# matches are the same after clamp);
|
|
55
|
+
# response.content.to_str.strip is genuine (String);
|
|
56
|
+
# all warn variations (warn no-arg, warn(nil), warn(e), warn(nil literal),
|
|
57
|
+
# nil-replacing-warn, warn-deletion) are genuine because the rescue block
|
|
58
|
+
# still returns 0.0 — warn is a side-effect not tested by value assertions
|
|
48
59
|
def score(actual:, expected:, input: nil)
|
|
49
60
|
prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
|
|
50
|
-
response = RubyLLM.chat(model: @model).ask(prompt)
|
|
61
|
+
response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.await
|
|
51
62
|
response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
|
|
52
63
|
rescue => e
|
|
53
64
|
raise if @raise_on_error
|
data/lib/phronomy/event_loop.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# Singleton event loop that manages all FSMSession instances.
|
|
5
5
|
#
|
|
6
|
-
# A single background thread reads from a global {Phronomy::AsyncQueue} and
|
|
6
|
+
# A single background thread reads from a global {Phronomy::Concurrency::AsyncQueue} and
|
|
7
7
|
# dispatches events to their target FSMSession. IO work (LLM calls, tool
|
|
8
8
|
# calls) must be dispatched via +Runtime.instance.spawn+ or
|
|
9
9
|
# +BlockingAdapterPool+, then post results back to the loop via
|
|
@@ -72,7 +72,7 @@ module Phronomy
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def initialize
|
|
75
|
-
@queue = Phronomy::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
|
|
75
|
+
@queue = Phronomy::Concurrency::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
|
|
76
76
|
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
77
77
|
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
78
78
|
# Mutex-backed FSM count for drain-mode shutdown.
|
|
@@ -80,7 +80,7 @@ module Phronomy
|
|
|
80
80
|
@fsm_count_cond = ConditionVariable.new
|
|
81
81
|
@fsm_count = 0
|
|
82
82
|
# Token cancelled when shutdown is requested; new child sessions receive it.
|
|
83
|
-
@shutdown_token = Phronomy::CancellationToken.new
|
|
83
|
+
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
84
84
|
# Fairness metrics (EventLoop thread only, except where noted)
|
|
85
85
|
@lag_mutex = Mutex.new
|
|
86
86
|
@last_lag_ns = 0
|
|
@@ -129,8 +129,8 @@ module Phronomy
|
|
|
129
129
|
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
130
130
|
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
131
131
|
#
|
|
132
|
-
# @param fsm_session [Phronomy::FSMSession]
|
|
133
|
-
# @return [Phronomy::AsyncQueue] resolves to final/halted context, or an Exception
|
|
132
|
+
# @param fsm_session [Phronomy::Agent::Lifecycle::FSMSession]
|
|
133
|
+
# @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
|
|
134
134
|
# @api private
|
|
135
135
|
def register(fsm_session)
|
|
136
136
|
if Phronomy::EventLoop.current?
|
|
@@ -141,7 +141,7 @@ module Phronomy
|
|
|
141
141
|
"Phronomy::EventLoop.instance.post(...) instead."
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
completion_queue = Phronomy::AsyncQueue.new
|
|
144
|
+
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
145
145
|
# Pass both session and completion_queue in the event payload so that the
|
|
146
146
|
# EventLoop thread is the sole writer of @fsms and @waiting.
|
|
147
147
|
@queue.push([Event.new(type: :start, target_id: fsm_session.id,
|
|
@@ -194,7 +194,7 @@ module Phronomy
|
|
|
194
194
|
return self if @task&.alive?
|
|
195
195
|
|
|
196
196
|
# Reset shutdown state so the loop can be restarted after a stop.
|
|
197
|
-
@shutdown_token = Phronomy::CancellationToken.new
|
|
197
|
+
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
198
198
|
@fsm_count_mutex.synchronize { @fsm_count = 0 }
|
|
199
199
|
@running = true
|
|
200
200
|
# The dispatch loop must always run in a real background thread.
|
|
@@ -11,7 +11,7 @@ module Phronomy
|
|
|
11
11
|
# @example Build a context for a new agent invocation
|
|
12
12
|
# ctx = Phronomy::InvocationContext.new(
|
|
13
13
|
# thread_id: "conv-123",
|
|
14
|
-
# cancellation_token: Phronomy::CancellationToken.timeout_after(30),
|
|
14
|
+
# cancellation_token: Phronomy::Concurrency::CancellationToken.timeout_after(30),
|
|
15
15
|
# max_parallel_tools: 5
|
|
16
16
|
# )
|
|
17
17
|
# agent.invoke("Hello", invocation_context: ctx)
|
|
@@ -127,7 +127,7 @@ module Phronomy
|
|
|
127
127
|
# @return [CancellationToken]
|
|
128
128
|
# @api private
|
|
129
129
|
def effective_cancellation_token
|
|
130
|
-
@cancellation_token || CancellationToken.new
|
|
130
|
+
@cancellation_token || Phronomy::Concurrency::CancellationToken.new
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Returns the cancellation token to use for an invocation, taking both the
|
|
@@ -144,7 +144,7 @@ module Phronomy
|
|
|
144
144
|
return @cancellation_token if @cancellation_token
|
|
145
145
|
return nil if @deadline.nil?
|
|
146
146
|
|
|
147
|
-
token = CancellationToken.new
|
|
147
|
+
token = Phronomy::Concurrency::CancellationToken.new
|
|
148
148
|
@deadline.attach_to(token)
|
|
149
149
|
token
|
|
150
150
|
end
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "knowledge_source/base"
|
|
4
|
-
require_relative "knowledge_source/static_knowledge"
|
|
5
|
-
require_relative "knowledge_source/rag_knowledge"
|
|
6
|
-
require_relative "knowledge_source/entity_knowledge"
|
|
7
|
-
|
|
8
3
|
module Phronomy
|
|
9
4
|
# KnowledgeSource provides the interface for supplying context region 3 (Knowledge)
|
|
10
5
|
# to the Context::Assembler.
|
|
@@ -14,27 +14,33 @@ module Phronomy
|
|
|
14
14
|
# c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
15
15
|
# end
|
|
16
16
|
class RubyLLM < Base
|
|
17
|
-
# Delegates to +chat.ask(message)
|
|
17
|
+
# Delegates to +chat.ask(message)+ or +chat.complete+ when message is nil.
|
|
18
18
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
19
|
+
# Passing +nil+ for +message+ is used by the ReAct loop for continuation
|
|
20
|
+
# turns where the user message has already been added to the chat history
|
|
21
|
+
# (e.g. after a tool result) and the LLM should continue without a new
|
|
22
|
+
# user turn.
|
|
23
|
+
#
|
|
24
|
+
# @param chat [Object] RubyLLM chat session
|
|
25
|
+
# @param message [String, nil] user message, or nil to continue the chat
|
|
26
|
+
# @param config [Hash] invocation config (not used directly by this impl)
|
|
22
27
|
# @return [Object] RubyLLM response
|
|
23
28
|
# @api private
|
|
24
29
|
def complete(chat, message, config: {})
|
|
25
|
-
chat.ask(message)
|
|
30
|
+
message ? chat.ask(message) : chat.complete
|
|
26
31
|
end
|
|
27
32
|
|
|
28
|
-
# Delegates to +chat.ask(message) { |chunk| block.call(chunk) }
|
|
33
|
+
# Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+ or
|
|
34
|
+
# +chat.complete(&block)+ when message is nil.
|
|
29
35
|
#
|
|
30
|
-
# @param chat [Object]
|
|
31
|
-
# @param message [String] user message
|
|
32
|
-
# @param config [Hash]
|
|
33
|
-
# @yield [chunk] streaming chunk forwarded from +chat.ask+
|
|
36
|
+
# @param chat [Object] RubyLLM chat session
|
|
37
|
+
# @param message [String, nil] user message, or nil to continue the chat
|
|
38
|
+
# @param config [Hash] invocation config
|
|
39
|
+
# @yield [chunk] streaming chunk forwarded from +chat.ask+ / +chat.complete+
|
|
34
40
|
# @return [Object] RubyLLM response
|
|
35
41
|
# @api private
|
|
36
42
|
def stream(chat, message, config: {}, &block)
|
|
37
|
-
chat.ask(message, &block)
|
|
43
|
+
message ? chat.ask(message, &block) : chat.complete(&block)
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
end
|