phronomy 0.14.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +236 -57
  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/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +471 -0
  14. data/lib/phronomy/agent/base.rb +500 -411
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -7
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 821e5e60bc5eacd862e98cbf03b74ba1de640d923c783d29ec560b4e0523dafa
4
- data.tar.gz: 515be391fa893cc679bcc348a5db5abddbb5d6714507f0adf3eae25a0d0caffd
3
+ metadata.gz: b0ea2e317999f3202e06d5afac0b46509fa22a813531919447040d1e048a087f
4
+ data.tar.gz: ae5740df9899b7463b35b54b885948ca1e0e42e01b8b85b1a93fd6437022c90b
5
5
  SHA512:
6
- metadata.gz: 744f401fb376638feea54c7639653ea92fcf1cde3043f0de656f1e16e2f14fbac15ae2c396d939e4f5810446acc4d65e29d4c95fe1049e02d759625b0b7c7f19
7
- data.tar.gz: 6835730faae5f27c80c0a2157f7e69e38f23c6b41e1cdbfa169532b786b981934d70a224bf793922e55fc0d7be4894d9ca9c984191bbf8e014d1aee158664ed9
6
+ metadata.gz: 123b0998a7a299f97ce981a3561c68990c41e05decb534b4c4664e441abd6cf368e04ecf6cea61df6a1ea2c4016850b093e0c208aa034fc02b1fa498e0078263
7
+ data.tar.gz: f1ba3ee6163b1a746bd99f00241daf286ac5eb3d8d1e30f7caa739de1de5dd900f3b19b90a063c4a9f19f494a1e9ac1dca59b4d32ade41deabe71f849803ac62
data/CHANGELOG.md CHANGED
@@ -9,6 +9,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
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
+
12
77
  ---
13
78
 
14
79
  ## [0.14.0] - 2026-07-27
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,21 +48,22 @@ 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
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 |
64
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 |
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 |
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
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
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
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 |
@@ -75,14 +75,14 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
75
75
  | **Workflow parallel pattern** — Concurrent branches via application-level threads (no built-in parallel primitive; see the Workflow section for the recommended pattern) | Beta |
76
76
  | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
77
77
  | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
78
- | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
79
- | **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 |
80
80
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
81
- | **`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 |
82
- | **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 |
83
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 |
84
84
 
85
- > **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
86
86
  > intended for gem consumers. Every entry has an associated stability label.
87
87
  > All other classes, modules, and methods — including everything in the
88
88
  > [Advanced / Internal APIs](#advanced--internal-apis) section below — are
@@ -125,10 +125,30 @@ Configure your provider credentials before using agents or chains:
125
125
  RubyLLM.configure do |c|
126
126
  c.openai_api_key = ENV["OPENAI_API_KEY"]
127
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
128
135
  end
129
136
  ```
130
137
 
131
- 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 |
132
152
 
133
153
  ### Optional dependencies
134
154
 
@@ -166,6 +186,75 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
166
186
  puts result[:output]
167
187
  ```
168
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
+
169
258
  ### Workflow — Stateful workflow with wait_state/send_event
170
259
 
171
260
  ```ruby runnable
@@ -203,20 +292,116 @@ final = app.send_event(state: state, event: :approve)
203
292
  puts "Approved: #{final.approved}" # => true
204
293
  ```
205
294
 
206
- In EventLoop mode (`c.event_loop = true`), use `invoke_async + Task#map` to run an agent
207
- asynchronously inside a Workflow entry action. The mapped Task returns a `WorkflowContext`,
208
- 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:
209
297
 
210
298
  ```ruby
211
- # EventLoop mode: workflow that runs an agent and captures the result.
212
- entry :translate, ->(ctx) {
213
- TranslationAgent.new.invoke_async(ctx.query).map do |result|
214
- 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
215
314
  end
216
- }
217
- 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
218
351
  ```
219
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
+ )
370
+ ```
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
+
220
405
  ### Multi-Agent — Agent-as-Tool pattern
221
406
 
222
407
  Wrap sub-agents as `Agent::Context::Capability::Base` subclasses so the orchestrator LLM can call them on demand.
@@ -403,7 +588,7 @@ rescue Phronomy::LowConfidenceError => e
403
588
  end
404
589
  ```
405
590
 
406
- ### Agent::Orchestrator — Parallel subagent dispatch
591
+ ### MultiAgent::Orchestrator — Parallel subagent dispatch
407
592
 
408
593
  > **Note:** `dispatch_parallel` and `fan_out` use plain Ruby threads. Use
409
594
  > `max_concurrency:` to cap the number of concurrent workers and `on_error:`
