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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -1134
- data/README.md +68 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +35 -19
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +6 -6
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +5 -29
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +45 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +23 -31
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- 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.
|