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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- 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 +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- 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/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- 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: b0ea2e317999f3202e06d5afac0b46509fa22a813531919447040d1e048a087f
|
|
4
|
+
data.tar.gz: ae5740df9899b7463b35b54b885948ca1e0e42e01b8b85b1a93fd6437022c90b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 123b0998a7a299f97ce981a3561c68990c41e05decb534b4c4664e441abd6cf368e04ecf6cea61df6a1ea2c4016850b093e0c208aa034fc02b1fa498e0078263
|
|
7
|
+
data.tar.gz: f1ba3ee6163b1a746bd99f00241daf286ac5eb3d8d1e30f7caa739de1de5dd900f3b19b90a063c4a9f19f494a1e9ac1dca59b4d32ade41deabe71f849803ac62
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,161 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Workflow#signal(thread_id:, event:, payload:)` for FIFO delivery to a live
|
|
15
|
+
Workflow FSMSession.
|
|
16
|
+
- Workflow transition `action:` callbacks, executed after source exit callbacks
|
|
17
|
+
and before target entry callbacks. Actions may accept `(context)` or
|
|
18
|
+
`(context, event)` and may return a replacement Workflow context.
|
|
19
|
+
- `InvalidAsyncWorkflowActionError` and
|
|
20
|
+
`InvalidAsyncTransitionActionError`. Transition actions may start async work,
|
|
21
|
+
but returning a `Phronomy::Task` is rejected rather than implicitly awaited.
|
|
22
|
+
- Symmetric Agent `on_event:` support for `invoke_async` and `stream_async`;
|
|
23
|
+
streaming differs only by adding `:token` events.
|
|
24
|
+
- `Agent#approve_async` and `Agent::Base.approve_async` resume a suspended
|
|
25
|
+
AgentInvocation without blocking the caller and return a `Phronomy::Task`.
|
|
26
|
+
- Streaming invocations resumed through `approve_async` continue to deliver
|
|
27
|
+
terminal stream events on the Runtime-owned EventLoop thread.
|
|
28
|
+
- `Phronomy::Metrics.snapshot` now reports `event_loop_queue_depth` and
|
|
29
|
+
`event_loop_queue_max_depth`.
|
|
30
|
+
- EventLoop emits a rate-limited warning when its shared event queue reaches
|
|
31
|
+
1,000 pending entries. Events are observed only; they are not dropped.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- Implicit awaiting of Task-returning Workflow/Agent/Tool entry actions and the
|
|
36
|
+
Workflow `action_timeout:` DSL. Entry actions are synchronous RTC callbacks.
|
|
37
|
+
- The duplicate caller-thread `WorkflowRunner#run_workflow` execution path.
|
|
38
|
+
- Agent-wide automatic replay: `Agent::Base.retry_policy` and the `Retryable`
|
|
39
|
+
concern. A failed AgentInvocation is no longer started again by Phronomy.
|
|
40
|
+
- Agent-class `invoke_timeout`. Callers that need a root deadline should pass an
|
|
41
|
+
`InvocationContext` with `deadline:` or `cancellation_token:`.
|
|
42
|
+
- Phronomy LLM operation timeout `config[:llm_timeout]`; configure RubyLLM's
|
|
43
|
+
`request_timeout` instead.
|
|
44
|
+
- Generic Tool retry DSL (`retry_on`, `retry_policies`) and
|
|
45
|
+
`config[:tool_timeout]`; Tool/client implementations own their transport policy.
|
|
46
|
+
- `max_parallel_tools` from Agent, AgentInvocation, ParallelToolChat, and
|
|
47
|
+
InvocationContext.
|
|
48
|
+
- Unused `InvocationContext#provider_limits`.
|
|
49
|
+
- `Configuration#stream_queue_max_size`, which no longer affected the
|
|
50
|
+
Runtime-owned EventLoop streaming path.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `Workflow#invoke`, `#invoke_async`, and `#stream` now share context
|
|
55
|
+
preparation, StateStore load/save, EventLoop registration, and FSMSession
|
|
56
|
+
execution.
|
|
57
|
+
- Workflow entry and transition action return values use the same
|
|
58
|
+
`set_graph_metadata` context protocol as FSMSession, including duck-typed
|
|
59
|
+
context replacements.
|
|
60
|
+
- Agent terminal outcomes are delivered to `on_event` before the returned Task
|
|
61
|
+
is settled. `Task#on_complete`, `wait_result`, and cancellation remain active.
|
|
62
|
+
- Mapping Agent events to Workflow events, correlation, stale-event handling,
|
|
63
|
+
result persistence, and external Task cancellation are application concerns.
|
|
64
|
+
- LLM transport timeout, transient-error retry, backoff, and jitter are delegated
|
|
65
|
+
to RubyLLM or another configured LLM adapter. Phronomy only translates the
|
|
66
|
+
adapter's final provider error.
|
|
67
|
+
- Agent execution now creates exactly one AgentInvocation session per call.
|
|
68
|
+
- Parallel Tool mode dispatches the complete authorized ToolCall batch; Runtime's
|
|
69
|
+
bounded workers and queues remain the coarse process-protection boundary.
|
|
70
|
+
- Caller-provided deadline and cancellation-token propagation is unchanged.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- `Agent#approve` now rejects EventLoop re-entry instead of synchronously waiting
|
|
75
|
+
for work that can only be dispatched by that same EventLoop.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## [0.14.0] - 2026-07-27
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- **Runtime-owned EventLoop lifecycle** (`refactor: Runtime-owned EventLoop lifecycle`):
|
|
84
|
+
- `Runtime#event_loop` lazy accessor replaces the standalone `EventLoop.instance` singleton.
|
|
85
|
+
- `EventLoop.instance` is retained as a deprecated compatibility bridge to `Runtime.instance.event_loop`.
|
|
86
|
+
- EventLoop dispatcher now runs in a dedicated real-thread service scheduler owned by `Runtime`,
|
|
87
|
+
removing the hidden `Runtime.new(scheduler: ThreadScheduler.new)` inside `EventLoop`.
|
|
88
|
+
- EventLoop lifecycle is terminal: `:running` → `:draining` → `:stopping` → `:terminated` / `:failed`.
|
|
89
|
+
- `STOP` Object sentinel replaces the `:__stop__` symbol; stale sentinel handling is removed.
|
|
90
|
+
- `@running` instance variable removed; dispatch loop driven solely by the STOP sentinel.
|
|
91
|
+
- EventLoop-specific `@shutdown_token` removed.
|
|
92
|
+
- `outstanding_sessions` counter covers queue-pending `:start` events from `register` time,
|
|
93
|
+
ensuring drain correctness when shutdown races with queued sessions.
|
|
94
|
+
- Shutdown timeout: `AbortAndStop` control command path plus `cancel!` → `join(cancel_grace)` fallback.
|
|
95
|
+
- `Runtime.reset_default!` replaces `EventLoop.reset!`; `Phronomy.reset_runtime!` performs real Runtime shutdown then config reset.
|
|
96
|
+
- `ShutdownResult` value object separates `runtime_outcome` from `cleanup_status`.
|
|
97
|
+
- `FSMSession` now receives `event_loop:` and `timer_queue_provider:` explicitly at build time.
|
|
98
|
+
- Agent / Workflow phase-machine builders capture the owning EventLoop at invocation time;
|
|
99
|
+
`EventLoop.instance` is no longer re-fetched inside async callbacks or timers.
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- **Double-dispatch regression (P0 hotfix — commit `2cc8c6c1`)**: `EventLoop#stop` no longer clears `@task`
|
|
104
|
+
while the dispatcher task is still alive. A subsequent `start` call detects the live task and does
|
|
105
|
+
not spawn a second dispatch loop on the same queue. Includes `:cancel_timeout` status for the case
|
|
106
|
+
where `cancel!` does not terminate the task within `cancel_grace` seconds.
|
|
107
|
+
- **`EventLoop.reset!` safety**: raises `Phronomy::Error` when the dispatcher task is still alive
|
|
108
|
+
after stop instead of unconditionally clearing the singleton.
|
|
109
|
+
- **`EventLoop#task_alive?`**: new public helper; thread-safe boolean for use in reset logic.
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
`BlockingAdapterPool#submit` previously stored the timeout value but never
|
|
113
|
+
registered a wall-clock timer, so `config: { llm_timeout: N }` and
|
|
114
|
+
`config: { tool_timeout: N }` had no effect for callers using `on_complete`
|
|
115
|
+
(the normal non-streaming Agent path). The timer is now armed before queue
|
|
116
|
+
admission and calls `fire_timeout!` when the deadline expires.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- **`blocking_wait(timeout:)` is now a waiter-local deadline only**:
|
|
121
|
+
Previously, the timeout passed to `blocking_wait` (or `wait_result`) would settle the
|
|
122
|
+
operation, set `abandoned? = true`, and increment `abandoned_count` — affecting
|
|
123
|
+
all future waiters and callbacks. It is now scoped to the single calling thread:
|
|
124
|
+
the caller receives `TimeoutError`, but the operation remains unsettled. Other
|
|
125
|
+
waiters or `on_complete` callbacks will still receive the eventual result unless
|
|
126
|
+
a separate submit-time deadline or cancellation settles the operation first.
|
|
127
|
+
**Callers that relied on `blocking_wait(timeout:)` to abandon and count an
|
|
128
|
+
operation must switch to a submit-time `timeout:` passed to `pool.submit`.**
|
|
129
|
+
|
|
130
|
+
- **Queue-timeout operations are not counted as abandoned**:
|
|
131
|
+
When a submit-time timeout fires before the worker picks up the operation,
|
|
132
|
+
the operation is settled with `TimeoutError` but `abandoned? == false` and
|
|
133
|
+
`abandoned_count` is not incremented. Only timeouts that fire while the block
|
|
134
|
+
is executing set `abandoned? = true`.
|
|
135
|
+
|
|
136
|
+
- **MCP client support now requires `mcp` 1.x**:
|
|
137
|
+
The `mcp` SDK 0.x dependency is no longer supported. The constraint is now
|
|
138
|
+
`mcp ~> 1.0`. `faraday` and `event_stream_parser` are added as direct runtime
|
|
139
|
+
dependencies so HTTP/SSE transport works without relying on transitive
|
|
140
|
+
resolution through RubyLLM.
|
|
141
|
+
|
|
142
|
+
- **MCP Tool error handling follows MCP 1.x semantics**:
|
|
143
|
+
JSON-RPC errors (`MCP::Client::ServerError`) are converted to
|
|
144
|
+
`Phronomy::ToolError`. Cancellations (`MCP::CancelledError`) are converted to
|
|
145
|
+
`Phronomy::CancellationError`. Tool-level `isError: true` results are
|
|
146
|
+
returned to the model as error text rather than raising, allowing the LLM to
|
|
147
|
+
self-correct.
|
|
148
|
+
|
|
149
|
+
- **MCP input schemas now use a strict supported subset of JSON Schema 2020-12**:
|
|
150
|
+
Unsupported structural keywords (`oneOf`, `anyOf`, `allOf`, `$ref`, etc.),
|
|
151
|
+
nested object/array types, and nullable type arrays fail fast with
|
|
152
|
+
`Phronomy::ToolError` at `from_server` time. Constraint-only annotations
|
|
153
|
+
(`minimum`, `maxLength`, `format`, etc.) produce a logger warning and are
|
|
154
|
+
otherwise ignored. See [`docs/mcp-client.md`](docs/mcp-client.md) for the
|
|
155
|
+
full supported schema subset.
|
|
156
|
+
|
|
157
|
+
- **MCP client cancellation now invalidates the transport**:
|
|
158
|
+
After a `MCP::CancelledError` the internal client reference is set to `nil`
|
|
159
|
+
and the old transport is closed asynchronously. The next tool call creates a
|
|
160
|
+
fresh connection, preventing stdio response mis-routing from a lingering SDK
|
|
161
|
+
worker thread.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
10
165
|
## [0.13.0] - 2026-07-23
|
|
11
166
|
|
|
12
167
|
### Added
|
data/README.md
CHANGED
|
@@ -27,7 +27,6 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
27
27
|
| Feature | Stability |
|
|
28
28
|
|---|---|
|
|
29
29
|
| **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
|
|
30
|
-
| **Workflow action_timeout** — Per-state `action_timeout:` keyword on `state` DSL; cancels Task-returning entry actions that exceed the limit and raises `Phronomy::ActionTimeoutError` | Beta |
|
|
31
30
|
| **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
|
|
32
31
|
| **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
|
|
33
32
|
| **Context Management** — Token budget calculation, estimation, and pruning; `Agent::Base` protected hooks: `build_context` (overridable), `trim_messages`, `trim_to_budget`, `compact_messages`, `budget_exceeded?`, `drop_messages_over` | Stable |
|
|
@@ -37,7 +36,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
37
36
|
| **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
|
|
38
37
|
| **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
|
|
39
38
|
| **Tracing** — Pluggable span-based observability | Stable |
|
|
40
|
-
| **Error Taxonomy** — `RateLimitError`, `AuthenticationError`, `ContextLengthError`, `TransportError`
|
|
39
|
+
| **Error Taxonomy** — final RubyLLM/provider errors are translated to `RateLimitError`, `AuthenticationError`, `ContextLengthError`, and `TransportError` without replaying the Agent invocation | Beta |
|
|
41
40
|
|
|
42
41
|
**Knowledge and integration**
|
|
43
42
|
|
|
@@ -49,22 +48,25 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
49
48
|
| **MCP Tool** — `Phronomy::Tools::Mcp`: Model Context Protocol server integration via the official `mcp` gem; `Phronomy::Tools::Agent`: wraps an agent class as a callable tool via `from_agent` | Beta |
|
|
50
49
|
| **Vector Search Tool** — `Phronomy::Tools::VectorSearch`: wraps a `VectorStore` and `Embeddings` adapter as a callable agent tool via `from_store` | Beta |
|
|
51
50
|
|
|
52
|
-
**Execution and reliability**
|
|
53
|
-
|
|
54
|
-
| Feature | Stability |
|
|
51
|
+
| **Execution and reliability** | |
|
|
55
52
|
|---|---|
|
|
56
|
-
| **
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
| **`
|
|
53
|
+
| **EventLoop** — Runtime-owned event-driven execution core shared by all Agent invocations, Tool invocations, and Workflow sessions. Not configurable; EventLoop is always active when the Runtime is running. `Phronomy::EventLoop` itself is an internal API | Beta |
|
|
54
|
+
| **`invoke` / `invoke_async`** — `Agent::Base#invoke` blocks the calling thread and returns the final result Hash; `Agent::Base#invoke_async` returns a `Phronomy::Task` immediately without blocking; `Workflow#invoke` and `Workflow#invoke_async` follow the same contract | Stable |
|
|
55
|
+
| **Agent async events** — `invoke_async(..., on_event:)` and `stream_async(..., on_event:)` share `:tool_call`, `:tool_result`, `:approval_required`, `:done`, `:error`, `:timeout`, and `:cancelled`; streaming adds `:token` | Beta |
|
|
56
|
+
| **`stream` / `stream_async`** — callbacks execute on the EventLoop thread and must return quickly; the block form remains a compatibility alias for `on_event:` | Beta |
|
|
57
|
+
| **`stream_callback_error_policy`** — Backward-compatible setting shared by `invoke_async` and `stream_async` terminal `on_event:` callbacks: `:report` (default) preserves the Agent result, while `:fail_task` fails the returned Task with `Phronomy::StreamCallbackError`; Agent execution errors are never replaced by callback errors | Beta |
|
|
58
|
+
| **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Stable |
|
|
59
|
+
| **`Task#map`** — transforms a Task's completed value and propagates failure/cancellation; Workflow entry and transition actions do not await mapped Tasks | Stable |
|
|
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
|
-
| **`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`;
|
|
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`; Tool-specific timeout/retry belongs to the Tool implementation or its client | 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
|
-
| **`Phronomy::Metrics`** — `Phronomy::Metrics.snapshot` returns task-tree and
|
|
67
|
-
| **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override
|
|
66
|
+
| **`Phronomy::Metrics`** — `Phronomy::Metrics.snapshot` returns task-tree, pool, EventLoop, and queue 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`; EventLoop queue keys: `event_loop_queue_depth` (current pending entries), `event_loop_queue_max_depth` (peak since start); a rate-limited warning is emitted when depth reaches 1,000 — events are not dropped | 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
|
|
|
@@ -73,14 +75,14 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
73
75
|
| **Workflow parallel pattern** — Concurrent branches via application-level threads (no built-in parallel primitive; see the Workflow section for the recommended pattern) | Beta |
|
|
74
76
|
| **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
|
|
75
77
|
| **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
+
| **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
79
|
+
| **`Phronomy::MultiAgent::TeamCoordinator`** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
|
|
78
80
|
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
+
| **Human-in-the-loop approval** — `Agent::Base#invoke` returns `{ suspended: true, agent_invocation_id: String, approval_request: Phronomy::Agent::ToolApprovalRequest }` when a tool requiring approval is encountered; `Agent::Base#approve(id, approval_request_id:, approved:)` (synchronous) or `Agent::Base#approve_async(id, approval_request_id:, approved:)` (returns `Task`) resumes execution; approval state is in-process only — not persisted across process restarts or shared across pods | Beta |
|
|
82
|
+
| **`tool_approval_policy`** — Instance-level callable that maps each `ToolApprovalRequest` to `:allow`, `:require_approval`, or `:reject`; set on the agent instance before invoking | Beta |
|
|
81
83
|
| **`Filter::Base` — unified value filter interface** — `Phronomy::Filter::Base` with a single abstract method `call(value, **context)`; apply to user input (`add_input_filter` / `input_filter` DSL), final LLM output (`add_output_filter` / `output_filter` DSL), or individual tool return values (`add_tool_result_filter(tool_class?, filter)` / `tool_result_filter` DSL); filters transform values and return the result, or raise `Phronomy::FilterBlockError` to reject; filter chains are composable; the same filter instance can be reused across all three sites | Beta |
|
|
82
84
|
|
|
83
|
-
> **Public API boundary**: The
|
|
85
|
+
> **Public API boundary**: The table above lists the primary public features
|
|
84
86
|
> intended for gem consumers. Every entry has an associated stability label.
|
|
85
87
|
> All other classes, modules, and methods — including everything in the
|
|
86
88
|
> [Advanced / Internal APIs](#advanced--internal-apis) section below — are
|
|
@@ -123,10 +125,30 @@ Configure your provider credentials before using agents or chains:
|
|
|
123
125
|
RubyLLM.configure do |c|
|
|
124
126
|
c.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
125
127
|
# c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
128
|
+
|
|
129
|
+
# RubyLLM owns LLM transport timeout and retry policy.
|
|
130
|
+
c.request_timeout = 120
|
|
131
|
+
c.max_retries = 3
|
|
132
|
+
c.retry_interval = 0.1
|
|
133
|
+
c.retry_backoff_factor = 2
|
|
134
|
+
c.retry_interval_randomness = 0.5
|
|
126
135
|
end
|
|
127
136
|
```
|
|
128
137
|
|
|
129
|
-
See the [RubyLLM documentation](https://rubyllm.com) for all supported providers.
|
|
138
|
+
See the [RubyLLM documentation](https://rubyllm.com) for all supported providers. Phronomy does not add another LLM timeout or retry layer.
|
|
139
|
+
|
|
140
|
+
### Execution-policy migration for 0.15
|
|
141
|
+
|
|
142
|
+
| Removed Phronomy setting | Replacement |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `retry_policy` | RubyLLM transport retry, or explicit application orchestration |
|
|
145
|
+
| `invoke_timeout` | `InvocationContext#deadline` or `cancellation_token` when the caller needs a root deadline |
|
|
146
|
+
| `config[:llm_timeout]` | `RubyLLM.configure { |c| c.request_timeout = ... }` |
|
|
147
|
+
| Tool `retry_on` | Tool/client-specific retry with explicit idempotency guarantees |
|
|
148
|
+
| `config[:tool_timeout]` | Tool/client-native timeout |
|
|
149
|
+
| `max_parallel_tools` | No replacement; `parallel_tool_execution` remains an on/off mode |
|
|
150
|
+
| `InvocationContext#provider_limits` | Configure the provider client directly |
|
|
151
|
+
| `stream_queue_max_size` | No replacement; the shared EventLoop queue is unbounded by design. Monitor `Metrics.snapshot[:event_loop_queue_depth]` instead |
|
|
130
152
|
|
|
131
153
|
### Optional dependencies
|
|
132
154
|
|
|
@@ -164,6 +186,75 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
|
|
|
164
186
|
puts result[:output]
|
|
165
187
|
```
|
|
166
188
|
|
|
189
|
+
#### Streaming
|
|
190
|
+
|
|
191
|
+
`stream` blocks the calling thread while delivering events; `stream_async` returns a `Task`
|
|
192
|
+
immediately. Callbacks always execute on the **EventLoop thread** — keep them lightweight and
|
|
193
|
+
do not call blocking I/O or synchronous Agent APIs inside a callback.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
# Synchronous streaming — blocks until done
|
|
197
|
+
result = ResearchAgent.new.stream("What happened in AI research this week?") do |event|
|
|
198
|
+
case event.type
|
|
199
|
+
when :token then print event.payload[:content]
|
|
200
|
+
when :tool_call then puts "\n[Calling: #{event.payload[:tool_call].name}]"
|
|
201
|
+
when :tool_result then puts "[Tool done]"
|
|
202
|
+
when :done then puts "\n---"
|
|
203
|
+
when :approval_required
|
|
204
|
+
# Approval needed — handle via approve_async (see Human-in-the-loop section)
|
|
205
|
+
when :error then warn "Error: #{event.payload[:error].message}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
puts result[:output]
|
|
209
|
+
|
|
210
|
+
# Non-blocking streaming — returns Task immediately
|
|
211
|
+
task = ResearchAgent.new.stream_async("Summarise AI news") do |event|
|
|
212
|
+
broadcast_to_websocket(event) if event.type == :token # must return quickly
|
|
213
|
+
end
|
|
214
|
+
result = task.wait_result
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Human-in-the-loop approval
|
|
218
|
+
|
|
219
|
+
When a tool is configured with `requires_approval true` and no `:allow` policy is set,
|
|
220
|
+
`invoke` suspends and returns `{ suspended: true, agent_invocation_id:, approval_request: }`.
|
|
221
|
+
Resume via `approve` (synchronous) or `approve_async` (non-blocking, safe inside callbacks):
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
agent = ResearchAgent.new
|
|
225
|
+
agent.tool_approval_policy { :require_approval }
|
|
226
|
+
|
|
227
|
+
result = agent.invoke("Run the search")
|
|
228
|
+
if result[:suspended]
|
|
229
|
+
request = result[:approval_request]
|
|
230
|
+
puts "Tool: #{request.items.first.tool_name}"
|
|
231
|
+
|
|
232
|
+
# From top-level code — synchronous
|
|
233
|
+
final = agent.approve(
|
|
234
|
+
result[:agent_invocation_id],
|
|
235
|
+
approval_request_id: request.id,
|
|
236
|
+
approved: true
|
|
237
|
+
)
|
|
238
|
+
puts final[:output]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Inside a stream callback — use approve_async to avoid EventLoop re-entry
|
|
242
|
+
agent.stream_async("Run it") do |event|
|
|
243
|
+
if event.type == :approval_required
|
|
244
|
+
req = event.payload[:request]
|
|
245
|
+
approve_task = agent.approve_async(
|
|
246
|
+
req.agent_invocation_id,
|
|
247
|
+
approval_request_id: req.id,
|
|
248
|
+
approved: true
|
|
249
|
+
)
|
|
250
|
+
# approve_task resolves when the resumed invocation completes
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
> **Important**: Approval state is stored in-process (`AgentInvocationRegistry`). It is **not**
|
|
256
|
+
> persisted across process restarts and is **not** shared between pods or processes.
|
|
257
|
+
|
|
167
258
|
### Workflow — Stateful workflow with wait_state/send_event
|
|
168
259
|
|
|
169
260
|
```ruby runnable
|
|
@@ -201,20 +292,116 @@ final = app.send_event(state: state, event: :approve)
|
|
|
201
292
|
puts "Approved: #{final.approved}" # => true
|
|
202
293
|
```
|
|
203
294
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
which `FSMSession` picks up via the standard `:action_completed` path:
|
|
295
|
+
Start the Agent as an asynchronous activity of the active state, then
|
|
296
|
+
map its lifecycle event to an application-defined Workflow event:
|
|
207
297
|
|
|
208
298
|
```ruby
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
299
|
+
class TranslationContext
|
|
300
|
+
include Phronomy::WorkflowContext
|
|
301
|
+
|
|
302
|
+
field :query
|
|
303
|
+
field :answer
|
|
304
|
+
field :error
|
|
305
|
+
|
|
306
|
+
def handle_fsm_event(event)
|
|
307
|
+
case event.type
|
|
308
|
+
when :translation_completed
|
|
309
|
+
self.answer = event.payload[:answer]
|
|
310
|
+
when :translation_failed
|
|
311
|
+
self.error = event.payload[:error]
|
|
312
|
+
end
|
|
313
|
+
false
|
|
213
314
|
end
|
|
214
|
-
|
|
215
|
-
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
workflow = nil
|
|
318
|
+
|
|
319
|
+
workflow = Phronomy::Workflow.define(TranslationContext) do
|
|
320
|
+
initial :translate
|
|
321
|
+
|
|
322
|
+
state :translate, action: ->(ctx) {
|
|
323
|
+
TranslationAgent.new.invoke_async(
|
|
324
|
+
ctx.query,
|
|
325
|
+
on_event: ->(event) {
|
|
326
|
+
case event.type
|
|
327
|
+
when :done
|
|
328
|
+
workflow.signal(
|
|
329
|
+
thread_id: ctx.thread_id,
|
|
330
|
+
event: :translation_completed,
|
|
331
|
+
payload: {answer: event.payload[:output]}
|
|
332
|
+
)
|
|
333
|
+
when :error, :timeout, :cancelled
|
|
334
|
+
workflow.signal(
|
|
335
|
+
thread_id: ctx.thread_id,
|
|
336
|
+
event: :translation_failed,
|
|
337
|
+
payload: {error: event.payload[:error]}
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
ctx
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
state :done
|
|
346
|
+
state :failed
|
|
347
|
+
|
|
348
|
+
transition from: :translate, on: :translation_completed, to: :done
|
|
349
|
+
transition from: :translate, on: :translation_failed, to: :failed
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
The application owns payload interpretation, correlation, and field updates.
|
|
354
|
+
Phronomy does not automatically copy Agent results into WorkflowContext.
|
|
355
|
+
|
|
356
|
+
Transitions may define an `action:` callback in addition to a `guard:`:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
transition(
|
|
360
|
+
from: :review,
|
|
361
|
+
on: :approved,
|
|
362
|
+
to: :publish,
|
|
363
|
+
guard: ->(context, event) {
|
|
364
|
+
event.payload[:request_id] == context.request_id
|
|
365
|
+
},
|
|
366
|
+
action: ->(context, event) {
|
|
367
|
+
context.merge(approved_by: event.payload[:reviewer])
|
|
368
|
+
}
|
|
369
|
+
)
|
|
216
370
|
```
|
|
217
371
|
|
|
372
|
+
The callback order for a successful transition is:
|
|
373
|
+
|
|
374
|
+
```text
|
|
375
|
+
source exit callbacks
|
|
376
|
+
-> selected transition action
|
|
377
|
+
-> target entry callbacks
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Transition actions may accept either `(context)` or `(context, event)`. A
|
|
381
|
+
returned Workflow context replaces the current context before target entry
|
|
382
|
+
callbacks run. Returning `nil` or another non-context value preserves the
|
|
383
|
+
current context.
|
|
384
|
+
|
|
385
|
+
Like entry actions, transition actions are synchronous Run-to-Completion
|
|
386
|
+
callbacks. They may start asynchronous work and register a listener that later
|
|
387
|
+
calls `Workflow#signal`, but they must return the context or `nil` immediately.
|
|
388
|
+
Returning `Phronomy::Task` raises
|
|
389
|
+
`Phronomy::InvalidAsyncTransitionActionError`; Phronomy does not implicitly
|
|
390
|
+
await it.
|
|
391
|
+
|
|
392
|
+
For a transition with `on:`, the two-argument action receives the external
|
|
393
|
+
`Phronomy::Event`. For an automatic transition without `on:`, it receives the
|
|
394
|
+
internal event:
|
|
395
|
+
|
|
396
|
+
```text
|
|
397
|
+
event.type == :state_completed
|
|
398
|
+
event.payload == nil
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
When several transitions have the same source and event, guards are evaluated
|
|
402
|
+
in declaration order. The first matching transition is selected, and only that
|
|
403
|
+
transition's action runs.
|
|
404
|
+
|
|
218
405
|
### Multi-Agent — Agent-as-Tool pattern
|
|
219
406
|
|
|
220
407
|
Wrap sub-agents as `Agent::Context::Capability::Base` subclasses so the orchestrator LLM can call them on demand.
|
|
@@ -401,7 +588,7 @@ rescue Phronomy::LowConfidenceError => e
|
|
|
401
588
|
end
|
|
402
589
|
```
|
|
403
590
|
|
|
404
|
-
###
|
|
591
|
+
### MultiAgent::Orchestrator — Parallel subagent dispatch
|
|
405
592
|
|
|
406
593
|
> **Note:** `dispatch_parallel` and `fan_out` use plain Ruby threads. Use
|
|
407
594
|
> `max_concurrency:` to cap the number of concurrent workers and `on_error:`
|
|
@@ -410,7 +597,7 @@ end
|
|
|
410
597
|
> fan-outs consider additional rate-limiting at the application level.
|
|
411
598
|
|
|
412
599
|
```ruby
|
|
413
|
-
class ResearchOrchestrator < Phronomy::
|
|
600
|
+
class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
414
601
|
model "gpt-4o"
|
|
415
602
|
instructions "Coordinate research tasks by dispatching to specialised agents."
|
|
416
603
|
|
|
@@ -425,7 +612,7 @@ result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
|
|
|
425
612
|
Programmatic parallel dispatch (no LLM loop):
|
|
426
613
|
|
|
427
614
|
```ruby
|
|
428
|
-
class MyOrchestrator < Phronomy::
|
|
615
|
+
class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
429
616
|
model "gpt-4o"
|
|
430
617
|
instructions "Orchestrate."
|
|
431
618
|
|
|
@@ -530,6 +717,10 @@ end
|
|
|
530
717
|
|
|
531
718
|
### MCP Tool — External tool servers
|
|
532
719
|
|
|
720
|
+
> **MCP 1.x required.** Phronomy targets `mcp ~> 1.0`. For supported JSON Schema
|
|
721
|
+
> constructs, error semantics, and client lifecycle contracts, see
|
|
722
|
+
> [`docs/mcp-client.md`](docs/mcp-client.md).
|
|
723
|
+
|
|
533
724
|
```ruby
|
|
534
725
|
search_tool = Phronomy::Tools::Mcp.from_server(
|
|
535
726
|
"stdio://./mcp-server",
|
|
@@ -567,7 +758,7 @@ puts result2[:output] # => "Your name is Alice."
|
|
|
567
758
|
Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
|
|
568
759
|
|
|
569
760
|
> **Note on `thread_id`**: `thread_id` is a correlation identifier used internally for
|
|
570
|
-
>
|
|
761
|
+
> EventLoop session routing and compaction context. It does **not** automatically persist or
|
|
571
762
|
> restore conversation history — you must pass `messages:` explicitly on each turn as shown above.
|
|
572
763
|
|
|
573
764
|
|
|
@@ -581,14 +772,16 @@ Phronomy.configure do |c|
|
|
|
581
772
|
c.before_completion = nil # optional; global hook lambda
|
|
582
773
|
c.trace_pii = false # default; set to true only when trace data contains no PII
|
|
583
774
|
c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
|
|
584
|
-
c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on
|
|
775
|
+
c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on shutdown
|
|
585
776
|
c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only); :cooperative (deprecated alias for :immediate)
|
|
586
|
-
c.strict_runtime_guards = false
|
|
777
|
+
c.strict_runtime_guards = false # when true, raises SchedulerReentrancyError on invoke-inside-task
|
|
778
|
+
c.stream_callback_error_policy = :report # :report (default) preserves Agent result; :fail_task fails Task with StreamCallbackError
|
|
587
779
|
end
|
|
588
780
|
```
|
|
589
781
|
|
|
590
782
|
`c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
|
|
591
|
-
`Workflow.define
|
|
783
|
+
`Workflow.define`, stream callback errors, EventLoop queue backlog warnings). When `nil`
|
|
784
|
+
(default), messages are written to `$stderr` via `Kernel#warn`.
|
|
592
785
|
|
|
593
786
|
> **Note**: When `trace_pii = false`, both the _input_ and the _output_ (LLM
|
|
594
787
|
> responses and tool results) are replaced with `[REDACTED]` in trace spans.
|
|
@@ -605,6 +798,10 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
|
605
798
|
| Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
|
|
606
799
|
| Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
|
|
607
800
|
| Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
|
|
801
|
+
| Streaming from top-level code | `agent.stream(input) { |event| ... }` — blocks until done; callbacks run on the EventLoop thread |
|
|
802
|
+
| Streaming non-blocking | `task = agent.stream_async(input) { |event| ... }` — returns Task immediately; callbacks run on the EventLoop thread |
|
|
803
|
+
| Resume approval from top-level code | `agent.approve(id, approval_request_id: r_id, approved: true)` — synchronous; blocks until resumed |
|
|
804
|
+
| Resume approval from an EventLoop callback | `agent.approve_async(id, approval_request_id: r_id, approved: true)` — returns Task; safe to call from a stream callback |
|
|
608
805
|
|
|
609
806
|
### Why this matters
|
|
610
807
|
|
|
@@ -700,8 +897,7 @@ class MyAgent < Phronomy::Agent::Base
|
|
|
700
897
|
model "gpt-4o"
|
|
701
898
|
max_output_tokens 4096 # override max_output_tokens from registry
|
|
702
899
|
context_overhead 600 # extra reservation for system prompt + tools
|
|
703
|
-
|
|
704
|
-
max_parallel_tools 4 # cap concurrent tool executions (default: 10)
|
|
900
|
+
# LLM timeout/retry is configured on RubyLLM, not on the Agent class.
|
|
705
901
|
end
|
|
706
902
|
```
|
|
707
903
|
|
|
@@ -727,7 +923,7 @@ Pass a `CancellationToken` to any agent via `config: { cancellation_token: token
|
|
|
727
923
|
Cancellation is checked at multiple granular checkpoints: before the LLM call,
|
|
728
924
|
after each streaming chunk, before each parallel
|
|
729
925
|
tool-call batch, and after each `before_completion` hook. `CancellationError` is
|
|
730
|
-
raised immediately
|
|
926
|
+
raised immediately. Phronomy does not replay the complete Agent invocation. No threads are force-killed — `ensure`
|
|
731
927
|
blocks always execute.
|
|
732
928
|
|
|
733
929
|
> **Cooperative cancellation — not preemptive**
|
|
@@ -750,6 +946,38 @@ blocks always execute.
|
|
|
750
946
|
> safety concerns. Ruby's GVL prevents fully preemptive cancellation without such
|
|
751
947
|
> risky interruption.
|
|
752
948
|
|
|
949
|
+
> **`timeout_after` vs `CancellationScope.deadline_in`**
|
|
950
|
+
>
|
|
951
|
+
> `CancellationToken.timeout_after(seconds)` uses lazy clock comparison: `cancelled?`
|
|
952
|
+
> returns `true` once the deadline elapses, but `on_cancel` callbacks are **not**
|
|
953
|
+
> fired. Bridges that rely on `on_cancel` — such as the `MCP::Cancellation` bridge
|
|
954
|
+
> in `Phronomy::Tools::Mcp#execute` — will therefore **not** be triggered on expiry.
|
|
955
|
+
>
|
|
956
|
+
> When you need the cancellation to propagate into in-flight I/O (e.g. an MCP
|
|
957
|
+
> `call_tool` request), use `CancellationScope` instead:
|
|
958
|
+
>
|
|
959
|
+
> ```ruby
|
|
960
|
+
> scope = Phronomy::Concurrency::CancellationScope.new.deadline_in(30)
|
|
961
|
+
> result = MyAgent.new.invoke("...", config: { cancellation_token: scope.token })
|
|
962
|
+
> ```
|
|
963
|
+
>
|
|
964
|
+
> `CancellationScope#deadline_in` registers a timer in the Runtime timer queue,
|
|
965
|
+
> which calls `cancel!` on expiry and fires all `on_cancel` callbacks — including
|
|
966
|
+
> the MCP bridge.
|
|
967
|
+
|
|
968
|
+
> **Transport timeout and retry ownership**
|
|
969
|
+
>
|
|
970
|
+
> Phronomy does not interpret `config[:llm_timeout]`, `config[:tool_timeout]`,
|
|
971
|
+
> Agent `retry_policy`, or Tool `retry_on`. Configure LLM transport behavior on
|
|
972
|
+
> RubyLLM (or another adapter) and configure Tool transport behavior on the Tool's
|
|
973
|
+
> HTTP/DB/MCP client. This ensures the layer capable of safely aborting the I/O owns
|
|
974
|
+
> the timeout and retry semantics.
|
|
975
|
+
>
|
|
976
|
+
> `InvocationContext#deadline` and `cancellation_token` remain available for a
|
|
977
|
+
> caller-defined root-operation boundary. They provide cooperative cancellation
|
|
978
|
+
> across the Phronomy execution tree; they do not replace provider-native socket,
|
|
979
|
+
> request, statement, or session timeouts.
|
|
980
|
+
>
|
|
753
981
|
```ruby
|
|
754
982
|
token = Phronomy::Concurrency::CancellationToken.new
|
|
755
983
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
#
|
|
9
9
|
# Scenarios:
|
|
10
10
|
# 1. Minimal agent (no tools, no knowledge) — baseline framework overhead.
|
|
11
|
-
# 2. Tool-aware agent with
|
|
11
|
+
# 2. Tool-aware agent with a registered stub Tool.
|
|
12
12
|
# 3. Agent#stream setup latency (first-chunk time with stubbed stream).
|
|
13
13
|
|
|
14
14
|
require "benchmark"
|
|
@@ -79,7 +79,6 @@ end
|
|
|
79
79
|
bench_tool_class = Class.new(Phronomy::Agent::Base) do
|
|
80
80
|
model "stub-model"
|
|
81
81
|
tools BenchNullTool
|
|
82
|
-
max_parallel_tools 4
|
|
83
82
|
|
|
84
83
|
define_method(:build_chat) { |*| BenchStubChat.new(BENCH_RESP) }
|
|
85
84
|
end
|
|
@@ -97,7 +96,7 @@ Benchmark.bm(50) do |x|
|
|
|
97
96
|
end
|
|
98
97
|
end
|
|
99
98
|
|
|
100
|
-
x.report("Agent#invoke —
|
|
99
|
+
x.report("Agent#invoke — tool-aware, #{AGENT_INVOKE_ITERATIONS} iters") do
|
|
101
100
|
AGENT_INVOKE_ITERATIONS.times do
|
|
102
101
|
BENCH_AGENT_TOOLS.invoke("ping", thread_id: "bench-#{rand(1_000_000)}")
|
|
103
102
|
end
|