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 +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +51 -2
- data/docs/mcp-client.md +75 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/base.rb +17 -17
- data/lib/phronomy/agent/context/capability/base.rb +6 -0
- data/lib/phronomy/agent/invocation_session.rb +10 -4
- data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
- data/lib/phronomy/configuration.rb +15 -46
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +319 -272
- data/lib/phronomy/engine/fsm_session.rb +17 -14
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/metrics.rb +4 -3
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +385 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
- data/lib/phronomy/workflow_context.rb +1 -2
- data/lib/phronomy/workflow_runner.rb +22 -12
- data/lib/phronomy.rb +24 -19
- metadata +47 -6
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 821e5e60bc5eacd862e98cbf03b74ba1de640d923c783d29ec560b4e0523dafa
|
|
4
|
+
data.tar.gz: 515be391fa893cc679bcc348a5db5abddbb5d6714507f0adf3eae25a0d0caffd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
data/docs/mcp-client.md
ADDED
|
@@ -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.
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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 =
|
|
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::
|
|
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
|
-
|
|
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 =
|
|
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::
|
|
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
|
-
|
|
159
|
+
machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
|
|
159
160
|
next if result.done?
|
|
160
161
|
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
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
|
-
@
|
|
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
|
data/lib/phronomy/diagnostics.rb
CHANGED
|
@@ -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::
|
|
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. " \
|