phronomy 0.13.0 → 0.14.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.
@@ -99,14 +99,19 @@ module Phronomy
99
99
  # @return [Phronomy::Task]
100
100
  # @api private
101
101
  def invoke_deferred(input, config: {})
102
+ runtime = Phronomy::Runtime.instance
103
+ event_loop = runtime.event_loop
102
104
  state, thread_id, recursion_limit, store = _build_initial_context(input, config)
103
105
  result_task = Phronomy::Task.deferred(name: "workflow-async:#{thread_id}")
104
- Phronomy::EventLoop.instance.start
105
- session = build_session_for(context: state, recursion_limit: recursion_limit)
106
+ session = build_session_for(
107
+ context: state,
108
+ recursion_limit: recursion_limit,
109
+ runtime: runtime
110
+ )
106
111
  if store && config[:thread_id]
107
112
  # Wrap so that state is persisted when the task resolves.
108
113
  persist_task = Phronomy::Task.deferred(name: "workflow-async-persist:#{thread_id}")
109
- Phronomy::EventLoop.instance.register(session, completion: persist_task)
114
+ event_loop.register(session, completion: persist_task)
110
115
  persist_task.on_complete do |result, error|
111
116
  store.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) unless error
112
117
  if error
@@ -118,7 +123,7 @@ module Phronomy
118
123
  end
119
124
  end
120
125
  else
121
- Phronomy::EventLoop.instance.register(session, completion: result_task)
126
+ event_loop.register(session, completion: result_task)
122
127
  end
123
128
  result_task
124
129
  end
@@ -202,7 +207,8 @@ module Phronomy
202
207
  end
203
208
 
204
209
  # Builds an FSMSession for the given context. Used in EventLoop mode.
205
- def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
210
+ def build_session_for(context:, recursion_limit:, runtime: Phronomy::Runtime.instance,
211
+ resume_event: nil, resume_phase: nil)
206
212
  Phronomy::FSMSession.new(
207
213
  id: context.thread_id,
208
214
  context: context,
@@ -214,24 +220,28 @@ module Phronomy
214
220
  external_events: @external_events,
215
221
  phase_machine_class: @phase_machine_class,
216
222
  recursion_limit: recursion_limit,
223
+ event_loop: runtime.event_loop,
224
+ timer_queue_provider: -> { runtime.timer_queue },
217
225
  action_timeouts: @action_timeouts,
218
226
  resume_event: resume_event,
219
227
  resume_phase: resume_phase
220
228
  )
221
229
  end
222
230
 
223
- # Executes the workflow via the singleton EventLoop.
231
+ # Executes the workflow via the default Runtime-owned EventLoop.
224
232
  # Blocks the calling thread on a completion queue until the workflow
225
233
  # finishes, halts at a wait state, or raises an error.
226
234
  def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
227
- # Ensure EventLoop is running. In tests, reset_runtime! resets the
228
- # singleton without restarting it; start is idempotent when already alive.
229
- Phronomy::EventLoop.instance.start
235
+ runtime = Phronomy::Runtime.instance
236
+ event_loop = runtime.event_loop
230
237
  session = build_session_for(
231
- context: context, recursion_limit: recursion_limit,
232
- resume_event: resume_event, resume_phase: resume_phase
238
+ context: context,
239
+ recursion_limit: recursion_limit,
240
+ runtime: runtime,
241
+ resume_event: resume_event,
242
+ resume_phase: resume_phase
233
243
  )
234
- completion_queue = Phronomy::EventLoop.instance.register(session)
244
+ completion_queue = event_loop.register(session)
235
245
  result = completion_queue.pop
236
246
  raise result if result.is_a?(Exception)
237
247
  result
data/lib/phronomy.rb CHANGED
@@ -77,6 +77,13 @@ module Phronomy
77
77
  # @see Phronomy::Runtime.in_scheduler_context?
78
78
  class SchedulerReentrancyError < Error; end
79
79
 
80
+ # Raised when work is submitted to a Runtime whose shutdown has begun, or
81
+ # when a Runtime cannot be reset because owned resources are still alive.
82
+ class RuntimeShutdownError < Error; end
83
+
84
+ # Raised when Runtime#shutdown is invoked from inside a Phronomy::Task.
85
+ class RuntimeShutdownReentrancyError < RuntimeShutdownError; end
86
+
80
87
  # Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
81
88
  # and the pipeline's combined confidence score falls below the configured threshold.
82
89
  #
@@ -175,28 +182,26 @@ module Phronomy
175
182
  @configuration = original
176
183
  end
177
184
 
178
- # Resets all Phronomy runtime state: configuration and the EventLoop
179
- # singleton (if running).
185
+ # Shuts down and clears the process-wide default Runtime, then resets
186
+ # global configuration. Intended for test suites only.
180
187
  #
181
- # **Intended for test suites only.** Stops any running EventLoop thread,
182
- # clears the EventLoop singleton, and resets configuration to defaults.
183
- # Call once before/after each example to ensure test isolation.
188
+ # Runtime execution failure and resource cleanup are separate. The
189
+ # singleton is cleared when cleanup completed, even if execution failed.
184
190
  #
