phronomy 0.22.0 → 0.24.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class HandoffProjection
6
+ CONTROL_CATEGORIES = %i[instruction handoff_responsibility].freeze
7
+ JSON_CATEGORIES = %i[assistant_message tool_message].freeze
8
+
9
+ Identity = Data.define(:agent_id)
10
+ Edge = Data.define(:policy, :target_agent)
11
+ Snapshot = Data.define(:handoff, :responsibility, :selection_intent)
12
+ private_constant :Identity, :Edge, :Snapshot
13
+
14
+ # A terminal command contains values only, captured on EventLoop.
15
+ def build_terminal(view:, manifest:, persistence:, source_agent_id:)
16
+ request = Snapshot.new(
17
+ handoff: Edge.new(policy: HandoffPolicy.from_h(view.policy), target_agent: Identity.new(agent_id: view.target_agent_id)),
18
+ responsibility: view.responsibility,
19
+ selection_intent: view.selection_intent.transform_keys(&:to_sym)
20
+ )
21
+ build(request: request, manifest: manifest, persistence: persistence,
22
+ source_agent: Identity.new(agent_id: source_agent_id))
23
+ end
24
+
25
+ def build(request:, manifest:, persistence:, source_agent:)
26
+ unless request.is_a?(HandoffRequest) || request.is_a?(Snapshot)
27
+ raise ArgumentError, "request must be a HandoffRequest"
28
+ end
29
+ unless manifest.is_a?(Phronomy::Agent::LLMInputManifest)
30
+ raise ArgumentError, "manifest must be an LLMInputManifest"
31
+ end
32
+
33
+ groups = project_visible_groups(manifest)
34
+ selected = groups.values.select do |group|
35
+ include_group?(request, group.fetch(:policy_category))
36
+ end
37
+
38
+ items = selected.flat_map do |group|
39
+ group.fetch(:segments).map do |segment|
40
+ materialize_item(
41
+ segment,
42
+ policy_category: group.fetch(:policy_category),
43
+ persistence: persistence,
44
+ source_agent: source_agent,
45
+ target_agent: request.handoff.target_agent
46
+ )
47
+ end
48
+ end
49
+
50
+ HandoffContext.new(
51
+ responsibility: request.responsibility,
52
+ items: items
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def project_visible_groups(manifest)
59
+ groups = {}
60
+ manifest.segments.each do |segment|
61
+ policy_category = policy_category_for(segment)
62
+ next unless policy_category
63
+
64
+ key = selection_group_key(segment, policy_category)
65
+ group = groups[key] ||= {
66
+ policy_category: policy_category,
67
+ segments: []
68
+ }
69
+ if group[:policy_category] != policy_category
70
+ raise Phronomy::HandoffError,
71
+ "one Handoff projection group spans incompatible policy categories"
72
+ end
73
+ group[:segments] << segment
74
+ end
75
+ groups
76
+ end
77
+
78
+ def policy_category_for(segment)
79
+ explicit = segment.metadata["handoff_policy_category"] ||
80
+ segment.metadata[:handoff_policy_category]
81
+ return explicit.to_sym if explicit
82
+
83
+ semantic_category =
84
+ segment.metadata["context_policy_semantic_category"] ||
85
+ segment.metadata[:context_policy_semantic_category]
86
+ case semantic_category&.to_sym
87
+ when :instruction
88
+ return nil
89
+ when :knowledge
90
+ return :knowledge
91
+ when :conversation
92
+ return :current_request if segment.delivery.to_sym == :ask_argument
93
+ return :history
94
+ end
95
+
96
+ return :current_request if segment.delivery.to_sym == :ask_argument
97
+ return nil if CONTROL_CATEGORIES.include?(segment.category.to_sym)
98
+ return :knowledge if segment.category.to_sym == :knowledge
99
+ unit_kind = segment.metadata["selection_unit_kind"] ||
100
+ segment.metadata[:selection_unit_kind]
101
+ return :tool_exchanges if unit_kind.to_s == "tool_exchange"
102
+
103
+ case segment.category.to_sym
104
+ when :external_message, :assistant_message, :tool_message,
105
+ :conversation, :memory, :summary, :structured_state
106
+ :history
107
+ end
108
+ end
109
+
110
+ def selection_group_key(segment, policy_category)
111
+ conversation_group_id =
112
+ segment.metadata["context_policy_conversation_group_id"] ||
113
+ segment.metadata[:context_policy_conversation_group_id]
114
+ if conversation_group_id
115
+ return "context-policy-conversation:#{conversation_group_id}"
116
+ end
117
+
118
+ # Compatibility for finalized pre-ACS-04 manifests. New manifests use
119
+ # context_policy_conversation_group_id and do not recreate Selection::Unit.
120
+ unit_id = segment.metadata["selection_unit_id"] ||
121
+ segment.metadata[:selection_unit_id]
122
+ return "unit:#{unit_id}" if unit_id
123
+
124
+ "segment:#{policy_category}:#{segment.position}"
125
+ end
126
+
127
+ def include_group?(request, category)
128
+ policy = request.handoff.policy
129
+ return true if policy.required?(category)
130
+ return false if policy.forbidden?(category)
131
+
132
+ request.selection_intent.fetch(category) do
133
+ policy.default_include?(category)
134
+ end
135
+ end
136
+
137
+ def materialize_item(segment, policy_category:, persistence:, source_agent:, target_agent:)
138
+ bytes = persistence.contents.fetch(segment.content_ref)
139
+ category = segment.category.to_sym
140
+ metadata = segment.metadata.to_h.transform_keys(&:to_s)
141
+ format = content_format_for(segment, metadata)
142
+ content = (format == :json) ? Phronomy::CanonicalJSON.load(bytes) : bytes.to_s
143
+ provenance = provenance_for(
144
+ metadata,
145
+ segment: segment,
146
+ source_agent: source_agent,
147
+ target_agent: target_agent
148
+ )
149
+
150
+ HandoffContext::Item.new(
151
+ candidate_category: category,
152
+ policy_category: policy_category,
153
+ role: segment.role,
154
+ content: content,
155
+ content_format: format,
156
+ tool_call_id: segment.tool_call_id,
157
+ provenance: provenance,
158
+ metadata: metadata.except(
159
+ "context_policy_content_format",
160
+ "context_policy_conversation_group_id",
161
+ "selection_candidate_id", "selection_unit_id", "selection_unit_kind",
162
+ "handoff_policy_category", "handoff_provenance"
163
+ )
164
+ )
165
+ end
166
+
167
+ def content_format_for(segment, metadata)
168
+ explicit = metadata["context_policy_content_format"] ||
169
+ metadata[:context_policy_content_format]
170
+ if explicit
171
+ format = explicit.to_sym
172
+ unless Phronomy::Agent::ContextPolicyInput::CONTENT_FORMATS.include?(format)
173
+ raise Phronomy::HandoffError,
174
+ "unsupported Context content format in Manifest: #{explicit.inspect}"
175
+ end
176
+ return format
177
+ end
178
+
179
+ JSON_CATEGORIES.include?(segment.category.to_sym) ? :json : :text
180
+ end
181
+
182
+ def provenance_for(metadata, segment:, source_agent:, target_agent:)
183
+ inherited = metadata["handoff_provenance"]
184
+ if inherited
185
+ base = HandoffContext::Provenance.new(
186
+ origin_agent_id: inherited["origin_agent_id"] || inherited[:origin_agent_id],
187
+ origin_record_id: inherited["origin_record_id"] || inherited[:origin_record_id],
188
+ origin_execution_id: inherited["origin_execution_id"] || inherited[:origin_execution_id],
189
+ origin_llm_call_id: inherited["origin_llm_call_id"] || inherited[:origin_llm_call_id],
190
+ origin_tool_call_id: inherited["origin_tool_call_id"] || inherited[:origin_tool_call_id],
191
+ transfer_path: inherited["transfer_path"] || inherited[:transfer_path]
192
+ )
193
+ return base.forwarded_to(target_agent.agent_id)
194
+ end
195
+
196
+ HandoffContext::Provenance.new(
197
+ origin_agent_id: metadata["source_agent_id"] || source_agent.agent_id,
198
+ origin_record_id: metadata["journal_record_id"],
199
+ origin_execution_id: metadata["source_execution_id"],
200
+ origin_llm_call_id: metadata["llm_call_id"],
201
+ origin_tool_call_id: segment.tool_call_id,
202
+ transfer_path: [source_agent.agent_id, target_agent.agent_id]
203
+ )
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Typed private control request produced from one intercepted Handoff capability.
6
+ # @api private
7
+ HandoffRequest = Data.define(
8
+ :handoff, :responsibility, :selection_intent, :llm_call_id, :tool_call_id
9
+ ) do
10
+ def initialize(handoff:, responsibility:, selection_intent:, llm_call_id: nil, tool_call_id: nil)
11
+ responsibility = responsibility.to_s.strip
12
+ raise ArgumentError, "Handoff responsibility must not be empty" if responsibility.empty?
13
+ unless handoff.is_a?(Handoff)
14
+ raise ArgumentError, "handoff must be a Phronomy::Agent::Handoff"
15
+ end
16
+
17
+ normalized = handoff.policy.selectable_categories.to_h do |category|
18
+ raw = selection_intent.fetch(category) do
19
+ handoff.policy.default_include?(category)
20
+ end
21
+ unless raw == true || raw == false
22
+ raise ArgumentError,
23
+ "Handoff selection for #{category.inspect} must be boolean"
24
+ end
25
+ [category, raw]
26
+ end.freeze
27
+
28
+ unknown = selection_intent.keys.map(&:to_sym) - handoff.policy.selectable_categories
29
+ unless unknown.empty?
30
+ raise ArgumentError,
31
+ "Handoff selection attempts to override non-selectable categories: #{unknown.inspect}"
32
+ end
33
+
34
+ super(
35
+ handoff: handoff,
36
+ responsibility: responsibility.freeze,
37
+ selection_intent: normalized,
38
+ llm_call_id: llm_call_id&.to_s&.freeze,
39
+ tool_call_id: tool_call_id&.to_s&.freeze
40
+ )
41
+ freeze
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Durable Agent-domain responsibility routing within one Persistence instance.
8
+ # @api public
9
+ class HandoffRunner
10
+ MAX_HANDOFFS = 20
11
+
12
+ # @api public
13
+ attr_reader :main_agent, :handoffs
14
+
15
+ # @api public
16
+ def initialize(main_agent:, handoffs: [])
17
+ @main_agent, @handoffs = main_agent, Array(handoffs).freeze
18
+ @persistence = main_agent.persistence
19
+ raise ArgumentError, "handoffs must contain Agent::Handoff" unless @handoffs.all? { |h| h.is_a?(Handoff) }
20
+ @agents = ([main_agent] + @handoffs.flat_map { |edge| [edge.source_agent, edge.target_agent] }).uniq.to_h { |a| [a.agent_id, a] }.freeze
21
+ unless @agents.values.all? { |a| a.persistence.equal?(@persistence) }
22
+ raise Phronomy::ConfigurationError, "Durable Handoff graph requires one Persistence instance"
23
+ end
24
+ if @handoffs.group_by { |h| [h.source_agent.agent_id, h.target_agent.agent_id] }.any? { |_, edges| edges.size > 1 }
25
+ raise ArgumentError, "Duplicate Source/Target Handoff edges"
26
+ end
27
+ @bindings = @handoffs.group_by { |h| h.source_agent.agent_id }.transform_values { |edges| edges.map { |h| HandoffCapabilityFactory.build(h) }.freeze }.freeze
28
+ @runtime = Phronomy::Runtime.instance
29
+ end
30
+
31
+ # @api public
32
+ def invoke(input, config: {})
33
+ trace_handle = Phronomy::Tracing::Automatic.start("multi_agent.turn",
34
+ input: input, main_agent_id: main_agent.agent_id,
35
+ **main_agent.send(:_build_caller_meta, config))
36
+ operation_error = result = nil
37
+ if Phronomy::Runtime.in_event_loop_context?
38
+ raise Phronomy::EventLoopReentrancyError, "HandoffRunner#invoke cannot block EventLoop"
39
+ end
40
+ unless Phronomy::Runtime.instance.equal?(@runtime)
41
+ raise Phronomy::RuntimeShutdownError, "HandoffRunner belongs to a previous Runtime"
42
+ end
43
+ config = config.merge(cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new)
44
+ @runtime.__admit_multi_agent(main_agent)
45
+ admitted = true
46
+ state = load_state
47
+ count = 0
48
+ loop do
49
+ active = @agents.fetch(state.active_agent_id) do
50
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks active Agent #{state.active_agent_id}"
51
+ end
52
+ context = state.active_handoff_context_ref && HandoffContext.from_h(@persistence.contents.fetch_json(state.active_handoff_context_ref))
53
+ wiring = config.merge(phronomy_handoff_bindings: @bindings.fetch(active.agent_id, []),
54
+ phronomy_handoff_context: context,
55
+ phronomy_coordination: {"kind" => "handoff", "main_agent_id" => main_agent.agent_id, "handoff_revision" => state.handoff_revision}).freeze
56
+ active.instance_variable_set(:@_phronomy_coordination_config, wiring)
57
+ result = if state.phase == "stable"
58
+ unfinished = @persistence.executions.list_active(active.agent_id)
59
+ if unfinished.empty?
60
+ active.invoke(input, config: wiring)
61
+ else
62
+ exact = unfinished.fetch(0)
63
+ unless unfinished.size == 1 && exact.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
64
+ raise Phronomy::Persistence::ConflictError, "Active Agent execution belongs to another coordination turn"
65
+ end
66
+ wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(exact.execution_id)
67
+ stored_input = @persistence.contents.fetch_text(exact.metadata.fetch("current_input_ref"))
68
+ ExactExecution.start(agent: active, execution_id: exact.execution_id, input: stored_input, config: wiring).wait_result
69
+ end
70
+ else
71
+ source = @persistence.executions.load(state.pending_source_execution_id)
72
+ unless @handoffs.any? { |edge| edge.source_agent.agent_id == source.agent_id && edge.target_agent.agent_id == state.active_agent_id }
73
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks committed Source/Target edge"
74
+ end
75
+ definition = state.metadata.fetch("target_definition")
76
+ unless active.class.agent_definition == definition.transform_keys(&:to_sym)
77
+ raise Phronomy::ConfigurationError, "Handoff Target definition mismatch"
78
+ end
79
+ wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(state.pending_target_execution_id)
80
+ ExactExecution.start(agent: active, execution_id: state.pending_target_execution_id,
81
+ input: context.responsibility, config: wiring).wait_result
82
+ end
83
+ execution = @persistence.executions.load(result.fetch(:execution_id))
84
+ if execution.status == :handed_off
85
+ count += 1
86
+ raise Phronomy::HandoffError, "Exceeded maximum Handoffs in one turn" if count > MAX_HANDOFFS
87
+ state = @persistence.handoff_states.load(main_agent.agent_id)
88
+ next
89
+ end
90
+ if execution.active?
91
+ raise Phronomy::ExecutionRehydrationRequiredError, "Handoff execution requires approval or recovery"
92
+ end
93
+ raise RecoverySupport.error_from_failure(result[:error]) if result[:error]
94
+ return result.reject { |key, _| key.to_s.start_with?("_phronomy_") || key == :handoff_request }.merge(agent: active)
95
+ end
96
+ rescue Phronomy::CancellationError => error
97
+ operation_error = error
98
+ cancel(state.pending_source_execution_id) if state && state.phase != "stable"
99
+ raise
100
+ rescue => error
101
+ operation_error = error
102
+ raise
103
+ ensure
104
+ @runtime.__release_multi_agent(main_agent) if admitted
105
+ Phronomy::Tracing::Automatic.finish(trace_handle, output: result && result[:output], error: operation_error)
106
+ end
107
+
108
+ # Durably scopes cancellation to an exact turn, including a transferred
109
+ # Target reservation. A completed older turn never cancels a later turn.
110
+ # @api public
111
+ def cancel(execution_id)
112
+ if Phronomy::Runtime.in_event_loop_context?
113
+ raise Phronomy::EventLoopReentrancyError, "HandoffRunner#cancel cannot block EventLoop"
114
+ end
115
+ intended = leaf = leaf_id = nil
116
+ begin
117
+ @persistence.transaction do |tx|
118
+ first = tx.executions.load(execution_id)
119
+ unless first.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
120
+ raise Phronomy::Persistence::ConflictError, "Execution does not belong to this Handoff anchor"
121
+ end
122
+ leaf = first
123
+ leaf_id = first.execution_id
124
+ seen = {}
125
+ while leaf&.status == :handed_off
126
+ raise Phronomy::Persistence::SerializationError, "Cyclic Handoff chain" if seen[leaf_id]
127
+ seen[leaf_id] = true
128
+ leaf_id = leaf.metadata.fetch("handoff_target_execution_id")
129
+ begin
130
+ leaf = tx.executions.load(leaf_id)
131
+ rescue Phronomy::Persistence::NotFoundError
132
+ leaf = nil
133
+ end
134
+ end
135
+ next if leaf&.terminal?
136
+ routing = tx.handoff_states.load(main_agent.agent_id)
137
+ unless routing && (leaf ? routing.active_agent_id == leaf.agent_id : routing.pending_target_execution_id == leaf_id)
138
+ raise Phronomy::Persistence::ConflictError, "Handoff routing no longer owns the requested turn"
139
+ end
140
+ ids = (Array(routing.metadata["cancelled_execution_ids"]) + [leaf_id]).uniq
141
+ intended = routing.with(phase: leaf ? routing.phase : "stable",
142
+ metadata: routing.metadata.merge("cancelled_execution_ids" => ids))
143
+ tx.handoff_states.save(main_agent.agent_id, expected_revision: routing.handoff_revision, state: intended)
144
+ end
145
+ rescue => error
146
+ confirmed = @persistence.handoff_states.load(main_agent.agent_id)
147
+ raise error unless intended && confirmed && Array(confirmed.metadata["cancelled_execution_ids"]).include?(leaf_id)
148
+ end
149
+ return @persistence.execution_result(leaf_id) if leaf&.terminal?
150
+ ExecutionCancellation.signal(leaf_id, leaf.agent_id) if leaf&.active?
151
+ {execution_id: leaf_id, cancellation_requested: !leaf&.terminal?}.freeze
152
+ end
153
+
154
+ # Reads a specified source/Target chain without current graph continuation.
155
+ # @api public
156
+ def result(execution_id)
157
+ @persistence.handoff_result(execution_id, main_agent_id: main_agent.agent_id)
158
+ end
159
+
160
+ private
161
+
162
+ def load_state
163
+ state = @persistence.handoff_states.load(main_agent.agent_id)
164
+ return state if state
165
+ now = Time.now.utc.iso8601(6)
166
+ initial = HandoffState.new(main_agent_id: main_agent.agent_id, handoff_revision: 1,
167
+ active_agent_id: main_agent.agent_id, active_handoff_context_ref: nil,
168
+ phase: "stable", pending_source_execution_id: nil, pending_target_execution_id: nil,
169
+ created_at: now, updated_at: now, metadata: {})
170
+ @persistence.transaction { |tx| tx.handoff_states.save(main_agent.agent_id, expected_revision: nil, state: initial) }
171
+ rescue => error
172
+ confirmed = @persistence.handoff_states.load(main_agent.agent_id)
173
+ raise error unless confirmed
174
+ confirmed
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Immutable current-format semantic record; contains no Runtime handles.
8
+ # @api private
9
+ class HandoffState
10
+ ATTRIBUTES = %w[main_agent_id handoff_revision active_agent_id active_handoff_context_ref phase pending_source_execution_id pending_target_execution_id created_at updated_at metadata].freeze
11
+ attr_reader(*ATTRIBUTES)
12
+
13
+ def initialize(**values)
14
+ source = values.transform_keys(&:to_s)
15
+ raise ArgumentError, "HandoffState schema mismatch" unless source.keys.sort == ATTRIBUTES.sort
16
+ canonical = Phronomy::CanonicalJSON.load(Phronomy::CanonicalJSON.dump(source))
17
+ ATTRIBUTES.each { |key| instance_variable_set("@#{key}", Phronomy::Agent::Immutable.copy(canonical.fetch(key))) }
18
+ raise ArgumentError, "missing main_agent_id" if main_agent_id.to_s.empty?
19
+ raise ArgumentError, "invalid handoff_revision" unless handoff_revision.is_a?(Integer) && handoff_revision >= 0
20
+ raise ArgumentError, "invalid metadata" unless metadata.is_a?(Hash)
21
+ raise ArgumentError, "invalid Handoff phase" unless %w[stable target_pending target_active].include?(phase)
22
+ raise ArgumentError, "missing active Agent" if active_agent_id.to_s.empty?
23
+ if phase != "stable" && pending_target_execution_id.to_s.empty?
24
+ raise ArgumentError, "pending Handoff requires exact Target execution"
25
+ end
26
+ freeze
27
+ end
28
+
29
+ def to_h = ATTRIBUTES.to_h { |key| [key, public_send(key)] }.freeze
30
+
31
+ def self.from_h(value)
32
+ new(**value.transform_keys(&:to_sym))
33
+ end
34
+
35
+ def with(**changes)
36
+ values = to_h.merge(changes.transform_keys(&:to_s))
37
+ values["handoff_revision"] = handoff_revision + 1 unless changes.key?(:handoff_revision)
38
+ values["updated_at"] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
39
+ self.class.from_h(values)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -13,7 +13,7 @@ module Phronomy
13
13
  class JournalRecord
14
14
  ATTRIBUTES = %i[
15
15
  record_id agent_id sequence execution_id llm_call_id kind channel role content_ref
16
- parent_id causation_id correlation_id visibility context_generation
16
+ parent_id causation_id visibility context_generation
17
17
  context_candidate occurred_at metadata
18
18
  ].freeze
19
19
 
@@ -31,7 +31,6 @@ module Phronomy
31
31
  content_ref: nil,
32
32
  parent_id: nil,
33
33
  causation_id: nil,
34
- correlation_id: nil,
35
34
  visibility: :agent,
36
35
  context_generation: 0,
37
36
  context_candidate: false,
@@ -52,14 +51,28 @@ module Phronomy
52
51
  self.class.from_h(to_h.merge("sequence" => sequence))
53
52
  end
54
53
 
54
+ # Current semantic payload representation. Persistence format identity and
55
+ # compatibility validation are owned by Persistence::DurableCodec.
55
56
  def to_h
56
57
  ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
57
58
  end
58
59
 
60
+ # Historical durable fields are not accepted here. Explicit migration must
61
+ # convert them to the current semantic payload first.
59
62
  def self.from_h(hash)
63
+ source = hash.to_h { |key, value| [key.to_s, value] }
64
+ expected = ATTRIBUTES.map(&:to_s).sort
65
+ actual = source.keys.sort
66
+ unless actual == expected
67
+ missing = expected - actual
68
+ unknown = actual - expected
69
+ raise ArgumentError,
70
+ "JournalRecord payload schema mismatch: " \
71
+ "missing=#{missing.inspect}, unknown=#{unknown.inspect}"
72
+ end
73
+
60
74
  new(**ATTRIBUTES.to_h do |name|
61
- key = hash.key?(name.to_s) ? name.to_s : name
62
- [name, hash[key]]
75
+ [name, source.fetch(name.to_s)]
63
76
  end)
64
77
  end
65
78
  end
@@ -5,7 +5,7 @@ module Phronomy
5
5
  # Immutable metadata passed to before_llm_input hooks. Runtime objects such
6
6
  # as RubyLLM::Chat, messages and the mutable Agent instance are not exposed.
7
7
  LLMInputBuildContext = Data.define(
8
- :agent_id, :agent_definition_id, :definition_version,
8
+ :agent_id, :agent_definition_id, :agent_definition_version,
9
9
  :config, :call_sequence
10
10
  ) do
11
11
  def initialize(**values)