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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. 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
@@ -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"