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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +30 -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 +273 -0
- data/docs/features.md +27 -1
- data/docs/getting-started.md +37 -1
- data/docs/migrations/0.19.md +154 -0
- data/docs/persistence-backends.md +504 -0
- data/docs/runtime-and-concurrency.md +93 -2
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- 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/agent/llm_call_record.rb +20 -0
- 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 +109 -6
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- 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 +9 -0
- metadata +12 -5
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- 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"
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -8,8 +8,8 @@ module Phronomy
|
|
|
8
8
|
class Workflow
|
|
9
9
|
include Phronomy::Runnable
|
|
10
10
|
|
|
11
|
-
def self.define(context_class,
|
|
12
|
-
builder = Builder.new(context_class,
|
|
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
|
|
44
|
+
# Sends an event to an active Workflow execution without blocking.
|
|
45
45
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
|
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,
|
|
80
|
+
def initialize(context_class, persistence: nil)
|
|
80
81
|
@context_class = context_class
|
|
81
|
-
@
|
|
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
|
-
|
|
171
|
+
persistence: @persistence
|
|
171
172
|
)
|
|
172
173
|
Workflow.new(runner)
|
|
173
174
|
end
|