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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -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 +152 -0
  15. data/docs/architecture/persistence.md +175 -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 +82 -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 +112 -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/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -0,0 +1,635 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class RecoveryCoordinator
6
+ # Resolution portion of durable Agent Recovery.
7
+ # @api private
8
+ module Resolution
9
+ private
10
+
11
+ def begin_resolve_on_event_loop(request)
12
+ event_loop = Phronomy::Runtime.instance.event_loop
13
+ state = event_loop.agent_execution_state(
14
+ request.execution_id
15
+ )
16
+ unless state && state.agent.equal?(agent)
17
+ request.completion.fail(
18
+ Phronomy::ExecutionRehydrationRequiredError.new(
19
+ "no live recovered execution #{request.execution_id}"
20
+ )
21
+ )
22
+ return
23
+ end
24
+
25
+ current = state.execution
26
+ unless current.execution_revision ==
27
+ request.expected_execution_revision
28
+ request.completion.fail(
29
+ Phronomy::Persistence::ConflictError.new(
30
+ "Recovery resolution revision conflict: expected " \
31
+ "#{request.expected_execution_revision}, actual " \
32
+ "#{current.execution_revision}"
33
+ )
34
+ )
35
+ return
36
+ end
37
+
38
+ descriptor =
39
+ RecoverySupport.recovery_descriptor(current)
40
+ unless descriptor &&
41
+ Phronomy::Recovery.subject_equal?(
42
+ descriptor.fetch(:subject),
43
+ request.subject
44
+ )
45
+ request.completion.fail(
46
+ ArgumentError.new(
47
+ "Recovery subject is not current for execution #{request.execution_id}"
48
+ )
49
+ )
50
+ return
51
+ end
52
+
53
+ unless Array(
54
+ descriptor.fetch(:allowed_outcomes)
55
+ ).map(&:to_sym).include?(request.outcome)
56
+ request.completion.fail(
57
+ ArgumentError.new(
58
+ "Recovery outcome #{request.outcome.inspect} is not allowed for the current subject"
59
+ )
60
+ )
61
+ return
62
+ end
63
+
64
+ operation = ResolutionOperation.new(
65
+ execution: current,
66
+ root: agent.agent_root,
67
+ subject: request.subject,
68
+ outcome: request.outcome,
69
+ result: request.result,
70
+ failure: request.failure
71
+ )
72
+ event_loop.mark_agent_execution_admission(
73
+ agent.agent_id,
74
+ execution_id: current.execution_id,
75
+ state: :recovery_required
76
+ )
77
+ task = Phronomy::Runtime.instance.offload.submit(
78
+ on_full: :raise
79
+ ) do
80
+ perform_resolution(operation)
81
+ end
82
+ task.on_complete do |result, error|
83
+ ready = ResolveReady.new(
84
+ coordinator: self,
85
+ request: request,
86
+ operation: operation,
87
+ result: result,
88
+ error: error
89
+ )
90
+ unless post_control(ready)
91
+ request.completion.fail(
92
+ Phronomy::RuntimeShutdownError.new(
93
+ "EventLoop rejected Recovery resolution apply"
94
+ )
95
+ )
96
+ end
97
+ end
98
+ rescue => caught
99
+ request.completion.fail(caught)
100
+ end
101
+
102
+ def perform_resolution(operation)
103
+ case operation.subject.fetch(:type)
104
+ when :llm_call
105
+ resolve_llm(operation)
106
+ when :tool_invocation
107
+ resolve_tool(operation)
108
+ else
109
+ raise ArgumentError,
110
+ "unsupported Recovery subject: #{operation.subject.inspect}"
111
+ end
112
+ end
113
+
114
+ def resolve_llm(operation)
115
+ current = operation.execution
116
+ llm_call_id =
117
+ operation.subject.fetch(:llm_call_id).to_s
118
+ unless current.metadata[
119
+ RecoverySupport::PENDING_LLM_ID_KEY
120
+ ].to_s == llm_call_id
121
+ raise Phronomy::Persistence::ConflictError,
122
+ "LLM Recovery subject is no longer pending"
123
+ end
124
+
125
+ case operation.outcome
126
+ when :not_performed
127
+ # The factual ambiguity is resolved, but Recovery deliberately does not
128
+ # invent a generic semantic-retry contract. Without an operation-
129
+ # specific replay contract, fail the logical execution explicitly
130
+ # rather than asking the Application to provide a second, non-factual
131
+ # "resolution" or blindly redispatching the Provider call.
132
+ failure = {
133
+ "class" => "Phronomy::Error",
134
+ "message" =>
135
+ "Recovery confirmed LLM call #{llm_call_id} was not performed; " \
136
+ "automatic semantic redispatch is unavailable without a replay contract"
137
+ }.freeze
138
+ recovery = {
139
+ "version" => RecoverySupport::CONTRACT_VERSION,
140
+ "resolution_outcome" => "not_performed",
141
+ "failure" => failure
142
+ }.freeze
143
+ updated = current.with(
144
+ phase: :recovery_resolved_failed,
145
+ metadata: current.metadata.merge(
146
+ RecoverySupport::RECOVERY_METADATA_KEY => recovery
147
+ )
148
+ )
149
+ intended = ResolutionResult.new(
150
+ execution: updated,
151
+ root: operation.root,
152
+ continuation: :failed_terminal,
153
+ failure: failure,
154
+ appended_records: [].freeze
155
+ )
156
+ save_resolution_result(current, intended)
157
+ when :failed
158
+ recovery = {
159
+ "version" => RecoverySupport::CONTRACT_VERSION,
160
+ "failure" => operation.failure
161
+ }
162
+ updated = current.with(
163
+ phase: :recovery_resolved_failed,
164
+ metadata: current.metadata.merge(
165
+ RecoverySupport::RECOVERY_METADATA_KEY =>
166
+ recovery
167
+ )
168
+ )
169
+ intended = ResolutionResult.new(
170
+ execution: updated,
171
+ root: operation.root,
172
+ continuation: :failed_terminal,
173
+ failure: operation.failure,
174
+ appended_records: [].freeze
175
+ )
176
+ save_resolution_result(current, intended)
177
+ when :succeeded
178
+ # simplecov:disable
179
+ outcome =
180
+ Phronomy::Agent::ProviderCallOutcome.from_h(
181
+ operation.result
182
+ )
183
+ updated = nil
184
+ with_resolution_f1_capture do |tx|
185
+ main = agent.send(:execution_coordinator)
186
+ snapshot = {
187
+ llm_results: [{
188
+ llm_call_id: llm_call_id,
189
+ response: outcome,
190
+ error: nil,
191
+ streaming: (
192
+ current.metadata[
193
+ RecoverySupport::INVOCATION_MODE_KEY
194
+ ].to_s == "stream"
195
+ ),
196
+ manifest_ref: current.metadata.fetch(
197
+ "manifest_ref"
198
+ ),
199
+ started_at: current.metadata[
200
+ RecoverySupport::PENDING_LLM_STARTED_AT_KEY
201
+ ] || current.updated_at
202
+ }.freeze].freeze,
203
+ runtime_events: [].freeze,
204
+ active_call: nil
205
+ }.freeze
206
+ records, calls = main.send(
207
+ :encode_runtime_records,
208
+ current,
209
+ tx: tx,
210
+ snapshot: snapshot,
211
+ context_candidate: true,
212
+ agent_root: operation.root
213
+ )
214
+ subjects =
215
+ RecoverySupport.build_tool_subjects(
216
+ current,
217
+ llm_call_id,
218
+ outcome
219
+ )
220
+ next_phase = subjects.empty? ?
221
+ :recovery_provider_completed : :recovery_tools
222
+ metadata = current.metadata.dup
223
+ metadata.delete(
224
+ RecoverySupport::PENDING_LLM_ID_KEY
225
+ )
226
+ metadata.delete(
227
+ RecoverySupport::PENDING_LLM_STARTED_AT_KEY
228
+ )
229
+ if subjects.empty?
230
+ metadata.delete(
231
+ RecoverySupport::RECOVERY_METADATA_KEY
232
+ )
233
+ else
234
+ metadata[
235
+ RecoverySupport::RECOVERY_METADATA_KEY
236
+ ] = RecoverySupport.build_recovery_hash(
237
+ subjects
238
+ )
239
+ end
240
+ updated = current.with(
241
+ phase: next_phase,
242
+ working_records:
243
+ current.working_records + records,
244
+ llm_calls: current.llm_calls + calls,
245
+ metadata: metadata
246
+ )
247
+ tx.executions.save(
248
+ current.execution_id,
249
+ expected_revision: current.execution_revision,
250
+ execution: updated
251
+ )
252
+ ResolutionResult.new(
253
+ execution: updated,
254
+ root: operation.root,
255
+ continuation: (
256
+ (updated.phase.to_sym ==
257
+ :recovery_provider_completed) ?
258
+ :provider_completed :
259
+ :resolution_required
260
+ ),
261
+ failure: nil,
262
+ appended_records: [].freeze
263
+ )
264
+ end
265
+ # simplecov:enable
266
+ end
267
+ end
268
+
269
+ def resolve_tool(operation)
270
+ # simplecov:disable
271
+ current = operation.execution
272
+ recovery = RecoverySupport.recovery_hash(current)
273
+ if recovery.nil? &&
274
+ %i[resuming dispatching_tools].include?(current.phase.to_sym)
275
+ subjects = RecoverySupport.pending_tool_subjects(current)
276
+ recovery = RecoverySupport.build_recovery_hash(subjects)
277
+ end
278
+ unless recovery
279
+ raise Phronomy::Persistence::ConflictError,
280
+ "Tool Recovery state is missing"
281
+ end
282
+
283
+ subject_entry = Array(
284
+ recovery["subjects"] || recovery[:subjects]
285
+ ).find do |entry|
286
+ hash = entry.to_h { |key, value| [key.to_s, value] }
287
+ hash.fetch("tool_invocation_id").to_s ==
288
+ operation.subject.fetch(:tool_invocation_id).to_s &&
289
+ hash.fetch("state", "unresolved") == "unresolved"
290
+ end
291
+ unless subject_entry
292
+ raise Phronomy::Persistence::ConflictError,
293
+ "Tool Recovery subject is no longer unresolved"
294
+ end
295
+ subject_entry =
296
+ subject_entry.to_h { |key, value| [key.to_s, value] }
297
+
298
+ case operation.outcome
299
+ when :not_performed
300
+ tool_id = subject_entry.fetch("tool_invocation_id").to_s
301
+ failure = {
302
+ "class" => "Phronomy::Error",
303
+ "message" =>
304
+ "Recovery confirmed Tool invocation #{tool_id} was not performed; " \
305
+ "automatic semantic redispatch is unavailable without a replay contract"
306
+ }.freeze
307
+ resolved_recovery =
308
+ RecoverySupport.update_recovery_subject(
309
+ recovery,
310
+ tool_invocation_id: tool_id,
311
+ state: :resolved,
312
+ outcome: :not_performed
313
+ ).merge(
314
+ "resolution_outcome" => "not_performed",
315
+ "failure" => failure
316
+ ).freeze
317
+ updated = current.with(
318
+ phase: :recovery_resolved_failed,
319
+ metadata: current.metadata.merge(
320
+ RecoverySupport::RECOVERY_METADATA_KEY =>
321
+ resolved_recovery
322
+ )
323
+ )
324
+ intended = ResolutionResult.new(
325
+ execution: updated,
326
+ root: operation.root,
327
+ continuation: :failed_terminal,
328
+ failure: failure,
329
+ appended_records: [].freeze
330
+ )
331
+ save_resolution_result(current, intended)
332
+ when :failed
333
+ recovery_with_failure =
334
+ RecoverySupport.update_recovery_subject(
335
+ recovery,
336
+ tool_invocation_id:
337
+ subject_entry.fetch("tool_invocation_id"),
338
+ state: :resolved,
339
+ outcome: :failed
340
+ ).merge(
341
+ "failure" => operation.failure
342
+ )
343
+ updated = current.with(
344
+ phase: :recovery_resolved_failed,
345
+ metadata: current.metadata.merge(
346
+ RecoverySupport::RECOVERY_METADATA_KEY =>
347
+ recovery_with_failure
348
+ )
349
+ )
350
+ intended = ResolutionResult.new(
351
+ execution: updated,
352
+ root: operation.root,
353
+ continuation: :failed_terminal,
354
+ failure: operation.failure,
355
+ appended_records: [].freeze
356
+ )
357
+ save_resolution_result(current, intended)
358
+ when :succeeded
359
+ updated = nil
360
+ with_resolution_f1_capture do |tx|
361
+ main = agent.send(:execution_coordinator)
362
+ result_ref = main.send(
363
+ :put_runtime_content,
364
+ tx,
365
+ operation.result
366
+ )
367
+ message = {
368
+ "role" => "tool",
369
+ "content" => operation.result.to_s,
370
+ "tool_call_id" =>
371
+ subject_entry.fetch("tool_call_id").to_s
372
+ }
373
+ runtime_event = StreamEvent.new(
374
+ type: :tool_result,
375
+ payload: {
376
+ tool_call_id:
377
+ subject_entry.fetch("tool_call_id").to_s,
378
+ tool_name:
379
+ subject_entry.fetch("tool_name").to_s,
380
+ tool_result: operation.result,
381
+ tool_message: message,
382
+ llm_call_id:
383
+ subject_entry.fetch("llm_call_id").to_s
384
+ }.freeze
385
+ )
386
+ records, _calls = main.send(
387
+ :encode_runtime_records,
388
+ current,
389
+ tx: tx,
390
+ snapshot: {
391
+ llm_results: [].freeze,
392
+ runtime_events: [runtime_event].freeze,
393
+ active_call: nil
394
+ }.freeze,
395
+ context_candidate: true,
396
+ agent_root: operation.root
397
+ )
398
+ next_recovery =
399
+ RecoverySupport.update_recovery_subject(
400
+ recovery,
401
+ tool_invocation_id:
402
+ subject_entry.fetch("tool_invocation_id"),
403
+ state: :resolved,
404
+ outcome: :succeeded,
405
+ result_ref: result_ref
406
+ )
407
+ unresolved =
408
+ RecoverySupport.unresolved_subjects(
409
+ next_recovery
410
+ )
411
+ next_phase = unresolved.empty? ?
412
+ :recovery_tools_completed : :recovery_tools
413
+ updated = current.with(
414
+ phase: next_phase,
415
+ working_records:
416
+ current.working_records + records,
417
+ metadata: current.metadata.merge(
418
+ RecoverySupport::RECOVERY_METADATA_KEY =>
419
+ next_recovery
420
+ )
421
+ )
422
+ tx.executions.save(
423
+ current.execution_id,
424
+ expected_revision: current.execution_revision,
425
+ execution: updated
426
+ )
427
+ ResolutionResult.new(
428
+ execution: updated,
429
+ root: operation.root,
430
+ continuation: (
431
+ (updated.phase.to_sym ==
432
+ :recovery_tools_completed) ?
433
+ :tools_completed :
434
+ :resolution_required
435
+ ),
436
+ failure: nil,
437
+ appended_records: [].freeze
438
+ )
439
+ end
440
+ end
441
+ end
442
+ # simplecov:enable
443
+
444
+ def known_non_f1_error?(error)
445
+ error.is_a?(Phronomy::Persistence::ConflictError) ||
446
+ error.is_a?(Phronomy::Persistence::NotFoundError) ||
447
+ error.is_a?(Phronomy::Persistence::SerializationError) ||
448
+ error.is_a?(Phronomy::Persistence::UnsupportedBackendError) ||
449
+ error.is_a?(ArgumentError) ||
450
+ error.is_a?(Phronomy::ConfigurationError)
451
+ end
452
+
453
+ def with_resolution_f1_capture
454
+ intended = nil
455
+ begin
456
+ agent.persistence.transaction do |tx|
457
+ intended = yield(tx)
458
+ intended
459
+ end
460
+ intended
461
+ rescue => caught
462
+ raise if known_non_f1_error?(caught)
463
+ raise unless intended
464
+
465
+ raise ResolutionOutcomeUnknownError.new(
466
+ caught,
467
+ intended
468
+ )
469
+ end
470
+ end
471
+
472
+ def save_resolution_result(current, result)
473
+ with_resolution_f1_capture do |tx|
474
+ tx.executions.save(
475
+ current.execution_id,
476
+ expected_revision: current.execution_revision,
477
+ execution: result.execution
478
+ )
479
+ result
480
+ end
481
+ end
482
+
483
+ def apply_resolve_on_event_loop(ready)
484
+ request = ready.request
485
+ event_loop = Phronomy::Runtime.instance.event_loop
486
+ state = event_loop.agent_execution_state(
487
+ request.execution_id
488
+ )
489
+ unless state && state.agent.equal?(agent)
490
+ request.completion.fail(
491
+ Phronomy::ExecutionRehydrationRequiredError.new(
492
+ "recovered execution disappeared before resolution apply"
493
+ )
494
+ )
495
+ return
496
+ end
497
+
498
+ if ready.error
499
+ reconcile_resolution_f1_on_event_loop(
500
+ ready,
501
+ state
502
+ )
503
+ return
504
+ end
505
+
506
+ result = ready.result
507
+ event_loop.replace_agent_execution(
508
+ request.execution_id,
509
+ execution: result.execution
510
+ )
511
+
512
+ case result.continuation
513
+ when :resolution_required
514
+ event_loop.mark_agent_execution_admission(
515
+ agent.agent_id,
516
+ execution_id: request.execution_id,
517
+ state: :recovery_required
518
+ )
519
+ descriptor =
520
+ RecoverySupport.recovery_descriptor(
521
+ result.execution
522
+ )
523
+ deliver_resolution_required(
524
+ result.execution,
525
+ descriptor
526
+ )
527
+ request.completion.complete(
528
+ {
529
+ execution_id: request.execution_id,
530
+ execution_revision:
531
+ result.execution.execution_revision,
532
+ recovery: :resolution_required
533
+ }.freeze
534
+ )
535
+ when :provider_completed
536
+ observe_recovery_execution(request.completion, result.execution)
537
+ continue_provider_completed_after_resolution(
538
+ event_loop,
539
+ state,
540
+ result.execution,
541
+ request.completion
542
+ )
543
+ when :tools_completed
544
+ observe_recovery_execution(request.completion, result.execution)
545
+ continue_tools_completed_after_resolution(
546
+ event_loop,
547
+ state,
548
+ result.execution,
549
+ request.completion
550
+ )
551
+ when :failed_terminal
552
+ observe_recovery_execution(request.completion, result.execution)
553
+ continue_failed_after_resolution(
554
+ event_loop,
555
+ state,
556
+ result.execution,
557
+ request.completion,
558
+ result.failure
559
+ )
560
+ else
561
+ request.completion.fail(
562
+ Phronomy::Error.new(
563
+ "unknown Recovery continuation: #{result.continuation.inspect}"
564
+ )
565
+ )
566
+ end
567
+ rescue => caught
568
+ request.completion.fail(caught)
569
+ end
570
+
571
+ def reconcile_resolution_f1_on_event_loop(ready, state)
572
+ request = ready.request
573
+ intended_result = ready.result
574
+ if intended_result.nil? &&
575
+ ready.error.is_a?(ResolutionOutcomeUnknownError)
576
+ intended_result = ready.error.intended_result
577
+ end
578
+ intended = intended_result&.execution
579
+ current = agent.persistence.executions.load(
580
+ request.execution_id
581
+ )
582
+
583
+ if intended &&
584
+ current.execution_revision ==
585
+ intended.execution_revision &&
586
+ current.to_h == intended.to_h
587
+ Phronomy::Runtime.instance.event_loop.replace_agent_execution(
588
+ request.execution_id,
589
+ execution: current
590
+ )
591
+ synthetic = ResolveReady.new(
592
+ coordinator: self,
593
+ request: request,
594
+ operation: ready.operation,
595
+ result: intended_result.class.new(
596
+ execution: current,
597
+ root: intended_result.root,
598
+ continuation: intended_result.continuation,
599
+ failure: intended_result.failure,
600
+ appended_records:
601
+ intended_result.appended_records
602
+ ),
603
+ error: nil
604
+ )
605
+ apply_resolve_on_event_loop(synthetic)
606
+ return
607
+ end
608
+
609
+ if current.execution_revision ==
610
+ ready.operation.execution.execution_revision &&
611
+ current.to_h ==
612
+ ready.operation.execution.to_h
613
+ failure = if ready.error.is_a?(
614
+ ResolutionOutcomeUnknownError
615
+ )
616
+ ready.error.original_error
617
+ else
618
+ ready.error
619
+ end
620
+ request.completion.fail(failure)
621
+ return
622
+ end
623
+
624
+ request.completion.fail(
625
+ Phronomy::Persistence::ConflictError.new(
626
+ "Recovery resolution durable outcome conflicts with both expected pre-state and intended post-state"
627
+ )
628
+ )
629
+ rescue => reconciliation_error
630
+ request.completion.fail(reconciliation_error)
631
+ end
632
+ end
633
+ end
634
+ end
635
+ end