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
@@ -2,19 +2,98 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
+ # ContentStore-backed durable codec boundary for one Provider input manifest.
6
+ # It is intentionally separate from Persistence::DurableRecord.
5
7
  class LLMInputManifest
6
- VERSION = 1
8
+ VERSION = "0.1"
7
9
  CALL_MODES = %i[ask complete].freeze
10
+ SEGMENT_DELIVERIES = %w[ask_argument chat_message].freeze
8
11
 
9
12
  Segment = Data.define(
10
13
  :position, :category, :role, :content_ref, :delivery,
11
14
  :tool_call_id, :metadata
12
15
  ) do
13
16
  def initialize(**values)
17
+ delivery = values[:delivery]
18
+ if delivery.nil? || !SEGMENT_DELIVERIES.include?(delivery.to_s)
19
+ raise ArgumentError, "unknown Segment delivery: #{delivery.inspect}"
20
+ end
14
21
  super(**values.merge(metadata: Immutable.copy(values[:metadata] || {})))
15
22
  freeze
16
23
  end
17
24
 
25
+ def self.from_h(hash)
26
+ source = LLMInputManifest.send(
27
+ :strict_source!,
28
+ hash,
29
+ required: %w[position category content_ref delivery metadata],
30
+ optional: %w[role tool_call_id],
31
+ label: "LLMInputManifest segment"
32
+ )
33
+ label = "LLMInputManifest segment"
34
+ position = LLMInputManifest.send(
35
+ :require_nonnegative_integer_field!,
36
+ source,
37
+ "position",
38
+ label: label
39
+ )
40
+ category = LLMInputManifest.send(
41
+ :require_nonempty_string_field!,
42
+ source,
43
+ "category",
44
+ label: label
45
+ )
46
+ role = LLMInputManifest.send(
47
+ :require_optional_string_field!,
48
+ source,
49
+ "role",
50
+ label: label
51
+ )
52
+ content_ref = LLMInputManifest.send(
53
+ :require_nonempty_string_field!,
54
+ source,
55
+ "content_ref",
56
+ label: label
57
+ )
58
+ delivery = LLMInputManifest.send(
59
+ :require_nonempty_string_field!,
60
+ source,
61
+ "delivery",
62
+ label: label
63
+ )
64
+ unless SEGMENT_DELIVERIES.include?(delivery)
65
+ raise Phronomy::Persistence::SerializationError,
66
+ "LLMInputManifest segment delivery must be one of #{SEGMENT_DELIVERIES.inspect}, got #{delivery.inspect}"
67
+ end
68
+ tool_call_id = LLMInputManifest.send(
69
+ :require_optional_string_field!,
70
+ source,
71
+ "tool_call_id",
72
+ label: label
73
+ )
74
+ metadata = LLMInputManifest.send(
75
+ :require_hash_field!,
76
+ source,
77
+ "metadata",
78
+ label: label
79
+ )
80
+
81
+ new(
82
+ position: position,
83
+ category: category.to_sym,
84
+ role: role&.to_sym,
85
+ content_ref: content_ref,
86
+ delivery: delivery.to_sym,
87
+ tool_call_id: tool_call_id,
88
+ metadata: metadata
89
+ )
90
+ rescue Phronomy::Persistence::SerializationError
91
+ raise
92
+ rescue => error
93
+ raise Phronomy::Persistence::SerializationError,
94
+ "invalid LLMInputManifest segment: #{error.class}: #{error.message}"
95
+ end
96
+
18
97
  def to_h
19
98
  {
20
99
  "position" => position,
@@ -28,6 +107,15 @@ module Phronomy
28
107
  end
29
108
  end
30
109
 
110
+ REQUIRED_KEYS = %w[
111
+ version call_sequence call_mode assembly_policy_version segments
112
+ model_config_ref
113
+ ].freeze
114
+ OPTIONAL_KEYS = %w[
115
+ tool_definitions_ref response_schema_ref ruby_llm_version
116
+ adapter_name adapter_version
117
+ ].freeze
118
+
31
119
  attr_reader :version, :call_sequence, :call_mode,
32
120
  :assembly_policy_version, :segments,
33
121
  :model_config_ref, :tool_definitions_ref, :response_schema_ref,
@@ -46,7 +134,7 @@ module Phronomy
46
134
  adapter_version: nil,
47
135
  version: VERSION
48
136
  )
49
- @version = Integer(version)
137
+ @version = String(version).freeze
50
138
  @call_sequence = Integer(call_sequence)
