phronomy 0.18.0 → 0.19.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 +25 -0
- data/README.md +2 -0
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
- data/docs/features.md +7 -1
- data/docs/getting-started.md +37 -1
- data/docs/migrations/0.19.md +148 -0
- data/docs/runtime-and-concurrency.md +93 -2
- data/lib/phronomy/agent/base.rb +81 -36
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +420 -249
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/configuration.rb +2 -1
- data/lib/phronomy/engine/event_loop.rb +86 -8
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime.rb +7 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +12 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +361 -95
- data/lib/phronomy.rb +3 -0
- metadata +4 -4
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
|
@@ -8,6 +8,8 @@ handle, not an execution backend. Synchronous work that must stay off EventLoop
|
|
|
8
8
|
|
|
9
9
|
For the design rationale, see Architecture Decision Record (ADR)
|
|
10
10
|
[ADR-010: EventLoop / FSMSession First Concurrency](decisions/010-cooperative-first-concurrency.md).
|
|
11
|
+
Durable-state ownership is defined by
|
|
12
|
+
[ADR-014: Unified Persistence and Durable-State Ownership](decisions/014-unified-persistence-durable-state.md).
|
|
11
13
|
|
|
12
14
|
## Runtime model
|
|
13
15
|
|
|
@@ -19,6 +21,7 @@ Runtime
|
|
|
19
21
|
│ ├─ Workflow
|
|
20
22
|
│ ├─ ToolInvocation
|
|
21
23
|
│ └─ MultiAgent fan-out
|
|
24
|
+
├─ process-local Agent ActivationRegistry
|
|
22
25
|
├─ OffloadPool (bounded operating-system Threads)
|
|
23
26
|
│ ├─ blocking input/output (I/O)
|
|
24
27
|
│ ├─ central-processing-unit (CPU)-bound synchronous work
|
|
@@ -32,6 +35,67 @@ Task = completion handle
|
|
|
32
35
|
The framework does not allocate one operating-system Thread per logical Agent/Workflow/Tool
|
|
33
36
|
lifecycle. Logical waits remain explicit states plus later EventLoop events.
|
|
34
37
|
|
|
38
|
+
## Live state and durable state
|
|
39
|
+
|
|
40
|
+
A live Agent or Workflow owns its current logical state. `Persistence` is the
|
|
41
|
+
last committed durable representation and recovery source; it is not reloaded at
|
|
42
|
+
every semantic boundary.
|
|
43
|
+
|
|
44
|
+
For Agents, the live owner consists of the Agent instance plus its current
|
|
45
|
+
`AgentRoot`, hydrated Journal view, and `AgentExecutionActivation`. Mutable
|
|
46
|
+
Agent/Execution/Journal state is not automatically reloaded before every LLM or
|
|
47
|
+
Tool step. Durable writes use optimistic revision/position guardrails; an
|
|
48
|
+
external writer that advances the durable base causes `Persistence::ConflictError`
|
|
49
|
+
rather than automatic reload or merge.
|
|
50
|
+
|
|
51
|
+
For Workflows, the current `WorkflowContext` and FSMSession own the active
|
|
52
|
+
logical state. A durable Workflow hydrates once at invocation/resume and saves
|
|
53
|
+
at the halted/terminal boundary.
|
|
54
|
+
|
|
55
|
+
Content-addressed `Persistence#contents` values are immutable. Fetching a known
|
|
56
|
+
content reference is value materialization rather than mutable state refresh.
|
|
57
|
+
|
|
58
|
+
## Workflow identities and durable admission
|
|
59
|
+
|
|
60
|
+
Workflow execution keeps three identities separate:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
session_id
|
|
64
|
+
application session/correlation identity, for example a Rails session
|
|
65
|
+
|
|
66
|
+
thread_id
|
|
67
|
+
durable Workflow identity and Persistence#workflow_states key
|
|
68
|
+
|
|
69
|
+
fsm_session_id
|
|
70
|
+
one Runtime FSMSession execution identity; generated again for each
|
|
71
|
+
invoke/resume operation
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The existing application `session_id` is tracing/caller metadata and is not used
|
|
75
|
+
for durable Workflow ownership. EventLoop registers active FSMs by
|
|
76
|
+
`fsm_session_id`; durable Workflow admission is a separate owner map:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
thread_id -> owner_fsm_session_id
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The owner is acquired before `workflow_states.load(thread_id)` and remains held
|
|
83
|
+
until the halted/terminal `workflow_states.save(...)` completes. Only the current
|
|
84
|
+
owner may release the admission. `fsm_session_id` is Runtime-only metadata and is
|
|
85
|
+
not stored in Workflow fields or durable snapshots.
|
|
86
|
+
|
|
87
|
+
The admission map belongs to one Runtime and is process-local. It prevents two
|
|
88
|
+
executions with the same durable `thread_id` from being admitted concurrently
|
|
89
|
+
inside that Runtime, but it is not shared across Ruby processes, containers, or
|
|
90
|
+
service replicas. Separate processes may therefore execute the same `thread_id`
|
|
91
|
+
concurrently unless the application adds distributed coordination.
|
|
92
|
+
|
|
93
|
+
`workflow_states` optimistic revisions detect stale terminal commits across those
|
|
94
|
+
processes. They do not prevent duplicate execution from starting and cannot undo
|
|
95
|
+
external side effects that both executions already performed before one save
|
|
96
|
+
loses the revision race. CAS is stale/double-commit detection, not a distributed
|
|
97
|
+
execution lock or duplicate-side-effect prevention mechanism.
|
|
98
|
+
|
|
35
99
|
## Tool execution modes
|
|
36
100
|
|
|
37
101
|
Phronomy exposes two execution modes for capabilities:
|
|
@@ -72,6 +136,28 @@ parent FSMSession
|
|
|
72
136
|
This distinction prevents worker-slot starvation when many logical lifecycles are
|
|
73
137
|
waiting at the same time.
|
|
74
138
|
|
|
139
|
+
## Persistence I/O boundary
|
|
140
|
+
|
|
141
|
+
`Persistence` repositories expose synchronous operations. Framework lifecycle
|
|
142
|
+
code must not perform potentially blocking durable reads/writes on EventLoop.
|
|
143
|
+
Agent preparation/commit and Workflow hydrate/save operations are submitted to
|
|
144
|
+
`OffloadPool`; completion continues through completion callbacks or explicit
|
|
145
|
+
EventLoop events.
|
|
146
|
+
|
|
147
|
+
A durable barrier may pause one logical lifecycle without blocking EventLoop.
|
|
148
|
+
The next Agent provider call does not start until the corresponding Manifest and
|
|
149
|
+
logical execution snapshot commit succeeds. A persistence failure or optimistic
|
|
150
|
+
conflict fails that step rather than continuing with stale state.
|
|
151
|
+
|
|
152
|
+
Approval wait is not a hydration boundary. The same live Agent instance,
|
|
153
|
+
Activation, and AgentInvocation remain the owner and are resumed after approval.
|
|
154
|
+
Approval itself remains an Agent-instance operation. An application that only has
|
|
155
|
+
an `execution_id` first resolves the current process's owner with
|
|
156
|
+
`Phronomy::Agent::Base.live_for_execution(execution_id)` or the expected concrete
|
|
157
|
+
Agent class, then calls `agent.approve(...)` or `agent.approve_async(...)`.
|
|
158
|
+
`live_for_execution` consults the Runtime-local ActivationRegistry and does not
|
|
159
|
+
load a replacement Agent from Persistence.
|
|
160
|
+
|
|
75
161
|
## Sync versus async application APIs
|
|
76
162
|
|
|
77
163
|
| Calling context | Recommended approach |
|
|
@@ -82,11 +168,12 @@ waiting at the same time.
|
|
|
82
168
|
| EventLoop callback | Never block waiting for a Task that requires EventLoop progress |
|
|
83
169
|
| Top-level streaming | `agent.stream(...)` |
|
|
84
170
|
| Non-blocking streaming | `agent.stream_async(...)` |
|
|
85
|
-
| Approval from EventLoop callback | `approve_async
|
|
171
|
+
| Approval from EventLoop callback | Resolve with `live_for_execution`, call `agent.approve_async(...)`, and return immediately |
|
|
86
172
|
|
|
87
173
|
Blocking synchronous APIs reject EventLoop re-entry with
|
|
88
174
|
`Phronomy::EventLoopReentrancyError` when waiting would stall the same EventLoop
|
|
89
|
-
needed for progress.
|
|
175
|
+
needed for progress. `live_for_execution` itself only performs a Runtime-local
|
|
176
|
+
registry lookup and does not wait for Task progress.
|
|
90
177
|
|
|
91
178
|
## Task
|
|
92
179
|
|
|
@@ -247,6 +334,10 @@ Use these to distinguish worker saturation from EventLoop backlog/latency.
|
|
|
247
334
|
Runtime-owned EventLoop, then closes pools and timers according to the Runtime
|
|
248
335
|
shutdown contract.
|
|
249
336
|
|
|
337
|
+
Workflow durable admission participates in EventLoop idleness: a Workflow whose
|
|
338
|
+
FSMSession has ended but whose durable save is still in flight remains owned until
|
|
339
|
+
that save completes and owner-aware admission is released.
|
|
340
|
+
|
|
250
341
|
`Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
|
|
251
342
|
Runtime shutdown before resetting configuration.
|
|
252
343
|
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -252,24 +252,22 @@ module Phronomy
|
|
|
252
252
|
new(agent_id: agent_id, persistence: persistence, load_existing: true)
|
|
253
253
|
end
|
|
254
254
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
config: config
|
|
272
|
-
)
|
|
255
|
+
# Resolves the live Agent instance that currently owns execution_id in
|
|
256
|
+
# this process. This is a Runtime-local lookup, not durable rehydration.
|
|
257
|
+
def live_for_execution(execution_id)
|
|
258
|
+
activation = Phronomy::Runtime.instance.__agent_activations.fetch(execution_id)
|
|
259
|
+
unless activation
|
|
260
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
261
|
+
"no live activation for #{execution_id}; durable rehydration is required"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
agent = activation.agent
|
|
265
|
+
unless agent.is_a?(self)
|
|
266
|
+
raise ArgumentError,
|
|
267
|
+
"live activation #{execution_id} belongs to #{agent.class}, not #{self}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
agent
|
|
273
271
|
end
|
|
274
272
|
end
|
|
275
273
|
|
|
@@ -283,21 +281,33 @@ module Phronomy
|
|
|
283
281
|
metadata: {},
|
|
284
282
|
load_existing: false
|
|
285
283
|
)
|
|
286
|
-
@persistence = persistence ||
|
|
284
|
+
@persistence = persistence ||
|
|
285
|
+
Phronomy.configuration.persistence ||
|
|
286
|
+
Phronomy::Persistence::InMemory.new
|
|
287
287
|
@agent_id = agent_id.to_s.freeze
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
288
|
+
|
|
289
|
+
if load_existing
|
|
290
|
+
root = records = nil
|
|
291
|
+
@persistence.transaction do |tx|
|
|
292
|
+
root = tx.agents.load(@agent_id)
|
|
293
|
+
records = tx.journals.read(
|
|
294
|
+
@agent_id,
|
|
295
|
+
limit: root.journal_position
|
|
296
|
+
)
|
|
297
297
|
end
|
|
298
|
-
|
|
298
|
+
validate_loaded_definition!(root)
|
|
299
|
+
@root = root
|
|
300
|
+
@_phronomy_journal_records = Array(records).dup.freeze
|
|
299
301
|
else
|
|
300
|
-
create_agent_root!(
|
|
302
|
+
@root = create_agent_root!(
|
|
303
|
+
context: context,
|
|
304
|
+
knowledge: knowledge,
|
|
305
|
+
metadata: metadata
|
|
306
|
+
)
|
|
307
|
+
@_phronomy_journal_records = @persistence.journals.read(
|
|
308
|
+
@agent_id,
|
|
309
|
+
limit: @root.journal_position
|
|
310
|
+
).dup.freeze
|
|
301
311
|
end
|
|
302
312
|
end
|
|
303
313
|
|
|
@@ -306,7 +316,10 @@ module Phronomy
|
|
|
306
316
|
end
|
|
307
317
|
|
|
308
318
|
def journal_projection
|
|
309
|
-
Agent::JournalProjection.new(
|
|
319
|
+
Agent::JournalProjection.new(
|
|
320
|
+
agent_root: @root,
|
|
321
|
+
records: _journal_records_snapshot
|
|
322
|
+
)
|
|
310
323
|
end
|
|
311
324
|
|
|
312
325
|
def transcript
|
|
@@ -334,13 +347,14 @@ module Phronomy
|
|
|
334
347
|
end
|
|
335
348
|
end
|
|
336
349
|
|
|
337
|
-
# Appends persistent Knowledge to the Agent Journal.
|
|
338
|
-
#
|
|
350
|
+
# Appends persistent Knowledge to the Agent Journal. The live Agent owns the
|
|
351
|
+
# current logical root/Journal view; Persistence is advanced optimistically.
|
|
339
352
|
def add_knowledge(content, metadata: {})
|
|
353
|
+
current = agent_root
|
|
340
354
|
next_root = nil
|
|
355
|
+
appended = nil
|
|
341
356
|
persistence.transaction do |tx|
|
|
342
357
|
tx.executions.assert_idle!(agent_id)
|
|
343
|
-
current = tx.agents.load(agent_id)
|
|
344
358
|
record = build_knowledge_record(
|
|
345
359
|
tx: tx,
|
|
346
360
|
root: current,
|
|
@@ -363,6 +377,7 @@ module Phronomy
|
|
|
363
377
|
root: next_root
|
|
364
378
|
)
|
|
365
379
|
end
|
|
380
|
+
_append_journal_records(appended)
|
|
366
381
|
@root = next_root
|
|
367
382
|
self
|
|
368
383
|
end
|
|
@@ -394,6 +409,7 @@ module Phronomy
|
|
|
394
409
|
tx.agents.delete(agent_id)
|
|
395
410
|
end
|
|
396
411
|
@root = nil
|
|
412
|
+
@_phronomy_journal_records = [].freeze
|
|
397
413
|
true
|
|
398
414
|
end
|
|
399
415
|
|
|
@@ -404,6 +420,17 @@ module Phronomy
|
|
|
404
420
|
|
|
405
421
|
private
|
|
406
422
|
|
|
423
|
+
def validate_loaded_definition!(loaded)
|
|
424
|
+
definition = self.class.agent_definition
|
|
425
|
+
return if loaded.agent_definition_id == definition.fetch(:id) &&
|
|
426
|
+
loaded.definition_version == definition.fetch(:version)
|
|
427
|
+
|
|
428
|
+
raise Phronomy::ConfigurationError,
|
|
429
|
+
"Agent definition mismatch for #{@agent_id}: stored " \
|
|
430
|
+
"#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
|
|
431
|
+
"#{definition.fetch(:id)}@#{definition.fetch(:version)}"
|
|
432
|
+
end
|
|
433
|
+
|
|
407
434
|
def create_agent_root!(context:, knowledge:, metadata:)
|
|
408
435
|
definition = self.class.agent_definition
|
|
409
436
|
root = Agent::AgentRoot.create(
|
|
@@ -480,10 +507,11 @@ module Phronomy
|
|
|
480
507
|
end
|
|
481
508
|
|
|
482
509
|
def mutate_context!(kind, context_affecting: true)
|
|
510
|
+
current = agent_root
|
|
483
511
|
next_root = nil
|
|
512
|
+
appended = nil
|
|
484
513
|
persistence.transaction do |tx|
|
|
485
514
|
tx.executions.assert_idle!(agent_id)
|
|
486
|
-
current = tx.agents.load(agent_id)
|
|
487
515
|
record = Agent::JournalRecord.new(
|
|
488
516
|
agent_id: agent_id,
|
|
489
517
|
kind: kind,
|
|
@@ -502,8 +530,13 @@ module Phronomy
|
|
|
502
530
|
context_revision: context_affecting ?
|
|
503
531
|
yield_context_revision(current, proposed) : current.context_revision
|
|
504
532
|
)
|
|
505
|
-
tx.agents.save(
|
|
533
|
+
tx.agents.save(
|
|
534
|
+
agent_id,
|
|
535
|
+
expected_revision: current.agent_revision,
|
|
536
|
+
root: next_root
|
|
537
|
+
)
|
|
506
538
|
end
|
|
539
|
+
_append_journal_records(appended)
|
|
507
540
|
@root = next_root
|
|
508
541
|
end
|
|
509
542
|
|
|
@@ -511,6 +544,18 @@ module Phronomy
|
|
|
511
544
|
(proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
|
|
512
545
|
end
|
|
513
546
|
|
|
547
|
+
def _journal_records_snapshot
|
|
548
|
+
@_phronomy_journal_records || [].freeze
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def _append_journal_records(records)
|
|
552
|
+
incoming = Array(records)
|
|
553
|
+
return _journal_records_snapshot if incoming.empty?
|
|
554
|
+
|
|
555
|
+
@_phronomy_journal_records =
|
|
556
|
+
(_journal_records_snapshot + incoming).freeze
|
|
557
|
+
end
|
|
558
|
+
|
|
514
559
|
public
|
|
515
560
|
|
|
516
561
|
def _add_handoff_tool(tool_class)
|
|
@@ -14,18 +14,20 @@ module Phronomy
|
|
|
14
14
|
agent:,
|
|
15
15
|
persistence:,
|
|
16
16
|
policy: ContextPolicies::Default.new,
|
|
17
|
-
candidate_resolver: nil
|
|
17
|
+
candidate_resolver: nil,
|
|
18
|
+
journal_records: nil
|
|
18
19
|
)
|
|
19
20
|
@agent = agent
|
|
20
21
|
@persistence = persistence
|
|
21
22
|
@policy = policy
|
|
23
|
+
@journal_records = journal_records
|
|
22
24
|
@candidate_resolver = candidate_resolver || ContextCandidateResolver.new(
|
|
23
25
|
content_loader: method(:fetch_content)
|
|
24
26
|
)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def build_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
|
|
28
|
-
projection =
|
|
30
|
+
projection = journal_projection(agent_root)
|
|
29
31
|
model_cfg = effective_model_config(config, patch)
|
|
30
32
|
tool_set = ToolDefinitionSet.build(@agent)
|
|
31
33
|
system_text = build_system_text(input)
|
|
@@ -65,7 +67,7 @@ module Phronomy
|
|
|
65
67
|
config: {},
|
|
66
68
|
patch: LLMInputPatch.empty
|
|
67
69
|
)
|
|
68
|
-
projection =
|
|
70
|
+
projection = journal_projection(agent_root)
|
|
69
71
|
model_cfg = effective_model_config(config, patch)
|
|
70
72
|
hook_candidates = normalize_candidates(patch.segment_candidates)
|
|
71
73
|
system_segments = base_manifest.segments.select do |segment|
|
|
@@ -96,6 +98,14 @@ module Phronomy
|
|
|
96
98
|
|
|
97
99
|
private
|
|
98
100
|
|
|
101
|
+
def journal_projection(agent_root)
|
|
102
|
+
if @journal_records
|
|
103
|
+
JournalProjection.new(agent_root: agent_root, records: @journal_records)
|
|
104
|
+
else
|
|
105
|
+
JournalProjection.new(persistence: @persistence, agent_root: agent_root)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
99
109
|
def assemble(
|
|
100
110
|
agent_root:,
|
|
101
111
|
execution:,
|