phronomy 0.22.0 → 0.23.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/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
data/lib/phronomy/persistence.rb
CHANGED
|
@@ -13,51 +13,73 @@ module Phronomy
|
|
|
13
13
|
optimistic_revision: true
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
-
#
|
|
16
|
+
# Content-addressed immutable content repository. ContentStore has its own
|
|
17
|
+
# codec/canonicalization boundary and is not wrapped in DurableRecord.
|
|
17
18
|
#
|
|
18
|
-
#
|
|
19
|
-
# implementation classes owned by the backend; they do not need to inherit
|
|
20
|
-
# from Phronomy repository base classes.
|
|
21
|
-
#
|
|
22
|
-
# @return [Object] content-addressed immutable content repository
|
|
19
|
+
# @return [Object]
|
|
23
20
|
# @api public
|
|
24
21
|
attr_reader :contents
|
|
25
22
|
|
|
26
|
-
#
|
|
23
|
+
# Runtime/domain-facing AgentRoot repository. The backend repository supplied
|
|
24
|
+
# to #initialize is record-oriented; this facade owns encode/decode.
|
|
25
|
+
#
|
|
26
|
+
# @return [Object]
|
|
27
27
|
# @api public
|
|
28
28
|
attr_reader :agents
|
|
29
29
|
|
|
30
|
-
#
|
|
30
|
+
# Runtime/domain-facing append-only Agent Journal repository.
|
|
31
|
+
#
|
|
32
|
+
# @return [Object]
|
|
31
33
|
# @api public
|
|
32
34
|
attr_reader :journals
|
|
33
35
|
|
|
34
|
-
#
|
|
36
|
+
# Runtime/domain-facing AgentExecution repository.
|
|
37
|
+
#
|
|
38
|
+
# @return [Object]
|
|
35
39
|
# @api public
|
|
36
40
|
attr_reader :executions
|
|
37
41
|
|
|
38
|
-
#
|
|
42
|
+
# Runtime/domain-facing durable Workflow snapshot repository.
|
|
43
|
+
#
|
|
44
|
+
# @return [Object]
|
|
39
45
|
# @api public
|
|
40
46
|
attr_reader :workflow_states
|
|
41
47
|
|
|
42
|
-
# Initializes a Persistence backend
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# {
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
48
|
+
# Initializes a Persistence backend from record-oriented storage
|
|
49
|
+
# repositories.
|
|
50
|
+
#
|
|
51
|
+
# Except for +contents+, backend repositories exchange
|
|
52
|
+
# {Phronomy::Persistence::DurableRecord} values. Phronomy's repository
|
|
53
|
+
# facades own current-format validation and domain-object encode/decode.
|
|
54
|
+
#
|
|
55
|
+
# Identity, revision, admission, and index metadata needed by a Backend is
|
|
56
|
+
# passed explicitly as repository arguments. A Backend must not inspect
|
|
57
|
+
# DurableRecord#payload to rediscover Phronomy domain semantics.
|
|
58
|
+
#
|
|
59
|
+
# Required raw repository shapes:
|
|
60
|
+
# - agents:
|
|
61
|
+
# create(agent_id:, agent_revision:, record:), load(id),
|
|
62
|
+
# save(id, expected_revision:, next_revision:, record:), delete(id)
|
|
63
|
+
# - journals:
|
|
64
|
+
# append(id, expected_position:, records:, record_ids:), read/head/delete
|
|
65
|
+
# - executions:
|
|
66
|
+
# create_active(execution_id:, agent_id:, execution_revision:, record:),
|
|
67
|
+
# load(id), save(id, expected_revision:, next_revision:, agent_id:,
|
|
68
|
+
# active:, record:), list_active/delete/delete_for_agent/assert_idle!
|
|
69
|
+
# - workflow_states:
|
|
70
|
+
# load(id), save(id, expected_revision:, next_revision:, record:), delete
|
|
71
|
+
#
|
|
72
|
+
# Subclasses normally construct backend-specific raw repository objects and
|
|
73
|
+
# call +super+. Construction fails fast when required capabilities are not
|
|
74
|
+
# advertised.
|
|
75
|
+
#
|
|
54
76
|
# @api public
|
|
55
77
|
def initialize(contents:, agents:, journals:, executions:, workflow_states:)
|
|
56
78
|
@contents = contents
|
|
57
|
-
@agents = agents
|
|
58
|
-
@journals = journals
|
|
59
|
-
@executions = executions
|
|
60
|
-
@workflow_states = workflow_states
|
|
79
|
+
@agents = RepositoryFacades::Agents.new(agents)
|
|
80
|
+
@journals = RepositoryFacades::Journals.new(journals)
|
|
81
|
+
@executions = RepositoryFacades::Executions.new(executions)
|
|
82
|
+
@workflow_states = RepositoryFacades::WorkflowStates.new(workflow_states)
|
|
61
83
|
validate_capabilities!
|
|
62
84
|
end
|
|
63
85
|
|
|
@@ -84,11 +106,9 @@ module Phronomy
|
|
|
84
106
|
|
|
85
107
|
# Executes one atomic durable transaction.
|
|
86
108
|
#
|
|
87
|
-
# The
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# backends are free to yield a separate transaction view backed by a checked
|
|
91
|
-
# out connection/session.
|
|
109
|
+
# The yielded object is a transaction-scoped Persistence view exposing the
|
|
110
|
+
# same domain-facing repository facades. Its underlying backend repositories
|
|
111
|
+
# remain DurableRecord-oriented.
|
|
92
112
|
#
|
|
93
113
|
# If the block raises, mutations made through the transaction view must not
|
|
94
114
|
# be committed. Storage failures whose commit outcome is fundamentally
|
|
@@ -103,6 +123,36 @@ module Phronomy
|
|
|
103
123
|
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
104
124
|
end
|
|
105
125
|
|
|
126
|
+
# Backend SPI helper for transaction implementations whose transaction-scoped
|
|
127
|
+
# raw repositories differ from the root repository objects.
|
|
128
|
+
#
|
|
129
|
+
# +watermark+ is a transaction-scoped object responding to
|
|
130
|
+
# +assert_agent_watermark!+. The returned view owns the same Phronomy codec
|
|
131
|
+
# facades as the root Persistence instance, so backend authors never need to
|
|
132
|
+
# instantiate RepositoryFacades directly.
|
|
133
|
+
#
|
|
134
|
+
# Backends whose repository objects are already transaction-scoped may yield
|
|
135
|
+
# +self+ and need not call this helper.
|
|
136
|
+
#
|
|
137
|
+
# @api public
|
|
138
|
+
def build_transaction_view(
|
|
139
|
+
contents:,
|
|
140
|
+
agents:,
|
|
141
|
+
journals:,
|
|
142
|
+
executions:,
|
|
143
|
+
workflow_states:,
|
|
144
|
+
watermark:
|
|
145
|
+
)
|
|
146
|
+
RepositoryFacades::View.new(
|
|
147
|
+
contents: contents,
|
|
148
|
+
agents: agents,
|
|
149
|
+
journals: journals,
|
|
150
|
+
executions: executions,
|
|
151
|
+
workflow_states: workflow_states,
|
|
152
|
+
watermark: watermark
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
106
156
|
# Verifies that a live Agent still owns the durable base it hydrated.
|
|
107
157
|
#
|
|
108
158
|
# This is a Backend SPI operation invoked by Phronomy at durable barriers.
|
|
@@ -111,16 +161,6 @@ module Phronomy
|
|
|
111
161
|
# supplied watermark in the same storage consistency view used by subsequent
|
|
112
162
|
# writes in the surrounding transaction.
|
|
113
163
|
#
|
|
114
|
-
# The method is a precondition check only. It must not reload or return
|
|
115
|
-
# replacement mutable Agent state; the live Agent remains the logical owner.
|
|
116
|
-
#
|
|
117
|
-
# @param agent_id [String]
|
|
118
|
-
# @param agent_revision [Integer]
|
|
119
|
-
# @param journal_position [Integer]
|
|
120
|
-
# @return [true]
|
|
121
|
-
# @raise [NotFoundError] when the Agent does not exist
|
|
122
|
-
# @raise [ConflictError] when either durable watermark component differs
|
|
123
|
-
# @raise [UnsupportedBackendError] when the backend does not implement the check
|
|
124
164
|
# @api public
|
|
125
165
|
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
126
166
|
raise UnsupportedBackendError,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Small private Recovery semantic/primitives layer shared by Agent and
|
|
5
|
+
# Workflow recovery. Entity-specific orchestration remains outside this module.
|
|
6
|
+
# @api private
|
|
7
|
+
module Recovery
|
|
8
|
+
RESUMABLE = :resumable
|
|
9
|
+
RECONCILABLE = :reconcilable
|
|
10
|
+
RESOLUTION_REQUIRED = :resolution_required
|
|
11
|
+
DISPOSITIONS = [RESUMABLE, RECONCILABLE, RESOLUTION_REQUIRED].freeze
|
|
12
|
+
|
|
13
|
+
ALLOWED_OUTCOMES = %i[succeeded failed not_performed].freeze
|
|
14
|
+
# Internal shorthand used by the Agent Recovery implementation. This is not
|
|
15
|
+
# a public API constant; both names refer to the same frozen value.
|
|
16
|
+
OUTCOMES = ALLOWED_OUTCOMES
|
|
17
|
+
|
|
18
|
+
MISSING = Object.new.freeze
|
|
19
|
+
|
|
20
|
+
Classification = Data.define(
|
|
21
|
+
:disposition, :reason, :subject, :allowed_outcomes, :facts
|
|
22
|
+
) do
|
|
23
|
+
def initialize(
|
|
24
|
+
disposition:,
|
|
25
|
+
reason:,
|
|
26
|
+
subject: nil,
|
|
27
|
+
allowed_outcomes: [],
|
|
28
|
+
facts: {}
|
|
29
|
+
)
|
|
30
|
+
normalized = disposition.to_sym
|
|
31
|
+
unless Phronomy::Recovery::DISPOSITIONS.include?(normalized)
|
|
32
|
+
raise ArgumentError, "unknown Recovery disposition: #{disposition.inspect}"
|
|
33
|
+
end
|
|
34
|
+
super(
|
|
35
|
+
disposition: normalized,
|
|
36
|
+
reason: reason.to_sym,
|
|
37
|
+
subject: subject && Phronomy::Recovery.normalize_subject(subject),
|
|
38
|
+
allowed_outcomes: Array(allowed_outcomes).map(&:to_sym).freeze,
|
|
39
|
+
facts: Phronomy::Agent::Immutable.copy(facts || {})
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def normalize_outcome(outcome)
|
|
47
|
+
normalized = outcome.to_sym
|
|
48
|
+
unless OUTCOMES.include?(normalized)
|
|
49
|
+
raise ArgumentError, "unsupported Recovery outcome: #{outcome.inspect}"
|
|
50
|
+
end
|
|
51
|
+
normalized
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_resolution_material!(outcome:, result_present:, error_present:)
|
|
55
|
+
normalized = normalize_outcome(outcome)
|
|
56
|
+
case normalized
|
|
57
|
+
when :succeeded
|
|
58
|
+
raise ArgumentError, "Recovery :succeeded requires result:" unless result_present
|
|
59
|
+
raise ArgumentError, "Recovery :succeeded forbids error:" if error_present
|
|
60
|
+
when :failed
|
|
61
|
+
raise ArgumentError, "Recovery :failed requires error:" unless error_present
|
|
62
|
+
raise ArgumentError, "Recovery :failed forbids result:" if result_present
|
|
63
|
+
when :not_performed
|
|
64
|
+
if result_present || error_present
|
|
65
|
+
raise ArgumentError, "Recovery :not_performed accepts neither result: nor error:"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_subject(subject)
|
|
72
|
+
hash = subject.to_h.transform_keys(&:to_sym)
|
|
73
|
+
type = hash.fetch(:type).to_sym
|
|
74
|
+
normalized = {type: type}
|
|
75
|
+
case type
|
|
76
|
+
when :llm_call
|
|
77
|
+
normalized[:llm_call_id] = required_id(hash, :llm_call_id)
|
|
78
|
+
when :tool_invocation
|
|
79
|
+
normalized[:tool_invocation_id] = required_id(hash, :tool_invocation_id)
|
|
80
|
+
when :persistence_operation
|
|
81
|
+
# Persistence operation references are structural only. Do not introduce
|
|
82
|
+
# a generic durable operation/recovery identity here.
|
|
83
|
+
normalized[:entity] = hash.fetch(:entity).to_sym
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "unsupported Recovery subject type: #{type.inspect}"
|
|
86
|
+
end
|
|
87
|
+
normalized.freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def subject_key(subject)
|
|
91
|
+
normalized = normalize_subject(subject)
|
|
92
|
+
case normalized.fetch(:type)
|
|
93
|
+
when :llm_call
|
|
94
|
+
"llm_call:#{normalized.fetch(:llm_call_id)}"
|
|
95
|
+
when :tool_invocation
|
|
96
|
+
"tool_invocation:#{normalized.fetch(:tool_invocation_id)}"
|
|
97
|
+
when :persistence_operation
|
|
98
|
+
"persistence_operation:#{normalized.fetch(:entity)}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def subject_equal?(left, right)
|
|
103
|
+
normalize_subject(left) == normalize_subject(right)
|
|
104
|
+
rescue ArgumentError, KeyError
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Compare authoritative current state with a known pre/post revision pair.
|
|
109
|
+
# This is deliberately small: callers still validate entity-specific content.
|
|
110
|
+
def compare_revisions(current_revision:, expected_pre_revision:, intended_post_revision:)
|
|
111
|
+
current = current_revision
|
|
112
|
+
return :post_state if current == intended_post_revision
|
|
113
|
+
return :pre_state if current == expected_pre_revision
|
|
114
|
+
|
|
115
|
+
:conflict
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Generic F1 helper for revisioned snapshot repositories such as Workflow.
|
|
119
|
+
# Some repositories return a newly allocated revision only after save, so
|
|
120
|
+
# callers may not know the intended post revision in advance. In that case
|
|
121
|
+
# the intended snapshot plus a revision different from the expected pre
|
|
122
|
+
# revision identifies the post-state.
|
|
123
|
+
def compare_revisioned_snapshot(
|
|
124
|
+
record:,
|
|
125
|
+
expected_pre_revision:,
|
|
126
|
+
intended_snapshot:,
|
|
127
|
+
intended_post_revision: nil
|
|
128
|
+
)
|
|
129
|
+
return :pre_state if record.nil? && expected_pre_revision.nil?
|
|
130
|
+
return :conflict if record.nil?
|
|
131
|
+
|
|
132
|
+
revision = fetch_value(record, :revision)
|
|
133
|
+
snapshot = fetch_value(record, :snapshot)
|
|
134
|
+
normalized_snapshot = normalize_value(snapshot)
|
|
135
|
+
normalized_intended = normalize_value(intended_snapshot)
|
|
136
|
+
|
|
137
|
+
if intended_post_revision
|
|
138
|
+
if revision == intended_post_revision &&
|
|
139
|
+
normalized_snapshot == normalized_intended
|
|
140
|
+
return :post_state
|
|
141
|
+
end
|
|
142
|
+
elsif normalized_snapshot == normalized_intended &&
|
|
143
|
+
revision != expected_pre_revision
|
|
144
|
+
return :post_state
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
return :pre_state if revision == expected_pre_revision
|
|
148
|
+
|
|
149
|
+
:conflict
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def normalize_value(value)
|
|
153
|
+
case value
|
|
154
|
+
when Hash
|
|
155
|
+
value.each_with_object({}) do |(key, child), result|
|
|
156
|
+
result[key.to_s] = normalize_value(child)
|
|
157
|
+
end
|
|
158
|
+
when Array
|
|
159
|
+
value.map { |child| normalize_value(child) }
|
|
160
|
+
when Symbol
|
|
161
|
+
value.to_s
|
|
162
|
+
else
|
|
163
|
+
value
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fetch_value(record, key)
|
|
168
|
+
return nil unless record
|
|
169
|
+
return record.public_send(key) if record.respond_to?(key)
|
|
170
|
+
return record[key] if record.respond_to?(:key?) && record.key?(key)
|
|
171
|
+
string_key = key.to_s
|
|
172
|
+
return record[string_key] if record.respond_to?(:key?) && record.key?(string_key)
|
|
173
|
+
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def required_id(hash, key)
|
|
178
|
+
value = hash.fetch(key)
|
|
179
|
+
text = value.to_s
|
|
180
|
+
raise ArgumentError, "#{key} must not be empty" if text.empty?
|
|
181
|
+
|
|
182
|
+
text.freeze
|
|
183
|
+
end
|
|
184
|
+
private_class_method :required_id
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -8,7 +8,7 @@ RSpec.shared_examples "a Journal repository" do
|
|
|
8
8
|
Phronomy::Agent::AgentRoot.create(
|
|
9
9
|
agent_id: "journal-agent-#{SecureRandom.uuid}",
|
|
10
10
|
agent_definition_id: "contract-agent",
|
|
11
|
-
|
|
11
|
+
agent_definition_version: 1
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -106,7 +106,7 @@ RSpec.shared_examples "a Journal repository" do
|
|
|
106
106
|
expected_position: 0,
|
|
107
107
|
records: [record]
|
|
108
108
|
)
|
|
109
|
-
end.to raise_error(Phronomy::Persistence::
|
|
109
|
+
end.to raise_error(Phronomy::Persistence::SerializationError)
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
it "reads records in sequence order and supports after/limit" do
|
|
@@ -17,15 +17,15 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
17
17
|
workflow_contract_value(record, :revision)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
it "returns nil for an unknown
|
|
20
|
+
it "returns nil for an unknown workflow_instance_id" do
|
|
21
21
|
expect(repository.load("missing-#{SecureRandom.uuid}")).to be_nil
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it "uses optimistic revisions for save" do
|
|
25
|
-
|
|
25
|
+
workflow_instance_id = "t1-#{SecureRandom.uuid}"
|
|
26
26
|
expect(
|
|
27
27
|
repository.save(
|
|
28
|
-
|
|
28
|
+
workflow_instance_id,
|
|
29
29
|
expected_revision: nil,
|
|
30
30
|
snapshot: {fields: {value: 1}, phase: "pause"}
|
|
31
31
|
)
|
|
@@ -33,7 +33,7 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
33
33
|
|
|
34
34
|
expect(
|
|
35
35
|
repository.save(
|
|
36
|
-
|
|
36
|
+
workflow_instance_id,
|
|
37
37
|
expected_revision: 1,
|
|
38
38
|
snapshot: {fields: {value: 2}, phase: "__end__"}
|
|
39
39
|
)
|
|
@@ -41,16 +41,16 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
it "rejects a stale expected_revision" do
|
|
44
|
-
|
|
44
|
+
workflow_instance_id = "t1-#{SecureRandom.uuid}"
|
|
45
45
|
repository.save(
|
|
46
|
-
|
|
46
|
+
workflow_instance_id,
|
|
47
47
|
expected_revision: nil,
|
|
48
48
|
snapshot: {fields: {value: 1}, phase: "pause"}
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
expect do
|
|
52
52
|
repository.save(
|
|
53
|
-
|
|
53
|
+
workflow_instance_id,
|
|
54
54
|
expected_revision: nil,
|
|
55
55
|
snapshot: {fields: {value: 2}, phase: "__end__"}
|
|
56
56
|
)
|
|
@@ -58,14 +58,14 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
it "returns a snapshot representation accepted by WorkflowRunner" do
|
|
61
|
-
|
|
61
|
+
workflow_instance_id = "t1-#{SecureRandom.uuid}"
|
|
62
62
|
repository.save(
|
|
63
|
-
|
|
63
|
+
workflow_instance_id,
|
|
64
64
|
expected_revision: nil,
|
|
65
65
|
snapshot: {fields: {value: 1}, phase: "pause"}
|
|
66
66
|
)
|
|
67
67
|
|
|
68
|
-
record = repository.load(
|
|
68
|
+
record = repository.load(workflow_instance_id)
|
|
69
69
|
snapshot = workflow_contract_snapshot(record)
|
|
70
70
|
fields = workflow_contract_value(snapshot, :fields)
|
|
71
71
|
|
|
@@ -75,14 +75,14 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
it "isolates stored snapshots from caller mutation" do
|
|
78
|
-
|
|
78
|
+
workflow_instance_id = "t1-#{SecureRandom.uuid}"
|
|
79
79
|
repository.save(
|
|
80
|
-
|
|
80
|
+
workflow_instance_id,
|
|
81
81
|
expected_revision: nil,
|
|
82
82
|
snapshot: {fields: {values: [1]}, phase: "pause"}
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
-
loaded = repository.load(
|
|
85
|
+
loaded = repository.load(workflow_instance_id)
|
|
86
86
|
snapshot = workflow_contract_snapshot(loaded)
|
|
87
87
|
fields = workflow_contract_value(snapshot, :fields)
|
|
88
88
|
values = workflow_contract_value(fields, :values)
|
|
@@ -93,7 +93,7 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
93
93
|
nil
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
reloaded = repository.load(
|
|
96
|
+
reloaded = repository.load(workflow_instance_id)
|
|
97
97
|
reloaded_fields = workflow_contract_value(
|
|
98
98
|
workflow_contract_snapshot(reloaded),
|
|
99
99
|
:fields
|
|
@@ -102,18 +102,18 @@ RSpec.shared_examples "a workflow state repository" do
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
it "deletes only at the expected revision" do
|
|
105
|
-
|
|
105
|
+
workflow_instance_id = "t1-#{SecureRandom.uuid}"
|
|
106
106
|
repository.save(
|
|
107
|
-
|
|
107
|
+
workflow_instance_id,
|
|
108
108
|
expected_revision: nil,
|
|
109
109
|
snapshot: {fields: {}, phase: "pause"}
|
|
110
110
|
)
|
|
111
111
|
|
|
112
112
|
expect do
|
|
113
|
-
repository.delete(
|
|
113
|
+
repository.delete(workflow_instance_id, expected_revision: 99)
|
|
114
114
|
end.to raise_error(Phronomy::Persistence::ConflictError)
|
|
115
115
|
|
|
116
|
-
repository.delete(
|
|
117
|
-
expect(repository.load(
|
|
116
|
+
repository.delete(workflow_instance_id, expected_revision: 1)
|
|
117
|
+
expect(repository.load(workflow_instance_id)).to be_nil
|
|
118
118
|
end
|
|
119
119
|
end
|
|
@@ -8,7 +8,7 @@ RSpec.shared_examples "an Agent repository" do
|
|
|
8
8
|
Phronomy::Agent::AgentRoot.create(
|
|
9
9
|
agent_id: "contract-agent-#{SecureRandom.uuid}",
|
|
10
10
|
agent_definition_id: "contract-agent",
|
|
11
|
-
|
|
11
|
+
agent_definition_version: 1
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -62,7 +62,7 @@ RSpec.shared_examples "an Agent repository" do
|
|
|
62
62
|
other = Phronomy::Agent::AgentRoot.create(
|
|
63
63
|
agent_id: "other-#{SecureRandom.uuid}",
|
|
64
64
|
agent_definition_id: "contract-agent",
|
|
65
|
-
|
|
65
|
+
agent_definition_version: 1
|
|
66
66
|
).with(agent_revision: 1)
|
|
67
67
|
|
|
68
68
|
expect do
|
|
@@ -71,7 +71,7 @@ RSpec.shared_examples "an Agent repository" do
|
|
|
71
71
|
expected_revision: 0,
|
|
72
72
|
root: other
|
|
73
73
|
)
|
|
74
|
-
end.to raise_error(Phronomy::Persistence::
|
|
74
|
+
end.to raise_error(Phronomy::Persistence::SerializationError)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
it "requires revision to advance exactly once" do
|
|
@@ -8,7 +8,7 @@ RSpec.shared_examples "an Execution repository" do
|
|
|
8
8
|
Phronomy::Agent::AgentRoot.create(
|
|
9
9
|
agent_id: "execution-agent-#{SecureRandom.uuid}",
|
|
10
10
|
agent_definition_id: "contract-agent",
|
|
11
|
-
|
|
11
|
+
agent_definition_version: 1
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -66,7 +66,7 @@ RSpec.shared_examples "an Execution repository" do
|
|
|
66
66
|
other_root = Phronomy::Agent::AgentRoot.create(
|
|
67
67
|
agent_id: "execution-agent-#{SecureRandom.uuid}",
|
|
68
68
|
agent_definition_id: "contract-agent",
|
|
69
|
-
|
|
69
|
+
agent_definition_version: 1
|
|
70
70
|
)
|
|
71
71
|
persistence.agents.create(other_root)
|
|
72
72
|
|
|
@@ -116,7 +116,7 @@ RSpec.shared_examples "an Execution repository" do
|
|
|
116
116
|
other_root = Phronomy::Agent::AgentRoot.create(
|
|
117
117
|
agent_id: "execution-agent-#{SecureRandom.uuid}",
|
|
118
118
|
agent_definition_id: "contract-agent",
|
|
119
|
-
|
|
119
|
+
agent_definition_version: 1
|
|
120
120
|
)
|
|
121
121
|
persistence.agents.create(other_root)
|
|
122
122
|
other = build_contract_execution(other_root).with(
|
|
@@ -131,7 +131,7 @@ RSpec.shared_examples "an Execution repository" do
|
|
|
131
131
|
expected_revision: 0,
|
|
132
132
|
execution: other
|
|
133
133
|
)
|
|
134
|
-
end.to raise_error(Phronomy::Persistence::
|
|
134
|
+
end.to raise_error(Phronomy::Persistence::SerializationError)
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
it "requires execution revision to advance exactly once" do
|
|
@@ -186,7 +186,7 @@ RSpec.shared_examples "an Execution repository" do
|
|
|
186
186
|
other_root = Phronomy::Agent::AgentRoot.create(
|
|
187
187
|
agent_id: "execution-agent-#{SecureRandom.uuid}",
|
|
188
188
|
agent_definition_id: "contract-agent",
|
|
189
|
-
|
|
189
|
+
agent_definition_version: 1
|
|
190
190
|
)
|
|
191
191
|
persistence.agents.create(other_root)
|
|
192
192
|
own = build_contract_execution(execution_agent_root)
|