51
139
  @call_mode = call_mode.to_sym
52
140
  @assembly_policy_version = Integer(assembly_policy_version)
@@ -61,6 +149,93 @@ module Phronomy
61
149
  freeze
62
150
  end
63
151
 
152
+ # Current-format-only durable decoder. Historical format conversion is an
153
+ # explicit migration operation and is never attempted here. Unlike the
154
+ # Ruby construction API, this decoder never coerces durable field types.
155
+ def self.from_h(hash)
156
+ source = strict_source!(
157
+ hash,
158
+ required: REQUIRED_KEYS,
159
+ optional: OPTIONAL_KEYS,
160
+ label: "LLMInputManifest"
161
+ )
162
+ label = "LLMInputManifest"
163
+ version = source.fetch("version")
164
+ unless version.is_a?(String) && version == VERSION
165
+ raise Phronomy::Persistence::SerializationError,
166
+ "unsupported LLMInputManifest version: #{version.inspect}; " \
167
+ "current version is #{VERSION.inspect}"
168
+ end
169
+
170
+ call_sequence = require_positive_integer_field!(
171
+ source,
172
+ "call_sequence",
173
+ label: label
174
+ )
175
+ call_mode = require_enum_string_field!(
176
+ source,
177
+ "call_mode",
178
+ CALL_MODES.map(&:to_s),
179
+ label: label
180
+ )
181
+ assembly_policy_version = require_integer_field!(
182
+ source,
183
+ "assembly_policy_version",
184
+ label: label
185
+ )
186
+ segments = require_array_field!(source, "segments", label: label)
187
+ .map { |segment| Segment.from_h(segment) }
188
+ model_config_ref = require_nonempty_string_field!(
189
+ source,
190
+ "model_config_ref",
191
+ label: label
192
+ )
193
+ tool_definitions_ref = require_optional_string_field!(
194
+ source,
195
+ "tool_definitions_ref",
196
+ label: label
197
+ )
198
+ response_schema_ref = require_optional_string_field!(
199
+ source,
200
+ "response_schema_ref",
201
+ label: label
202
+ )
203
+ ruby_llm_version = require_optional_string_field!(
204
+ source,
205
+ "ruby_llm_version",
206
+ label: label
207
+ )
208
+ adapter_name = require_optional_string_field!(
209
+ source,
210
+ "adapter_name",
211
+ label: label
212
+ )
213
+ adapter_version = require_optional_string_field!(
214
+ source,
215
+ "adapter_version",
216
+ label: label
217
+ )
218
+
219
+ new(
220
+ version: version,
221
+ call_sequence: call_sequence,
222
+ call_mode: call_mode.to_sym,
223
+ assembly_policy_version: assembly_policy_version,
224
+ segments: segments,
225
+ model_config_ref: model_config_ref,
226
+ tool_definitions_ref: tool_definitions_ref,
227
+ response_schema_ref: response_schema_ref,
228
+ ruby_llm_version: ruby_llm_version,
229
+ adapter_name: adapter_name,
230
+ adapter_version: adapter_version
231
+ )
232
+ rescue Phronomy::Persistence::SerializationError
233
+ raise
234
+ rescue => error
235
+ raise Phronomy::Persistence::SerializationError,
236
+ "invalid LLMInputManifest: #{error.class}: #{error.message}"
237
+ end
238
+
64
239
  def referenced_content_refs
65
240
  ([model_config_ref, tool_definitions_ref, response_schema_ref] +
66
241
  segments.map(&:content_ref)).compact.uniq.freeze
@@ -82,9 +257,109 @@ module Phronomy
82
257
  }.compact
83
258
  end
84
259
 