@@ -412,7 +597,7 @@ end
412
597
  > fan-outs consider additional rate-limiting at the application level.
413
598
 
414
599
  ```ruby
415
- class ResearchOrchestrator < Phronomy::Agent::Orchestrator
600
+ class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
416
601
  model "gpt-4o"
417
602
  instructions "Coordinate research tasks by dispatching to specialised agents."
418
603
 
@@ -427,7 +612,7 @@ result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
427
612
  Programmatic parallel dispatch (no LLM loop):
428
613
 
429
614
  ```ruby
430
- class MyOrchestrator < Phronomy::Agent::Orchestrator
615
+ class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
431
616
  model "gpt-4o"
432
617
  instructions "Orchestrate."
433
618
 
@@ -573,7 +758,7 @@ puts result2[:output] # => "Your name is Alice."
573
758
  Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
574
759
 
575
760
  > **Note on `thread_id`**: `thread_id` is a correlation identifier used internally for
576
- > 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
577
762
  > restore conversation history — you must pass `messages:` explicitly on each turn as shown above.
578
763
 
579
764
 
@@ -587,14 +772,16 @@ Phronomy.configure do |c|
587
772
  c.before_completion = nil # optional; global hook lambda
588
773
  c.trace_pii = false # default; set to true only when trace data contains no PII
589
774
  c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
590
- 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
591
776
  c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only); :cooperative (deprecated alias for :immediate)
592
- 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
593
779
  end
594
780
  ```
595
781
 
596
782
  `c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
597
- `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`.
598
785
 
599
786
  > **Note**: When `trace_pii = false`, both the _input_ and the _output_ (LLM
600
787
  > responses and tool results) are replaced with `[REDACTED]` in trace spans.
@@ -611,6 +798,10 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
611
798
  | Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
612
799
  | Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
613
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 |
614
805
 
615
806
  ### Why this matters
616
807
 
@@ -706,8 +897,7 @@ class MyAgent < Phronomy::Agent::Base
706
897
  model "gpt-4o"
707
898
  max_output_tokens 4096 # override max_output_tokens from registry
708
899
  context_overhead 600 # extra reservation for system prompt + tools
709
- invoke_timeout 30 # raise Phronomy::TimeoutError after 30 s (wait timeout, not cancellation)
710
- max_parallel_tools 4 # cap concurrent tool executions (default: 10)
900
+ # LLM timeout/retry is configured on RubyLLM, not on the Agent class.
711
901
  end
