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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. 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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- class Runtime
4
+ module Concurrency
5
5
  # Lazy cache of {ConcurrencyGate} instances, keyed by resource name.
6
6
  #
7
7
  # Gate concurrency caps are read from {Phronomy::Configuration} when a gate
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- class Runtime
4
+ module Concurrency
5
5
  # Registry and lifecycle manager for {BlockingAdapterPool} instances.
6
6
  #
7
7
  # Maintains one unnamed "default" pool (accessed via {#default_pool}) and
@@ -5,14 +5,8 @@ module Phronomy
5
5
  # context assembly.
6
6
  #
7
7
  # Sub-modules are auto-loaded by Zeitwerk:
8
- # Phronomy::Context::TokenEstimator
9
- # Phronomy::Context::TokenBudget
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"
@@ -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
@@ -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
@@ -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
- # @param chat [Object] RubyLLM chat session
20
- # @param message [String] user message
21
- # @param config [Hash] invocation config (not used directly by this impl)
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] RubyLLM chat session
31
- # @param message [String] user message
32
- # @param config [Hash] invocation config
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