phronomy 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -91,9 +91,10 @@ stub_agent_class = Class.new(Phronomy::Agent::Base) do
|
|
|
91
91
|
define_method(:invoke) do |_input, messages: [], thread_id: nil, config: {}|
|
|
92
92
|
{output: "stub", messages: []}
|
|
93
93
|
end
|
|
94
|
+
define_method(:invoke_async) { |input, **_kw| Phronomy::Runtime.instance.spawn(name: "bench-stub") { invoke(input) } }
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
orchestrator_class = Class.new(Phronomy::
|
|
97
|
+
orchestrator_class = Class.new(Phronomy::MultiAgent::Orchestrator)
|
|
97
98
|
orchestrator = orchestrator_class.new
|
|
98
99
|
|
|
99
100
|
PARALLEL_ITERATIONS = 200
|
|
@@ -108,7 +109,7 @@ end
|
|
|
108
109
|
# ---------------------------------------------------------------------------
|
|
109
110
|
# Target 5: CancellationToken#cancelled? throughput (8 threads)
|
|
110
111
|
# ---------------------------------------------------------------------------
|
|
111
|
-
CANCEL_TOKEN = Phronomy::CancellationToken.new
|
|
112
|
+
CANCEL_TOKEN = Phronomy::Concurrency::CancellationToken.new
|
|
112
113
|
CANCEL_ITERATIONS = 10_000
|
|
113
114
|
|
|
114
115
|
t5 = Benchmark.measure("CancellationToken#cancelled? (8 threads)") do
|
|
@@ -121,7 +122,7 @@ end
|
|
|
121
122
|
# ---------------------------------------------------------------------------
|
|
122
123
|
# Target 6: CancellationToken#raise_if_cancelled! hot path (no-op, single thread)
|
|
123
124
|
# ---------------------------------------------------------------------------
|
|
124
|
-
RAISE_TOKEN = Phronomy::CancellationToken.new # not cancelled — no-op path
|
|
125
|
+
RAISE_TOKEN = Phronomy::Concurrency::CancellationToken.new # not cancelled — no-op path
|
|
125
126
|
RAISE_ITERATIONS = 200_000
|
|
126
127
|
|
|
127
128
|
t6 = Benchmark.measure("CancellationToken#raise_if_cancelled! (no-op)") do
|
|
@@ -134,12 +135,12 @@ end
|
|
|
134
135
|
BenchMsg = Struct.new(:content) unless defined?(BenchMsg)
|
|
135
136
|
|
|
136
137
|
TRIM_ELEMENTS = Array.new(2_000) { |i| {seq: i, message: BenchMsg.new("msg #{i}"), tokens: 10, role: :user} }
|
|
137
|
-
TRIM_BUDGET = Phronomy::
|
|
138
|
+
TRIM_BUDGET = Phronomy::LlmContextWindow::TokenBudget.new(context_window: 4096, max_output_tokens: 512)
|
|
138
139
|
TRIM_ITERATIONS = 500
|
|
139
140
|
|
|
140
141
|
t7 = Benchmark.measure("TrimContext#remove (2000-element history)") do
|
|
141
142
|
TRIM_ITERATIONS.times do
|
|
142
|
-
tc = Phronomy::Context::TrimContext.new(message_elements: TRIM_ELEMENTS, budget: TRIM_BUDGET)
|
|
143
|
+
tc = Phronomy::Agent::Context::Conversation::TrimContext.new(message_elements: TRIM_ELEMENTS, budget: TRIM_BUDGET)
|
|
143
144
|
tc.remove((0...200).to_a) # remove 200 oldest messages
|
|
144
145
|
end
|
|
145
146
|
end
|
|
@@ -23,22 +23,22 @@ BENCH_TOKEN_ITERATIONS = 10_000
|
|
|
23
23
|
puts "=== bench_token_estimator ==="
|
|
24
24
|
Benchmark.bm(30) do |x|
|
|
25
25
|
x.report("estimate(short text)") do
|
|
26
|
-
BENCH_TOKEN_ITERATIONS.times { Phronomy::
|
|
26
|
+
BENCH_TOKEN_ITERATIONS.times { Phronomy::LlmContextWindow::TokenEstimator.estimate(SHORT_TEXT) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
x.report("estimate(medium text 500c)") do
|
|
30
|
-
BENCH_TOKEN_ITERATIONS.times { Phronomy::
|
|
30
|
+
BENCH_TOKEN_ITERATIONS.times { Phronomy::LlmContextWindow::TokenEstimator.estimate(MEDIUM_TEXT) }
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
x.report("estimate(long text 10k c)") do
|
|
34
|
-
BENCH_TOKEN_ITERATIONS.times { Phronomy::
|
|
34
|
+
BENCH_TOKEN_ITERATIONS.times { Phronomy::LlmContextWindow::TokenEstimator.estimate(LONG_TEXT) }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
x.report("estimate(100 messages)") do
|
|
38
|
-
BENCH_TOKEN_ITERATIONS.times { Phronomy::
|
|
38
|
+
BENCH_TOKEN_ITERATIONS.times { Phronomy::LlmContextWindow::TokenEstimator.estimate(MESSAGES_100) }
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
x.report("estimate(1000 messages)") do
|
|
42
|
-
(BENCH_TOKEN_ITERATIONS / 10).times { Phronomy::
|
|
42
|
+
(BENCH_TOKEN_ITERATIONS / 10).times { Phronomy::LlmContextWindow::TokenEstimator.estimate(MESSAGES_1000) }
|
|
43
43
|
end
|
|
44
44
|
end
|
|
@@ -43,7 +43,7 @@ end
|
|
|
43
43
|
|
|
44
44
|
# --- static_knowledge_chunks cache ---
|
|
45
45
|
|
|
46
|
-
class BenchKnowledgeSource < Phronomy::
|
|
46
|
+
class BenchKnowledgeSource < Phronomy::Agent::Context::Knowledge::Source::Base
|
|
47
47
|
def fetch(query: nil)
|
|
48
48
|
[{content: "Cached knowledge fact.", type: :static}]
|
|
49
49
|
end
|
|
@@ -28,7 +28,7 @@ BENCH_VS_ITERS = {100 => 100, 1_000 => 20, 10_000 => 5}.freeze
|
|
|
28
28
|
puts "=== bench_vector_store_inmemory ==="
|
|
29
29
|
Benchmark.bm(35) do |x|
|
|
30
30
|
[100, 1_000, 10_000].each do |n|
|
|
31
|
-
store = Phronomy::VectorStore::InMemory.new(dimension: DIM)
|
|
31
|
+
store = Phronomy::Agent::Context::Knowledge::VectorStore::InMemory.new(dimension: DIM)
|
|
32
32
|
populate(store, n)
|
|
33
33
|
iters = BENCH_VS_ITERS[n]
|
|
34
34
|
|
|
@@ -49,3 +49,27 @@ transport layer participation.
|
|
|
49
49
|
- Users who expect "cancel" semantics from a timeout will be surprised.
|
|
50
50
|
- Proper cancellation requires the `CancellationToken` feature (#216), which
|
|
51
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.
|
|
75
|
+
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# ADR-006: Built-in Guardrail Implementations
|
|
1
|
+
# ADR-006: Minimal Built-in Guardrail Implementations
|
|
2
2
|
|
|
3
3
|
## Status
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Amended (see Amendment section below)
|
|
6
6
|
|
|
7
7
|
## Context
|
|
8
8
|
|
|
@@ -46,3 +46,21 @@ Users are responsible for implementing domain-specific guardrail logic.
|
|
|
46
46
|
**Negative / Tradeoffs:**
|
|
47
47
|
- Users must implement their own guardrails from scratch. Providing a cookbook
|
|
48
48
|
of example patterns in the README partially mitigates this.
|
|
49
|
+
|
|
50
|
+
## Amendment — `PromptInjectionGuardrail` Added
|
|
51
|
+
|
|
52
|
+
After the original decision was accepted, `Guardrail::PromptInjectionGuardrail`
|
|
53
|
+
was introduced as the **one exception** to the "no built-ins" rule.
|
|
54
|
+
|
|
55
|
+
**Rationale for the exception:**
|
|
56
|
+
- Prompt injection patterns are broadly applicable across almost all LLM
|
|
57
|
+
applications regardless of domain, unlike PII patterns which are locale-specific.
|
|
58
|
+
- A lightweight, pure-regex implementation has no third-party dependency and
|
|
59
|
+
adds negligible gem weight.
|
|
60
|
+
- It serves as a documented reference implementation that users can subclass with
|
|
61
|
+
`extra_patterns:` to extend.
|
|
62
|
+
|
|
63
|
+
**Scope of the exception:**
|
|
64
|
+
Only prompt-injection detection is provided as a built-in. PII scanning,
|
|
65
|
+
content classification, and toxic-content filtering remain out of scope per the
|
|
66
|
+
original decision.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# ADR-010: Cooperative-First Concurrency — BlockingAdapterPool for Uncontrollable I/O
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted — updated 2026-05-25 to document current scheduler landscape and
|
|
6
|
+
production-cooperative roadmap (Issues #331, #332, #334).
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
Phronomy provides its own concurrency primitives:
|
|
11
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Decision
|
|
40
|
+
|
|
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
|
|
60
|
+
```
|
|
61
|
+
|
|
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 }
|
|
80
|
+
```
|
|
81
|
+
|
|
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
|
|
102
|
+
|
|
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`.
|
|
106
|
+
|
|
107
|
+
```
|
|
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
|
+
|
|
184
|
+
## Consequences
|
|
185
|
+
|
|
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
|
+
|