712
902
  ```
713
903
 
@@ -733,7 +923,7 @@ Pass a `CancellationToken` to any agent via `config: { cancellation_token: token
733
923
  Cancellation is checked at multiple granular checkpoints: before the LLM call,
734
924
  after each streaming chunk, before each parallel
735
925
  tool-call batch, and after each `before_completion` hook. `CancellationError` is
736
- 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`
737
927
  blocks always execute.
738
928
 
739
929
  > **Cooperative cancellation — not preemptive**
@@ -775,30 +965,19 @@ blocks always execute.
775
965
  > which calls `cancel!` on expiry and fires all `on_cancel` callbacks — including
776
966
  > the MCP bridge.
777
967
 
778
- > **`config[:tool_timeout]` / `config[:llm_timeout]` caller protection, not
779
- > worker termination**
968
+ > **Transport timeout and retry ownership**
780
969
  >
781
- > These keys set a submit-time deadline in `BlockingAdapterPool`. The timer is
782
- > armed at submit time (including queue wait) and calls `fire_timeout!` when the
783
- > deadline expires. The worker thread is never forcibly interrupted.
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.
784
975
  >
785
- > **Timeout behaviour depends on when the deadline fires:**
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.
786
980
  >
787
- > | Situation | `TimeoutError`? | `abandoned?` | `abandoned_count` |
788
- > |---|---|---|---|
789
- > | Deadline fires while worker is executing | ✅ via `on_complete` | `true` | +1 |
790
- > | Deadline fires while op is still queued | ✅ via `on_complete` | `false` | unchanged |
791
- > | `blocking_wait(timeout:)` expires | ✅ to that waiter only | unchanged | unchanged |
792
- > | `CancellationToken` cancelled | `CancellationError` | — | — |
793
- > | Streaming path | not guaranteed (separate fix needed) | — | — |
794
- >
795
- > `blocking_wait(timeout:)` is a **waiter-local** deadline — the operation remains
796
- > unsettled and other waiters or `on_complete` callbacks will still receive the
797
- > eventual result (unless a submit-time deadline also fires).
798
- >
799
- > Size `blocking_io_pool_size` to account for the worst-case number of
800
- > concurrently abandoned workers that may accumulate before the pool is saturated.
801
-
802
981
  ```ruby
803
982
  token = Phronomy::Concurrency::CancellationToken.new
804
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
@@ -2,74 +2,21 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted
5
+ Superseded by [ADR-011](011-delegate-transport-policy-to-adapters.md).
6
6
 
7
- ## Context
7
+ ## Historical decision
8
8
 
9
- `Agent::Base` exposes `invoke_timeout N` as a class-level DSL. When an invocation
10
- exceeds the timeout, `Phronomy::TimeoutError` is raised to the caller.
9
+ Phronomy previously exposed `Agent::Base.invoke_timeout` as a class-level wait
10
+ boundary. The initial implementation did not stop background work; a later
11
+ implementation attached a cancellation scope to the complete Agent invocation.
11
12
 
12
- The question is: should the timeout also stop the agent's background work?
13
-
14
- Ruby's `Timeout.timeout` / `Thread#kill` can interrupt a running thread, but
15
- doing so is unsafe: it can leave mutexes locked, database connections in a broken
16
- state, and external API calls mid-flight without cleanup. `Thread#raise` has the
17
- same hazards because it can interrupt anywhere inside a `rescue`/`ensure` block.
18
-
19
- Cooperative cancellation (checking a shared flag periodically) is safe but
20
- requires every tool, every LLM call, and every framework-internal loop to
21
- participate — a significant API surface change.
22
-
23
- ## Decision
24
-
25
- `invoke_timeout` is a **wait timeout only**. When the deadline is reached:
26
-
27
- - `TimeoutError` is raised in the calling thread.
28
- - The agent's background thread continues running until it either completes
29
- normally or is garbage-collected when the process ends.
30
- - No cancellation signal is sent to the agent.
31
-
32
- This is explicitly documented in the README and in the DSL source.
33
-
34
- A proper cooperative cancellation mechanism is tracked in Issue #216
35
- (`CancellationToken`), which is a separate feature requiring agent, tool, and
36
- transport layer participation.
37
-
38
- ## Consequences
39
-
40
- **Positive:**
41
- - No risk of leaving shared resources (DB connections, mutexes, sockets) in a
42
- broken state due to forced thread interruption.
43
- - Implementation is simple: `Timeout.timeout` on the calling side only.
44
- - The contract is explicit and predictable.
45
-
46
- **Negative / Tradeoffs:**
47
- - Background threads may continue consuming resources (LLM API quota, etc.)
48
- after the caller has given up.
49
- - Users who expect "cancel" semantics from a timeout will be surprised.
50
- - Proper cancellation requires the `CancellationToken` feature (#216), which
51
- has not yet been implemented.
52
-
53
- ## Extension: PendingOperation#await cooperative cancellation semantics
54
-
55
- `BlockingAdapterPool::PendingOperation#await` also supports both `timeout:` and
56
- `cancellation_token:` parameters. The same non-preemptive rule applies here,
57
- consistent with ADR-010 (cooperative-first, non-preemptive concurrency model):
58
-
59
- 1. **No forcible thread termination.** When a `cancellation_token` is cancelled,
60
- `CancellationError` is raised to the `await` caller; when the timeout fires,
61
- `TimeoutError` is raised instead. In both cases, the underlying worker thread
62
- is **not** killed. The worker runs its block to natural completion.
63
- 2. **Cooperative, not preemptive.** Cancellation takes effect only at `await`
64
- call sites or at explicit `token.check!` checkpoints inside the submitted
65
- block. Code that ignores the token will not be interrupted.
66
- 3. **Timeout scope.** `timeout:` at `await` time is measured from the moment
67
- `await` is called. If both submit-time and await-time timeouts are provided,
68
- the earlier deadline wins.
69
- 4. **Error propagation.** `CancellationError` (or `TimeoutError`) is raised to
70
- the `await` caller; the submitter is responsible for handling it.
71
-
72
- These semantics are identical in spirit to the `invoke_timeout` decision above:
73
- the framework exposes a *wait* boundary, not a hard-kill boundary. Safe resource
74
- cleanup is the caller's responsibility.
13
+ The API has been removed. An Agent class no longer owns a default invocation
14
+ timeout. Applications that need a deadline for a particular root operation pass
15
+ an `InvocationContext` with `deadline:` or `cancellation_token:`. Those values are
16
+ coordination context supplied by the caller, not an implicit Agent execution
17
+ policy.
75
18
 
19
+ LLM transport timeout belongs to RubyLLM or another configured LLM adapter.
20
+ Tool transport timeout belongs to the Tool implementation or its underlying
21
+ client. Phronomy retains generic deadline and cancellation primitives for the
22
+ execution tree it coordinates.