phronomy 0.17.0 → 0.18.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1134
  3. data/README.md +68 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  8. data/docs/features.md +87 -0
  9. data/docs/getting-started.md +351 -0
  10. data/docs/migrations/0.15.md +35 -0
  11. data/docs/migrations/0.16.md +43 -0
  12. data/docs/runtime-and-concurrency.md +258 -0
  13. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  14. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  15. data/lib/phronomy/agent/async_event_api.rb +3 -3
  16. data/lib/phronomy/agent/base.rb +35 -19
  17. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  18. data/lib/phronomy/agent/execution_coordinator.rb +6 -6
  19. data/lib/phronomy/agent/shared_state.rb +2 -0
  20. data/lib/phronomy/agent/tool_executor.rb +29 -71
  21. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  23. data/lib/phronomy/configuration.rb +5 -29
  24. data/lib/phronomy/diagnostics.rb +12 -41
  25. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  26. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  27. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  28. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  29. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  31. data/lib/phronomy/engine/event_loop.rb +89 -190
  32. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  33. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  34. data/lib/phronomy/engine/runtime.rb +45 -158
  35. data/lib/phronomy/engine/task.rb +136 -277
  36. data/lib/phronomy/llm_adapter/base.rb +14 -14
  37. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  38. data/lib/phronomy/llm_adapter.rb +2 -2
  39. data/lib/phronomy/metrics.rb +15 -30
  40. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  41. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  42. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  43. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  44. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  45. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  46. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  47. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  48. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  49. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  50. data/lib/phronomy/testing/eval/runner.rb +52 -0
  51. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  52. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  53. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  54. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  55. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  56. data/lib/phronomy/testing/eval.rb +9 -0
  57. data/lib/phronomy/testing/fake_clock.rb +6 -53
  58. data/lib/phronomy/testing.rb +2 -6
  59. data/lib/phronomy/tools/agent.rb +141 -6
  60. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  61. data/lib/phronomy/vector_store/base.rb +2 -2
  62. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  63. data/lib/phronomy/version.rb +1 -1
  64. data/lib/phronomy/workflow_runner.rb +2 -4
  65. data/lib/phronomy.rb +7 -4
  66. data/scripts/api_snapshot.rb +4 -4
  67. metadata +23 -31
  68. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  69. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  70. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  71. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  72. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  73. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  74. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  75. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  76. data/lib/phronomy/engine/task/backend.rb +0 -80
  77. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  78. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  79. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  80. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  81. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  82. data/lib/phronomy/engine/task_group.rb +0 -193
  83. data/lib/phronomy/eval/comparison.rb +0 -47
  84. data/lib/phronomy/eval/dataset.rb +0 -45
  85. data/lib/phronomy/eval/eval_case.rb +0 -17
  86. data/lib/phronomy/eval/eval_result.rb +0 -29
  87. data/lib/phronomy/eval/metrics.rb +0 -66
  88. data/lib/phronomy/eval/runner.rb +0 -94
  89. data/lib/phronomy/eval/scorer/base.rb +0 -22
  90. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  91. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  92. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  93. data/lib/phronomy/eval/scorer.rb +0 -9
  94. data/lib/phronomy/eval.rb +0 -7
  95. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  96. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -0,0 +1,351 @@
