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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd9fb679db0e4d4c582a82dad543ca0d93806878e0936404cd4255d2a48a741b
4
- data.tar.gz: 511a6bfb90ec0524aafb201996bc251c9b1923a2d3ff4d1b389aa7d62f2d3395
3
+ metadata.gz: 821e5e60bc5eacd862e98cbf03b74ba1de640d923c783d29ec560b4e0523dafa
4
+ data.tar.gz: 515be391fa893cc679bcc348a5db5abddbb5d6714507f0adf3eae25a0d0caffd
5
5
  SHA512:
6
- metadata.gz: 9efc33585c40e3d5b4559121cd5654c9687608ff05ab684818e27ac5d4672264352fb68fbf4efa6436ed8666a0b194acfdcea7511fce67a5eda50e690cd80803
7
- data.tar.gz: 0142742c7875411622ad37ae2918164130bd84fe237da2e100b69578f67380a08c61ae6ca77cfdcac5892ea7bd56140c39b374b8d0543d11cd359bda587f12df
6
+ metadata.gz: 744f401fb376638feea54c7639653ea92fcf1cde3043f0de656f1e16e2f14fbac15ae2c396d939e4f5810446acc4d65e29d4c95fe1049e02d759625b0b7c7f19
7
+ data.tar.gz: 6835730faae5f27c80c0a2157f7e69e38f23c6b41e1cdbfa169532b786b981934d70a224bf793922e55fc0d7be4894d9ca9c984191bbf8e014d1aee158664ed9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ---
13
+
14
+ ## [0.14.0] - 2026-07-27
15
+
16
+ ### Changed
17
+
18
+ - **Runtime-owned EventLoop lifecycle** (`refactor: Runtime-owned EventLoop lifecycle`):
19
+ - `Runtime#event_loop` lazy accessor replaces the standalone `EventLoop.instance` singleton.
20
+ - `EventLoop.instance` is retained as a deprecated compatibility bridge to `Runtime.instance.event_loop`.
21
+ - EventLoop dispatcher now runs in a dedicated real-thread service scheduler owned by `Runtime`,
22
+ removing the hidden `Runtime.new(scheduler: ThreadScheduler.new)` inside `EventLoop`.
23
+ - EventLoop lifecycle is terminal: `:running` → `:draining` → `:stopping` → `:terminated` / `:failed`.
24
+ - `STOP` Object sentinel replaces the `:__stop__` symbol; stale sentinel handling is removed.
25
+ - `@running` instance variable removed; dispatch loop driven solely by the STOP sentinel.
26
+ - EventLoop-specific `@shutdown_token` removed.
27
+ - `outstanding_sessions` counter covers queue-pending `:start` events from `register` time,
28
+ ensuring drain correctness when shutdown races with queued sessions.
29
+ - Shutdown timeout: `AbortAndStop` control command path plus `cancel!` → `join(cancel_grace)` fallback.
30
+ - `Runtime.reset_default!` replaces `EventLoop.reset!`; `Phronomy.reset_runtime!` performs real Runtime shutdown then config reset.
31
+ - `ShutdownResult` value object separates `runtime_outcome` from `cleanup_status`.
32
+ - `FSMSession` now receives `event_loop:` and `timer_queue_provider:` explicitly at build time.
33
+ - Agent / Workflow phase-machine builders capture the owning EventLoop at invocation time;
34
+ `EventLoop.instance` is no longer re-fetched inside async callbacks or timers.
35
+
36
+ ### Fixed
37
+
38
+ - **Double-dispatch regression (P0 hotfix — commit `2cc8c6c1`)**: `EventLoop#stop` no longer clears `@task`
39
+ while the dispatcher task is still alive. A subsequent `start` call detects the live task and does
40
+ not spawn a second dispatch loop on the same queue. Includes `:cancel_timeout` status for the case
41
+ where `cancel!` does not terminate the task within `cancel_grace` seconds.
42
+ - **`EventLoop.reset!` safety**: raises `Phronomy::Error` when the dispatcher task is still alive
43
+ after stop instead of unconditionally clearing the singleton.
44
+ - **`EventLoop#task_alive?`**: new public helper; thread-safe boolean for use in reset logic.
45
+
46
+
47
+ `BlockingAdapterPool#submit` previously stored the timeout value but never
48
+ registered a wall-clock timer, so `config: { llm_timeout: N }` and
49
+ `config: { tool_timeout: N }` had no effect for callers using `on_complete`
50
+ (the normal non-streaming Agent path). The timer is now armed before queue
51
+ admission and calls `fire_timeout!` when the deadline expires.
52
+
53
+ ### Changed
54
+
55
+ - **`blocking_wait(timeout:)` is now a waiter-local deadline only**:
56
+ Previously, the timeout passed to `blocking_wait` (or `wait_result`) would settle the
57
+ operation, set `abandoned? = true`, and increment `abandoned_count` — affecting
58
+ all future waiters and callbacks. It is now scoped to the single calling thread:
59
+ the caller receives `TimeoutError`, but the operation remains unsettled. Other
60
+ waiters or `on_complete` callbacks will still receive the eventual result unless
61
+ a separate submit-time deadline or cancellation settles the operation first.
62
+ **Callers that relied on `blocking_wait(timeout:)` to abandon and count an
63
+ operation must switch to a submit-time `timeout:` passed to `pool.submit`.**
64
+
65
+ - **Queue-timeout operations are not counted as abandoned**:
66
+ When a submit-time timeout fires before the worker picks up the operation,
67
+ the operation is settled with `TimeoutError` but `abandoned? == false` and
68
+ `abandoned_count` is not incremented. Only timeouts that fire while the block
69
+ is executing set `abandoned? = true`.
70
+
71
+ - **MCP client support now requires `mcp` 1.x**:
72
+ The `mcp` SDK 0.x dependency is no longer supported. The constraint is now
73
+ `mcp ~> 1.0`. `faraday` and `event_stream_parser` are added as direct runtime
74
+ dependencies so HTTP/SSE transport works without relying on transitive
75
+ resolution through RubyLLM.
76
+
77
+ - **MCP Tool error handling follows MCP 1.x semantics**:
78
+ JSON-RPC errors (`MCP::Client::ServerError`) are converted to
79
+ `Phronomy::ToolError`. Cancellations (`MCP::CancelledError`) are converted to
80
+ `Phronomy::CancellationError`. Tool-level `isError: true` results are
81
+ returned to the model as error text rather than raising, allowing the LLM to
82
+ self-correct.
83
+
84
+ - **MCP input schemas now use a strict supported subset of JSON Schema 2020-12**:
85
+ Unsupported structural keywords (`oneOf`, `anyOf`, `allOf`, `$ref`, etc.),
86
+ nested object/array types, and nullable type arrays fail fast with
87
+ `Phronomy::ToolError` at `from_server` time. Constraint-only annotations
88
+ (`minimum`, `maxLength`, `format`, etc.) produce a logger warning and are
89
+ otherwise ignored. See [`docs/mcp-client.md`](docs/mcp-client.md) for the
90
+ full supported schema subset.
91
+
92
+ - **MCP client cancellation now invalidates the transport**:
93
+ After a `MCP::CancelledError` the internal client reference is set to `nil`
94
+ and the old transport is closed asynchronously. The next tool call creates a
95
+ fresh connection, preventing stdio response mis-routing from a lingering SDK
96
+ worker thread.
97
+
98
+ ---
99
+
10
100
  ## [0.13.0] - 2026-07-23
