phronomy 0.16.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -2,7 +2,12 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted
5
+ Superseded by ADR-013.
6
+
7
+ This ADR records the historical decision for the former KnowledgeSource-based
8
+ architecture. `static_knowledge`, `KnowledgeSource`, `StaticKnowledge`,
9
+ `EntityKnowledge` and the class-level Knowledge cache are no longer part of the
10
+ active design.
6
11
 
7
12
  ## Context
8
13
 
@@ -43,3 +48,9 @@ re-fetched on each invocation because their content depends on runtime state.
43
48
  non-static knowledge source.
44
49
  - In tests, the cache must be cleared between examples. `Phronomy.reset_runtime!`
45
50
  handles this.
51
+
52
+ ## Supersession
53
+
54
+ ADR-013 replaces the source-object/cache model with Journal-backed persistent
55
+ Knowledge selected through Context Policy. The historical rationale above is
56
+ retained only to explain the superseded architecture.
@@ -2,51 +2,49 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted
6
-
7
- ## Context
8
-
9
- `Agent::Orchestrator#dispatch_parallel` runs multiple sub-agent invocations
10
- concurrently. The Ruby concurrency primitives available are:
11
-
12
- 1. **OS threads** (`Thread`): true OS-level threads, subject to Ruby's GVL for
13
- CPU-bound work, but I/O-bound work (LLM API calls, tool HTTP requests)
14
- releases the GVL and runs in parallel.
15
- 2. **Ractors**: actor-model isolation, no shared mutable state between Ractors.
16
- True parallel for CPU-bound work but requires strict object isolation.
17
- 3. **Fibers / async**: cooperative concurrency via Fiber scheduler (e.g.,
18
- `async` gem). Non-blocking I/O without multiple threads.
19
- 4. **`concurrent-ruby` thread pool**: managed pool of OS threads.
20
-
21
- LLM calls and tool invocations are overwhelmingly I/O-bound (HTTP requests).
22
- Under the GVL, OS threads are sufficient to achieve meaningful parallelism for
23
- these workloads. Ractors require that all objects passed between them are
24
- shareable, which is incompatible with RubyLLM's mutable chat objects and
25
- `WorkflowContext` instances without significant refactoring.
26
-
27
- Fibers require an async-compatible HTTP library stack throughout (RubyLLM,
28
- Faraday, etc.), which is not guaranteed today.
29
-
30
- ## Decision
31
-
32
- `dispatch_parallel` spawns one OS thread per task using Ruby's `Thread.new`.
33
- A `max_concurrency:` cap (default: unlimited) uses a `Mutex`-guarded counter to
34
- limit the number of simultaneously active threads when specified.
35
-
36
- ## Consequences
37
-
38
- **Positive:**
39
- - Transparent parallelism for I/O-bound LLM/tool calls with no dependency
40
- changes.
41
- - Compatible with all Ruby versions in the support matrix (3.2, 3.3, 3.4, head).
42
- - Simple to reason about: each task is an independent thread; results are
43
- collected in input order.
44
-
45
- **Negative / Tradeoffs:**
46
- - CPU-bound work inside agents does not benefit from true parallelism due to
47
- the GVL. (In practice, agents are almost always I/O-bound.)
48
- - Spawning many threads simultaneously (no `max_concurrency:`) can exhaust
49
- system thread limits under high load. Users should set `max_concurrency:` for
50
- large fan-outs.
51
- - Ractor-based isolation (if ever needed for security sandboxing) would require
52
- significant API changes to `WorkflowContext` and RubyLLM integration.
5
+ **Superseded by ADR-010 on 2026-08-13.**
6
+
7
+ This file is retained as decision history. Its original implementation — one OS
8
+ Thread per `dispatch_parallel` child — is no longer the active architecture.
9
+
10
+ ## Historical context
11
+
12
+ The original Orchestrator implementation needed concurrent subagent execution
13
+ before Phronomy had a common EventLoop/FSMSession control model. It therefore
14
+ used `Thread.new` per child Agent, optionally bounded by `max_concurrency`.
15
+
16
+ That choice provided straightforward parallelism for I/O-heavy Agent calls but
17
+ also tied logical child concurrency directly to OS-thread count.
18
+
19
+ ## Superseding decision
20
+
21
+ Phronomy now models fan-out as a parent `FanOutInvocation` / FSMSession:
22
+
23
+ ```text
24
+ FanOut FSMSession
25
+ |
26
+ +-- start child Agent A asynchronously
27
+ +-- start child Agent B asynchronously
28
+ +-- start child Agent C asynchronously
29
+ |
30
+ +-- child completion events
31
+ |
32
+ +-- aggregate / fail / timeout / cancel
33
+ ```
34
+
35
+ `max_concurrency` limits how many child Agent invocations are active. It does
36
+ not create a corresponding set of Threads.
37
+
38
+ Each child Agent runs through the common Agent FSMSession/EventLoop lifecycle.
39
+ When a child performs synchronous work that must not run on EventLoop, only that
40
+ synchronous operation is executed on the bounded `OffloadPool`.
41
+
42
+ Therefore:
43
+
44
+ - `dispatch_parallel` does not create raw Threads;
45
+ - no `Runtime#spawn` or Task execution backend is involved;
46
+ - child completion is delivered by EventLoop events;
47
+ - result ordering, fail/skip policy, timeout, and cancellation belong to the
48
+ FanOut state machine.
49
+
50
+ See ADR-010 for the current concurrency model and Thread boundary.
@@ -1,248 +1,175 @@
1
- # ADR-010: Cooperative-First Concurrency BlockingAdapterPool for Uncontrollable I/O
1
+ # ADR-010: EventLoop / FSMSession First Concurrency
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted — updated 2026-05-25 to document current scheduler landscape and
6
- production-cooperative roadmap (Issues #331, #332, #334).
5
+ Accepted — revised for the OffloadPool execution boundary.
7
6
 
8
7
  ## Context
9
8
 
10
- Phronomy provides its own concurrency primitives:
9
+ Phronomy must support many concurrently waiting Agent, Workflow, ToolInvocation,
10
+ and MultiAgent lifecycles without allocating one OS Thread per logical task.
11
+ At the same time, application and third-party code may contain synchronous work
12
+ that must not execute on the single Runtime EventLoop thread.
11
13
 
12
- - **`Phronomy::Runtime`** task scheduler; backend is configurable.
13
- The current production default is `:thread` (`ThreadScheduler` / `ThreadBackend`).
14
- See the backend landscape table below for all supported values.
15
- *(Historical note: early versions used `:cooperative` as the default; it is now
16
- a deprecated alias for `:immediate`.)*
17
- - **`Phronomy::EventLoop`** — singleton event dispatcher; drives the cooperative
18
- task cycle.
19
-
20
- Early implementations occasionally forced `ThreadBackend` inside framework
21
- components (e.g., `Agent::FSM#spawn_agent_task` in commit `0cb8510`) to work
22
- around a perceived limitation: if an agent makes a blocking I/O call with the
23
- cooperative backend active, it blocks the calling thread.
24
-
25
- This approach was identified as wrong for two reasons:
26
-
27
- 1. **Misplaced responsibility** — threading the *application's* I/O at the
28
- *framework* layer prevents the `runtime_backend` configuration from being
29
- honored and removes the app developer's control over the concurrency model.
30
- 2. **Violation of layering** — the framework should not assume that its callers
31
- are cooperative-scheduler-aware; it should provide primitives and let the
32
- caller decide the backend.
33
-
34
- There is a separate, legitimate category of blocking I/O: third-party gems
35
- (RubyLLM, ActiveRecord, Redis, Faraday, etc.) that perform blocking system
36
- calls internally and cannot be made non-blocking from the Phronomy side.
37
- These must be handled differently from application-controlled I/O.
14
+ Classifying arbitrary application work as I/O-bound, CPU-bound, or external
15
+ process work is not a responsibility the framework can reliably infer. The
16
+ architecturally relevant distinction for Phronomy is whether a unit of work can
17
+ safely run to completion on EventLoop or must be moved off the control thread.
38
18
 
39
19
  ## Decision
40
20
 
41
- ### Runtime backend landscape
42
-
43
- | Backend | Scheduler class | Role | Production use? |
44
- |---------|-----------------|------|----------------|
45
- | `:thread` | `ThreadScheduler` / `ThreadBackend` | **Default.** One OS thread per task. Provides true parallelism for blocking I/O workflows. | Yes |
46
- | `:immediate` | `FakeScheduler` / `ImmediateBackend` | **Unit test double.** Tasks run synchronously on the caller's thread; no extra threads. | Tests only |
47
- | `:fiber` | `DeterministicScheduler` / `FiberBackend` | **Experimental validation backend.** Runs tasks as Ruby Fibers to verify that framework components are truly non-blocking. Use in CI to catch inadvertent blocking; never use in production. Not a planned production replacement for `:thread`; preemptive scheduling will not be added. | No |
48
-
49
- Note: `:cooperative` is a deprecated alias for `:immediate` and must not be used in new code.
50
-
51
- ### Rule 1 — Cooperative-first for core control
52
-
53
- The core control flow of every Phronomy component **Agent, Workflow, Tool
54
- orchestration, Orchestrator, RAG pipeline, Streaming** MUST be implemented
55
- using cooperative task / event / scheduler primitives:
56
-
57
- ```
58
- Runtime.instance.spawn(name: "...") { ... } # respects configured backend
59
- EventLoop.instance.post { ... } # event dispatch
21
+ Framework lifecycle coordination uses one Runtime-owned EventLoop and explicit
22
+ FSMSession state/events. **Task is a completion handle**, not an execution
23
+ backend.
24
+
25
+ Phronomy defines two Tool execution modes:
26
+
27
+ - `:cooperative` short, EventLoop-safe work, or a specialized asynchronous
28
+ implementation that starts another Phronomy lifecycle and immediately returns
29
+ a completion handle.
30
+ - `:offloaded` — synchronous work that must not run on EventLoop. It executes in
31
+ the bounded `OffloadPool`.
32
+
33
+ `OffloadPool` is the thread execution boundary for synchronous work that must be
34
+ kept off EventLoop. It may contain blocking I/O, CPU-bound Ruby work, or other
35
+ application-defined long-running synchronous calls.
36
+
37
+ Workload classification such as I/O-bound versus CPU-bound is application-owned.
38
+ Phronomy does not provide separate `:blocking_io`, `:cpu_bound`, or
39
+ `:external_process` Tool execution modes.
40
+
41
+ Logical waits are never offloaded merely to obtain concurrency. Waiting for an
42
+ Agent, Workflow, ToolInvocation, approval, timer, or another Task is represented
43
+ as FSMSession state plus a later EventLoop event.
44
+
45
+ ## Runtime model
46
+
47
+ ```text
48
+ Runtime
49
+ ├─ EventLoop (one control-plane OS Thread)
50
+ │ └─ FSMSession
51
+ │ ├─ Agent
52
+ │ ├─ Workflow
53
+ │ ├─ ToolInvocation
54
+ │ └─ MultiAgent fan-out
55
+ ├─ OffloadPool (bounded OS Threads)
56
+ │ ├─ blocking I/O
57
+ │ ├─ CPU-bound synchronous work
58
+ │ └─ other long synchronous work
59
+ ├─ named OffloadPools
60
+ └─ EventLoop-driven timers
61
+
62
+ Task = completion handle
60
63
  ```
61
64
 
62
- Do NOT force `ThreadBackend` or `ThreadScheduler` inside framework components
63
- unless Rule 2 explicitly applies.
64
-
65
- ### Rule 2 ThreadScheduler only for framework-owned infinite loops
66
-
67
- A framework component MAY use a dedicated `ThreadScheduler` (or `ThreadBackend`)
68
- if and only if **not** threading would unconditionally block the framework's own
69
- infinite loop.
70
-
71
- The only current example satisfying this criterion:
72
-
73
- ```ruby
74
- # EventLoop#start — the run_loop is the framework's own infinite dispatch loop.
75
- # It MUST run in its own thread; the cooperative backend cannot yield itself.
76
- thread_runtime = Phronomy::Runtime.new(
77
- scheduler: Phronomy::Runtime::ThreadScheduler.new
78
- )
79
- @task = thread_runtime.spawn(name: "event-loop") { run_loop }
65
+ ## Capacity and starvation
66
+
67
+ The default OffloadPool is a shared bounded resource. CPU-heavy work can occupy
68
+ slots that would otherwise be available to I/O, and slow I/O can do the same in
69
+ reverse. Phronomy guarantees bounded worker count, bounded queue depth,
70
+ backpressure, timeout/cancellation settlement, abandoned-worker accounting,
71
+ metrics, and lifecycle shutdown. It does **not** guarantee work-class fairness,
72
+ CPU isolation, core reservation, or CPU-bound speedup.
73
+
74
+ Applications own capacity planning through `offload_pool_size` and
75
+ `offload_queue_size`. Where isolation is required, applications may use
76
+ `Runtime#pool(name, size:, queue_size:)` to create independent named OffloadPool
77
+ resource domains.
78
+
79
+ ## EventLoop admission rule
80
+
81
+ An EventLoop action must not block while waiting for a free OffloadPool queue
82
+ slot. Framework-owned EventLoop-origin submissions therefore use non-blocking
83
+ admission (`on_full: :raise`) and propagate `BackpressureError` through the
84
+ normal FSM/completion path.
85
+
86
+ External management threads may deliberately choose other admission policies
87
+ when blocking the caller is acceptable.
88
+
89
+ ## Timeout and cancellation
90
+
91
+ An OffloadPool submit-time timeout settles the caller-facing PendingOperation.
92
+ It does not asynchronously interrupt a running worker Thread. If execution has
93
+ already started, the operation becomes abandoned, the worker may continue until
94
+ the submitted synchronous call returns, and that eventual worker result is
95
+ discarded.
96
+
97
+ The cancellation token passed to `OffloadPool#submit` follows the same
98
+ caller-facing settlement model:
99
+
100
+ - cancellation before worker execution prevents the submitted block from
101
+ starting;
102
+ - cancellation after worker execution starts settles the caller-facing
103
+ PendingOperation immediately, marks the operation abandoned, and allows the
104
+ worker to continue until the synchronous call returns;
105
+ - cancellation does not use `Thread#raise`;
106
+ - application code may observe the same CancellationToken and terminate its own
107
+ synchronous operation cooperatively.
108
+
109
+ A submit token with a monotonic deadline is connected to the Runtime timer queue,
110
+ so deadline expiry becomes explicit cancellation without a polling Thread.
111
+
112
+ `PendingOperation#blocking_wait(timeout:)` is a low-level synchronous bridge for
113
+ non-EventLoop callers such as tests and diagnostics. Its timeout is waiter-local:
114
+ it raises `TimeoutError` only to that caller and does not settle the
115
+ PendingOperation, cancel the submitted operation, or mark it abandoned.
116
+ PendingOperation does not define a waiter-local cancellation token;
117
+ operation-wide cancellation is represented only by the token passed to
118
+ `OffloadPool#submit`.
119
+
120
+ Independent notification callbacks are fault-isolated. A `StandardError` from one
121
+ `CancellationToken#on_cancel`, `Task#on_complete`, or
122
+ `PendingOperation#on_complete` subscriber is logged and does not suppress later
123
+ subscribers. This rule applies to notification fan-out; continuation or
124
+ transformation callbacks still report their own failures through the operation
125
+ they construct.
126
+
127
+ `abandoned_count` and the exported `offload_pool_abandoned_total` metric are
128
+ cumulative: they count operations whose caller-facing submit timeout or submit
129
+ cancellation settled after worker execution had already started.
130
+ `abandoned_active_count` and `offload_pool_abandoned_active` are current-state
131
+ values: they count only abandoned operations whose synchronous worker is still
132
+ occupying OffloadPool capacity.
133
+
134
+ ## CPU-bound work
135
+
136
+ CPU-bound work is allowed through `:offloaded`. Thread offload protects the
137
+ EventLoop from direct long synchronous execution but does not remove CRuby GVL
138
+ contention or physical CPU contention. Those are explicitly outside the core
139
+ OffloadPool guarantee.
140
+
141
+ A future subprocess capability may provide process isolation, hard process
142
+ termination, stdout/stderr capture, and CPU-worker separation. That future
143
+ implementation belongs to the offload subsystem and does not reintroduce a
144
+ Tool-level `:external_process` execution class.
145
+
146
+ ## Prohibited pattern
147
+
148
+ ```text
149
+ OffloadPool worker
150
+ → child_agent.invoke_async
151
+ → wait_result
80
152
  ```
81
153
 
82
- **Legitimate ThreadScheduler exceptions (exhaustive list):**
83
-
84
- | Component | Reason |
85
- |---|---|
86
- | `EventLoop#start` | `run_loop` is the framework's own `while @running` infinite dispatch loop; running it on the shared scheduler would consume the scheduler, preventing all other tasks from running |
87
-
88
- **Handler constraints for EventLoop:**
89
-
90
- - Handler code runs **on the EventLoop thread**. Do not perform blocking
91
- operations (database, LLM, HTTP) directly inside a handler — this stalls all
92
- session processing.
93
- - Do **not** call `Workflow#invoke` from within a handler. That call blocks
94
- until the EventLoop processes events, causing a deadlock. Use the async
95
- pattern: schedule work via `Runtime.instance.spawn` or `BlockingAdapterPool`,
96
- then post results back with `EventLoop#post`.
97
-
98
- All other framework components — including FSM, orchestration, RAG, streaming —
99
- do NOT own an infinite loop and therefore MUST use `Runtime.instance.spawn`.
100
-
101
- ### Rule 3 — BlockingAdapterPool for uncontrollable blocking I/O
154
+ and equivalently for Workflow/ToolInvocation/Task lifecycles.
102
155
 
103
- Third-party gems whose internal I/O Phronomy cannot control (RubyLLM, ActiveRecord,
104
- Redis client, Faraday, etc.) MUST be isolated behind a bounded
105
- `BlockingAdapterPool`.
156
+ That pattern converts a logical wait into worker-slot occupancy and can create
157
+ pool starvation. The correct model is:
106
158
 
159
+ ```text
160
+ parent FSMSession
161
+ → start child lifecycle
162
+ → return completion handle immediately
163
+ → child settles
164
+ → post parent EventLoop event
107
165
  ```
108
- ┌─────────────────────────────────────┐
109
- │ Cooperative EventLoop / Runtime │
110
- │ (FakeScheduler, ImmediateBackend) │
111
- │ │
112
- │ Agent ──► BlockingAdapterPool ──► RubyLLM (HTTP)
113
- │ RAG ──► BlockingAdapterPool ──► ActiveRecord / Redis
114
- └─────────────────────────────────────┘
115
-
116
- ┌─────────┴─────────┐
117
- │ Thread pool │ ← bounded (max_threads:)
118
- │ (OS threads) │
119
- └───────────────────┘
120
- ```
121
-
122
- Properties of `BlockingAdapterPool`:
123
- - Uses OS threads internally (they are the correct tool for I/O that releases
124
- the GVL).
125
- - **Always bounded** — configurable `max_threads:` (no unbounded `Thread.new`).
126
- - Returns a `Phronomy::Task`-compatible future so the cooperative layer can
127
- await results without blocking the EventLoop.
128
- - Is the **only** place in the framework that creates raw threads for I/O.
129
-
130
- > **Note:** `dispatch_parallel` (ADR-008) currently creates one thread per
131
- > sub-agent via `Thread.new`. This predates the `BlockingAdapterPool` concept
132
- > and is a candidate for future migration. Until that migration, it remains an
133
- > accepted exception per ADR-008.
134
-
135
- ## Scheduler Landscape (as of 2026-05-25)
136
-
137
- Three scheduler backends currently exist:
138
-
139
- | Symbol / Class | Status | Purpose |
140
- |---|---|---|
141
- | `:thread` — `ThreadScheduler` / `ThreadBackend` | **Production default** | One OS thread per task; GVL-releasing I/O works transparently |
142
- | `:immediate` — `FakeScheduler` / `ImmediateBackend` | Test / CI | Synchronous; block runs to completion before `spawn` returns; no threads |
143
- | `:fiber` — `DeterministicScheduler` / `FiberBackend` | **Experimental validation backend** | Tick-based Fiber scheduler with virtual clock; enables deterministic concurrency tests without wall-clock timers. Named backend added in Issue #334. |
144
-
145
- ### `:cooperative` deprecation
146
-
147
- `:cooperative` was a silent alias for `:immediate` (mapped to `FakeScheduler`).
148
- As of Issue #332, it now emits a `WARN`-level deprecation message and must not
149
- be used in new code. Issue #334 introduced `:fiber` as the named experimental
150
- validation backend; `:cooperative` is retained only for backwards compatibility.
151
-
152
- ### DeterministicScheduler as a stepping stone
153
-
154
- `DeterministicScheduler` is the foundation for the long-term production
155
- cooperative runtime goal. It provides:
156
-
157
- - Ready-queue dispatch, virtual timer heap, scheduler signal API
158
- - `FiberBackend` wrapping tasks as Fibers
159
- - `tick` / `run_until_idle` / `advance(seconds)` for deterministic test control
160
-
161
- **Remaining gaps versus a full production cooperative runtime:**
162
-
163
- - No real-wall-clock timer integration (`TimerQueue` runs as a background thread — Issue #331)
164
- - No `BlockingAdapterPool` integration for LLM/network calls (Issue #280)
165
- - Scheduler must be driven manually (`tick`) rather than event-loop-driven
166
-
167
- The minimum delta to promote `DeterministicScheduler` to production use:
168
-
169
- 1. Integrate `TimerQueue` into the scheduler `tick` cycle (eliminates timer thread — Issue #331)
170
- 2. Implement `BlockingAdapterPool` and wire LLM/tool calls through it (Issue #280)
171
- 3. ~~Expose as a named `runtime_backend` (`:fiber`)~~ — **done** (Issue #334)
172
- 4. Add real-wall-clock integration (replace virtual time with `Process.clock_gettime`)
173
-
174
- Progress is tracked in Issues #331 and #280.
175
-
176
- ### TimerQueue background thread
177
-
178
- `Runtime::TimerQueue` currently runs as a dedicated background OS thread
179
- (`Thread.new { run_loop }`). This is an accepted interim implementation — it is
180
- robust for production but violates the cooperative-first goal. The long-term
181
- target is to integrate timer firing into the scheduler's own `tick` cycle so
182
- that no separate timer thread is needed. Progress tracked in Issue #331.
183
166
 
184
167
  ## Consequences
185
168
 
186
- ### Positive
187
- truly means cooperative for all framework-controlled paths.
188
- - Thread creation is isolated and bounded; no accidental unbounded thread
189
- proliferation from deep inside the framework.
190
- - The concurrency model is layered and explicit:
191
- cooperative layer adapter boundary bounded thread pool → blocking gem.
192
- - Tests can reliably use `FakeScheduler` by default and opt in to `:thread`
193
- only where true concurrency is required.
194
- - `DeterministicScheduler` + `FiberBackend` provide a deterministic test
195
- foundation for cooperative concurrency without wall-clock timers.
196
-
197
- ### Negative / Tradeoffs
198
- - `BlockingAdapterPool` is not yet implemented (as of ADR-010 acceptance).
199
- Until it is, callers that need real blocking I/O must opt in to `:thread`
200
- backend explicitly (e.g., in tests via `around` blocks).
201
- - `dispatch_parallel` remains on raw threads (ADR-008) until migrated.
202
- - `TimerQueue` runs as a background OS thread (interim; see Issue #331).
203
- - `DeterministicScheduler` backs the named `:fiber` backend (Issue #334
204
- resolved). It remains experimental and not for production use; the remaining
205
- steps toward a full production cooperative runtime are `TimerQueue`
206
- integration (Issue #331) and `BlockingAdapterPool` completion (Issue #280).
207
-
208
- ## Derived Checklist
209
-
210
- When writing or reviewing any Phronomy component, apply this checklist:
211
-
212
- | Question | Correct action |
213
- |---|---|
214
- | Does this component own a `while true` / `loop do` that blocks forever? | May use dedicated `ThreadScheduler` (Rule 2) |
215
- | Does this component call into app/agent code (LLM, tools)? | `Runtime.instance.spawn` — no ThreadBackend (Rule 1) |
216
- | Does this component call a blocking gem (RubyLLM, AR, Redis)? | Route through `BlockingAdapterPool` (Rule 3) |
217
- | Is this a test that needs real concurrency (sleep + cancel)? | `around` block with `c.runtime_backend = :thread` opt-in |
218
- | Anything else | Default: `Runtime.instance.spawn` |
219
-
220
- ## Related ADRs and Issues
221
-
222
- - ADR-008: Orchestrator Uses OS Threads for Parallel Dispatch (predates this
223
- ADR; `dispatch_parallel` is a future migration candidate)
224
- - Issue #331: TimerQueue scheduler integration (long-term — eliminate timer thread)
225
- - Issue #334: Promote DeterministicScheduler to production cooperative runtime
226
- - Issue #332: `:cooperative` alias deprecation (resolved 2026-05-25)
227
- - Issue #280: MCP transports behind BlockingAdapterPool (pending)
228
-
229
- ## Non-goals
230
-
231
- The following capabilities are intentionally out of scope for this framework's
232
- concurrency layer:
233
-
234
- - **CPU-bound process pool** — A `ProcessPoolExecutor` equivalent is not part
235
- of core framework by default. CPU-intensive tool work belongs at the
236
- application layer (fork, Sidekiq, etc.). A separate ADR would be required
237
- to introduce one.
238
- - **External process manager** — Spawning, monitoring, or restarting external
239
- subprocesses is not currently a framework responsibility. A separate ADR
240
- would be required.
241
- - **Preemptive scheduling** — The cooperative-first model is non-preemptive by
242
- design. Introducing a preemptive scheduler or promoting `:fiber` to
243
- production default is not currently planned; a separate ADR would be required.
244
- - **Additional ToolExecutor core execution routes** — Only `:cooperative` and
245
- `:blocking_io` are core dispatch routes. `:cpu_bound` and `:external_process`
246
- are compatibility aliases that fall back to `:blocking_io` with a warning.
247
- Any genuinely new core execution route requires a new ADR.
248
-
169
+ - There is one explicit framework continuation model: FSMSession + EventLoop.
170
+ - `Task` stays thread-free and represents settlement only.
171
+ - Tool execution classification becomes `:cooperative` / `:offloaded`.
172
+ - CPU/I/O classification and resource sizing are application responsibilities.
173
+ - Named pools remain available for application-managed resource isolation.
174
+ - Production Fiber execution is not part of the architecture.
175
+ - Raw production Threads remain confined to EventLoop and OffloadPool.
@@ -2,13 +2,13 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Partially Superseded by ADR-012 — 2026-08-08
5
+ Superseded by ADR-012 — 2026-08-08
6
6
 
7
7
  Originally proposed — 2026-05-31.
8
8
 
9
9
  ## Supersession Note
10
10
 
11
- ADR-012, **Canonical Complete Execution Log and Context Policy**, supersedes the architectural parts of this ADR that treat the legacy `build_context` / `LlmContextWindow::Assembler` path as the long-term single authority for LLM input.
11
+ ADR-012, **Canonical Complete Execution Log and Context Policy**, supersedes this ADR as an active architecture contract. This document is retained as historical design analysis; references below to `build_context`, `context_overhead`, and `LlmContextWindow::Assembler` are non-normative.
12
12
 
13
13
  In particular, the following parts of this ADR are no longer normative for the stateful Agent architecture:
14
14