1
+ # Getting started
2
+
3
+ This guide contains the setup and introductory examples that were previously
4
+ embedded in the repository README. The README remains the project entry point;
5
+ this document carries the longer operational examples.
6
+
7
+ ## Install
8
+
9
+ Add Phronomy to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "phronomy"
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Phronomy uses RubyLLM for Large Language Model (LLM) access. Configure provider credentials and the
22
+ transport retry/timeout policy on RubyLLM itself:
23
+
24
+ ```ruby
25
+ RubyLLM.configure do |c|
26
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
27
+ # c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
28
+
29
+ c.request_timeout = 120
30
+ c.max_retries = 3
31
+ c.retry_interval = 0.1
32
+ c.retry_backoff_factor = 2
33
+ c.retry_interval_randomness = 0.5
34
+ end
35
+ ```
36
+
37
+ Phronomy does not add a second LLM transport timeout/retry layer on top of the
38
+ configured adapter.
39
+
40
+ ## Optional dependencies
41
+
42
+ Install only the backend gems required by your application:
43
+
44
+ | Gem | Required for |
45
+ |---|---|
46
+ | `pgvector` | `Phronomy::VectorStore::Pgvector` |
47
+ | `redis` | `Phronomy::VectorStore::RedisSearch` |
48
+ | `opentelemetry-api` | `Phronomy::Tracing::OpenTelemetryTracer` |
49
+
50
+ ## Define a Tool and Agent
51
+
52
+ ```ruby
53
+ class WebSearch < Phronomy::Agent::Context::Capability::Base
54
+ description "Search the web"
55
+ param :query, type: :string, desc: "Search query"
56
+
57
+ def execute(query:)
58
+ "Mock search result for: #{query}"
59
+ end
60
+ end
61
+
62
+ class ResearchAgent < Phronomy::Agent::Base
63
+ agent_definition id: "research-agent", version: 1
64
+ model "gpt-4o"
65
+ instructions "You are a research assistant. Use tools to answer questions."
66
+ tools(WebSearch => nil)
67
+ max_iterations 5
68
+ end
69
+
70
+ result = ResearchAgent.new.invoke("Research Ruby AI frameworks")
71
+ puts result[:output]
72
+ ```
73
+
74
+ Every concrete stateful Agent definition declares a stable definition ID and
75
+ version. The definition identity is checked when persisted Agent state is loaded.
76
+
77
+ ## Stateful Agent persistence
78
+
79
+ Phronomy Agents own their conversation history and persistent Knowledge. The
80
+ application does not need to pass the previous `messages` array back into every
81
+ invocation.
82
+
83
+ ```ruby
84
+ persistence = Phronomy::Persistence::InMemory.new
85
+
86
+ agent = ResearchAgent.create(
87
+ agent_id: "research-session-42",
88
+ knowledge: ["Customer tier: enterprise"],
89
+ persistence: persistence
90
+ )
91
+
92
+ agent.invoke("My name is Alice.")
93
+ agent.add_knowledge("Customer locale: ja-JP")
94
+ result = agent.invoke("What is my name?")
95
+
96
+ puts result[:output]
97
+ ```
98
+
99
+ Load the same Agent again when the same Persistence backend is available:
100
+
101
+ ```ruby
102
+ agent = ResearchAgent.load(
103
+ "research-session-42",
104
+ persistence: persistence
105
+ )
106
+
107
+ agent.invoke("Continue our previous discussion.")
108
+ ```
109
+
110
+ The active transcript and Knowledge views can be advanced independently without
111
+ deleting the append-only canonical Journal:
112
+
113
+ ```ruby
114
+ agent.clear_transcript!
115
+ agent.clear_knowledge!
116
+ agent.reset_context!
117
+ ```
118
+
119
+ `purge!` is different: it permanently removes the Agent and persisted execution
120
+ history from the configured Persistence backend.
121
+
122
+ ## Sync and async Agent APIs
123
+
124
+ Use synchronous APIs at an external/top-level application boundary and async
125
+ APIs when the caller must remain non-blocking.
126
+
127
+ ```ruby
128
+ result = agent.invoke("Hello")
129
+
130
+ task = agent.invoke_async("Hello")
131
+ result = task.wait_result
132
+ ```
133
+
134
+ `Task#wait_result` is for an external caller. Do not block EventLoop waiting for
135
+ a Task that can only complete through that same EventLoop.
136
+
137
+ Streaming follows the same split:
138
+
139
+ ```ruby
140
+ agent.stream("Explain the design") do |event|
141
+ puts event.payload if event.type == :token
142
+ end
143
+ ```
144
+
145
+ ```ruby
146
+ task = agent.stream_async(
147
+ "Explain the design",
148
+ on_event: ->(event) { puts event.payload if event.type == :token }
149
+ )
150
+ ```
151
+
152
+ Streaming callbacks execute on EventLoop and therefore should return quickly.
153
+
154
+ ## Human-in-the-loop approval
155
+
156
+ A Tool requiring approval can suspend an Agent invocation. Resume it with the
157
+ approval request identifier returned by the suspension result.
158
+
159
+ At a top-level synchronous boundary:
160
+
161
+ ```ruby
162
+ result = agent.invoke("Perform the requested protected action")
163
+
164
+ if result[:suspended]
165
+ request = result[:approval_request]
166
+ result = agent.approve(
167
+ result[:execution_id],
168
+ approval_request_id: request.id,
169
+ approved: true
170
+ )
171
+ end
172
+ ```
173
+
174
+ From an EventLoop callback, use `approve_async` rather than blocking EventLoop.
175
+
176
+ ## Workflow basics
177
+
178
+ A Workflow is state-machine-driven and can halt at an explicit wait state:
179
+
180
+ ```ruby
181
+ class ReviewContext
182
+ include Phronomy::WorkflowContext
183
+ field :draft, type: :replace
184
+ field :feedback, type: :replace
185
+ field :approved, type: :replace, default: false
186
+ end
187
+
188
+ write_draft = ->(state) { state.merge(draft: "Draft content") }
189
+ review_draft = ->(state) { state.merge(feedback: "Feedback on: #{state.draft}") }
190
+
191
+ workflow = Phronomy::Workflow.define(ReviewContext) do
192
+ initial :write
193
+ state :write, action: write_draft
194
+ state :review, action: review_draft
195
+ wait_state :awaiting_approval
196
+ state :finalize, action: ->(s) { s.merge(approved: true) }
197
+
198
+ transition from: :write, to: :review
199
+ transition from: :review, to: :awaiting_approval
200
+ transition from: :awaiting_approval, on: :approve, to: :finalize
201
+ transition from: :awaiting_approval, on: :reject, to: :write
202
+ transition from: :finalize, to: :__finish__
203
+ end
204
+
205
+ state = workflow.invoke({draft: ""}, config: {thread_id: "doc-1"})
206
+ final = workflow.send_event(state: state, event: :approve)
207
+ puts final.approved
208
+ ```
209
+
210
+ Workflow entry and transition actions are synchronous Run-to-Completion
211
+ callbacks. If a Workflow needs an Agent or another asynchronous lifecycle, start
212
+ it asynchronously, return the Workflow context immediately, and deliver its
213
+ completion later with `Workflow#signal`.
214
+
215
+ ```ruby
216
+ class AnswerContext
217
+ include Phronomy::WorkflowContext
218
+
219
+ field :question, type: :replace, default: ""
220
+ field :answer, type: :replace, default: nil
221
+ field :thread_id, type: :replace, default: nil
222
+ end
223
+
224
+ class ResearchAgent < Phronomy::Agent::Base
225
+ agent_definition id: "research-agent", version: 1
226
+ model "gpt-4o-mini"
227
+ instructions "Research the question and return a concise answer."
228
+ end
229
+
230
+ my_agent = ResearchAgent.new
231
+ workflow = nil
232
+
233
+ workflow = Phronomy::Workflow.define(AnswerContext) do
234
+ initial :asking
235
+ state :asking
236
+ state :done
237
+
238
+ entry :asking, ->(ctx) {
239
+ thread_id = ctx.thread_id
240
+
241
+ my_agent.invoke_async(
242
+ ctx.question,
243
+ on_event: ->(event) {
244
+ next unless event.type == :done
245
+
246
+ workflow.signal(
247
+ thread_id: thread_id,
248
+ event: :answer_ready,
249
+ payload: {answer: event.payload[:output]}
250
+ )
251
+ }
252
+ )
253
+
254
+ ctx
255
+ }
256
+
257
+ transition(
258
+ from: :asking,
259
+ on: :answer_ready,
260
+ to: :done,
261
+ action: ->(ctx, event) { ctx.merge(answer: event.payload[:answer]) }
262
+ )
263
+ end
264
+ ```
265
+
266
+ Returning a `Phronomy::Task` from a Workflow entry/transition action is not an
267
+ implicit await mechanism and is rejected.
268
+
269
+ ## Agent as Tool
270
+
271
+ Expose a child Agent using `Phronomy::Tools::Agent.from_agent` rather than calling
272
+ a synchronous child Agent from a Tool worker:
273
+
274
+ ```ruby
275
+ ResearchTool = Phronomy::Tools::Agent.from_agent(
276
+ ResearchAgent,
277
+ tool_name: "research",
278
+ description: "Delegate research to the research Agent"
279
+ )
280
+
281
+ class OrchestratorAgent < Phronomy::Agent::Base
282
+ agent_definition id: "orchestrator-agent", version: 1
283
+ model "gpt-4o"
284
+ instructions "Use the research Tool when research is required."
285
+ tools(ResearchTool => nil)
286
+ end
287
+ ```
288
+
289
+ Agent-backed Tools return control to EventLoop while the child lifecycle is
290
+ waiting. They do not occupy an OffloadPool worker merely to wait for a child
291
+ Agent result.
292
+
293
+ ## Filters
294
+
295
+ Filters can transform or reject values at Agent boundaries:
296
+
297
+ ```ruby
298
+ class NoCreditCardFilter < Phronomy::Filter::Base
299
+ def call(value, **_context)
300
+ block!("Credit card numbers are not allowed") if value.match?(/\d{4}-\d{4}-\d{4}-\d{4}/)
301
+ value
302
+ end
303
+ end
304
+
305
+ agent.add_input_filter(NoCreditCardFilter.new)
306
+ ```
307
+
308
+ Phronomy includes `PromptInjectionFilter` as a baseline pattern filter. It is not
309
+ a complete security policy for untrusted input.
310
+
311
+ ## Persistent Knowledge and per-call context
312
+
313
+ Register durable Knowledge on the Agent:
314
+
315
+ ```ruby
316
+ agent.add_knowledge(
317
+ "Customer locale: ja-JP",
318
+ metadata: {"origin" => "customer_profile"}
319
+ )
320
+ ```
321
+
322
+ Request-scoped context can instead be supplied through `before_llm_input` using
323
+ `LLMInputPatch#segment_candidates`; those candidates enter Context Policy for the
324
+ specific call and are not persisted to the Journal.
325
+
326
+ ## Model Context Protocol (MCP)
327
+
328
+ Phronomy targets MCP 1.x through the official `mcp` gem:
329
+
330
+ ```ruby
331
+ search_tool = Phronomy::Tools::Mcp.from_server(
332
+ "stdio://./mcp-server",
333
+ tool_name: "web_search"
334
+ )
335
+
336
+ begin
337
+ # use search_tool
338
+ ensure
339
+ search_tool.close
340
+ end
341
+ ```
342
+
343
+ See [MCP client](mcp-client.md) for schema, error, cancellation, and lifecycle
344
+ contracts.
345
+
346
+ ## Next steps
347
+
348
+ - [Features and API stability](features.md)
349
+ - [Runtime and concurrency](runtime-and-concurrency.md)
350
+ - [Architecture decisions](decisions/)
351
+ - [Migration guides](migrations/)
@@ -0,0 +1,35 @@
1
+ # Migration from 0.15-era execution-policy APIs
2
+
3
+ This guide preserves the execution-policy migration information that previously
4
+ lived in the top-level README. It is historical upgrade guidance; new code should
5
+ follow the current contracts documented in
6
+ [Runtime and concurrency](../runtime-and-concurrency.md).
7
+
8
+ | Removed Phronomy setting/API | Replacement |
9
+ |---|---|
10
+ | `retry_policy` | RubyLLM transport retry, or explicit application orchestration |
11
+ | `invoke_timeout` | `InvocationContext#deadline` or `cancellation_token` when the caller needs a root deadline |
12
+ | `config[:llm_timeout]` | `RubyLLM.configure { |c| c.request_timeout = ... }` |
13
+ | Tool `retry_on` | Tool/client-specific retry with explicit idempotency guarantees |
14
+ | `config[:tool_timeout]` | Tool/client-native timeout |
15
+ | `max_parallel_tools` | No replacement; `parallel_tool_execution` remains an on/off mode |
16
+ | `InvocationContext#provider_limits` | Configure the provider client directly |
17
+ | `stream_queue_max_size` | No replacement; the shared EventLoop queue is unbounded by design. Monitor `Metrics.snapshot[:event_loop_queue_depth]` instead |
18
+
19
+ ## Timeout ownership
20
+
21
+ Phronomy owns logical lifecycle deadlines and cooperative cancellation. Provider,
22
+ HTTP, database, MCP, and other transport-level timeouts belong to the relevant
23
+ client library.
24
+
25
+ A caller-defined root deadline can be represented through `InvocationContext` or
26
+ a cancellation token, but that does not replace operation-native socket/request/
27
+ statement timeouts for a synchronous call already in progress.
28
+
29
+ ## Retry ownership
30
+
31
+ Automatic replay of an entire Agent invocation is not a generic framework retry
32
+ policy. Configure transport retry on RubyLLM or another adapter, and implement
33
+ application orchestration explicitly when replay is semantically safe.
34
+
35
+ See [`CHANGELOG.md`](../../CHANGELOG.md) for the release-by-release history.
@@ -0,0 +1,43 @@
1
+ # 0.16 cleanup migration
2
+
3
+ This guide preserves the compatibility cleanup information that previously lived
4
+ in the top-level README. It is intended for applications upgrading older Phronomy
5
+ code; new applications should use the current documentation directly.
6
+
7
+ | Removed API | Current contract |
8
+ |---|---|
9
+ | `context_overhead` | Manifest-first assembly budgets actual mandatory + selected content |
10
+ | Tool `on_error :return_empty` | Use `:raise` or `:suppress` |
11
+ | `dispatch_parallel(..., force_kill:)` / `fan_out(..., force_kill:)` | Cooperative cancellation; no force-kill switch |
12
+ | `runtime_backend` | Removed. Phronomy has one control model: EventLoop/FSMSession for lifecycle coordination and OffloadPool for synchronous work that must stay off EventLoop |
13
+ | `Runtime.instance = ...` | Runtime replacement is test/internal infrastructure, not a public setter |
14
+ | `Runtime#spawn` / `TaskGroup` | Removed. Start framework async work through domain async APIs (`invoke_async`, Workflow events, ToolInvocation, FanOut) |
15
+ | `tools ToolA, ToolB` | Use `tools(ToolA => nil, ToolB => nil)` |
16
+ | `CancellationToken.new(deadline: Time...)` | Use `CancellationToken.timeout_after(seconds)` for a lazy token deadline, or a Runtime-backed cancellation scope when callback delivery on deadline is required |
17
+ | `StaticKnowledge` / `EntityKnowledge` / `Knowledge::Base` / `Phronomy::KnowledgeSource` | Register plain persistent Knowledge with `knowledge:` or `add_knowledge` |
18
+ | `static_knowledge*` class APIs | Persistent Knowledge belongs to Agent instances and is Journal-backed |
19
+ | `clear_memory!` | Use `clear_knowledge!`; conversation history is controlled independently with `clear_transcript!` |
20
+
21
+ The legacy `build_context` / `LlmContextWindow::Assembler` extension path is no
22
+ longer an active API. Stateful Agent input is assembled through the canonical:
23
+
24
+ ```text
25
+ Journal
26
+ → Context candidates
27
+ → Context Policy
28
+ → Large Language Model (LLM) Input Manifest
29
+ → runtime projection
30
+ ```
31
+
32
+ ## Concurrency model
33
+
34
+ The active concurrency model is EventLoop/FSMSession for logical lifecycle
35
+ coordination plus OffloadPool for synchronous work that cannot run on EventLoop.
36
+ There is no configurable runtime backend and no generic framework task-spawn
37
+ backend.
38
+
39
+ For the current design, see
40
+ [Runtime and concurrency](../runtime-and-concurrency.md) and
41
+ [ADR-010](../decisions/010-cooperative-first-concurrency.md).
42
+
43
+ See [`CHANGELOG.md`](../../CHANGELOG.md) for release details.
@@ -0,0 +1,258 @@
1
+ # Runtime and concurrency
2
+
3
+ Phronomy uses an **EventLoop / FSMSession first** architecture for framework
4
+ lifecycle coordination. `FSMSession` is the framework finite-state-machine session
5
+ used to represent explicit lifecycle state and events. A `Task` is a completion
6
+ handle, not an execution backend. Synchronous work that must stay off EventLoop is isolated in the bounded
7
+ `OffloadPool`.
8
+
9
+ For the design rationale, see Architecture Decision Record (ADR)
10
+ [ADR-010: EventLoop / FSMSession First Concurrency](decisions/010-cooperative-first-concurrency.md).
11
+
12
+ ## Runtime model
13
+
14
+ ```text
15
+ Runtime
16
+ ├─ EventLoop (one control-plane operating-system Thread)
17
+ │ └─ FSMSession
18
+ │ ├─ Agent
19
+ │ ├─ Workflow
20
+ │ ├─ ToolInvocation
21
+ │ └─ MultiAgent fan-out
22
+ ├─ OffloadPool (bounded operating-system Threads)
23
+ │ ├─ blocking input/output (I/O)
24
+ │ ├─ central-processing-unit (CPU)-bound synchronous work
25
+ │ └─ other long synchronous work
26
+ ├─ named OffloadPools
27
+ └─ EventLoop-driven timers
28
+
29
+ Task = completion handle
30
+ ```
31
+
32
+ The framework does not allocate one operating-system Thread per logical Agent/Workflow/Tool
33
+ lifecycle. Logical waits remain explicit states plus later EventLoop events.
34
+
35
+ ## Tool execution modes
36
+
37
+ Phronomy exposes two execution modes for capabilities:
38
+
39
+ - `:cooperative` — short EventLoop-safe work, or a specialized asynchronous Tool
40
+ that starts another Phronomy lifecycle and returns immediately.
41
+ - `:offloaded` — synchronous work that must not run to completion on EventLoop.
42
+
43
+ Phronomy does not classify application work into framework-level I/O/CPU/process
44
+ execution modes. That workload classification and capacity planning belong to the
45
+ application.
46
+
47
+ A CPU-heavy operation may therefore use `:offloaded`, but thread offload does not
48
+ remove CRuby Global VM Lock contention or physical CPU contention.
49
+
50
+ ## Logical waiting versus offload
51
+
52
+ Do not offload a logical wait merely to make it asynchronous.
53
+
54
+ Prohibited shape:
55
+
56
+ ```text
57
+ OffloadPool worker
58
+ → child_agent.invoke_async
59
+ → wait_result
60
+ ```
61
+
62
+ Correct shape:
63
+
64
+ ```text
65
+ parent FSMSession
66
+ → start child lifecycle
67
+ → return immediately
68
+ → child settles
69
+ → post parent EventLoop event
70
+ ```
71
+
72
+ This distinction prevents worker-slot starvation when many logical lifecycles are
73
+ waiting at the same time.
74
+
75
+ ## Sync versus async application APIs
76
+
77
+ | Calling context | Recommended approach |
78
+ |---|---|
79
+ | Top-level application code | `agent.invoke(...)` when blocking the caller is acceptable |
80
+ | Top-level explicit async | `task = agent.invoke_async(...)`; optionally `task.wait_result` outside EventLoop |
81
+ | Workflow entry/transition action | Start async work and continue through `Workflow#signal` |
82
+ | EventLoop callback | Never block waiting for a Task that requires EventLoop progress |
83
+ | Top-level streaming | `agent.stream(...)` |
84
+ | Non-blocking streaming | `agent.stream_async(...)` |
85
+ | Approval from EventLoop callback | `approve_async` |
86
+
87
+ Blocking synchronous APIs reject EventLoop re-entry with
88
+ `Phronomy::EventLoopReentrancyError` when waiting would stall the same EventLoop
89
+ needed for progress.
90
+
91
+ ## Task
92
+
93
+ `Phronomy::Task` is thread-free. It represents one terminal result:
94
+
95
+ - completed value,
96
+ - failure,
97
+ - cancellation.
98
+
99
+ `Task#wait_result(timeout:)` is a bridge for external synchronous callers. It is
100
+ not the framework continuation mechanism.
101
+
102
+ `Task#map` is application-level composition. A transformation exception settles
103
+ the mapped Task as failed. This is different from independent notification
104
+ callbacks, described below.
105
+
106
+ ## OffloadPool
107
+
108
+ `OffloadPool` is a bounded worker pool for synchronous work that must not execute
109
+ on EventLoop.
110
+
111
+ Its guarantees include:
112
+
113
+ - bounded worker count,
114
+ - bounded queue depth,
115
+ - queue backpressure,
116
+ - operation-wide submit timeout/cancellation settlement,
117
+ - abandoned-worker accounting,
118
+ - runtime metrics,
119
+ - shutdown/drain behavior.
120
+
121
+ It does not guarantee CPU/I/O fairness or CPU isolation. Applications that need
122
+ resource isolation can create named Runtime pools.
123
+
124
+ ### EventLoop queue admission
125
+
126
+ Framework-owned EventLoop-origin submissions must not wait for a free worker
127
+ queue slot. They use non-blocking admission (`on_full: :raise`) and route
128
+ `BackpressureError` through the ordinary FSM/completion path.
129
+
130
+ External management threads may choose a blocking admission policy when blocking
131
+ the caller is acceptable.
132
+
133
+ ## PendingOperation and blocking_wait
134
+
135
+ `OffloadPool#submit` returns a private `PendingOperation` immediately after queue
136
+ admission.
137
+
138
+ `PendingOperation#blocking_wait(timeout:)` is intentionally a **low-level
139
+ synchronous bridge** for non-EventLoop callers such as tests and diagnostics.
140
+ The timeout belongs only to that waiter:
141
+
142
+ - it raises `TimeoutError` to that calling thread,
143
+ - it does not settle the PendingOperation,
144
+ - it does not cancel the submitted operation,
145
+ - it does not mark the operation abandoned.
146
+
147
+ There is no waiter-local `cancellation_token:` argument. Operation-wide
148
+ cancellation belongs exclusively to `OffloadPool#submit(cancellation_token:)`.
149
+
150
+ ## Submit timeout and cancellation
151
+
152
+ Submit-time timeout and submit cancellation settle the caller-facing operation.
153
+ They do **not** asynchronously interrupt an already-running synchronous worker.
154
+
155
+ ### Before worker start
156
+
157
+ If timeout/cancellation wins before execution starts:
158
+
159
+ - the PendingOperation settles,
160
+ - the submitted block does not run,
161
+ - the operation is not counted as abandoned.
162
+
163
+ ### After worker start
164
+
165
+ If timeout/cancellation wins after execution starts:
166
+
167
+ - the PendingOperation settles immediately,
168
+ - the operation is marked abandoned,
169
+ - the worker is allowed to continue until its synchronous call returns,
170
+ - the eventual worker result is discarded.
171
+
172
+ Phronomy does not use `Thread#raise` to inject an exception into the worker.
173
+ Application/library code that needs hard or transport-level deadlines should use
174
+ its native timeout or, in the future, an appropriate process-isolation mechanism.
175
+
176
+ ### CancellationToken deadlines
177
+
178
+ `CancellationToken.timeout_after(seconds)` uses a monotonic deadline.
179
+ `cancelled?` becomes true after that deadline, but the token itself does not own a
180
+ Thread.
181
+
182
+ Components requiring callback delivery for a monotonic deadline must promote the
183
+ deadline to explicit `cancel!` through the Runtime timer queue. OffloadPool does
184
+ this for its submit cancellation token.
185
+
186
+ `CancellationScope#deadline_in` is appropriate when the application needs a
187
+ Runtime-timer-backed cancellation scope whose `on_cancel` subscribers are fired
188
+ on expiry.
189
+
190
+ ## Independent notification callbacks
191
+
192
+ Independent notification fan-out is fault-isolated.
193
+
194
+ The rule applies to:
195
+
196
+ - `CancellationToken#on_cancel`,
197
+ - `Task#on_complete`,
198
+ - `PendingOperation#on_complete`,
199
+ - EventLoop timer callbacks.
200
+
201
+ A `StandardError` from one independent subscriber is logged and does not suppress
202
+ later subscribers.
203
+
204
+ This is deliberately different from a continuation/transform such as
205
+ `Task#map`: a transform exception is the outcome of the derived operation and is
206
+ therefore propagated into that derived Task.
207
+
208
+ Callback execution thread is not guaranteed for low-level completion handles.
209
+ Callbacks must therefore be thread-safe and should complete quickly. Framework
210
+ lifecycle code normally turns completion into an explicit EventLoop event rather
211
+ than mutating unrelated logical state from a worker thread.
212
+
213
+ ## Abandoned-worker metrics
214
+
215
+ Two metrics answer different operational questions:
216
+
217
+ - `offload_pool_abandoned_total` — cumulative count of operations that became
218
+ abandoned after worker execution had started.
219
+ - `offload_pool_abandoned_active` — current number of abandoned operations whose
220
+ synchronous workers still occupy pool capacity.
221
+
222
+ Example:
223
+
224
+ ```text
225
+ offload_pool_size = 10
226
+ offload_pool_active = 10
227
+ offload_pool_abandoned_active = 8
228
+ offload_pool_abandoned_total = 523
229
+ offload_pool_queue_length = 40
230
+ ```
231
+
232
+ This means 10 workers are currently executing, 8 of them are doing work whose
233
+ caller-facing result has already been abandoned, 523 abandonment events have
234
+ occurred since process start, and 40 operations are queued.
235
+
236
+ `Phronomy::Diagnostics.dump` exposes the same distinction for point-in-time
237
+ troubleshooting.
238
+
239
+ ## EventLoop metrics
240
+
241
+ `Phronomy::Metrics.snapshot` also reports EventLoop queue depth and lag values.
242
+ Use these to distinguish worker saturation from EventLoop backlog/latency.
243
+
244
+ ## Shutdown
245
+
246
+ `Runtime#shutdown` is terminal for that Runtime. It drains/terminates the
247
+ Runtime-owned EventLoop, then closes pools and timers according to the Runtime
248
+ shutdown contract.
249
+
250
+ `Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
251
+ Runtime shutdown before resetting configuration.
252
+
253
+ ## Further design records
254
+
255
+ The `docs/decisions/` directory contains the historical and current Architecture
256
+ Decision Records (ADRs). When an older ADR is superseded, use the superseding
257
+ section/current ADR as the active design contract and keep the earlier document
258
+ as historical rationale.