11
101
 
12
102
  ### Added
data/README.md CHANGED
@@ -60,11 +60,13 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
60
60
  | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword; bridged to `MCP::Cancellation` in `Phronomy::Tools::Mcp#execute` | Experimental |
61
61
  | **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
62
62
  | **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process`; `config[:tool_timeout]` sets the per-submit timeout forwarded to `BlockingAdapterPool` for abandoned-operation tracking | Experimental |
63
+ | **`blocking_io_pool_size` / `blocking_io_queue_size`** — Configure the default `BlockingAdapterPool` via `Phronomy.configure { \|c\| c.blocking_io_pool_size = 20; c.blocking_io_queue_size = 200 }`; all LLM calls, MCP tool calls, and other blocking I/O share this pool; defaults: `pool_size: 10`, `queue_size: 100` | Beta |
63
64
  | **`invocation_context:` keyword on `Agent#invoke` / `Workflow#invoke`** — Pass a `Phronomy::InvocationContext` directly; `thread_id`, `cancellation_token`, and `deadline`-based timeout are derived from it; `task_id` / `parent_task_id` appear in trace spans automatically; `config:` keys remain supported as backward-compat aliases | Beta |
64
- | **`ConcurrencyGate` — unified backpressure** — Counting semaphore that enforces per-resource concurrency caps (`max_concurrent_agent_tasks`, `max_concurrent_tool_tasks`, `max_concurrent_workflow_tasks`, `max_concurrent_llm_calls`, `max_concurrent_vector_searches`); configured via `Phronomy.configure`; backpressure behaviour follows the global `backpressure` setting (`:wait`, `:raise`/`:reject`, `:timeout`); `nil` cap = unlimited (default) | Beta |
65
65
  | **Cooperative scheduler yield points** — `Runtime#yield` (cooperative yield; yields the current task's time slice); `Runtime#yield_if_needed(every: N)` (thread-local counter, yields every N calls); CPU-bound detection when `blocking_detect_threshold_ms` is set (warns and increments `non_yield_threshold_violation_count` when a task runs longer than the threshold without yielding); `starvation_threshold_ms` configuration field (default: 50ms) | Beta |
66
66
  | **`Phronomy::Metrics`** — `Phronomy::Metrics.snapshot` returns task-tree and pool counters; task-centric keys: `active_agent_tasks`, `active_tool_tasks`, `active_workflow_tasks`, `active_llm_tasks`, `task_wait_time_p50_ms`, `task_wait_time_p95_ms`, `task_run_time_p50_ms`, `task_run_time_p95_ms`, `cancelled_tasks`, `failed_tasks`, `non_yield_threshold_violation_count`; pool/event-loop keys remain for backward compatibility; `Runtime#task_snapshot` exposes task-centric metrics directly | Beta |
67
- | **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override and full runtime reset for test isolation | Beta |
67
+ | **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override; `reset_runtime!` performs a full `Runtime#shutdown` (including EventLoop termination) then resets configuration; intended for test isolation | Beta |
68
+ | **`Runtime#event_loop`** — Returns the Runtime-owned `EventLoop` instance; lazy-initialised on first access; EventLoop lifetime is tied to the owning Runtime | Beta |
69
+ | **`Runtime#shutdown(timeout:, cancel_grace:)`** — Irreversible Runtime shutdown: drains active sessions, terminates the EventLoop dispatcher, then stops pools and timers; returns a `ShutdownResult` with `runtime_outcome` and `cleanup_status` fields | Beta |
68
70
 
69
71
  **Agent patterns**
70
72
 
@@ -530,6 +532,10 @@ end
530
532
 
531
533
  ### MCP Tool — External tool servers
532
534
 
535
+ > **MCP 1.x required.** Phronomy targets `mcp ~> 1.0`. For supported JSON Schema
536
+ > constructs, error semantics, and client lifecycle contracts, see
537
+ > [`docs/mcp-client.md`](docs/mcp-client.md).
538
+
533
539
  ```ruby
534
540
  search_tool = Phronomy::Tools::Mcp.from_server(
535
541
  "stdio://./mcp-server",
@@ -750,6 +756,49 @@ blocks always execute.
750
756
  > safety concerns. Ruby's GVL prevents fully preemptive cancellation without such
751
757
  > risky interruption.
752
758
 
759
+ > **`timeout_after` vs `CancellationScope.deadline_in`**
760
+ >
761
+ > `CancellationToken.timeout_after(seconds)` uses lazy clock comparison: `cancelled?`
762
+ > returns `true` once the deadline elapses, but `on_cancel` callbacks are **not**
763
+ > fired. Bridges that rely on `on_cancel` — such as the `MCP::Cancellation` bridge
764
+ > in `Phronomy::Tools::Mcp#execute` — will therefore **not** be triggered on expiry.
765
+ >
766
+ > When you need the cancellation to propagate into in-flight I/O (e.g. an MCP
767
+ > `call_tool` request), use `CancellationScope` instead:
768
+ >
769
+ > ```ruby
770
+ > scope = Phronomy::Concurrency::CancellationScope.new.deadline_in(30)
771
+ > result = MyAgent.new.invoke("...", config: { cancellation_token: scope.token })
772
+ > ```
773
+ >
774
+ > `CancellationScope#deadline_in` registers a timer in the Runtime timer queue,
775
+ > which calls `cancel!` on expiry and fires all `on_cancel` callbacks — including
776
+ > the MCP bridge.
777
+
778
+ > **`config[:tool_timeout]` / `config[:llm_timeout]` — caller protection, not
779
+ > worker termination**
780
+ >
781
+ > These keys set a submit-time deadline in `BlockingAdapterPool`. The timer is
782
+ > armed at submit time (including queue wait) and calls `fire_timeout!` when the
783
+ > deadline expires. The worker thread is never forcibly interrupted.
784
+ >
785
+ > **Timeout behaviour depends on when the deadline fires:**
786
+ >
787
+ > | Situation | `TimeoutError`? | `abandoned?` | `abandoned_count` |
788
+ > |---|---|---|---|
789
+ > | Deadline fires while worker is executing | ✅ via `on_complete` | `true` | +1 |
790
+ > | Deadline fires while op is still queued | ✅ via `on_complete` | `false` | unchanged |
791
+ > | `blocking_wait(timeout:)` expires | ✅ to that waiter only | unchanged | unchanged |
792
+ > | `CancellationToken` cancelled | `CancellationError` | — | — |
793
+ > | Streaming path | not guaranteed (separate fix needed) | — | — |
794
+ >
795
+ > `blocking_wait(timeout:)` is a **waiter-local** deadline — the operation remains
796
+ > unsettled and other waiters or `on_complete` callbacks will still receive the
797
+ > eventual result (unless a submit-time deadline also fires).
798
+ >
799
+ > Size `blocking_io_pool_size` to account for the worst-case number of
800
+ > concurrently abandoned workers that may accumulate before the pool is saturated.
801
+
753
802
  ```ruby
754
803
  token = Phronomy::Concurrency::CancellationToken.new
755
804
 
@@ -0,0 +1,75 @@
1
+ # MCP client support
2
+
3
+ Phronomy supports the official `mcp` Ruby SDK **1.x**. MCP SDK 0.x is not supported.
4
+
5
+ ## Transports
6
+
7
+ `Phronomy::Tools::Mcp.from_server` supports:
8
+
9
+ - `stdio://...`
10
+ - `http://...`
11
+ - `https://...`
12
+
13
+ HTTP and SSE support are standard dependencies of Phronomy through `faraday` and
14
+ `event_stream_parser`.
15
+
16
+ ## Input-schema subset
17
+
18
+ Phronomy intentionally supports a strict subset of JSON Schema 2020-12 for MCP
19
+ Tool inputs.
20
+
21
+ Supported root keywords:
22
+
23
+ - `$schema`
24
+ - `type` (`object` only)
25
+ - `properties`
26
+ - `required`
27
+ - `title`
28
+ - `description`
29
+ - `additionalProperties` when omitted or `false`
30
+
31
+ Supported property types:
32
+
33
+ - `string`
34
+ - `integer`
35
+ - `number`
36
+ - `boolean`
37
+
38
+ Supported property keywords:
39
+
40
+ - `type`
41
+ - `description`
42
+ - `enum`
43
+ - `title`
44
+
45
+ The following validation keywords are accepted but ignored with a warning:
46
+
47
+ - `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
48
+ - `minLength`, `maxLength`, `pattern`, `format`, `default`, `examples`
49
+
50
+ Unknown keywords, structural composition (`oneOf`, `anyOf`, `allOf`, `$ref`,
51
+ conditionals), arrays, nested objects, and nullable type arrays fail fast with
52
+ `Phronomy::ToolError` rather than silently changing the Tool contract.
53
+
54
+ When `additionalProperties` is omitted, the remote schema is accepted, but
55
+ Phronomy still exposes and accepts only names declared in `properties`.
56
+
57
+ `outputSchema` is detected and reported with a warning, but is not validated in
58
+ this phase.
59
+
60
+ ## Errors and cancellation
61
+
62
+ - JSON-RPC errors raised by the SDK are converted to `Phronomy::ToolError`.
63
+ - MCP Tool results with `isError: true` are returned to the model as Tool error
64
+ text so the model can correct its arguments.
65
+ - An expired HTTP session is reconnected for future calls. The failed Tool call
66
+ is not replayed because Tool side effects are not assumed to be idempotent.
67
+ - Cancellation invalidates the current client and transport. The next call creates
68
+ a new connection, preventing an abandoned SDK worker from sharing the old stdio
69
+ stream with a new request.
70
+
71
+ Calls, reconnects, and explicit `close` operations on a Tool instance are
72
+ serialized. `close` synchronously closes the current client and permits a later
73
+ call to reconnect. A transport detached by cancellation is owned by the bounded
74
+ MCP cleanup pool and is drained during Runtime shutdown; `close` does not wait
75
+ for an older detached transport.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec path: ".."
6
+
7
+ gem "mcp", "= 1.0.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "webrick", "~> 1.8"
@@ -546,15 +546,9 @@ module Phronomy
546
546
  if invocation_context
547
547
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
548
548
  end
549
- bp = Phronomy.configuration.backpressure
550
- on_full = (bp == :raise) ? :reject : (bp || :wait)
551
- bp_timeout = Phronomy.configuration.backpressure_timeout
552
549
  result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
553
- gate = Phronomy::Runtime.instance.gate(:agent)
554
- gate.acquire(on_full: on_full, timeout: bp_timeout) do
555
- _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
556
- return result_task
557
- end
550
+ _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
551
+ result_task
558
552
  end
559
553
 
560
554
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
@@ -843,16 +837,17 @@ module Phronomy
843
837
  effective_config = thread_id ? config.merge(thread_id: thread_id) : config
844
838
  # Fail fast when the token is already cancelled before any LLM call.
845
839
  check_cancellation!(effective_config, "invocation cancelled")
846
- # Ensure EventLoop is running. start is idempotent when already alive.
847
- Phronomy::EventLoop.instance.start
840
+ runtime = Phronomy::Runtime.instance
841
+ event_loop = runtime.event_loop
848
842
  trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
849
843
  session = Agent::InvocationSession.build(
850
844
  agent: self,
851
845
  input: input,
852
846
  messages: messages,
853
- config: effective_config
847
+ config: effective_config,
848
+ runtime: runtime
854
849
  )
855
- completion_queue = Phronomy::EventLoop.instance.register(session)
850
+ completion_queue = event_loop.register(session)
856
851
  ctx = completion_queue.pop
857
852
  raise ctx if ctx.is_a?(Exception)
858
853
  result = _extract_invoke_result(ctx, session.id)
@@ -868,15 +863,17 @@ module Phronomy
868
863
  def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
869
864
  effective_config = thread_id ? config.merge(thread_id: thread_id) : config
870
865
  check_cancellation!(effective_config, "invocation cancelled")
871
- Phronomy::EventLoop.instance.start
866
+ runtime = Phronomy::Runtime.instance
867
+ event_loop = runtime.event_loop
872
868
  session = Agent::InvocationSession.build(
873
869
  agent: self,
874
870
  input: input,
875
871
  messages: messages,
876
- config: effective_config
872
+ config: effective_config,
873
+ runtime: runtime
877
874
  )
878
875
  source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
879
- Phronomy::EventLoop.instance.register(session, completion: source_task)
876
+ event_loop.register(session, completion: source_task)
880
877
  session_id = session.id
881
878
  policy = self.class._retry_policy
882
879
 
@@ -956,13 +953,16 @@ module Phronomy
956
953
  # Builds and runs a resume FSMSession for the given context and event.
957
954
  # @api private
958
955
  def _resume_fsm(ctx, event)
956
+ runtime = Phronomy::Runtime.instance
957
+ event_loop = runtime.event_loop
959
958
  session = Agent::InvocationSession.build_for_resume(
960
959
  agent: self,
961
960
  context: ctx,
962
961
  resume_event: event,
963
- resume_phase: :awaiting_approval
962
+ resume_phase: :awaiting_approval,
963
+ runtime: runtime
964
964
  )
965
- completion_queue = Phronomy::EventLoop.instance.register(session)
965
+ completion_queue = event_loop.register(session)
966
966
  resumed_ctx = completion_queue.pop
967
967
  raise resumed_ctx if resumed_ctx.is_a?(Exception)
968
968
  _extract_invoke_result(resumed_ctx, session.id)
@@ -290,6 +290,12 @@ module Phronomy
290
290
  case param_type
291
291
  when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
292
292
  when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
293
+ when "boolean"
294
+ unless v == true || v == false
295
+ raise ArgumentError,
296
+ "boolean enum values must be true or false (got: #{v.inspect})"
297
+ end
298
+ v
293
299
  else v.to_s
294
300
  end
295
301
  end
@@ -19,7 +19,7 @@ module Phronomy
19
19
  # messages: [],
20
20
  # config: { thread_id: "t-1" }
21
21
  # )
22
- # completion_queue = Phronomy::EventLoop.instance.register(session)
22
+ # completion_queue = Phronomy::Runtime.instance.event_loop.register(session)
23
23
  # ctx = completion_queue.pop
24
24
  #
25
25
  # == Streaming mode
@@ -56,7 +56,8 @@ module Phronomy
56
56
  # @param on_event [Proc, nil] stream event callback (stream mode only)
57
57
  # @return [Phronomy::FSMSession]
58
58
  # @api private
59
- def self.build(agent:, input:, messages:, config:, mode: :invoke, on_event: nil)
59
+ def self.build(agent:, input:, messages:, config:, mode: :invoke, on_event: nil,
60
+ runtime: Phronomy::Runtime.instance)
60
61
  ctx = Agent::InvocationContext.new(
61
62
  agent: agent,
62
63
  input: input,
@@ -95,7 +96,9 @@ module Phronomy
95
96
  approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
96
97
  reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
97
98
  },
98
- recursion_limit: fsm_recursion_limit
99
+ recursion_limit: fsm_recursion_limit,
100
+ event_loop: runtime.event_loop,
101
+ timer_queue_provider: -> { runtime.timer_queue }
99
102
  )
100
103
  end
101
104
 
@@ -108,7 +111,8 @@ module Phronomy
108
111
  # @param resume_phase [Symbol] the wait state to resume from
109
112
  # @return [Phronomy::FSMSession]
110
113
  # @api private
111
- def self.build_for_resume(agent:, context:, resume_event:, resume_phase:)
114
+ def self.build_for_resume(agent:, context:, resume_event:, resume_phase:,
115
+ runtime: Phronomy::Runtime.instance)
112
116
  actions = build_entry_actions(agent)
113
117
  phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
114
118
 
@@ -129,6 +133,8 @@ module Phronomy
129
133
  reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
130
134
  },
131
135
  recursion_limit: fsm_recursion_limit,
136
+ event_loop: runtime.event_loop,
137
+ timer_queue_provider: -> { runtime.timer_queue },
132
138
  resume_event: resume_event,
133
139
  resume_phase: resume_phase
134
140
  )
@@ -115,6 +115,7 @@ module Phronomy
115
115
  # FSM session id — set by FSMSession so async task spawns know the
116
116
  # target_id for EventLoop events.
117
117
  attr_accessor :session_id
118
+ attr_accessor :event_loop, :timer_queue_provider
118
119
 
119
120
  def initialize
120
121
  super
@@ -155,10 +156,10 @@ module Phronomy
155
156
  machine.async_pending = true
156
157
  session_id = machine.session_id
157
158
  if timeout_secs
158
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
159
+ machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
159
160
  next if result.done?
160
161
 
161
- Phronomy::EventLoop.instance.post(
162
+ machine.event_loop.post(
162
163
  Phronomy::Event.new(
163
164
  type: :error,
164
165
  target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
@@ -171,7 +172,7 @@ module Phronomy
171
172
  end
172
173
  result.on_complete do |task_result, error|
173
174
  if error
174
- Phronomy::EventLoop.instance.post(
175
+ machine.event_loop.post(
175
176
  Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
176
177
  )
177
178
  next
@@ -181,7 +182,7 @@ module Phronomy
181
182
  else
182
183
  Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
183
184
  end
184
- Phronomy::EventLoop.instance.post(ev)
185
+ machine.event_loop.post(ev)
185
186
  end
186
187
  end
187
188
  end
@@ -86,20 +86,6 @@ module Phronomy
86
86
  # Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
87
87
  attr_accessor :llm_adapter
88
88
 
89
- # Default backpressure strategy for {BlockingAdapterPool#submit} when the
90
- # queue is full. One of +:wait+ (block until a slot is available),
91
- # +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
92
- # {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
93
- # @return [:wait, :raise, :timeout]
94
- attr_accessor :backpressure
95
-
96
- # Seconds to wait before raising {Phronomy::TimeoutError} when
97
- # +backpressure+ is +:timeout+.
98
- # @return [Numeric, nil]
99
- attr_accessor :backpressure_timeout
100
-
101
- # Warn when an event spends longer than this many seconds waiting in the
102
- # EventLoop queue before being dispatched (starvation detection).
103
89
  # Set to +nil+ to disable the warning.
104
90
  # @return [Numeric, nil]
105
91
  attr_accessor :event_loop_starvation_threshold_seconds
@@ -120,37 +106,25 @@ module Phronomy
120
106
  # @return [Float, nil]
121
107
  attr_accessor :blocking_detect_threshold_ms
122
108
 
123
- # Maximum number of concurrent agent tasks (invoke_async calls in-flight).
124
- # nil = unlimited (default). When at capacity, behaviour is controlled by
125
- # +backpressure+ (:wait, :raise/:reject, :timeout).
126
- # @return [Integer, nil]
127
- attr_accessor :max_concurrent_agent_tasks
128
-
129
- # Maximum number of concurrent tool tasks (parallel tool calls in-flight).
130
- # nil = unlimited (default).
131
- # @return [Integer, nil]
132
- attr_accessor :max_concurrent_tool_tasks
133
-
134
- # Maximum number of concurrent workflow tasks.
135
- # nil = unlimited (default).
136
- # @return [Integer, nil]
137
- attr_accessor :max_concurrent_workflow_tasks
138
-
139
- # Maximum number of concurrent LLM calls in-flight.
140
- # nil = unlimited (default).
141
- # @return [Integer, nil]
142
- attr_accessor :max_concurrent_llm_calls
143
-
144
109
  # Upper bound on the number of streaming token chunks that may be buffered
145
110
  # in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
146
111
  # throttled. When nil (default), the queue is unbounded.
147
112
  # @return [Integer, nil]
148
113
  attr_accessor :stream_queue_max_size
149
114
 
150
- # Maximum number of concurrent vector-store searches in-flight.
151
- # nil = unlimited (default).
152
- # @return [Integer, nil]
153
- attr_accessor :max_concurrent_vector_searches
115
+ # Number of OS worker threads in the default {BlockingAdapterPool}.
116
+ # All LLM calls, MCP tool calls, and other blocking I/O share this pool.
117
+ # Increase for higher LLM/tool throughput; decrease to limit
118
+ # concurrency (e.g. to stay within a provider's rate limit).
119
+ # Default: 10.
120
+ # @return [Integer]
121
+ attr_accessor :blocking_io_pool_size
122
+
123
+ # Maximum number of operations that may wait in the {BlockingAdapterPool}
124
+ # queue before {Phronomy::BackpressureError} is raised (on_full: :raise) or
125
+ # the caller blocks (on_full: :wait, the default). Default: 100.
126
+ # @return [Integer]
127
+ attr_accessor :blocking_io_queue_size
154
128
 
155
129
  # Scheduler starvation threshold (milliseconds).
156
130
  # When a task waits more than this many milliseconds after calling
@@ -195,18 +169,13 @@ module Phronomy
195
169
  @parallel_tool_execution = false
196
170
  @event_loop_stop_grace_seconds = 5
197
171
  @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
198
- @backpressure = :wait
199
- @backpressure_timeout = nil
200
172
  @event_loop_starvation_threshold_seconds = nil
201
173
  @event_loop_dispatch_threshold_seconds = nil
202
174
  @scheduler_debug = false
203
175
  @blocking_detect_threshold_ms = nil
204
- @max_concurrent_agent_tasks = nil
205
- @max_concurrent_tool_tasks = nil
206
- @max_concurrent_workflow_tasks = nil
207
- @max_concurrent_llm_calls = nil
208
176
  @stream_queue_max_size = nil
209
- @max_concurrent_vector_searches = nil
177
+ @blocking_io_pool_size = 10
178
+ @blocking_io_queue_size = 100
210
179
  @starvation_threshold_ms = 50
211
180
  @runtime_backend = :thread
212
181
  @strict_runtime_guards = false
@@ -52,7 +52,7 @@ module Phronomy
52
52
  # @return [void]
53
53
  # @api private
54
54
  def self.assert_not_in_event_loop!
55
- return unless Phronomy::EventLoop.current?
55
+ return unless Phronomy::Runtime.in_event_loop_context?
56
56
 
57
57
  raise Phronomy::SchedulerReentrancyError,
58
58
  "Blocking invoke called from inside an EventLoop action. " \