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
@@ -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.
@@ -19,18 +19,25 @@ class ImportContext
19
19
  end
20
20
  end
21
21
 
22
+ # Example application service for synchronous work that must stay off EventLoop.
23
+ # The worker Thread belongs to Phronomy's bounded OffloadPool; the Workflow
24
+ # itself never blocks EventLoop waiting for the operation.
25
+ def start_import_async
26
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
27
+ # Replace with blocking file/DB/network work or another long synchronous call.
28
+ 100
29
+ end
30
+ end
31
+
22
32
  workflow = nil
23
33
 
24
34
  workflow = Phronomy::Workflow.define(ImportContext) do
25
35
  initial :importing
26
36
 
27
37
  state :importing, action: ->(context) {
28
- task = Phronomy::Runtime.instance.spawn do
29
- # Replace with application-owned asynchronous work.
30
- 100
31
- end
38
+ operation = start_import_async
32
39
 
33
- task.on_complete do |record_count, error|
40
+ operation.on_complete do |record_count, error|
34
41
  workflow.signal(
35
42
  thread_id: context.thread_id,
36
43
  event: error ? :import_failed : :import_completed,
@@ -41,7 +48,8 @@ workflow = Phronomy::Workflow.define(ImportContext) do
41
48
  )
42
49
  end
43
50
 
44
- # Do not return task. The state is active after this synchronous entry ends.
51
+ # Do not return the completion handle. The state remains active after this
52
+ # synchronous action returns, and later completion arrives as an FSM event.
45
53
  context
46
54
  }
47
55
 
@@ -29,7 +29,6 @@ module Phronomy
29
29
  CALLBACK_FAILED_EVENTS = %i[application_callback_failed].freeze
30
30
 
31
31
  attr_accessor :input,
32
- :messages,
33
32
  :chat,
34
33
  :output,
35
34
  :usage,
@@ -59,18 +58,15 @@ module Phronomy
59
58
  def initialize(
60
59
  agent:,
61
60
  input:,
62
- messages:,
63
61
  config:,
64
62
  approval_policy: nil,
65
63
  approval_listener: nil,
66
64
  event_listener: nil,
67
- stream_listener: nil,
68
65
  mode: nil,
69
66
  id: nil
70
67
  )
71
68
  @agent = agent
72
69
  @input = input
73
- @messages = Array(messages)
74
70
  @config = config
75
71
  @thread_id = config[:thread_id]
76
72
  @id = (id || config[:agent_invocation_id] || SecureRandom.uuid).to_s
@@ -80,8 +76,8 @@ module Phronomy
80
76
  end
81
77
  @approval_policy = invocation_policy || approval_policy
82
78
  @approval_listener = approval_listener
83
- @event_listener = event_listener || stream_listener
84
- @mode = (mode || (stream_listener ? :stream : :invoke)).to_sym
79
+ @event_listener = event_listener
80
+ @mode = (mode || :invoke).to_sym
85
81
 
86
82
  @chat = nil
87
83
  @output = nil
@@ -104,14 +100,6 @@ module Phronomy
104
100
  @tool_batch_llm_call_id = nil
105
101
  end
106
102
 
107
- def stream_listener
108
- @event_listener
109
- end
110
-
111
- def stream_listener=(listener)
112
- @event_listener = listener
113
- end
114
-
115
103
  def streaming?
116
104
  @mode == :stream
117
105
  end
@@ -180,29 +168,9 @@ module Phronomy
180
168
  true
181
169
  end
182
170
 
183
- def apply_fsm_action_result(result)
184
- event_type =
185
- if result.respond_to?(:error) &&
186
- result.error &&
187
- !result.error.is_a?(ToolCallIntercepted)
188
- :llm_failed
189
- else
190
- :llm_completed
191
- end
192
- handle_fsm_event(
193
- Phronomy::Event.new(
194
- type: event_type,
195
- target_id: @id,
196
- payload: result
197
- )
198
- )
199
- self
200
- end
201
-
202
171
  def accept_tool_calls!(tool_calls, llm_call_id: nil)
203
172
  @user_message_sent = true
204
173
  @pending_tool_calls = Array(tool_calls)
205
- @messages = @chat.messages
206
174
  @tool_batch_llm_call_id = (llm_call_id || @current_llm_call_id)&.to_s
207
175
  @current_llm_call_id = nil
208
176
  @pending_tool_calls.each do |tool_call|
@@ -227,7 +195,6 @@ module Phronomy
227
195
  @user_message_sent = true
228
196
  @output = response.content
229
197
  @usage = Phronomy::TokenUsage.from_tokens(response.tokens)
230
- @messages = @chat.messages
231
198
  @pending_tool_calls = []
232
199
  @current_llm_call_id = nil
233
200
  self
@@ -298,7 +265,6 @@ module Phronomy
298
265
  )
299
266
  )
300
267
  end
301
- @messages = @chat.messages
302
268
  clear_tool_batch!
303
269
  self
304
270
  end