260
+ class << self
261
+ private
262
+
263
+ def strict_source!(hash, required:, optional:, label:)
264
+ unless hash.is_a?(Hash)
265
+ raise Phronomy::Persistence::SerializationError,
266
+ "#{label} must be a Hash"
267
+ end
268
+ unless hash.keys.all? { |key| key.is_a?(String) }
269
+ raise Phronomy::Persistence::SerializationError,
270
+ "#{label} keys must all be String"
271
+ end
272
+
273
+ actual = hash.keys
274
+ missing = required - actual
275
+ unknown = actual - (required + optional)
276
+ unless missing.empty? && unknown.empty?
277
+ details = []
278
+ details << "missing=#{missing.inspect}" unless missing.empty?
279
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
280
+ raise Phronomy::Persistence::SerializationError,
281
+ "#{label} schema mismatch (#{details.join(", ")})"
282
+ end
283
+ Phronomy::CanonicalJSON.dump(hash)
284
+ hash
285
+ rescue ArgumentError => error
286
+ raise Phronomy::Persistence::SerializationError,
287
+ "#{label} is not canonical JSON compatible: #{error.message}"
288
+ end
289
+
290
+ def require_integer_field!(hash, key, label:)
291
+ value = hash.fetch(key)
292
+ return value if value.is_a?(Integer)
293
+
294
+ raise Phronomy::Persistence::SerializationError,
295
+ "#{label} #{key} must be an Integer"
296
+ end
297
+
298
+ def require_positive_integer_field!(hash, key, label:)
299
+ value = hash.fetch(key)
300
+ return value if value.is_a?(Integer) && value.positive?
301
+
302
+ raise Phronomy::Persistence::SerializationError,
303
+ "#{label} #{key} must be a positive Integer"
304
+ end
305
+
306
+ def require_nonnegative_integer_field!(hash, key, label:)
307
+ value = hash.fetch(key)
308
+ return value if value.is_a?(Integer) && value >= 0
309
+
310
+ raise Phronomy::Persistence::SerializationError,
311
+ "#{label} #{key} must be a non-negative Integer"
312
+ end
313
+
314
+ def require_nonempty_string_field!(hash, key, label:)
315
+ value = hash.fetch(key)
316
+ return value if value.is_a?(String) && !value.empty?
317
+
318
+ raise Phronomy::Persistence::SerializationError,
319
+ "#{label} #{key} must be a non-empty String"
320
+ end
321
+
322
+ def require_optional_string_field!(hash, key, label:)
323
+ value = hash[key]
324
+ return value if value.nil? || value.is_a?(String)
325
+
326
+ raise Phronomy::Persistence::SerializationError,
327
+ "#{label} #{key} must be a String or nil"
328
+ end
329
+
330
+ def require_enum_string_field!(hash, key, allowed, label:)
331
+ value = hash.fetch(key)
332
+ return value if value.is_a?(String) && allowed.include?(value)
333
+
334
+ raise Phronomy::Persistence::SerializationError,
335
+ "#{label} #{key} must be one of #{allowed.inspect}"
336
+ end
337
+
338
+ def require_array_field!(hash, key, label:)
339
+ value = hash.fetch(key)
340
+ return value if value.is_a?(Array)
341
+
342
+ raise Phronomy::Persistence::SerializationError,
343
+ "#{label} #{key} must be an Array"
344
+ end
345
+
346
+ def require_hash_field!(hash, key, label:)
347
+ value = hash.fetch(key)
348
+ return value if value.is_a?(Hash)
349
+
350
+ raise Phronomy::Persistence::SerializationError,
351
+ "#{label} #{key} must be a Hash"
352
+ end
353
+ end
354
+
85
355
  private
86
356
 
87
357
  def validate!
358
+ unless version == VERSION
359
+ raise Phronomy::ConfigurationError,
360
+ "unsupported LLMInputManifest version: #{version.inspect}; " \
361
+ "current version is #{VERSION.inspect}"
362
+ end
88
363
  raise ArgumentError, "invalid manifest call mode: #{call_mode.inspect}" unless CALL_MODES.include?(call_mode)
89
364
  raise ArgumentError, "call_sequence must be positive" unless call_sequence.positive?
90
365
  expected = (0...segments.length).to_a
@@ -2,20 +2,25 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Immutable carrier for one LLM adapter operation outcome.
5
+ # Immutable carrier for one Provider operation outcome.
6
6
  #
7
- # Worker/timer threads create this value without mutating AgentInvocation.
8
- # AgentInvocationSessionBuilder posts it as :llm_completed or :llm_failed,
9
- # and AgentInvocation applies it on the EventLoop thread.
7
+ # The Provider Call identity is allocated on EventLoop before transport starts.
8
+ # Worker completion carries that semantic identity back to the owning
9
+ # FSMSession, which validates it against the currently active call before
10
+ # applying any live state.
10
11
  #
11
12
  # @api private
12
13
  class LLMOperationResult
13
- attr_reader :response, :error, :streaming
14
+ attr_reader :llm_call_id, :response, :error, :streaming
14
15
 