185
- # @example
186
- # config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
191
+ # @param timeout [Numeric] maximum graceful wait for Runtime tasks and
192
+ # EventLoop shutdown
193
+ # @return [Phronomy::Runtime::ShutdownResult]
187
194
  # @api public
188
- def reset_runtime!
189
- # Do NOT stop the EventLoop here. Since Phase 2, all Agent#invoke calls
190
- # go through FSMSession + EventLoop. Stopping and restarting the EventLoop
191
- # on every after(:each) hook would add thread creation overhead per test.
192
- # run_via_event_loop and _invoke_via_fsm restart it lazily if it was reset.
193
- #
194
- # Preserve event_loop_stop_grace_seconds from the current configuration
195
- # so that test suites that set it to 0 (for fast teardown) keep that value
196
- # across configuration resets.
197
- prev_grace = @configuration&.event_loop_stop_grace_seconds
198
- @configuration = Configuration.new
199
- @configuration.event_loop_stop_grace_seconds = prev_grace if prev_grace
195
+ def reset_runtime!(timeout: configuration.event_loop_stop_grace_seconds)
196
+ previous_grace = @configuration&.event_loop_stop_grace_seconds
197
+ result = Runtime.reset_default!(timeout: timeout)
198
+
199
+ new_configuration = Configuration.new
200
+ if previous_grace
201
+ new_configuration.event_loop_stop_grace_seconds = previous_grace
202
+ end
203
+ @configuration = new_configuration
204
+ result
200
205
  end
201
206
  end
202
207
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-23 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -66,18 +66,58 @@ dependencies:
66
66
  version: '0.6'
67
67
  - !ruby/object:Gem::Dependency
68
68
  name: mcp
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.0'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: faraday
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '2'
88
+ - - "<"
89
+ - !ruby/object:Gem::Version
90
+ version: '3'
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '2'
98
+ - - "<"
99
+ - !ruby/object:Gem::Version
100
+ version: '3'
101
+ - !ruby/object:Gem::Dependency
102
+ name: event_stream_parser
69
103
  requirement: !ruby/object:Gem::Requirement
70
104
  requirements:
71
105
  - - ">="
72
106
  - !ruby/object:Gem::Version
73
- version: '0.3'
107
+ version: '1'
108
+ - - "<"
109
+ - !ruby/object:Gem::Version
110
+ version: '2'
74
111
  type: :runtime
75
112
  prerelease: false
76
113
  version_requirements: !ruby/object:Gem::Requirement
77
114
  requirements:
78
115
  - - ">="
79
116
  - !ruby/object:Gem::Version
80
- version: '0.3'
117
+ version: '1'
118
+ - - "<"
119
+ - !ruby/object:Gem::Version
120
+ version: '2'
81
121
  description: Phronomy is a Ruby AI agent framework that provides composable building
82
122
  blocks — Agents, Workflows, Tools, Filters, and Tracing — for building AI agents
83
123
  in Ruby. Powered by RubyLLM for LLM abstraction.
@@ -115,6 +155,8 @@ files:
115
155
  - docs/decisions/009-state-store-abstraction.md
116
156
  - docs/decisions/010-cooperative-first-concurrency.md
117
157
  - docs/decisions/011-build-context-as-single-llm-input-authority.md
158
+ - docs/mcp-client.md
159
+ - gemfiles/mcp_1_0.gemfile
118
160
  - lib/phronomy.rb
119
161
  - lib/phronomy/agent.rb
120
162
  - lib/phronomy/agent/base.rb
@@ -143,9 +185,7 @@ files:
143
185
  - lib/phronomy/engine/concurrency/blocking_adapter_pool.rb
144
186
  - lib/phronomy/engine/concurrency/cancellation_scope.rb
145
187
  - lib/phronomy/engine/concurrency/cancellation_token.rb
146
- - lib/phronomy/engine/concurrency/concurrency_gate.rb
147
188
  - lib/phronomy/engine/concurrency/deadline.rb
148
- - lib/phronomy/engine/concurrency/gate_registry.rb
149
189
  - lib/phronomy/engine/concurrency/pool_registry.rb
150
190
  - lib/phronomy/engine/event_loop.rb
151
191
  - lib/phronomy/engine/fsm_session.rb
@@ -155,6 +195,7 @@ files:
155
195
  - lib/phronomy/engine/runtime/runtime_metrics.rb
156
196
  - lib/phronomy/engine/runtime/scheduler.rb
157
197
  - lib/phronomy/engine/runtime/scheduler_timer_adapter.rb
198
+ - lib/phronomy/engine/runtime/shutdown_result.rb
158
199
  - lib/phronomy/engine/runtime/task_registry.rb
159
200
  - lib/phronomy/engine/runtime/thread_scheduler.rb
160
201
  - lib/phronomy/engine/runtime/timer_queue.rb
@@ -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