phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -1,346 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- # Factory that builds a Phronomy::FSMSession configured for a single
8
- # Agent#invoke execution.
9
- #
10
- # This is the Agent counterpart to WorkflowRunner — it assembles the
11
- # FSMSession with the correct phase machine class, entry actions, and
12
- # context, then hands it to EventLoop for execution.
13
- #
14
- # == Usage
15
- #
16
- # session = Agent::InvocationSession.build(
17
- # agent: my_agent,
18
- # input: "What is Ruby?",
19
- # messages: [],
20
- # config: { thread_id: "t-1" }
21
- # )
22
- # completion_queue = Phronomy::EventLoop.instance.register(session)
23
- # ctx = completion_queue.pop
24
- #
25
- # == Streaming mode
26
- #
27
- # Pass +mode: :stream+ and an +on_event:+ block to receive token/tool events.
28
- # The state graph is identical; only the +:calling_llm+ entry action differs.
29
- #
30
- # @api private
31
- class InvocationSession
32
- # States that have an automatic transition after their action completes.
33
- AUTO_STATE_SET = {
34
- idle: true,
35
- filtering_input: true,
36
- building_context: true,
37
- calling_llm: true,
38
- executing_tool: true,
39
- output_filtering: true
40
- }.freeze
41
-
42
- # All declared action states (terminals excluded).
43
- DECLARED_STATES = %i[
44
- idle filtering_input building_context calling_llm
45
- executing_tool awaiting_approval output_filtering
46
- completed blocked
47
- ].freeze
48
-
49
- # Builds a Phronomy::FSMSession for the given agent invocation.
50
- #
51
- # @param agent [Phronomy::Agent::Base]
52
- # @param input [String, Hash]
53
- # @param messages [Array]
54
- # @param config [Hash]
55
- # @param mode [:invoke, :stream]
56
- # @param on_event [Proc, nil] stream event callback (stream mode only)
57
- # @return [Phronomy::FSMSession]
58
- # @api private
59
- def self.build(agent:, input:, messages:, config:, mode: :invoke, on_event: nil)
60
- ctx = Agent::InvocationContext.new(
61
- agent: agent,
62
- input: input,
63
- messages: messages,
64
- config: config
65
- )
66
-
67
- actions = (mode == :stream && on_event) ?
68
- build_stream_entry_actions(agent, on_event) :
69
- build_entry_actions(agent)
70
-
71
- # Calculate recursion_limit for the FSM:
72
- # Base states: idle→filtering_input→building_context→calling_llm→
73
- # output_filtering→completed = 6 transitions
74
- # Each tool call loop: calling_llm→executing_tool→calling_llm = 2 transitions
75
- # Safety margin: +4
76
- iterations = agent.class.max_iterations || 10
77
- fsm_recursion_limit = 6 + (iterations * 2) + 4
78
-
79
- # Entry actions are registered as after_transition callbacks in the
80
- # phase machine class. Pass empty hash to FSMSession (it uses @entry_actions
81
- # only for the entry_point state, which has no action for :idle).
82
- phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
83
- session_id = config[:thread_id] || SecureRandom.uuid
84
-
85
- Phronomy::FSMSession.new(
86
- id: session_id,
87
- context: ctx,
88
- entry_point: :idle,
89
- phase_machine_class: phase_machine,
90
- entry_actions: {},
91
- auto_state_set: AUTO_STATE_SET,
92
- declared_states: DECLARED_STATES,
93
- wait_state_names: %i[awaiting_approval],
94
- external_events: {
95
- approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
96
- reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
97
- },
98
- recursion_limit: fsm_recursion_limit
99
- )
100
- end
101
-
102
- # Builds a FSMSession that resumes an existing InvocationContext
103
- # from a wait state (e.g. :awaiting_approval) using an external event.
104
- #
105
- # @param agent [Phronomy::Agent::Base]
106
- # @param context [Phronomy::Agent::InvocationContext] suspended context
107
- # @param resume_event [Symbol] e.g. :approve or :reject
108
- # @param resume_phase [Symbol] the wait state to resume from
109
- # @return [Phronomy::FSMSession]
110
- # @api private
111
- def self.build_for_resume(agent:, context:, resume_event:, resume_phase:)
112
- actions = build_entry_actions(agent)
113
- phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
114
-
115
- iterations = agent.class.max_iterations || 10
116
- fsm_recursion_limit = 6 + (iterations * 2) + 4
117
-
118
- Phronomy::FSMSession.new(
119
- id: context.session_id || SecureRandom.uuid,
120
- context: context,
121
- entry_point: :idle,
122
- phase_machine_class: phase_machine,
123
- entry_actions: {},
124
- auto_state_set: AUTO_STATE_SET,
125
- declared_states: DECLARED_STATES,
126
- wait_state_names: %i[awaiting_approval],
127
- external_events: {
128
- approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
129
- reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
130
- },
131
- recursion_limit: fsm_recursion_limit,
132
- resume_event: resume_event,
133
- resume_phase: resume_phase
134
- )
135
- end
136
-
137
- # ---------------------------------------------------------------------------
138
- # Entry action builders
139
- # ---------------------------------------------------------------------------
140
-
141
- # @api private
142
- def self.build_entry_actions(agent)
143
- {
144
- # :idle has no action — FSMSession auto-transitions to :filtering_input
145
- filtering_input: [method(:filtering_input_action).curry.call(agent)],
146
- building_context: [method(:building_context_action).curry.call(agent)],
147
- calling_llm: [method(:calling_llm_action).curry.call(agent)],
148
- executing_tool: [method(:executing_tool_action).curry.call(agent)],
149
- output_filtering: [method(:output_filtering_action).curry.call(agent)]
150
- }
151
- end
152
- private_class_method :build_entry_actions
153
-
154
- # @api private
155
- def self.build_stream_entry_actions(agent, on_event)
156
- build_entry_actions(agent).merge(
157
- calling_llm: [method(:calling_llm_stream_action).curry.call(agent, on_event)]
158
- )
159
- end
160
- private_class_method :build_stream_entry_actions
161
-
162
- # ----------------------------------------------------------------
163
- # Individual entry action implementations
164
- # ----------------------------------------------------------------
165
-
166
- def self.filtering_input_action(agent, ctx)
167
- begin
168
- ctx.input = agent.send(:run_input_filters!, ctx.input)
169
- rescue Phronomy::FilterBlockError => e
170
- ctx.input_blocked = true
171
- ctx.block_error = e
172
- end
173
- ctx
174
- end
175
- private_class_method :filtering_input_action
176
-
177
- def self.building_context_action(agent, ctx)
178
- ctx.chat = agent.send(:build_chat)
179
- context = agent.send(
180
- :build_context,
181
- ctx.input,
182
- messages: ctx.messages,
183
- thread_id: ctx.thread_id,
184
- config: ctx.config,
185
- budget: agent.send(:build_token_budget),
186
- instruction: agent.send(:build_instructions, ctx.input),
187
- tools: agent.class.tools + agent.send(:_handoff_tools)
188
- )
189
- agent.send(:_apply_context_to_chat, ctx.chat, context)
190
- # Run before-completion hooks (e.g. memory injection) once per invocation.
191
- agent.send(:run_before_completion_hooks!, ctx.chat, ctx.config)
192
- # Register the tool-call interceptor so every tool call routes through
193
- # :executing_tool in the FSM instead of executing inside RubyLLM's loop.
194
- ctx.chat.on_tool_call do |tool_call|
195
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
196
- end
197
- ctx
198
- end
199
- private_class_method :building_context_action
200
-
201
- def self.calling_llm_action(agent, ctx)
202
- # Returns a Task.deferred — no extra OS thread is created.
203
- # The BlockingAdapterPool worker thread completes the LLM call and
204
- # resolves result_task via on_complete, which then triggers the
205
- # FSMSession's dispatch_task_in_event_loop on_complete callback to
206
- # post :action_completed back to the EventLoop.
207
- user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
208
- agent.send(:check_cancellation!, ctx.config, "invocation cancelled before LLM call")
209
- adapter = Phronomy.configuration.llm_adapter
210
- op = adapter.complete_async(ctx.chat, user_message, config: ctx.config)
211
- result_task = Phronomy::Task.deferred(name: "agent-llm:#{ctx.thread_id}")
212
- op.on_complete do |response, error|
213
- if error.is_a?(Phronomy::Agent::ToolCallIntercepted)
214
- ctx.user_message_sent = true
215
- ctx.pending_tool_call = error.tool_call
216
- ctx.tool_call_pending = true
217
- ctx.messages = ctx.chat.messages
218
- result_task.backend.unblock(ctx, nil)
219
- result_task.transition!(:completed, value: ctx)
220
- elsif error
221
- result_task.backend.unblock(nil, error)
222
- result_task.transition!(:failed, error: error)
223
- else
224
- ctx.user_message_sent = true
225
- ctx.output = response.content
226
- ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
227
- ctx.messages = ctx.chat.messages
228
- ctx.tool_call_pending = false
229
- result_task.backend.unblock(ctx, nil)
230
- result_task.transition!(:completed, value: ctx)
231
- end
232
- end
233
- result_task
234
- end
235
- private_class_method :calling_llm_action
236
-
237
- def self.calling_llm_stream_action(agent, on_event, ctx)
238
- user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
239
- # Streaming requires a background thread because chunk_queue.pop is a
240
- # blocking drain loop that must not run on the EventLoop thread.
241
- # The on_complete pattern used in calling_llm_action cannot be applied
242
- # here because tokens must be delivered incrementally via on_event
243
- # before the final response arrives. This spawn is therefore
244
- # intentional and classified under ADR-010 Rule 2 (blocking loop).
245
- Phronomy::Runtime.instance.spawn(name: "agent-llm-stream:#{ctx.thread_id}") do
246
- adapter = Phronomy.configuration.llm_adapter
247
- chunk_queue = Phronomy::Concurrency::AsyncQueue.new(
248
- max_size: Phronomy.configuration.stream_queue_max_size
249
- )
250
- pending = adapter.stream_async(
251
- ctx.chat, user_message,
252
- config: ctx.config,
253
- enqueue_to: chunk_queue
254
- )
255
- loop do
256
- chunk = chunk_queue.pop
257
- break if chunk.nil?
258
- on_event.call(Phronomy::Agent::StreamEvent.new(
259
- type: :token, payload: {content: chunk.content}
260
- ))
261
- end
262
- response = pending.blocking_wait
263
- ctx.user_message_sent = true
264
- ctx.output = response.content
265
- ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
266
- ctx.messages = ctx.chat.messages
267
- ctx.tool_call_pending = false
268
- ctx
269
- end
270
- end
271
- private_class_method :calling_llm_stream_action
272
-
273
- def self.executing_tool_action(agent, ctx)
274
- tc = ctx.pending_tool_call
275
- tool_instance = ctx.chat.tools[tc.name.to_sym]
276
-
277
- unless tool_instance
278
- # Tool not found — inject an error result and continue the LLM loop.
279
- ctx.chat.add_message(
280
- role: :tool,
281
- content: "Tool not found.",
282
- tool_call_id: tc.id
283
- )
284
- ctx.pending_tool_call = nil
285
- ctx.tool_call_pending = false
286
- ctx.approval_required = false
287
- return ctx
288
- end
289
-
290
- if tool_instance.requires_approval && !ctx.sync_approval_handler
291
- if ctx.approved
292
- # Human approved via Agent.approve — execute the tool and continue.
293
- ctx.approved = false # consume the approval flag
294
- else
295
- # No sync handler and not yet approved — suspend for HITL.
296
- ctx.approval_required = true
297
- return ctx
298
- end
299
- end
300
-
301
- # Dispatch the tool off the EventLoop thread via ToolExecutor, which
302
- # routes based on the tool's execution_mode class attribute:
303
- # :blocking_io (default) → BlockingAdapterPool (bounded thread pool)
304
- # :cooperative → Runtime.instance.spawn (scheduler task)
305
- # Wrap the awaitable in Task.deferred so FSMSession recognises it as
306
- # an async action and sets async_pending = true.
307
- tc_id = tc.id
308
- tc_args = tc.arguments
309
- tc_name = tc.name
310
- ct = ctx.config[:cancellation_token]
311
- awaitable = tool_instance.call_async(tc_args, cancellation_token: ct, config: ctx.config)
312
- result_task = Phronomy::Task.deferred(name: "tool-exec:#{tc_name}")
313
- awaitable.on_complete do |result, error|
314
- if error
315
- result_task.backend.unblock(nil, error)
316
- result_task.transition!(:failed, error: error)
317
- else
318
- ctx.chat.add_message(
319
- role: :tool,
320
- content: result.to_s,
321
- tool_call_id: tc_id
322
- )
323
- ctx.pending_tool_call = nil
324
- ctx.tool_call_pending = false
325
- ctx.approval_required = false
326
- result_task.backend.unblock(ctx, nil)
327
- result_task.transition!(:completed, value: ctx)
328
- end
329
- end
330
- result_task
331
- end
332
- private_class_method :executing_tool_action
333
-
334
- def self.output_filtering_action(agent, ctx)
335
- begin
336
- ctx.output = agent.send(:run_output_filters!, ctx.output)
337
- rescue Phronomy::FilterBlockError => e
338
- ctx.output_blocked = true
339
- ctx.block_error = e
340
- end
341
- ctx
342
- end
343
- private_class_method :output_filtering_action
344
- end
345
- end
346
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # In-process registry for Agent invocations suspended at :awaiting_approval.
6
- #
7
- # When an agent invocation halts waiting for human approval, the
8
- # InvocationContext is stored here keyed by session_id.
9
- # Agent::Base.approve / Agent::Base.reject look up and remove the context
10
- # to build a resume session.
11
- #
12
- # Thread-safe. Each process has one shared instance via module methods.
13
- # Cross-process persistence is out of scope (future SessionStore feature).
14
- #
15
- # @api private
16
- module SuspendedSessionRegistry
17
- @sessions = {}
18
- @mutex = Mutex.new
19
-
20
- # Stores a suspended context under the given session_id.
21
- # @param session_id [String]
22
- # @param ctx [Phronomy::Agent::InvocationContext]
23
- # @return [void]
24
- # @api private
25
- def self.store(session_id, ctx)
26
- @mutex.synchronize { @sessions[session_id] = ctx }
27
- end
28
-
29
- # Retrieves and removes the suspended context for session_id.
30
- # Returns nil when no matching session exists.
31
- # @param session_id [String]
32
- # @return [Phronomy::Agent::InvocationContext, nil]
33
- # @api private
34
- def self.fetch(session_id)
35
- @mutex.synchronize { @sessions.delete(session_id) }
36
- end
37
-
38
- # Returns true when a session is suspended under the given id.
39
- # @param session_id [String]
40
- # @return [Boolean]
41
- # @api private
42
- def self.exists?(session_id)
43
- @mutex.synchronize { @sessions.key?(session_id) }
44
- end
45
-
46
- # Clears all suspended sessions. Intended for test teardown only.
47
- # @return [void]
48
- # @api private
49
- def self.clear!
50
- @mutex.synchronize { @sessions.clear }
51
- end
52
- end
53
- end
54
- end
@@ -1,157 +0,0 @@
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
@@ -1,51 +0,0 @@
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
- vector: :max_concurrent_vector_searches
18
- }.freeze
19
- private_constant :GATE_CONFIG_MAP
20
-
21
- def initialize
22
- @mutex = Mutex.new
23
- @gates = {}
24
- end
25
-
26
- # Returns (or lazily creates) the gate for +name+.
27
- # @param name [Symbol]
28
- # @return [ConcurrencyGate]
29
- # @api private
30
- def get(name)
31
- @mutex.synchronize { @gates[name] ||= _build(name) }
32
- end
33
-
34
- # Drops the cached gate for +name+ so the next {#get} rebuilds it.
35
- # @param name [Symbol]
36
- # @return [void]
37
- # @api private
38
- def reset(name)
39
- @mutex.synchronize { @gates.delete(name) }
40
- end
41
-
42
- private
43
-
44
- def _build(name)
45
- config_key = GATE_CONFIG_MAP[name]
46
- max = config_key ? Phronomy.configuration.public_send(config_key) : nil
47
- ConcurrencyGate.new(max_concurrent: max, name: name)
48
- end
49
- end
50
- end
51
- end