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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a Journal repository" do
6
+ let(:journal_repository) { persistence.journals }
7
+ let(:journal_agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "journal-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ def build_contract_journal_record(agent_id:, record_id: SecureRandom.uuid, content_ref: nil)
16
+ Phronomy::Agent::JournalRecord.new(
17
+ agent_id: agent_id,
18
+ record_id: record_id,
19
+ kind: :knowledge,
20
+ channel: :context,
21
+ role: :user,
22
+ content_ref: content_ref,
23
+ context_candidate: true
24
+ )
25
+ end
26
+
27
+ before do
28
+ persistence.agents.create(journal_agent_root)
29
+ end
30
+
31
+ it "appends records at the expected position and assigns durable sequences" do
32
+ first = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
33
+ second = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
34
+
35
+ appended = journal_repository.append(
36
+ journal_agent_root.agent_id,
37
+ expected_position: 0,
38
+ records: [first, second]
39
+ )
40
+
41
+ expect(appended.map(&:sequence)).to eq([1, 2])
42
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(2)
43
+ end
44
+
45
+ it "rejects an unexpected append position" do
46
+ record = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
47
+
48
+ expect do
49
+ journal_repository.append(
50
+ journal_agent_root.agent_id,
51
+ expected_position: 1,
52
+ records: [record]
53
+ )
54
+ end.to raise_error(Phronomy::Persistence::ConflictError)
55
+ end
56
+
57
+ it "rejects a duplicate record_id already present in the Journal" do
58
+ record_id = SecureRandom.uuid
59
+ first = build_contract_journal_record(
60
+ agent_id: journal_agent_root.agent_id,
61
+ record_id: record_id
62
+ )
63
+ duplicate = build_contract_journal_record(
64
+ agent_id: journal_agent_root.agent_id,
65
+ record_id: record_id
66
+ )
67
+ journal_repository.append(
68
+ journal_agent_root.agent_id,
69
+ expected_position: 0,
70
+ records: [first]
71
+ )
72
+
73
+ expect do
74
+ journal_repository.append(
75
+ journal_agent_root.agent_id,
76
+ expected_position: 1,
77
+ records: [duplicate]
78
+ )
79
+ end.to raise_error(Phronomy::Persistence::ConflictError)
80
+ end
81
+
82
+ it "rejects duplicate record_ids within one append" do
83
+ record_id = SecureRandom.uuid
84
+ records = 2.times.map do
85
+ build_contract_journal_record(
86
+ agent_id: journal_agent_root.agent_id,
87
+ record_id: record_id
88
+ )
89
+ end
90
+
91
+ expect do
92
+ journal_repository.append(
93
+ journal_agent_root.agent_id,
94
+ expected_position: 0,
95
+ records: records
96
+ )
97
+ end.to raise_error(Phronomy::Persistence::ConflictError)
98
+ end
99
+
100
+ it "rejects records belonging to another Agent" do
101
+ record = build_contract_journal_record(agent_id: "other-agent")
102
+
103
+ expect do
104
+ journal_repository.append(
105
+ journal_agent_root.agent_id,
106
+ expected_position: 0,
107
+ records: [record]
108
+ )
109
+ end.to raise_error(Phronomy::Persistence::ConflictError)
110
+ end
111
+
112
+ it "reads records in sequence order and supports after/limit" do
113
+ records = 3.times.map do
114
+ build_contract_journal_record(agent_id: journal_agent_root.agent_id)
115
+ end
116
+ appended = journal_repository.append(
117
+ journal_agent_root.agent_id,
118
+ expected_position: 0,
119
+ records: records
120
+ )
121
+
122
+ expect(journal_repository.read(journal_agent_root.agent_id).map(&:record_id))
123
+ .to eq(appended.map(&:record_id))
124
+ expect(journal_repository.read(journal_agent_root.agent_id, after: 1).map(&:sequence))
125
+ .to eq([2, 3])
126
+ expect(journal_repository.read(journal_agent_root.agent_id, limit: 2).map(&:sequence))
127
+ .to eq([1, 2])
128
+ end
129
+
130
+ it "isolates durable Journal state from mutation of returned collections" do
131
+ record = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
132
+ journal_repository.append(
133
+ journal_agent_root.agent_id,
134
+ expected_position: 0,
135
+ records: [record]
136
+ )
137
+ loaded = journal_repository.read(journal_agent_root.agent_id)
138
+
139
+ begin
140
+ loaded.clear
141
+ rescue FrozenError
142
+ nil
143
+ end
144
+
145
+ expect(journal_repository.read(journal_agent_root.agent_id).length).to eq(1)
146
+ end
147
+
148
+ it "returns an empty Journal with head zero when no records exist" do
149
+ expect(journal_repository.read(journal_agent_root.agent_id)).to eq([])
150
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(0)
151
+ end
152
+
153
+ it "deletes the Agent Journal" do
154
+ journal_repository.append(
155
+ journal_agent_root.agent_id,
156
+ expected_position: 0,
157
+ records: [build_contract_journal_record(agent_id: journal_agent_root.agent_id)]
158
+ )
159
+ journal_repository.delete(journal_agent_root.agent_id)
160
+
161
+ expect(journal_repository.read(journal_agent_root.agent_id)).to eq([])
162
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(0)
163
+ end
164
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a Persistence backend" do
6
+ let(:backend_agent_root) do
7
+ Phronomy::Agent::AgentRoot.create(
8
+ agent_id: "backend-agent-#{SecureRandom.uuid}",
9
+ agent_definition_id: "contract-agent",
10
+ definition_version: 1
11
+ )
12
+ end
13
+
14
+ def build_backend_execution(root)
15
+ record = Phronomy::Agent::JournalRecord.new(
16
+ agent_id: root.agent_id,
17
+ kind: :input_received,
18
+ channel: :external,
19
+ role: :user,
20
+ context_candidate: false
21
+ )
22
+ Phronomy::Agent::AgentExecution.start(agent_root: root, input_record: record)
23
+ end
24
+
25
+ it "advertises every required capability" do
26
+ Phronomy::Persistence::REQUIRED_CAPABILITIES.each do |name, required_value|
27
+ expect(persistence.capabilities[name]).to eq(required_value)
28
+ end
29
+ end
30
+
31
+ it "yields a transaction view that exposes the complete durable SPI" do
32
+ persistence.transaction do |tx|
33
+ expect(tx).to respond_to(
34
+ :contents,
35
+ :agents,
36
+ :journals,
37
+ :executions,
38
+ :workflow_states,
39
+ :assert_agent_watermark!
40
+ )
41
+ end
42
+ end
43
+
44
+ it "returns the transaction block result" do
45
+ expect(persistence.transaction { |_tx| :contract_result }).to eq(:contract_result)
46
+ end
47
+
48
+ it "commits changes across durable repositories as one transaction" do
49
+ root = backend_agent_root
50
+ execution = build_backend_execution(root)
51
+ workflow_id = "workflow-#{SecureRandom.uuid}"
52
+ content_id = nil
53
+
54
+ persistence.transaction do |tx|
55
+ content_id = tx.contents.put_text("committed")
56
+ tx.agents.create(root)
57
+ appended = tx.journals.append(
58
+ root.agent_id,
59
+ expected_position: 0,
60
+ records: [
61
+ Phronomy::Agent::JournalRecord.new(
62
+ agent_id: root.agent_id,
63
+ kind: :knowledge,
64
+ channel: :context,
65
+ role: :user,
66
+ content_ref: content_id,
67
+ context_candidate: true
68
+ )
69
+ ]
70
+ )
71
+ tx.executions.create_active(execution)
72
+ tx.workflow_states.save(
73
+ workflow_id,
74
+ expected_revision: nil,
75
+ snapshot: {fields: {value: "committed"}, phase: "pause"}
76
+ )
77
+
78
+ updated_root = root.with(
79
+ agent_revision: 1,
80
+ journal_position: appended.length,
81
+ lifecycle_status: :active
82
+ )
83
+ tx.agents.save(root.agent_id, expected_revision: 0, root: updated_root)
84
+ end
85
+
86
+ expect(persistence.contents.exist?(content_id)).to be(true)
87
+ expect(persistence.agents.load(root.agent_id).agent_revision).to eq(1)
88
+ expect(persistence.journals.head(root.agent_id)).to eq(1)
89
+ expect(persistence.executions.load(execution.execution_id).execution_id)
90
+ .to eq(execution.execution_id)
91
+ expect(persistence.workflow_states.load(workflow_id)).not_to be_nil
92
+ end
93
+
94
+ it "rolls back all durable repositories when the transaction block raises" do
95
+ root = backend_agent_root
96
+ execution = build_backend_execution(root)
97
+ workflow_id = "workflow-#{SecureRandom.uuid}"
98
+ content_id = nil
99
+
100
+ expect do
101
+ persistence.transaction do |tx|
102
+ content_id = tx.contents.put_text("temporary-#{SecureRandom.uuid}")
103
+ tx.agents.create(root)
104
+ tx.journals.append(
105
+ root.agent_id,
106
+ expected_position: 0,
107
+ records: [
108
+ Phronomy::Agent::JournalRecord.new(
109
+ agent_id: root.agent_id,
110
+ kind: :knowledge,
111
+ channel: :context,
112
+ role: :user,
113
+ content_ref: content_id,
114
+ context_candidate: true
115
+ )
116
+ ]
117
+ )
118
+ tx.executions.create_active(execution)
119
+ tx.workflow_states.save(
120
+ workflow_id,
121
+ expected_revision: nil,
122
+ snapshot: {fields: {value: "temporary"}, phase: "pause"}
123
+ )
124
+ raise "rollback-contract"
125
+ end
126
+ end.to raise_error("rollback-contract")
127
+
128
+ expect(persistence.contents.exist?(content_id)).to be(false)
129
+ expect do
130
+ persistence.agents.load(root.agent_id)
131
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
132
+ expect(persistence.journals.head(root.agent_id)).to eq(0)
133
+ expect do
134
+ persistence.executions.load(execution.execution_id)
135
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
136
+ expect(persistence.workflow_states.load(workflow_id)).to be_nil
137
+ end
138
+
139
+ it "accepts the current Agent revision and Journal position watermark" do
140
+ root = backend_agent_root
141
+ persistence.agents.create(root)
142
+
143
+ expect(
144
+ persistence.assert_agent_watermark!(
145
+ agent_id: root.agent_id,
146
+ agent_revision: root.agent_revision,
147
+ journal_position: root.journal_position
148
+ )
149
+ ).to be(true)
150
+ end
151
+
152
+ it "raises ConflictError when the durable Agent revision has advanced" do
153
+ root = backend_agent_root
154
+ persistence.agents.create(root)
155
+ advanced = root.with(agent_revision: 1)
156
+ persistence.agents.save(root.agent_id, expected_revision: 0, root: advanced)
157
+
158
+ expect do
159
+ persistence.assert_agent_watermark!(
160
+ agent_id: root.agent_id,
161
+ agent_revision: 0,
162
+ journal_position: 0
163
+ )
164
+ end.to raise_error(Phronomy::Persistence::ConflictError)
165
+ end
166
+
167
+ it "raises ConflictError when the durable Journal position has advanced" do
168
+ root = backend_agent_root
169
+ persistence.agents.create(root)
170
+ persistence.journals.append(
171
+ root.agent_id,
172
+ expected_position: 0,
173
+ records: [
174
+ Phronomy::Agent::JournalRecord.new(
175
+ agent_id: root.agent_id,
176
+ kind: :knowledge,
177
+ channel: :context,
178
+ role: :user,
179
+ context_candidate: true
180
+ )
181
+ ]
182
+ )
183
+
184
+ expect do
185
+ persistence.assert_agent_watermark!(
186
+ agent_id: root.agent_id,
187
+ agent_revision: 0,
188
+ journal_position: 0
189
+ )
190
+ end.to raise_error(Phronomy::Persistence::ConflictError)
191
+ end
192
+
193
+ it "rolls back earlier writes when a watermark precondition fails" do
194
+ root = backend_agent_root
195
+ persistence.agents.create(root)
196
+ advanced = root.with(agent_revision: 1)
197
+ persistence.agents.save(root.agent_id, expected_revision: 0, root: advanced)
198
+ temporary_content_id = nil
199
+
200
+ expect do
201
+ persistence.transaction do |tx|
202
+ temporary_content_id = tx.contents.put_text(
203
+ "watermark-rollback-#{SecureRandom.uuid}"
204
+ )
205
+ tx.assert_agent_watermark!(
206
+ agent_id: root.agent_id,
207
+ agent_revision: 0,
208
+ journal_position: 0
209
+ )
210
+ end
211
+ end.to raise_error(Phronomy::Persistence::ConflictError)
212
+
213
+ expect(persistence.contents.exist?(temporary_content_id)).to be(false)
214
+ end
215
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a workflow state repository" do
6
+ let(:repository) { persistence.workflow_states }
7
+
8
+ def workflow_contract_value(hash, key)
9
+ hash.key?(key) ? hash[key] : hash[key.to_s]
10
+ end
11
+
12
+ def workflow_contract_snapshot(record)
13
+ workflow_contract_value(record, :snapshot)
14
+ end
15
+
16
+ def workflow_contract_revision(record)
17
+ workflow_contract_value(record, :revision)
18
+ end
19
+
20
+ it "returns nil for an unknown thread_id" do
21
+ expect(repository.load("missing-#{SecureRandom.uuid}")).to be_nil
22
+ end
23
+
24
+ it "uses optimistic revisions for save" do
25
+ thread_id = "t1-#{SecureRandom.uuid}"
26
+ expect(
27
+ repository.save(
28
+ thread_id,
29
+ expected_revision: nil,
30
+ snapshot: {fields: {value: 1}, phase: "pause"}
31
+ )
32
+ ).to eq(1)
33
+
34
+ expect(
35
+ repository.save(
36
+ thread_id,
37
+ expected_revision: 1,
38
+ snapshot: {fields: {value: 2}, phase: "__end__"}
39
+ )
40
+ ).to eq(2)
41
+ end
42
+
43
+ it "rejects a stale expected_revision" do
44
+ thread_id = "t1-#{SecureRandom.uuid}"
45
+ repository.save(
46
+ thread_id,
47
+ expected_revision: nil,
48
+ snapshot: {fields: {value: 1}, phase: "pause"}
49
+ )
50
+
51
+ expect do
52
+ repository.save(
53
+ thread_id,
54
+ expected_revision: nil,
55
+ snapshot: {fields: {value: 2}, phase: "__end__"}
56
+ )
57
+ end.to raise_error(Phronomy::Persistence::ConflictError)
58
+ end
59
+
60
+ it "returns a snapshot representation accepted by WorkflowRunner" do
61
+ thread_id = "t1-#{SecureRandom.uuid}"
62
+ repository.save(
63
+ thread_id,
64
+ expected_revision: nil,
65
+ snapshot: {fields: {value: 1}, phase: "pause"}
66
+ )
67
+
68
+ record = repository.load(thread_id)
69
+ snapshot = workflow_contract_snapshot(record)
70
+ fields = workflow_contract_value(snapshot, :fields)
71
+
72
+ expect(workflow_contract_revision(record)).to eq(1)
73
+ expect(workflow_contract_value(fields, :value)).to eq(1)
74
+ expect(workflow_contract_value(snapshot, :phase)).to eq("pause")
75
+ end
76
+
77
+ it "isolates stored snapshots from caller mutation" do
78
+ thread_id = "t1-#{SecureRandom.uuid}"
79
+ repository.save(
80
+ thread_id,
81
+ expected_revision: nil,
82
+ snapshot: {fields: {values: [1]}, phase: "pause"}
83
+ )
84
+
85
+ loaded = repository.load(thread_id)
86
+ snapshot = workflow_contract_snapshot(loaded)
87
+ fields = workflow_contract_value(snapshot, :fields)
88
+ values = workflow_contract_value(fields, :values)
89
+
90
+ begin
91
+ values << 2
92
+ rescue FrozenError
93
+ nil
94
+ end
95
+
96
+ reloaded = repository.load(thread_id)
97
+ reloaded_fields = workflow_contract_value(
98
+ workflow_contract_snapshot(reloaded),
99
+ :fields
100
+ )
101
+ expect(workflow_contract_value(reloaded_fields, :values)).to eq([1])
102
+ end
103
+
104
+ it "deletes only at the expected revision" do
105
+ thread_id = "t1-#{SecureRandom.uuid}"
106
+ repository.save(
107
+ thread_id,
108
+ expected_revision: nil,
109
+ snapshot: {fields: {}, phase: "pause"}
110
+ )
111
+
112
+ expect do
113
+ repository.delete(thread_id, expected_revision: 99)
114
+ end.to raise_error(Phronomy::Persistence::ConflictError)
115
+
116
+ repository.delete(thread_id, expected_revision: 1)
117
+ expect(repository.load(thread_id)).to be_nil
118
+ end
119
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "an Agent repository" do
6
+ let(:agent_repository) { persistence.agents }
7
+ let(:agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "contract-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ it "creates and loads an AgentRoot" do
16
+ expect(agent_repository.create(agent_root).to_h).to eq(agent_root.to_h)
17
+ expect(agent_repository.load(agent_root.agent_id).to_h).to eq(agent_root.to_h)
18
+ end
19
+
20
+ it "rejects duplicate create" do
21
+ agent_repository.create(agent_root)
22
+
23
+ expect do
24
+ agent_repository.create(agent_root)
25
+ end.to raise_error(Phronomy::Persistence::ConflictError)
26
+ end
27
+
28
+ it "raises NotFoundError for a missing Agent" do
29
+ expect do
30
+ agent_repository.load("missing-#{SecureRandom.uuid}")
31
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
32
+ end
33
+
34
+ it "saves only at the expected revision" do
35
+ agent_repository.create(agent_root)
36
+ updated = agent_root.with(agent_revision: agent_root.agent_revision + 1)
37
+
38
+ expect(
39
+ agent_repository.save(
40
+ agent_root.agent_id,
41
+ expected_revision: agent_root.agent_revision,
42
+ root: updated
43
+ ).to_h
44
+ ).to eq(updated.to_h)
45
+
46
+ expect(agent_repository.load(agent_root.agent_id).agent_revision).to eq(1)
47
+ end
48
+
49
+ it "rejects a stale expected revision" do
50
+ agent_repository.create(agent_root)
51
+ first = agent_root.with(agent_revision: 1)
52
+ agent_repository.save(agent_root.agent_id, expected_revision: 0, root: first)
53
+ stale = agent_root.with(agent_revision: 1)
54
+
55
+ expect do
56
+ agent_repository.save(agent_root.agent_id, expected_revision: 0, root: stale)
57
+ end.to raise_error(Phronomy::Persistence::ConflictError)
58
+ end
59
+
60
+ it "rejects an Agent identity mismatch" do
61
+ agent_repository.create(agent_root)
62
+ other = Phronomy::Agent::AgentRoot.create(
63
+ agent_id: "other-#{SecureRandom.uuid}",
64
+ agent_definition_id: "contract-agent",
65
+ definition_version: 1
66
+ ).with(agent_revision: 1)
67
+
68
+ expect do
69
+ agent_repository.save(
70
+ agent_root.agent_id,
71
+ expected_revision: 0,
72
+ root: other
73
+ )
74
+ end.to raise_error(Phronomy::Persistence::ConflictError)
75
+ end
76
+
77
+ it "requires revision to advance exactly once" do
78
+ agent_repository.create(agent_root)
79
+ skipped = agent_root.with(agent_revision: 2)
80
+
81
+ expect do
82
+ agent_repository.save(
83
+ agent_root.agent_id,
84
+ expected_revision: 0,
85
+ root: skipped
86
+ )
87
+ end.to raise_error(Phronomy::Persistence::ConflictError)
88
+ end
89
+
90
+ it "deletes idempotently" do
91
+ agent_repository.create(agent_root)
92
+ agent_repository.delete(agent_root.agent_id)
93
+ expect { agent_repository.delete(agent_root.agent_id) }.not_to raise_error
94
+
95
+ expect do
96
+ agent_repository.load(agent_root.agent_id)
97
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
98
+ end
99
+ end