15
- def initialize(response: nil, error: nil, streaming: false)
16
+ def initialize(llm_call_id:, response: nil, error: nil, streaming: false)
17
+ id = llm_call_id&.to_s
18
+ raise ArgumentError, "LLMOperationResult requires llm_call_id" if id.nil? || id.empty?
19
+
20
+ @llm_call_id = id.freeze
16
21
  @response = response
17
22
  @error = error
18
- @streaming = streaming
23
+ @streaming = !!streaming
19
24
  freeze
20
25
  end
21
26
  end
@@ -4,12 +4,6 @@ require "state_machines"
4
4
 
5
5
  module Phronomy
6
6
  module Agent
7
- # Compiles AgentInvocation phase topology.
8
- #
9
- # Async completion is represented by explicit FSM events. This builder does
10
- # not await Tasks or register Task callbacks.
11
- #
12
- # @api private
13
7
  class PhaseMachineBuilder
14
8
  TOOL_EVENTS = %i[
15
9
  tool_authorized
@@ -43,6 +37,7 @@ module Phronomy
43
37
  state :recording_tool_results
44
38
  state :suspended
45
39
  state :output_filtering
40
+ state :handed_off
46
41
  state :completed
47
42
  state :blocked
48
43
  state :failed
@@ -70,7 +65,6 @@ module Phronomy
70
65
  if: ->(machine) { machine.context&.ready_to_dispatch? }
71
66
  transition evaluating_tools: :waiting_for_tools
72
67
 
73
- transition dispatching_tools: :evaluating_tools
74
68
  transition recording_tool_results: :calling_llm
75
69
 
76
70
  transition output_filtering: :completed,
@@ -80,6 +74,12 @@ module Phronomy
80
74
  end
81
75
 
82
76
  event :llm_completed do
77
+ transition calling_llm: :failed,
78
+ if: ->(machine) { machine.context&.callback_failed? }
79
+ transition calling_llm: :failed,
80
+ if: ->(machine) { machine.context&.handoff_failed? }
81
+ transition calling_llm: :handed_off,
82
+ if: ->(machine) { machine.context&.handoff_requested? }
83
83
  transition calling_llm: :starting_tools,
84
84
  if: ->(machine) { machine.context&.tool_call_pending? }
85
85
  transition calling_llm: :output_filtering
@@ -89,6 +89,18 @@ module Phronomy
89
89
  transition calling_llm: :failed
90
90
  end
91
91
 
92
+ event :llm_setup_failed do
93
+ transition calling_llm: :failed
94
+ end
95
+
96
+ event :tool_setup_failed do
97
+ transition dispatching_tools: :failed
98
+ end
99
+
100
+ event :tool_dispatch_prepared do
101
+ transition dispatching_tools: :evaluating_tools
102
+ end
103
+
92
104
  TOOL_EVENTS.each do |event_name|
93
105
  event event_name do
94
106
  transition waiting_for_tools: :evaluating_tools
@@ -3,11 +3,10 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  # Immutable, Phronomy-owned snapshot of one completed Provider assistant output.
6
- # It is captured before Agent-owned Tool execution begins, so durable logging
7
- # never depends on RubyLLM's later control flow or Application callbacks.
8
6
  ProviderCallOutcome = Data.define(:role, :content, :tool_calls, :usage, :metadata) do
9
7
  def self.capture(message)
10
8
  return if message.nil?
9
+ return message if message.is_a?(self)
11
10
 
12
11
  calls = if message.respond_to?(:tool_calls) && message.tool_calls
13
12
  source = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
@@ -32,6 +31,17 @@ module Phronomy
32
31
  )
33
32
  end
34
33
 
34
+ def self.from_h(hash)
35
+ source = hash.to_h { |key, value| [key.to_s, value] }
36
+ new(
37
+ role: source.fetch("role", "assistant"),
38
+ content: source["content"],
39
+ tool_calls: source.fetch("tool_calls", []),
40
+ usage: source.fetch("usage", {}),
41
+ metadata: source.fetch("metadata", {})
42
+ )
43
+ end
44
+
35
45
  def self.tool_call_hash(call)
36
46
  return call.to_h if call.respond_to?(:to_h)
37
47
 
@@ -54,11 +64,7 @@ module Phronomy
54
64
  when String, Integer, Float, TrueClass, FalseClass, NilClass
55
65
  value
56
66
  else
57
- if value.respond_to?(:to_h)
58
- normalize(value.to_h)
59
- else
60
- value.to_s
61
- end
67
+ value.respond_to?(:to_h) ? normalize(value.to_h) : value.to_s
62
68
  end
