phronomy 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd9fb679db0e4d4c582a82dad543ca0d93806878e0936404cd4255d2a48a741b
4
- data.tar.gz: 511a6bfb90ec0524aafb201996bc251c9b1923a2d3ff4d1b389aa7d62f2d3395
3
+ metadata.gz: b0ea2e317999f3202e06d5afac0b46509fa22a813531919447040d1e048a087f
4
+ data.tar.gz: ae5740df9899b7463b35b54b885948ca1e0e42e01b8b85b1a93fd6437022c90b
5
5
  SHA512:
6
- metadata.gz: 9efc33585c40e3d5b4559121cd5654c9687608ff05ab684818e27ac5d4672264352fb68fbf4efa6436ed8666a0b194acfdcea7511fce67a5eda50e690cd80803
7
- data.tar.gz: 0142742c7875411622ad37ae2918164130bd84fe237da2e100b69578f67380a08c61ae6ca77cfdcac5892ea7bd56140c39b374b8d0543d11cd359bda587f12df
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` (subclasses of `Phronomy::Error`) raised at the agent retry boundary | Beta |
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
- | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
57
- | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#invoke_async` + `Task#map` (child-agent pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
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 | Experimental |
59
- | **`Task#map`** transforms a `Task`'s completed value via a block; returns a new `Task` whose value is the block's return value; if the source task fails or is cancelled the mapped task propagates the error without calling the block; primary use-case: `invoke_async.map { \|r\| ctx.merge(answer: r[:output]) }` to wire agent results into a `WorkflowContext` | Experimental |
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`; `config[:tool_timeout]` sets the per-submit timeout forwarded to `BlockingAdapterPool` for abandoned-operation tracking | Experimental |
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 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 |
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
- | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
77
- | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
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
- | **`ScopePolicy`**Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
80
- | **HITL Checkpoint/Resume** `Agent::Base#invoke` returns `{ suspended: true, checkpoint: Checkpoint }` when an approval-required tool is encountered without a synchronous handler; `Agent::Base#resume(checkpoint, approved:)` resumes execution; `Agent::Base.resume(checkpoint, approved:)` (class-level) resolves the agent class automatically; `Checkpoint#to_h` / `Checkpoint.from_h` for serialization; `Agent::Base#checkpoint_store=` for custom idempotency backends; `CheckpointAlreadyResumedError` raised on duplicate resume | Experimental |
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 tables above are the complete list of classes, modules, and features
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
- In EventLoop mode (`c.event_loop = true`), use `invoke_async + Task#map` to run an agent
205
- asynchronously inside a Workflow entry action. The mapped Task returns a `WorkflowContext`,
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
- # EventLoop mode: workflow that runs an agent and captures the result.
210
- entry :translate, ->(ctx) {
211
- TranslationAgent.new.invoke_async(ctx.query).map do |result|
212
- ctx.merge(answer: result[:output]) # returns WorkflowContext
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
- transition from: :translate, to: :done # no on: needed
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
- ### Agent::Orchestrator — Parallel subagent dispatch
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::Agent::Orchestrator
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::Agent::Orchestrator
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
- > checkpoint/compaction context and EventLoop routing. It does **not** automatically persist or
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 EventLoop#stop(drain: true)
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 # when true, raises on invoke-inside-task
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`). When `nil` (default), messages are written to `$stderr` via `Kernel#warn`.
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
- invoke_timeout 30 # raise Phronomy::TimeoutError after 30 s (wait timeout, not cancellation)
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 and is never retried. No threads are force-killed — `ensure`
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 max_parallel_tools=4 (4 stub tools per turn).
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 — 4 parallel stub tools, #{AGENT_INVOKE_ITERATIONS} iters") do
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