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.
@@ -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
 
@@ -252,24 +252,22 @@ module Phronomy
252
252
  new(agent_id: agent_id, persistence: persistence, load_existing: true)
253
253
  end
254
254
 
255
- def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
256
- approve_async(
257
- execution_id,
258
- approval_request_id: approval_request_id,
259
- approved: approved,
260
- config: config,
261
- persistence: persistence
262
- ).wait_result
263
- end
264
-
265
- def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
266
- execution = persistence.executions.load(execution_id)
267
- load(execution.agent_id, persistence: persistence).approve_async(
268
- execution_id,
269
- approval_request_id: approval_request_id,
270
- approved: approved,
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 || Phronomy::Persistence::InMemory.new
284
+ @persistence = persistence ||
285
+ Phronomy.configuration.persistence ||
286
+ Phronomy::Persistence::InMemory.new
287
287
  @agent_id = agent_id.to_s.freeze
288
- @root = if load_existing
289
- loaded = @persistence.agents.load(@agent_id)
290
- definition = self.class.agent_definition
291
- unless loaded.agent_definition_id == definition.fetch(:id) &&
292
- loaded.definition_version == definition.fetch(:version)
293
- raise Phronomy::ConfigurationError,
294
- "Agent definition mismatch for #{@agent_id}: stored " \
295
- "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
296
- "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
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
- loaded
298
+ validate_loaded_definition!(root)
299
+ @root = root
300
+ @_phronomy_journal_records = Array(records).dup.freeze
299
301
  else
300
- create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
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(persistence: persistence, agent_root: @root)
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
- # Knowledge is an optional Context candidate; it is not part of #transcript.
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(agent_id, expected_revision: current.agent_revision, root: next_root)
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 = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
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 = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
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:,