63
69
  end
64
70
  private_class_method :normalize
@@ -78,6 +84,16 @@ module Phronomy
78
84
  freeze
79
85
  end
80
86
 
87
+ def to_h
88
+ {
89
+ "role" => role&.to_s,
90
+ "content" => content,
91
+ "tool_calls" => tool_calls,
92
+ "usage" => usage,
93
+ "metadata" => metadata
94
+ }.compact
95
+ end
96
+
81
97
  def content_present?
82
98
  !content.nil? && !(content.respond_to?(:empty?) && content.empty?)
83
99
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class RecoveryCoordinator
6
+ # Both resolution apply and restart use this interpretation of saved facts.
7
+ # Runtime tasks and sessions are projections, never continuation authority.
8
+ # @api private
9
+ module Continuation
10
+ private
11
+
12
+ def recovery_action(execution)
13
+ return :framework_tools if framework_batch?(execution)
14
+
15
+ case execution.phase.to_sym
16
+ when :recovery_provider_completed
17
+ execution.metadata["framework_calls_pending"] ? :framework_calls : :output
18
+ when :recovery_tools_completed
19
+ execution.metadata["framework_calls_pending"] ? :framework_calls : :followup
20
+ when :recovery_resolved_failed
21
+ :failed_terminal
22
+ else
23
+ :resolution_required
24
+ end
25
+ end
26
+
27
+ # Read durable inputs before entering EventLoop. The returned messages are
28
+ # operation-local projections transferred to the new invocation, never a
29
+ # shared cache or a second source of continuation decisions.
30
+ def prepare_recovery_material(execution, projection: nil)
31
+ action = recovery_action(execution)
32
+ suspended = execution.status == :suspended || execution.phase.to_sym == :resuming
33
+ return if !suspended && %i[resolution_required failed_terminal].include?(action)
34
+
35
+ unless projection
36
+ _manifest, projection = RecoverySupport.materialize_projection(agent, execution.metadata.fetch("manifest_ref"))
37
+ end
38
+ records = execution.working_records.select { |record| %i[assistant_message tool_message].include?(record.kind.to_sym) }
39
+ materializer = RubyLLMMaterializer.new(agent: agent, persistence: agent.persistence)
40
+ messages = records.map { |record| materializer.materialize_journal_record(record) }.freeze
41
+ assistant = messages.reverse.find { |message| message.role.to_sym == :assistant }
42
+ output, usage = RecoverySupport.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
43
+ RecoveryMaterial.new(projection: projection, messages: messages,
44
+ assistant_message: assistant, output: output, usage: usage)
45
+ end
46
+
47
+ def continue_recovery_on_event_loop(execution, completion, material:)
48
+ event_loop = @runtime.event_loop
49
+ action = recovery_action(execution)
50
+ if action == :resolution_required
51
+ event_loop.mark_agent_execution_admission(agent.agent_id,
52
+ execution_id: execution.execution_id, state: :recovery_required)
53
+ deliver_resolution_required(execution, coordination_recovery_descriptor(execution))
54
+ completion.complete({execution_id: execution.execution_id,
55
+ execution_revision: execution.execution_revision,
56
+ recovery: :resolution_required}.freeze)
57
+ return
58
+ end
59
+
60
+ observe_recovery_execution(completion, execution)
61
+ main = agent.send(:execution_coordinator_for, agent.__coordination_config)
62
+ projection = event_loop.agent_execution_state(execution.execution_id).runtime_projection
63
+ if action == :failed_terminal
64
+ invocation = build_failed_recovery_invocation(execution, main)
65
+ else
66
+ projection = material.projection
67
+ invocation = if action == :framework_tools
68
+ RecoverySupport.build_invocation_for_suspended(agent, execution, projection, main,
69
+ agent.send(:_phronomy_event_listener), assistant_message: material.assistant_message)
70
+ else
71
+ RecoverySupport.build_chat_for_recovery(agent, execution, projection, main,
72
+ agent.send(:_phronomy_event_listener), messages: material.messages)
73
+ end
74
+ if execution.phase.to_sym == :recovery_provider_completed
75
+ invocation.output, invocation.usage = material.output, material.usage
76
+ end
77
+ prepare_saved_provider_calls(execution, invocation, material.assistant_message) if action == :framework_calls
78
+ end
79
+
80
+ event_loop.replace_agent_execution(execution.execution_id, execution: execution,
81
+ runtime_projection: projection, invocation: invocation, fsm_session_id: nil)
82
+ if action == :failed_terminal
83
+ failure = execution.metadata.dig(RecoverySupport::RECOVERY_METADATA_KEY, "failure") ||
84
+ {"class" => "Phronomy::Error", "message" => "Recovery-resolved failure"}
85
+ main.send(:begin_terminal_commit_on_event_loop,
86
+ event_loop.agent_execution_state(execution.execution_id), completion, invocation,
87
+ RecoverySupport.error_from_failure(failure), fsm_session_id: nil)
88
+ return
89
+ end
90
+
91
+ event_loop.mark_agent_execution_admission(agent.agent_id,
92
+ execution_id: execution.execution_id, state: :executing)
93
+ case action
94
+ when :framework_tools
95
+ event_loop.register_agent_completion_waiter(execution.execution_id, completion)
96
+ main.send(:start_framework_tools_on_event_loop, execution.execution_id, completion)
97
+ when :framework_calls, :output
98
+ # Re-enter the ordinary FSM before output filtering so its failures
99
+ # settle through the same durable terminal barrier as a live call.
100
+ start_recovery_session(event_loop, main, execution, invocation, completion,
101
+ resume_event: :llm_completed, resume_phase: :calling_llm)
102
+ when :followup
103
+ start_recovery_session(event_loop, main, execution, invocation, completion,
104
+ resume_event: :state_completed, resume_phase: :recording_tool_results)
105
+ end
106
+ end
107
+
108
+ def prepare_saved_provider_calls(execution, invocation, message)
109
+ record = RecoverySupport.latest_assistant_record(execution)
110
+ calls = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
111
+ framework_calls = calls.select { |call| agent.__framework_call?(call.name) }
112
+ if framework_calls.empty?
113
+ raise Phronomy::ExecutionRehydrationRequiredError, "Saved framework call wiring is missing"
114
+ end
115
+ invocation.accept_tool_calls!(framework_calls, llm_call_id: record.llm_call_id)
116
+ end
117
+
118
+ def build_failed_recovery_invocation(execution, main)
119
+ Phronomy::Agent::AgentInvocation.new(agent: agent, input: nil,
120
+ config: {execution_id: execution.execution_id, phronomy_execution_coordinator: main},
121
+ event_listener: agent.send(:_phronomy_event_listener),
122
+ mode: (execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] || "invoke").to_sym,
123
+ execution_id: execution.execution_id)
124
+ end
125
+
126
+ def start_recovery_session(event_loop, main, execution, invocation, completion, resume_event:, resume_phase:)
127
+ session = AgentInvocationSessionBuilder.build_for_resume(agent_invocation: invocation,
128
+ resume_event: resume_event, resume_phase: resume_phase, runtime: @runtime)
129
+ event_loop.replace_agent_execution(execution.execution_id, invocation: invocation, fsm_session_id: session.id)
130
+ event_loop.register_agent_completion_waiter(execution.execution_id, completion)
131
+ source = Phronomy::Task.deferred(name: "#{completion.name}-source")
132
+ source.on_complete do |completed, error|
133
+ main.send(:finish_on_event_loop, execution.execution_id, completion,
134
+ completed || session.context, error, fsm_session_id: session.id)
135
+ end
136
+ event_loop.register(session, completion: source)
137
+ end
138
+
139
+ def deliver_resolution_required(execution, descriptor)
140
+ unless descriptor
141
+ raise Phronomy::ExecutionRehydrationRequiredError,
142
+ "Recovery state has no current unresolved subject"
143
+ end
144
+ listener = agent.send(:_phronomy_event_listener)
145
+ unless listener
146
+ raise Phronomy::ConfigurationError,
147
+ "Recovery resolution requires an Agent on_event listener"
148
+ end
149
+ payload = RecoverySupport.event_payload(
150
+ execution,
151
+ descriptor
152
+ )
153
+ callback_error = agent.send(
154
+ :_deliver_stream_event,
155
+ listener,
156
+ StreamEvent.new(
157
+ type: :recovery_resolution_required,
158
+ payload: payload
159
+ )
160
+ )
161
+ if callback_error
162
+ raise agent.send(
163
+ :_build_stream_callback_error,
164
+ event_type: :recovery_resolution_required,
165
+ callback_error: callback_error,
166
+ result: payload
167
+ )
168
+ end
169
+ nil
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end