phronomy 0.18.0 → 0.20.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/CONTRIBUTING.md +30 -0
  4. data/README.md +2 -0
  5. data/docs/decisions/009-state-store-abstraction.md +1 -1
  6. data/docs/decisions/014-unified-persistence-durable-state.md +273 -0
  7. data/docs/features.md +27 -1
  8. data/docs/getting-started.md +37 -1
  9. data/docs/migrations/0.19.md +154 -0
  10. data/docs/persistence-backends.md +504 -0
  11. data/docs/runtime-and-concurrency.md +93 -2
  12. data/lib/phronomy/agent/agent_execution.rb +29 -0
  13. data/lib/phronomy/agent/base.rb +81 -36
  14. data/lib/phronomy/agent/context_assembler.rb +13 -3
  15. data/lib/phronomy/agent/execution_coordinator.rb +420 -249
  16. data/lib/phronomy/agent/journal_projection.rb +5 -1
  17. data/lib/phronomy/agent/llm_call_record.rb +20 -0
  18. data/lib/phronomy/configuration.rb +2 -1
  19. data/lib/phronomy/engine/event_loop.rb +86 -8
  20. data/lib/phronomy/engine/fsm_session.rb +6 -4
  21. data/lib/phronomy/engine/runtime.rb +7 -0
  22. data/lib/phronomy/persistence/in_memory.rb +113 -8
  23. data/lib/phronomy/persistence.rb +109 -6
  24. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
  25. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
  26. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
  27. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
  28. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
  29. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
  30. data/lib/phronomy/testing/persistence_contract.rb +41 -0
  31. data/lib/phronomy/version.rb +1 -1
  32. data/lib/phronomy/workflow.rb +10 -9
  33. data/lib/phronomy/workflow_runner.rb +361 -95
  34. data/lib/phronomy.rb +9 -0
  35. metadata +12 -5
  36. data/lib/phronomy/state_store/base.rb +0 -48
  37. data/lib/phronomy/state_store/in_memory.rb +0 -62
  38. data/scripts/check_private_enforcement.rb +0 -93
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "an Execution repository" do
6
+ let(:execution_repository) { persistence.executions }
7
+ let(:execution_agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ def build_contract_execution(root)
16
+ input_record = Phronomy::Agent::JournalRecord.new(
17
+ agent_id: root.agent_id,
18
+ kind: :input_received,
19
+ channel: :external,
20
+ role: :user,
21
+ context_candidate: false
22
+ )
23
+ Phronomy::Agent::AgentExecution.start(
24
+ agent_root: root,
25
+ input_record: input_record
26
+ )
27
+ end
28
+
29
+ before do
30
+ persistence.agents.create(execution_agent_root)
31
+ end
32
+
33
+ it "creates and loads an active execution" do
34
+ execution = build_contract_execution(execution_agent_root)
35
+
36
+ expect(execution_repository.create_active(execution).to_h).to eq(execution.to_h)
37
+ expect(execution_repository.load(execution.execution_id).to_h).to eq(execution.to_h)
38
+ end
39
+
40
+ it "raises NotFoundError for a missing execution" do
41
+ expect do
42
+ execution_repository.load("missing-#{SecureRandom.uuid}")
43
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
44
+ end
45
+
46
+ it "rejects a duplicate execution_id" do
47
+ execution = build_contract_execution(execution_agent_root)
48
+ execution_repository.create_active(execution)
49
+
50
+ expect do
51
+ execution_repository.create_active(execution)
52
+ end.to raise_error(Phronomy::Persistence::ConflictError)
53
+ end
54
+
55
+ it "admits at most one active or suspended execution for one Agent" do
56
+ first = build_contract_execution(execution_agent_root)
57
+ second = build_contract_execution(execution_agent_root)
58
+ execution_repository.create_active(first)
59
+
60
+ expect do
61
+ execution_repository.create_active(second)
62
+ end.to raise_error(Phronomy::AgentBusyError)
63
+ end
64
+
65
+ it "allows different Agents to have active executions" do
66
+ other_root = Phronomy::Agent::AgentRoot.create(
67
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
68
+ agent_definition_id: "contract-agent",
69
+ definition_version: 1
70
+ )
71
+ persistence.agents.create(other_root)
72
+
73
+ expect do
74
+ execution_repository.create_active(build_contract_execution(execution_agent_root))
75
+ execution_repository.create_active(build_contract_execution(other_root))
76
+ end.not_to raise_error
77
+ end
78
+
79
+ it "saves only at the expected execution revision" do
80
+ execution = build_contract_execution(execution_agent_root)
81
+ execution_repository.create_active(execution)
82
+ updated = execution.with(status: :active, phase: :calling_llm)
83
+
84
+ expect(
85
+ execution_repository.save(
86
+ execution.execution_id,
87
+ expected_revision: 0,
88
+ execution: updated
89
+ ).to_h
90
+ ).to eq(updated.to_h)
91
+ expect(execution_repository.load(execution.execution_id).execution_revision).to eq(1)
92
+ end
93
+
94
+ it "rejects a stale execution revision" do
95
+ execution = build_contract_execution(execution_agent_root)
96
+ execution_repository.create_active(execution)
97
+ updated = execution.with(status: :active, phase: :calling_llm)
98
+ execution_repository.save(
99
+ execution.execution_id,
100
+ expected_revision: 0,
101
+ execution: updated
102
+ )
103
+
104
+ expect do
105
+ execution_repository.save(
106
+ execution.execution_id,
107
+ expected_revision: 0,
108
+ execution: updated
109
+ )
110
+ end.to raise_error(Phronomy::Persistence::ConflictError)
111
+ end
112
+
113
+ it "rejects an execution identity mismatch" do
114
+ execution = build_contract_execution(execution_agent_root)
115
+ execution_repository.create_active(execution)
116
+ other_root = Phronomy::Agent::AgentRoot.create(
117
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
118
+ agent_definition_id: "contract-agent",
119
+ definition_version: 1
120
+ )
121
+ persistence.agents.create(other_root)
122
+ other = build_contract_execution(other_root).with(
123
+ execution_revision: 1,
124
+ status: :active,
125
+ phase: :calling_llm
126
+ )
127
+
128
+ expect do
129
+ execution_repository.save(
130
+ execution.execution_id,
131
+ expected_revision: 0,
132
+ execution: other
133
+ )
134
+ end.to raise_error(Phronomy::Persistence::ConflictError)
135
+ end
136
+
137
+ it "requires execution revision to advance exactly once" do
138
+ execution = build_contract_execution(execution_agent_root)
139
+ execution_repository.create_active(execution)
140
+ skipped = execution.with(
141
+ execution_revision: 2,
142
+ status: :active,
143
+ phase: :calling_llm
144
+ )
145
+
146
+ expect do
147
+ execution_repository.save(
148
+ execution.execution_id,
149
+ expected_revision: 0,
150
+ execution: skipped
151
+ )
152
+ end.to raise_error(Phronomy::Persistence::ConflictError)
153
+ end
154
+
155
+ it "lists active executions for one Agent" do
156
+ execution = build_contract_execution(execution_agent_root)
157
+ execution_repository.create_active(execution)
158
+
159
+ expect(execution_repository.list_active(execution_agent_root.agent_id).map(&:execution_id))
160
+ .to eq([execution.execution_id])
161
+ end
162
+
163
+ it "asserts idle state and rejects active Agents" do
164
+ expect do
165
+ execution_repository.assert_idle!(execution_agent_root.agent_id)
166
+ end.not_to raise_error
167
+
168
+ execution_repository.create_active(build_contract_execution(execution_agent_root))
169
+
170
+ expect do
171
+ execution_repository.assert_idle!(execution_agent_root.agent_id)
172
+ end.to raise_error(Phronomy::AgentBusyError)
173
+ end
174
+
175
+ it "deletes one execution" do
176
+ execution = build_contract_execution(execution_agent_root)
177
+ execution_repository.create_active(execution)
178
+ execution_repository.delete(execution.execution_id)
179
+
180
+ expect do
181
+ execution_repository.load(execution.execution_id)
182
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
183
+ end
184
+
185
+ it "deletes all executions for one Agent without deleting other Agents' executions" do
186
+ other_root = Phronomy::Agent::AgentRoot.create(
187
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
188
+ agent_definition_id: "contract-agent",
189
+ definition_version: 1
190
+ )
191
+ persistence.agents.create(other_root)
192
+ own = build_contract_execution(execution_agent_root)
193
+ other = build_contract_execution(other_root)
194
+ execution_repository.create_active(own)
195
+ execution_repository.create_active(other)
196
+
197
+ execution_repository.delete_for_agent(execution_agent_root.agent_id)
198
+
199
+ expect(execution_repository.list_active(execution_agent_root.agent_id)).to be_empty
200
+ expect(execution_repository.load(other.execution_id).execution_id).to eq(other.execution_id)
201
+ end
202
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+
5
+ begin
6
+ require "rspec/core"
7
+ require "rspec/expectations"
8
+ rescue LoadError => error
9
+ raise LoadError,
10
+ "Phronomy Persistence contract support requires RSpec. " \
11
+ "Add `rspec` to the backend project's development/test dependencies before " \
12
+ "requiring `phronomy/testing/persistence_contract`.",
13
+ error.backtrace
14
+ end
15
+
16
+ module Phronomy
17
+ module Testing
18
+ # Explicitly loaded RSpec shared examples for Persistence backend authors.
19
+ #
20
+ # This namespace is intentionally excluded from Phronomy's production
21
+ # Zeitwerk eager-load path. Requiring this file is the opt-in boundary that
22
+ # loads RSpec and the backend conformance suite.
23
+ module PersistenceContract
24
+ SHARED_EXAMPLES = [
25
+ "a persistence content store",
26
+ "an Agent repository",
27
+ "a Journal repository",
28
+ "an Execution repository",
29
+ "a workflow state repository",
30
+ "a Persistence backend"
31
+ ].freeze
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative "persistence_contract/a_content_store"
37
+ require_relative "persistence_contract/an_agent_repository"
38
+ require_relative "persistence_contract/a_journal_repository"
39
+ require_relative "persistence_contract/an_execution_repository"
40
+ require_relative "persistence_contract/a_workflow_state_repository"
41
+ require_relative "persistence_contract/a_persistence_backend"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.18.0"
4
+ VERSION = "0.20.0"
5
5
  end
@@ -8,8 +8,8 @@ module Phronomy
8
8
  class Workflow
9
9
  include Phronomy::Runnable
10
10
 
11
- def self.define(context_class, state_store: nil, &block)
12
- builder = Builder.new(context_class, state_store: state_store)
11
+ def self.define(context_class, persistence: nil, &block)
12
+ builder = Builder.new(context_class, persistence: persistence)
13
13
  builder.instance_eval(&block)
14
14
  builder.build
15
15
  end
@@ -41,12 +41,13 @@ module Phronomy
41
41
  @runner.send_event(state: state, event: event, input: input)
42
42
  end
43
43
 
44
- # Sends an event to an active Workflow session without blocking.
44
+ # Sends an event to an active Workflow execution without blocking.
45
45
  #
46
- # This method is safe to call from an Agent/Tool listener running on the
47
- # EventLoop thread because it only enqueues a later dispatch.
46
+ # +thread_id+ is the logical/durable Workflow identity. EventLoop resolves it
47
+ # to the currently owning Runtime-only fsm_session_id. InvocationContext's
48
+ # application session_id is unrelated to this routing.
48
49
  #
49
- # @return [Boolean] true when admitted; false when the session is not live
50
+ # @return [Boolean] true when admitted; false when the Workflow is not live
50
51
  # or Runtime shutdown has begun
51
52
  # @api public
52
53
  def signal(thread_id:, event:, payload: nil)
@@ -76,9 +77,9 @@ module Phronomy
76
77
  class Builder
77
78
  FINISH = Phronomy::WorkflowRunner::FINISH
78
79
 
79
- def initialize(context_class, state_store: nil)
80
+ def initialize(context_class, persistence: nil)
80
81
  @context_class = context_class
81
- @state_store = state_store
82
+ @persistence = persistence
82
83
  @initial = nil
83
84
  @declared_states = []
84
85
  @entry_actions = {}
@@ -167,7 +168,7 @@ module Phronomy
167
168
  external_events: external_events,
168
169
  entry_point: @initial || @declared_states.first,
169
170
  wait_state_names: @wait_state_names.dup,
170
- state_store: @state_store
171
+ persistence: @persistence
171
172
  )
172
173
  Workflow.new(runner)
173
174
  end