phronomy 0.26.0 → 0.27.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 (304) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +4 -0
  4. data/VERIFY.sh +27 -27
  5. data/benchmark/bench_agent_invoke.rb +26 -22
  6. data/benchmark/bench_context_assembler.rb +4 -5
  7. data/docs/architecture/agent-chat-and-state-ownership.md +147 -0
  8. data/docs/architecture/agent-configuration-and-tool-binding.md +139 -0
  9. data/docs/architecture/agent-context.md +4 -2
  10. data/docs/architecture/agent-transition-ownership.md +89 -0
  11. data/docs/architecture/before-llm-input.md +6 -0
  12. data/docs/architecture/context-management.md +30 -0
  13. data/docs/architecture/context-preparation-steps.md +90 -0
  14. data/docs/architecture/entry-action-and-team-wording.md +72 -0
  15. data/docs/architecture/execution-metadata-and-values.md +80 -0
  16. data/docs/architecture/generator-verifier-ownership.md +111 -0
  17. data/docs/architecture/multi-agent-handoff.md +8 -2
  18. data/docs/architecture/persistence-refactoring-plan.md +50 -0
  19. data/docs/architecture/persistence.md +27 -4
  20. data/docs/architecture/refactoring-closure.md +334 -0
  21. data/docs/architecture/remaining-refactoring-plan.md +374 -0
  22. data/docs/architecture/rubyllm-2-token-ownership.md +82 -0
  23. data/docs/architecture/tool-schema-recording-gap.md +50 -0
  24. data/docs/architecture/tracing.md +1 -1
  25. data/docs/architecture/workflow-terminal-ownership-design.md +104 -0
  26. data/docs/architecture.md +209 -0
  27. data/docs/decisions/014-unified-persistence-durable-state.md +11 -0
  28. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +5 -1
  29. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +13 -3
  30. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +5 -1
  31. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +5 -0
  32. data/docs/decisions/031-durable-multi-agent-coordination.md +5 -0
  33. data/docs/decisions/032-storage-backend-composition.md +80 -0
  34. data/docs/decisions/033-domain-persistence-ownership.md +79 -0
  35. data/docs/decisions/034-handoff-runner-coordination-ownership.md +76 -0
  36. data/docs/decisions/035-tool-executor-capability-ownership.md +68 -0
  37. data/docs/decisions/036-context-contract-ownership.md +86 -0
  38. data/docs/decisions/037-common-definition-ownership.md +62 -0
  39. data/docs/decisions/038-responsibility-based-source-layout.md +131 -0
  40. data/docs/decisions/039-runtime-configuration-lifecycle-ownership.md +74 -0
  41. data/docs/decisions/040-configuration-default-composition.md +94 -0
  42. data/docs/decisions/041-feature-owned-identity-registries.md +100 -0
  43. data/docs/decisions/042-feature-owned-execution-state.md +106 -0
  44. data/docs/decisions/043-storage-execution-constraint-notifications.md +75 -0
  45. data/docs/decisions/044-agent-default-and-one-shot-composition.md +106 -0
  46. data/docs/decisions/045-worker-input-restriction-ownership.md +100 -0
  47. data/docs/decisions/046-agent-responsibility-layout-and-shared-records.md +110 -0
  48. data/docs/decisions/047-recovered-execution-continuation-contract.md +116 -0
  49. data/docs/decisions/048-dispatch-preparation-worker-ownership.md +120 -0
  50. data/docs/decisions/049-initial-preparation-worker-ownership.md +111 -0
  51. data/docs/decisions/050-approval-resume-snapshot-and-commit-ownership.md +98 -0
  52. data/docs/decisions/051-execution-outcome-worker-ownership.md +151 -0
  53. data/docs/decisions/052-tool-invocation-restoration-ownership.md +92 -0
  54. data/docs/decisions/053-shared-state-coordination-ownership.md +71 -0
  55. data/docs/decisions/054-workflow-terminal-save-single-owner.md +76 -0
  56. data/docs/decisions/055-terminal-observer-failure-settlement.md +60 -0
  57. data/docs/decisions/056-workflow-terminal-policy-ownership.md +87 -0
  58. data/docs/decisions/057-storage-transaction-boundaries.md +73 -0
  59. data/docs/decisions/058-neutral-storage-primitives.md +78 -0
  60. data/docs/decisions/README.md +35 -6
  61. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +6 -0
  62. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +6 -0
  63. data/docs/features.md +9 -3
  64. data/docs/getting-started.md +4 -4
  65. data/docs/migrations/0.15.md +5 -0
  66. data/docs/migrations/durable-semantic-coordination-v2.md +6 -0
  67. data/docs/migrations/handoff-runner-multi-agent.md +35 -0
  68. data/docs/migrations/neutral-storage-spi.md +43 -0
  69. data/docs/migrations/parallel-tool-chat-removal.md +49 -0
  70. data/docs/migrations/shared-state-multi-agent.md +44 -0
  71. data/docs/migrations/storage-backend-composition.md +117 -0
  72. data/docs/migrations/storage-transaction-boundaries.md +74 -0
  73. data/docs/persistence-backends.md +162 -204
  74. data/docs/runtime-and-concurrency.md +117 -32
  75. data/lib/phronomy/agent/api/agent.rb +17 -0
  76. data/lib/phronomy/agent/async_event_api.rb +2 -2
  77. data/lib/phronomy/agent/base.rb +51 -241
  78. data/lib/phronomy/{agent.rb → agent/composition/run_once.rb} +4 -13
  79. data/lib/phronomy/agent/context/capability/base.rb +67 -26
  80. data/lib/phronomy/agent/context/capability/tool_executor.rb +62 -0
  81. data/lib/phronomy/agent/{context_assembler.rb → context_assembly/context_assembler.rb} +143 -88
  82. data/lib/phronomy/agent/{context_importer.rb → context_assembly/context_importer.rb} +2 -2
  83. data/lib/phronomy/agent/{ruby_llm_materializer.rb → context_assembly/ruby_llm_materializer.rb} +4 -7
  84. data/lib/phronomy/agent/context_assembly/runtime_chat_builder.rb +36 -0
  85. data/lib/phronomy/agent/context_assembly/saved_context_reader.rb +53 -0
  86. data/lib/phronomy/agent/context_assembly/state_writer.rb +165 -0
  87. data/lib/phronomy/agent/context_assembly/token_budget_resolver.rb +22 -0
  88. data/lib/phronomy/agent/{context_plan.rb → context_contract/context_plan.rb} +1 -1
  89. data/lib/phronomy/agent/{context_policy_input.rb → context_contract/context_policy_input.rb} +6 -6
  90. data/lib/phronomy/agent/{llm_input_build_context.rb → context_contract/llm_input_build_context.rb} +1 -1
  91. data/lib/phronomy/agent/{llm_input_manifest.rb → context_contract/llm_input_manifest.rb} +20 -20
  92. data/lib/phronomy/agent/{llm_input_patch.rb → context_contract/llm_input_patch.rb} +2 -2
  93. data/lib/phronomy/agent/{agent_execution.rb → execution/agent_execution.rb} +8 -4
  94. data/lib/phronomy/agent/{agent_invocation.rb → execution/agent_invocation.rb} +8 -13
  95. data/lib/phronomy/agent/{agent_invocation_session_builder.rb → execution/agent_invocation_session_builder.rb} +19 -95
  96. data/lib/phronomy/agent/execution/approval_resume_commit.rb +108 -0
  97. data/lib/phronomy/agent/execution/dispatch_preparation.rb +305 -0
  98. data/lib/phronomy/agent/{exact_execution.rb → execution/exact_execution.rb} +9 -10
  99. data/lib/phronomy/agent/{execution_cancellation.rb → execution/execution_cancellation.rb} +2 -3
  100. data/lib/phronomy/agent/execution/execution_coordinator.rb +1925 -0
  101. data/lib/phronomy/agent/execution/execution_failure.rb +30 -0
  102. data/lib/phronomy/agent/execution/execution_metadata.rb +53 -0
  103. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +344 -0
  104. data/lib/phronomy/agent/execution/execution_registry.rb +459 -0
  105. data/lib/phronomy/agent/execution/execution_session_runner.rb +118 -0
  106. data/lib/phronomy/agent/execution/initial_preparation.rb +421 -0
  107. data/lib/phronomy/agent/execution/invocation_transitions.rb +86 -0
  108. data/lib/phronomy/agent/{phase_machine_builder.rb → execution/phase_machine_builder.rb} +20 -71
  109. data/lib/phronomy/agent/{provider_call_outcome.rb → execution/provider_call_outcome.rb} +9 -9
  110. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +210 -0
  111. data/lib/phronomy/agent/{handoff_context.rb → handoff/handoff_context.rb} +2 -2
  112. data/lib/phronomy/agent/handoff/handoff_execution_coordinator.rb +15 -0
  113. data/lib/phronomy/agent/handoff/handoff_outcome_committer.rb +131 -0
  114. data/lib/phronomy/agent/{handoff_state.rb → handoff/handoff_state.rb} +1 -1
  115. data/lib/phronomy/agent/{journal_projection.rb → journal/journal_projection.rb} +4 -0
  116. data/lib/phronomy/agent/{journal_record.rb → journal/journal_record.rb} +3 -3
  117. data/lib/phronomy/agent/{llm_call_record.rb → journal/llm_call_record.rb} +2 -2
  118. data/lib/phronomy/agent/{agent_root.rb → lifecycle/agent_root.rb} +6 -2
  119. data/lib/phronomy/agent/lifecycle/default_persistence.rb +29 -0
  120. data/lib/phronomy/{engine/runtime/agent_ownership_registry.rb → agent/lifecycle/ownership_registry.rb} +24 -10
  121. data/lib/phronomy/{agent_already_exists_error.rb → agent/lifecycle_contract/agent_already_exists_error.rb} +2 -0
  122. data/lib/phronomy/{agent_busy_error.rb → agent/lifecycle_contract/agent_busy_error.rb} +2 -0
  123. data/lib/phronomy/{agent_purged_error.rb → agent/lifecycle_contract/agent_purged_error.rb} +2 -0
  124. data/lib/phronomy/agent/lifecycle_contract/handoff_error.rb +7 -0
  125. data/lib/phronomy/{stream_callback_error.rb → agent/lifecycle_contract/stream_callback_error.rb} +2 -0
  126. data/lib/phronomy/agent/persistence/agent_repository.rb +61 -0
  127. data/lib/phronomy/agent/persistence/codec.rb +358 -0
  128. data/lib/phronomy/agent/persistence/execution_repository.rb +108 -0
  129. data/lib/phronomy/agent/persistence/handoff_state_repository.rb +58 -0
  130. data/lib/phronomy/agent/persistence/journal_repository.rb +54 -0
  131. data/lib/phronomy/agent/persistence/queries.rb +61 -0
  132. data/lib/phronomy/agent/persistence/storage_schema.rb +24 -0
  133. data/lib/phronomy/agent/persistence/watermark.rb +27 -0
  134. data/lib/phronomy/agent/recovery/invocation_restorer.rb +132 -0
  135. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/continuation.rb +21 -45
  136. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/installation.rb +32 -37
  137. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/resolution.rb +37 -40
  138. data/lib/phronomy/agent/{recovery_coordinator.rb → recovery/recovery_coordinator.rb} +6 -10
  139. data/lib/phronomy/agent/recovery/recovery_support.rb +227 -0
  140. data/lib/phronomy/agent/selection/candidate.rb +1 -1
  141. data/lib/phronomy/agent/{approval_evaluation_request.rb → tool_execution/approval_evaluation_request.rb} +1 -12
  142. data/lib/phronomy/agent/{tool_approval_request.rb → tool_execution/tool_approval_request.rb} +1 -10
  143. data/lib/phronomy/agent/tool_execution/tool_binding.rb +90 -0
  144. data/lib/phronomy/agent/{tool_call_intercepted.rb → tool_execution/tool_call_intercepted.rb} +2 -2
  145. data/lib/phronomy/agent/{tool_definition_set.rb → tool_execution/tool_definition_set.rb} +9 -4
  146. data/lib/phronomy/agent/{tool_invocation.rb → tool_execution/tool_invocation.rb} +69 -34
  147. data/lib/phronomy/common/configuration_error.rb +7 -0
  148. data/lib/phronomy/common/error.rb +5 -0
  149. data/lib/phronomy/{agent → common/values}/immutable.rb +9 -1
  150. data/lib/phronomy/common/values/serializable.rb +32 -0
  151. data/lib/phronomy/{configuration.rb → configuration/configuration.rb} +14 -5
  152. data/lib/phronomy/configuration/global_configuration.rb +26 -0
  153. data/lib/phronomy/content_store/storage_schema.rb +11 -0
  154. data/lib/phronomy/content_store/stored_contents.rb +43 -0
  155. data/lib/phronomy/engine/backpressure_error.rb +7 -0
  156. data/lib/phronomy/{blocking.rb → engine/blocking.rb} +1 -1
  157. data/lib/phronomy/engine/cancellation_error.rb +7 -0
  158. data/lib/phronomy/engine/concurrency/cancellation_token.rb +4 -0
  159. data/lib/phronomy/engine/concurrency/offload_pool.rb +4 -0
  160. data/lib/phronomy/engine/concurrency/operation_binding.rb +1 -1
  161. data/lib/phronomy/engine/concurrency/worker_input_restricted.rb +14 -0
  162. data/lib/phronomy/engine/event_loop.rb +158 -624
  163. data/lib/phronomy/engine/event_loop_reentrancy_error.rb +8 -0
  164. data/lib/phronomy/{execution.rb → engine/execution.rb} +0 -7
  165. data/lib/phronomy/{execution_cancellation_error.rb → engine/execution_cancellation_error.rb} +2 -0
  166. data/lib/phronomy/engine/execution_receiver.rb +65 -0
  167. data/lib/phronomy/{execution_timeout_error.rb → engine/execution_timeout_error.rb} +2 -0
  168. data/lib/phronomy/engine/fsm_protocol.rb +14 -0
  169. data/lib/phronomy/engine/fsm_session.rb +35 -28
  170. data/lib/phronomy/{invalid_async_entry_action_error.rb → engine/invalid_async_entry_action_error.rb} +2 -0
  171. data/lib/phronomy/{invalid_async_transition_action_error.rb → engine/invalid_async_transition_action_error.rb} +2 -0
  172. data/lib/phronomy/{invalid_async_workflow_action_error.rb → engine/invalid_async_workflow_action_error.rb} +2 -0
  173. data/lib/phronomy/engine/pool_shutdown_error.rb +7 -0
  174. data/lib/phronomy/engine/recursion_limit_error.rb +7 -0
  175. data/lib/phronomy/engine/runtime.rb +80 -84
  176. data/lib/phronomy/engine/runtime_shutdown_error.rb +7 -0
  177. data/lib/phronomy/engine/runtime_shutdown_reentrancy_error.rb +7 -0
  178. data/lib/phronomy/engine/scheduler_reentrancy_error.rb +9 -0
  179. data/lib/phronomy/engine/task_result.rb +4 -0
  180. data/lib/phronomy/engine/timeout_error.rb +7 -0
  181. data/lib/phronomy/filter/contract/filter_block_error.rb +14 -0
  182. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +89 -0
  183. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +57 -0
  184. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +112 -0
  185. data/lib/phronomy/generation/generator_verifier.rb +118 -0
  186. data/lib/phronomy/generation/low_confidence_error.rb +14 -0
  187. data/lib/phronomy/llm_context_window/token_budget.rb +6 -7
  188. data/lib/phronomy/llm_contract/authentication_error.rb +7 -0
  189. data/lib/phronomy/{context_budget_exceeded_error.rb → llm_contract/context_budget_exceeded_error.rb} +2 -0
  190. data/lib/phronomy/llm_contract/context_length_error.rb +7 -0
  191. data/lib/phronomy/llm_contract/rate_limit_error.rb +7 -0
  192. data/lib/phronomy/{token_usage.rb → llm_contract/token_usage.rb} +2 -2
  193. data/lib/phronomy/llm_contract/transport_error.rb +7 -0
  194. data/lib/phronomy/multi_agent/admission_registry.rb +22 -2
  195. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +5 -5
  196. data/lib/phronomy/{agent → multi_agent}/handoff_runner.rb +18 -17
  197. data/lib/phronomy/multi_agent/orchestrator.rb +2 -2
  198. data/lib/phronomy/multi_agent/persistence/codec.rb +55 -0
  199. data/lib/phronomy/multi_agent/persistence/queries.rb +30 -0
  200. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +108 -0
  201. data/lib/phronomy/multi_agent/persistence/team_repository.rb +61 -0
  202. data/lib/phronomy/{agent → multi_agent}/shared_state.rb +56 -39
  203. data/lib/phronomy/multi_agent/storage_contract/team_storage_schema.rb +15 -0
  204. data/lib/phronomy/multi_agent/team_coordinator.rb +21 -18
  205. data/lib/phronomy/multi_agent/team_execution.rb +1 -1
  206. data/lib/phronomy/{engine/runtime → multi_agent}/team_ownership_registry.rb +14 -4
  207. data/lib/phronomy/multi_agent/team_root.rb +1 -1
  208. data/lib/phronomy/output_parser/contract/parse_error.rb +7 -0
  209. data/lib/phronomy/persistence/api/persistence.rb +140 -0
  210. data/lib/phronomy/persistence/migration/initial_format_migration.rb +19 -19
  211. data/lib/phronomy/persistence_composition/repositories.rb +77 -0
  212. data/lib/phronomy/persistence_composition/storage_schema.rb +24 -0
  213. data/lib/phronomy/{execution_rehydration_required_error.rb → recovery/execution_rehydration_required_error.rb} +2 -0
  214. data/lib/phronomy/{recovery.rb → recovery/recovery.rb} +1 -1
  215. data/lib/phronomy/runtime_composition/agent_defaults.rb +7 -0
  216. data/lib/phronomy/runtime_composition/configuration_defaults.rb +9 -0
  217. data/lib/phronomy/runtime_composition/global_runtime.rb +19 -0
  218. data/lib/phronomy/storage/backend.rb +101 -0
  219. data/lib/phronomy/storage/backends/in_memory.rb +157 -0
  220. data/lib/phronomy/storage/blob_conflict_error.rb +10 -0
  221. data/lib/phronomy/storage/blobs.rb +31 -0
  222. data/lib/phronomy/storage/condition.rb +25 -0
  223. data/lib/phronomy/storage/condition_failed_error.rb +16 -0
  224. data/lib/phronomy/storage/conflict_error.rb +9 -0
  225. data/lib/phronomy/{persistence → storage}/durable_record.rb +12 -12
  226. data/lib/phronomy/storage/entry.rb +33 -0
  227. data/lib/phronomy/storage/guard_ref.rb +13 -0
  228. data/lib/phronomy/storage/not_found_error.rb +9 -0
  229. data/lib/phronomy/storage/record_codec.rb +177 -0
  230. data/lib/phronomy/storage/records.rb +61 -0
  231. data/lib/phronomy/storage/resource.rb +126 -0
  232. data/lib/phronomy/storage/scope.rb +25 -0
  233. data/lib/phronomy/storage/serialization_error.rb +9 -0
  234. data/lib/phronomy/storage/streams.rb +44 -0
  235. data/lib/phronomy/storage/transaction_error.rb +10 -0
  236. data/lib/phronomy/storage/unique_constraint_error.rb +17 -0
  237. data/lib/phronomy/storage/unsupported_backend_error.rb +9 -0
  238. data/lib/phronomy/storage/validation.rb +53 -0
  239. data/lib/phronomy/storage/view.rb +131 -0
  240. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +1 -1
  241. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +4 -4
  242. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +10 -7
  243. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +2 -2
  244. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +6 -6
  245. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +6 -6
  246. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +8 -8
  247. data/lib/phronomy/testing/persistence_contract/neutral_storage_primitives.rb +263 -0
  248. data/lib/phronomy/testing/persistence_contract/storage_transaction_boundaries.rb +123 -0
  249. data/lib/phronomy/testing/persistence_contract.rb +4 -0
  250. data/lib/phronomy/tool/contract/tool_error.rb +7 -0
  251. data/lib/phronomy/tools/agent.rb +1 -1
  252. data/lib/phronomy/version.rb +1 -1
  253. data/lib/phronomy/{workflow.rb → workflow/execution/workflow.rb} +8 -6
  254. data/lib/phronomy/{workflow_context.rb → workflow/execution/workflow_context.rb} +4 -0
  255. data/lib/phronomy/workflow/execution/workflow_context_ownership_error.rb +7 -0
  256. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +188 -0
  257. data/lib/phronomy/{workflow_runner.rb → workflow/execution/workflow_runner.rb} +122 -71
  258. data/lib/phronomy/workflow/execution/workflow_terminal_policy.rb +40 -0
  259. data/lib/phronomy/workflow/persistence/codec.rb +153 -0
  260. data/lib/phronomy/workflow/persistence/state_repository.rb +57 -0
  261. data/lib/phronomy/workflow/phase_machine_builder.rb +2 -2
  262. data/lib/phronomy/workflow/storage_contract/workflow_storage_schema.rb +9 -0
  263. data/lib/phronomy.rb +52 -94
  264. data/scripts/api_snapshot.rb +1 -1
  265. data/scripts/storage_spi_snapshot.rb +36 -0
  266. data/sig/phronomy/agent.rbs +0 -1
  267. data/sig/phronomy/execution_receiver.rbs +34 -0
  268. data/sig/phronomy/handoff.rbs +4 -2
  269. data/sig/phronomy/multi_agent.rbs +18 -0
  270. data/sig/phronomy/persistence.rbs +7 -91
  271. data/sig/phronomy/storage.rbs +174 -0
  272. data/sig/phronomy/tool.rbs +10 -1
  273. metadata +230 -99
  274. data/lib/phronomy/agent/execution_coordinator.rb +0 -3151
  275. data/lib/phronomy/agent/handoff_execution_coordinator.rb +0 -143
  276. data/lib/phronomy/agent/recovery_support.rb +0 -504
  277. data/lib/phronomy/agent/token_budget_resolver.rb +0 -70
  278. data/lib/phronomy/agent/tool_executor.rb +0 -55
  279. data/lib/phronomy/generator_verifier.rb +0 -369
  280. data/lib/phronomy/invalid_context_budget_configuration_error.rb +0 -8
  281. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +0 -116
  282. data/lib/phronomy/persistence/durable_codec.rb +0 -706
  283. data/lib/phronomy/persistence/in_memory.rb +0 -690
  284. data/lib/phronomy/persistence/repository_facades.rb +0 -535
  285. data/lib/phronomy/persistence.rb +0 -276
  286. data/lib/phronomy/ruby_llm_patches.rb +0 -24
  287. data/lib/phronomy/workflow_recovery.rb +0 -123
  288. /data/lib/phronomy/agent/{context_candidate_resolver.rb → context_assembly/context_candidate_resolver.rb} +0 -0
  289. /data/lib/phronomy/agent/{context_policy_input_builder.rb → context_assembly/context_policy_input_builder.rb} +0 -0
  290. /data/lib/phronomy/agent/{context_plan_validator.rb → context_contract/context_plan_validator.rb} +0 -0
  291. /data/lib/phronomy/agent/{context_policy.rb → context_contract/context_policy.rb} +0 -0
  292. /data/lib/phronomy/agent/{llm_operation_result.rb → execution/llm_operation_result.rb} +0 -0
  293. /data/lib/phronomy/agent/{handoff.rb → handoff/handoff.rb} +0 -0
  294. /data/lib/phronomy/agent/{handoff_capability_factory.rb → handoff/handoff_capability_factory.rb} +0 -0
  295. /data/lib/phronomy/agent/{handoff_policy.rb → handoff/handoff_policy.rb} +0 -0
  296. /data/lib/phronomy/agent/{handoff_projection.rb → handoff/handoff_projection.rb} +0 -0
  297. /data/lib/phronomy/agent/{handoff_request.rb → handoff/handoff_request.rb} +0 -0
  298. /data/lib/phronomy/agent/{tool_invocation_session_builder.rb → tool_execution/tool_invocation_session_builder.rb} +0 -0
  299. /data/lib/phronomy/{canonical_json.rb → common/canonical_json.rb} +0 -0
  300. /data/lib/phronomy/{diagnostics.rb → engine/diagnostics.rb} +0 -0
  301. /data/lib/phronomy/{event.rb → engine/event.rb} +0 -0
  302. /data/lib/phronomy/{invocation_context.rb → engine/invocation_context.rb} +0 -0
  303. /data/lib/phronomy/{metrics.rb → engine/metrics.rb} +0 -0
  304. /data/lib/phronomy/{runnable.rb → engine/runnable.rb} +0 -0
@@ -1,3151 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
- require "securerandom"
5
-
6
- module Phronomy
7
- module Agent
8
- class ExecutionCoordinator
9
- # External/API -> EventLoop control messages.
10
- StartCommand = Data.define(
11
- :coordinator, :input, :config, :mode,
12
- :approval_policy, :approval_listener, :on_event, :result_task,
13
- :admission_token
14
- )
15
- ResumeCommand = Data.define(
16
- :coordinator, :execution_id, :approval_request_id,
17
- :approved, :config, :result_task
18
- )
19
-
20
- # EventLoop -> Offload operation-specific immutable snapshots.
21
- InitialPreparationCommand = Data.define(
22
- :root, :journal_records, :input, :config, :preparation_replayable
23
- )
24
- ProviderDispatchPreparationCommand = Data.define(
25
- :execution_id, :fsm_session_id, :expected_execution_revision,
26
- :root, :journal_records, :execution, :base_manifest,
27
- :invocation_config, :runtime_snapshot, :streaming,
28
- :pending_llm_call_id, :pending_llm_started_at
29
- )
30
- ToolDispatchPreparationCommand = Data.define(
31
- :execution_id, :fsm_session_id, :expected_execution_revision,
32
- :root, :execution, :runtime_snapshot, :tool_batch_snapshot
33
- )
34
- ProviderDispatchPreparationReconciliationCommand = Data.define(
35
- :operation, :intended_result, :original_error
36
- )
37
- ToolDispatchPreparationReconciliationCommand = Data.define(
38
- :operation, :intended_result, :original_error
39
- )
40
- ResumeCommitCommand = Data.define(
41
- :execution_id, :expected_execution_revision,
42
- :root, :execution, :approval_request_id, :approved
43
- )
44
- HandoffTerminalView = Data.define(
45
- :target_agent_id, :responsibility, :selection_intent,
46
- :llm_call_id, :tool_call_id, :policy
47
- )
48
- TerminalView = Data.define(
49
- :phase, :output, :usage, :approval_request, :rejected,
50
- :input_blocked, :output_blocked, :block_error,
51
- :invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested
52
- )
53
- TerminalCommitCommand = Data.define(
54
- :execution_id, :fsm_session_id, :expected_execution_revision,
55
- :root, :journal_records, :execution, :runtime_snapshot,
56
- :terminal_view, :state_required
57
- )
58
- TerminalDelivery = Data.define(
59
- :result_task, :application_listener, :approval_listener,
60
- :handoff_request, :handoff_manifest
61
- )
62
-
63
- # Offload -> EventLoop operation results. No live Agent/Invocation object is
64
- # mutated by a worker; these values are validated and applied on EventLoop.
65
- InitialPreparationResult = Data.define(
66
- :execution, :root, :runtime_projection, :filtered_input,
67
- :config, :appended_records, :error, :admission_outcome
68
- )
69
- ProviderDispatchPreparationResult = Data.define(:execution, :runtime_projection, :error)
70
- ToolDispatchPreparationResult = Data.define(:execution)
71
- ProviderDispatchPreparationReconciliationResult = Data.define(
72
- :disposition, :preparation_result
73
- )
74
- ToolDispatchPreparationReconciliationResult = Data.define(
75
- :disposition, :preparation_result
76
- )
77
- ResumeCommitResult = Data.define(:execution, :root)
78
- TerminalOutcome = Data.define(
79
- :type, :execution, :root, :appended_records,
80
- :result, :error, :approval_request
81
- )
82
-
83
- InitialPreparationReady = Data.define(
84
- :coordinator, :request, :result, :error
85
- )
86
- InitialPreparationRecoveryReady = Data.define(
87
- :coordinator, :execution_id, :expected_execution_revision,
88
- :result_task, :load_completion, :result, :error
89
- )
90
- ProviderDispatchPreparationReady = Data.define(
91
- :coordinator, :operation, :result, :error
92
- )
93
- ToolDispatchPreparationReady = Data.define(
94
- :coordinator, :operation, :result, :error
95
- )
96
- ProviderDispatchPreparationReconciliationReady = Data.define(
97
- :coordinator, :command, :result, :error
98
- )
99
- ToolDispatchPreparationReconciliationReady = Data.define(
100
- :coordinator, :command, :result, :error
101
- )
102
- ResumeCommitReady = Data.define(
103
- :coordinator, :request, :operation, :result, :error
104
- )
105
- TerminalCommitReady = Data.define(
106
- :coordinator, :operation, :delivery, :outcome, :error
107
- )
108
- DeferredTerminalCommand = Data.define(
109
- :coordinator, :execution_id, :result_task, :invocation,
110
- :source_error, :fsm_session_id
111
- )
112
-
113
- class PreparationOutcomeUnknownError < Phronomy::Error
114
- attr_reader :original_error, :intended_result
115
-
116
- def initialize(original_error, intended_result)
117
- @original_error = original_error
118
- @intended_result = intended_result
119
- super(
120
- "durable dispatch preparation outcome is uncertain: " \
121
- "#{original_error.class}: #{original_error.message}"
122
- )
123
- set_backtrace(original_error.backtrace)
124
- end
125
- end
126
- private_constant :PreparationOutcomeUnknownError
127
-
128
- def initialize(agent)
129
- @agent = agent
130
- end
131
-
132
- def start(
133
- input, config: {}, mode: :invoke,
134
- approval_policy: nil, approval_listener: nil, on_event: nil
135
- )
136
- @agent.send(:__assert_live_agent!)
137
- @agent.send(:_reject_removed_generic_identity_keys!, config)
138
- result_task = Phronomy::TaskResult.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
139
- if config[:invocation_context]
140
- binding = Phronomy::Execution.__operation_binding(
141
- invocation_context: config[:invocation_context],
142
- cancellation_token: config[:cancellation_token]
143
- )
144
- binding.bind(result_task)
145
- config = config.merge(cancellation_token: binding.token)
146
- end
147
- command = StartCommand.new(
148
- coordinator: self,
149
- input: input,
150
- config: @agent.__invocation_config(config).dup.freeze,
151
- mode: mode.to_sym,
152
- approval_policy: approval_policy,
153
- approval_listener: approval_listener,
154
- on_event: on_event,
155
- result_task: result_task,
156
- admission_token: Object.new.freeze
157
- )
158
- unless post_control(Phronomy::Runtime.instance, command)
159
- fail_task(result_task, runtime_rejected_error(:start))
160
- end
161
- result_task
162
- rescue => error
163
- fail_task(result_task, translated(error)) if defined?(result_task) && result_task
164
- result_task
165
- end
166
-
167
- def resume(
168
- execution_id,
169
- approval_request_id:,
170
- approved:,
171
- config: {}
172
- )
173
- @agent.send(:__assert_live_agent!)
174
- @agent.send(:_reject_removed_generic_identity_keys!, config)
175
- result_task = Phronomy::TaskResult.deferred(
176
- name: "agent-approval-resume:#{execution_id}"
177
- )
178
- command = ResumeCommand.new(
179
- coordinator: self,
180
- execution_id: execution_id.to_s.freeze,
181
- approval_request_id: approval_request_id.to_s.freeze,
182
- approved: !!approved,
183
- config: config.dup.freeze,
184
- result_task: result_task
185
- )
186
- unless post_control(Phronomy::Runtime.instance, command)
187
- fail_task(result_task, runtime_rejected_error(:resume))
188
- end
189
- result_task
190
- rescue => error
191
- fail_task(result_task, translated(error)) if defined?(result_task) && result_task
192
- result_task
193
- end
194
-
195
- # Called only from an Agent FSMSession external-operation dispatch state on
196
- # EventLoop. Each method captures one operation-specific immutable snapshot
197
- # and sends only that snapshot to OffloadPool.
198
- # @api private
199
- def prepare_provider_dispatch(invocation, event_sink:, streaming:)
200
- runtime = Phronomy::Runtime.instance
201
- event_loop = runtime.event_loop
202
- assert_event_loop!(event_loop)
203
- state = event_loop.agent_execution_state(invocation.execution_id)
204
- validate_live_session!(state, invocation, event_sink.fsm_session_id)
205
- unless event_loop.fsm_session_state(event_sink.fsm_session_id) == :calling_llm
206
- raise Phronomy::Error,
207
- "Provider dispatch preparation requires the owning FSMSession to be in :calling_llm"
208
- end
209
-
210
- operation = ProviderDispatchPreparationCommand.new(
211
- execution_id: state.execution_id,
212
- fsm_session_id: event_sink.fsm_session_id.to_s.freeze,
213
- expected_execution_revision: state.execution.execution_revision,
214
- root: @agent.agent_root,
215
- journal_records: @agent.send(:_journal_records_snapshot),
216
- execution: state.execution,
217
- base_manifest: state.base_manifest,
218
- invocation_config: invocation.config.dup.freeze,
219
- runtime_snapshot: invocation.runtime_snapshot,
220
- streaming: !!streaming,
221
- pending_llm_call_id: SecureRandom.uuid.to_s.freeze,
222
- pending_llm_started_at: Time.now.utc.iso8601(6).freeze
223
- )
224
- submit_provider_dispatch_preparation(operation)
225
- nil
226
- rescue => error
227
- event_sink.post(:llm_setup_failed, translated(error))
228
- nil
229
- end
230
-
231
- # @api private
232
- def prepare_tool_dispatch(invocation, event_sink:)
233
- runtime = Phronomy::Runtime.instance
234
- event_loop = runtime.event_loop
235
- assert_event_loop!(event_loop)
236
- state = event_loop.agent_execution_state(invocation.execution_id)
237
- validate_live_session!(state, invocation, event_sink.fsm_session_id)
238
- unless event_loop.fsm_session_state(event_sink.fsm_session_id) == :dispatching_tools
239
- raise Phronomy::Error,
240
- "Tool dispatch preparation requires the owning FSMSession to be in :dispatching_tools"
241
- end
242
-
243
- tool_batch_snapshot = RecoverySupport.build_tool_batch_snapshot(invocation)
244
- if tool_batch_snapshot.empty?
245
- raise Phronomy::Error, "Tool dispatch preparation requires a non-empty Tool batch"
246
- end
247
-
248
- operation = ToolDispatchPreparationCommand.new(
249
- execution_id: state.execution_id,
250
- fsm_session_id: event_sink.fsm_session_id.to_s.freeze,
251
- expected_execution_revision: state.execution.execution_revision,
252
- root: @agent.agent_root,
253
- execution: state.execution,
254
- runtime_snapshot: invocation.runtime_snapshot,
255
- tool_batch_snapshot: tool_batch_snapshot
256
- )
257
- submit_tool_dispatch_preparation(operation)
258
- nil
259
- rescue => error
260
- event_sink.post(:tool_setup_failed, translated(error))
261
- nil
262
- end
263
-
264
- def submit_provider_dispatch_preparation(operation)
265
- runtime = Phronomy::Runtime.instance
266
- task = runtime.offload.submit(on_full: :raise) do
267
- perform_provider_dispatch_preparation(operation)
268
- end
269
- task.on_complete do |result, error|
270
- ready = ProviderDispatchPreparationReady.new(
271
- coordinator: self,
272
- operation: operation,
273
- result: result,
274
- error: error
275
- )
276
- unless post_control(runtime, ready)
277
- Phronomy.configuration.logger&.warn(
278
- "[Phronomy] EventLoop rejected Provider dispatch preparation result for " \
279
- "#{operation.execution_id}"
280
- )
281
- end
282
- end
283
- end
284
-
285
- def submit_tool_dispatch_preparation(operation)
286
- runtime = Phronomy::Runtime.instance
287
- task = runtime.offload.submit(on_full: :raise) do
288
- perform_tool_dispatch_preparation(operation)
289
- end
290
- task.on_complete do |result, error|
291
- ready = ToolDispatchPreparationReady.new(
292
- coordinator: self,
293
- operation: operation,
294
- result: result,
295
- error: error
296
- )
297
- unless post_control(runtime, ready)
298
- Phronomy.configuration.logger&.warn(
299
- "[Phronomy] EventLoop rejected Tool dispatch preparation result for " \
300
- "#{operation.execution_id}"
301
- )
302
- end
303
- end
304
- end
305
-
306
- def submit_provider_dispatch_preparation_reconciliation(operation, uncertainty)
307
- # simplecov:disable
308
- runtime = Phronomy::Runtime.instance
309
- command = ProviderDispatchPreparationReconciliationCommand.new(
310
- operation: operation,
311
- intended_result: uncertainty.intended_result,
312
- original_error: uncertainty.original_error
313
- )
314
- task = runtime.offload.submit(on_full: :raise) do
315
- perform_provider_dispatch_preparation_reconciliation(command)
316
- end
317
- task.on_complete do |result, error|
318
- ready = ProviderDispatchPreparationReconciliationReady.new(
319
- coordinator: self,
320
- command: command,
321
- result: result,
322
- error: error
323
- )
324
- unless post_control(runtime, ready)
325
- Phronomy.configuration.logger&.warn(
326
- "[Phronomy] EventLoop rejected Provider dispatch reconciliation result for #{operation.execution_id}"
327
- )
328
- end
329
- end
330
- rescue => error
331
- mark_barrier_recovery_required(operation, error)
332
- # simplecov:enable
333
- end
334
-
335
- def submit_tool_dispatch_preparation_reconciliation(operation, uncertainty)
336
- # simplecov:disable
337
- runtime = Phronomy::Runtime.instance
338
- command = ToolDispatchPreparationReconciliationCommand.new(
339
- operation: operation,
340
- intended_result: uncertainty.intended_result,
341
- original_error: uncertainty.original_error
342
- )
343
- task = runtime.offload.submit(on_full: :raise) do
344
- perform_tool_dispatch_preparation_reconciliation(command)
345
- end
346
- task.on_complete do |result, error|
347
- ready = ToolDispatchPreparationReconciliationReady.new(
348
- coordinator: self,
349
- command: command,
350
- result: result,
351
- error: error
352
- )
353
- unless post_control(runtime, ready)
354
- Phronomy.configuration.logger&.warn(
355
- "[Phronomy] EventLoop rejected Tool dispatch reconciliation result for #{operation.execution_id}"
356
- )
357
- end
358
- end
359
- rescue => error
360
- mark_barrier_recovery_required(operation, error)
361
- # simplecov:enable
362
- end
363
-
364
- # Every control message is delivered by EventLoop. This method is the only
365
- # coordinator entry point allowed to advance Phronomy-managed live state.
366
- # @api private
367
- def deliver_on_event_loop(command)
368
- event_loop = Phronomy::Runtime.instance.event_loop
369
- assert_event_loop!(event_loop)
370
-
371
- case command
372
- when StartCommand
373
- begin_start_on_event_loop(command)
374
- when InitialPreparationReady
375
- apply_initial_preparation_on_event_loop(command)
376
- when InitialPreparationRecoveryReady
377
- apply_initial_preparation_recovery_on_event_loop(command)
378
- when ProviderDispatchPreparationReady
379
- apply_provider_dispatch_preparation_on_event_loop(command)
380
- when ToolDispatchPreparationReady
381
- apply_tool_dispatch_preparation_on_event_loop(command)
382
- when ProviderDispatchPreparationReconciliationReady
383
- apply_provider_dispatch_preparation_reconciliation_on_event_loop(command)
384
- when ToolDispatchPreparationReconciliationReady
385
- apply_tool_dispatch_preparation_reconciliation_on_event_loop(command)
386
- when ResumeCommand
387
- begin_resume_on_event_loop(command)
388
- when ResumeCommitReady
389
- apply_resume_commit_on_event_loop(command)
390
- when TerminalCommitReady
391
- apply_terminal_commit_on_event_loop(command)
392
- when DeferredTerminalCommand
393
- resume_deferred_terminal_on_event_loop(command)
394
- else
395
- raise Phronomy::Error, "unknown Agent control command: #{command.class}"
396
- end
397
- end
398
-
399
- private
400
-
401
- # ----------------------------------------------------------------------
402
- # Initial preparation
403
- # ----------------------------------------------------------------------
404
-
405
- def begin_start_on_event_loop(request)
406
- runtime = Phronomy::Runtime.instance
407
- event_loop = runtime.event_loop
408
- scope = request.config[:invocation_context]&.__execution_scope
409
- if scope && (!scope.__open? || request.config[:cancellation_token]&.cancelled?)
410
- deliver_start_failure_on_event_loop(request, scope.__cancellation_error)
411
- return
412
- end
413
- root = @agent.agent_root
414
- if root.lifecycle_status == :closed
415
- deliver_start_failure_on_event_loop(
416
- request,
417
- Phronomy::Error.new("agent is closed: #{@agent.agent_id}")
418
- )
419
- return
420
- end
421
-
422
- @agent.send(:__assert_live_agent!)
423
- admitted = false
424
- submitted = false
425
- event_loop.admit_agent_execution(
426
- @agent.agent_id,
427
- owner_token: request.admission_token
428
- )
429
- admitted = true
430
-
431
- # Ownership purge may begin on an application thread between the first
432
- # live-owner check and EventLoop admission. Re-check after the slot is
433
- # installed: once this check succeeds, purge observes the admission and
434
- # must abort instead of deleting the Agent underneath this start.
435
- @agent.send(:__assert_live_agent!)
436
-
437
- operation = InitialPreparationCommand.new(
438
- root: root,
439
- journal_records: @agent.send(:_journal_records_snapshot),
440
- input: request.input,
441
- config: request.config,
442
- preparation_replayable: initial_preparation_replayable?(
443
- request.input,
444
- request.config,
445
- request.approval_policy
446
- )
447
- )
448
- task = runtime.offload.submit(on_full: :raise) do
449
- perform_initial_preparation(operation)
450
- end
451
- submitted = true
452
- task.on_complete do |result, error|
453
- ready = InitialPreparationReady.new(
454
- coordinator: self,
455
- request: request,
456
- result: result,
457
- error: error
458
- )
459
- fail_task(request.result_task, runtime_rejected_error(:initial_preparation)) unless
460
- post_control(runtime, ready)
461
- end
462
- rescue => error
463
- if admitted
464
- if submitted
465
- event_loop.mark_agent_admission_recovery_required(
466
- @agent.agent_id,
467
- owner_token: request.admission_token
468
- )
469
- else
470
- event_loop.release_agent_execution_admission(
471
- @agent.agent_id,
472
- owner_token: request.admission_token
473
- )
474
- end
475
- end
476
- deliver_start_failure_on_event_loop(request, translated(error))
477
- end
478
-
479
- def perform_initial_preparation(operation)
480
- begin
481
- raw_message = @agent.send(:extract_message, operation.input)
482
- rescue => error
483
- return InitialPreparationResult.new(
484
- execution: nil,
485
- root: operation.root,
486
- runtime_projection: nil,
487
- filtered_input: nil,
488
- config: operation.config,
489
- appended_records: [].freeze,
490
- error: translated(error),
491
- admission_outcome: :not_established
492
- )
493
- end
494
-
495
- begin
496
- execution, active_root = admit_execution(
497
- raw_message,
498
- root: operation.root,
499
- mode: (operation.config[:phronomy_recovery_mode] || :invoke).to_sym,
500
- config: operation.config,
501
- preparation_replayable: operation.preparation_replayable
502
- )
503
- rescue Phronomy::AgentBusyError => error
504
- # A durable busy conflict proves that another nonterminal logical
505
- # Execution already exists for this agent_id. Process loss does not
506
- # make that execution terminal; keep Runtime admission fail-closed
507
- # until ACS-15 recovery/reconciliation can establish current lineage.
508
- return InitialPreparationResult.new(
509
- execution: nil,
510
- root: operation.root,
511
- runtime_projection: nil,
512
- filtered_input: nil,
513
- config: operation.config,
514
- appended_records: [].freeze,
515
- error: translated(error),
516
- admission_outcome: :recovery_required
517
- )
518
- rescue Phronomy::Persistence::ConflictError,
519
- Phronomy::Persistence::NotFoundError,
520
- Phronomy::Persistence::SerializationError,
521
- ArgumentError,
522
- Phronomy::ConfigurationError => error
523
- return InitialPreparationResult.new(
524
- execution: nil,
525
- root: operation.root,
526
- runtime_projection: nil,
527
- filtered_input: nil,
528
- config: operation.config,
529
- appended_records: [].freeze,
530
- error: translated(error),
531
- admission_outcome: :not_established
532
- )
533
- rescue => error
534
- return InitialPreparationResult.new(
535
- execution: nil,
536
- root: operation.root,
537
- runtime_projection: nil,
538
- filtered_input: nil,
539
- config: operation.config,
540
- appended_records: [].freeze,
541
- error: translated(error),
542
- admission_outcome: :outcome_unknown
543
- )
544
- end
545
-
546
- perform_admitted_initial_preparation(
547
- input: operation.input,
548
- config: operation.config,
549
- execution: execution,
550
- active_root: active_root,
551
- journal_records: operation.journal_records
552
- )
553
- end
554
-
555
- def perform_admitted_initial_preparation(
556
- input:,
557
- config:,
558
- execution:,
559
- active_root:,
560
- journal_records:
561
- )
562
- current_execution = execution
563
- begin
564
- effective_config = config
565
- @agent.send(
566
- :check_cancellation!,
567
- effective_config,
568
- "invocation cancelled before input filtering"
569
- )
570
- filtered_input = @agent.send(:run_input_filters!, input)
571
- @agent.send(
572
- :check_cancellation!,
573
- effective_config,
574
- "invocation cancelled before context assembly"
575
- )
576
- filtered_message = @agent.send(:extract_message, filtered_input)
577
- manifest = manifest_ref = active_execution = nil
578
-
579
- # Content-addressed source material may be written before the Policy call.
580
- # No mutable Agent/Execution repository state is committed here.
581
- filtered_ref = @agent.persistence.contents.put_text(filtered_message)
582
- input_record = JournalRecord.new(
583
- agent_id: @agent.agent_id,
584
- execution_id: current_execution.execution_id,
585
- kind: :external_message,
586
- channel: :external,
587
- role: :user,
588
- content_ref: filtered_ref,
589
- context_generation: active_root.transcript_generation,
590
- context_candidate: true,
591
- metadata: {"handoff_policy_category" => "current_request"}
592
- )
593
- staged = current_execution.with(
594
- execution_revision: current_execution.execution_revision,
595
- working_records: current_execution.working_records + [input_record],
596
- metadata: current_execution.metadata.merge(
597
- "current_input_ref" => filtered_ref,
598
- "current_input_record_id" => input_record.record_id
599
- )
600
- )
601
- assembler = ContextAssembler.new(
602
- agent: @agent,
603
- persistence: @agent.persistence,
604
- journal_records: journal_records
605
- )
606
- prepared = assembler.prepare_initial(
607
- input: filtered_input,
608
- agent_root: active_root,
609
- execution: staged,
610
- config: effective_config,
611
- patch: @agent.send(
612
- :run_before_llm_input_hooks,
613
- call_sequence: 1,
614
- config: effective_config
615
- )
616
- )
617
- @agent.send(
618
- :check_cancellation!,
619
- effective_config,
620
- "invocation cancelled after context policy"
621
- )
622
-
623
- # Revalidate the durable base only after Application Policy returns.
624
- # ContextPolicy never runs while this transaction is open.
625
- @agent.persistence.transaction do |tx|
626
- assert_local_durable_base!(tx, active_root)
627
- manifest, manifest_ref = assembler.finalize(prepared, persistence: tx)
628
- active_execution = staged.with(
629
- status: :active,
630
- phase: :calling_llm,
631
- metadata: staged.metadata.merge(
632
- "base_manifest_ref" => manifest_ref,
633
- "manifest_ref" => manifest_ref,
634
- "manifest_refs" => [manifest_ref]
635
- )
636
- )
637
- tx.executions.save(
638
- current_execution.execution_id,
639
- expected_revision: current_execution.execution_revision,
640
- execution: active_execution
641
- )
642
- end
643
- current_execution = active_execution
644
-
645
- projection = RubyLLMMaterializer.new(
646
- agent: @agent,
647
- persistence: @agent.persistence
648
- ).materialize(manifest: manifest, manifest_ref: manifest_ref)
649
- InitialPreparationResult.new(
650
- execution: active_execution,
651
- root: active_root,
652
- runtime_projection: projection,
653
- filtered_input: filtered_input,
654
- config: effective_config,
655
- appended_records: [].freeze,
656
- error: nil,
657
- admission_outcome: :active
658
- )
659
- rescue => error
660
- failure = commit_preparation_failure(
661
- current_execution,
662
- active_root,
663
- error
664
- )
665
- InitialPreparationResult.new(
666
- execution: failure.fetch(:execution),
667
- root: failure.fetch(:root),
668
- runtime_projection: nil,
669
- filtered_input: nil,
670
- config: config,
671
- appended_records: failure.fetch(:appended_records),
672
- error: failure.fetch(:error),
673
- admission_outcome: :terminal
674
- )
675
- end
676
- end
677
-
678
- def admit_execution(
679
- raw_message,
680
- root:,
681
- mode: :invoke,
682
- config: {},
683
- preparation_replayable: false
684
- )
685
- if root.lifecycle_status == :closed
686
- raise Phronomy::Error,
687
- "agent is closed: #{@agent.agent_id}"
688
- end
689
-
690
- execution = next_root = nil
691
- mode = mode.to_sym
692
- pending_llm_call_id = SecureRandom.uuid.to_s.freeze
693
- pending_started_at = Time.now.utc.iso8601(6).freeze
694
-
695
- @agent.persistence.transaction do |tx|
696
- input_ref = tx.contents.put_text(raw_message)
697
- durable_context_ref = if config.key?(:durable_context)
698
- tx.contents.put_json(config.fetch(:durable_context))
699
- end
700
- input_record = JournalRecord.new(
701
- agent_id: @agent.agent_id,
702
- kind: :input_received,
703
- channel: :external,
704
- role: :user,
705
- content_ref: input_ref,
706
- context_generation: root.transcript_generation,
707
- context_candidate: false
708
- )
709
- execution = AgentExecution.start(
710
- agent_root: root,
711
- input_record: input_record,
712
- execution_id: config[:phronomy_reserved_execution_id] || SecureRandom.uuid,
713
- metadata: {
714
- "coordination" => config[:phronomy_coordination],
715
- "current_input_ref" => input_ref,
716
- "durable_context_ref" => durable_context_ref,
717
- "preparation_replayable" => !!preparation_replayable,
718
- RecoverySupport::CONTRACT_VERSION_KEY =>
719
- RecoverySupport::CONTRACT_VERSION,
720
- RecoverySupport::INVOCATION_MODE_KEY => mode.to_s,
721
- RecoverySupport::PENDING_LLM_ID_KEY =>
722
- pending_llm_call_id,
723
- RecoverySupport::PENDING_LLM_STARTED_AT_KEY =>
724
- pending_started_at
725
- }.compact
726
- )
727
- input_record = JournalRecord.from_h(
728
- input_record.to_h.merge(
729
- "execution_id" => execution.execution_id
730
- )
731
- )
732
- execution = execution.with(
733
- execution_revision: 0,
734
- working_records: [input_record]
735
- )
736
- validate_coordination_admission!(tx, execution)
737
- tx.executions.create_active(execution)
738
- next_root = root.with(
739
- agent_revision: root.agent_revision + 1,
740
- lifecycle_status: :active
741
- )
742
- tx.agents.save(
743
- root.agent_id,
744
- expected_revision: root.agent_revision,
745
- root: next_root
746
- )
747
- end
748
- [execution, next_root]
749
- end
750
-
751
- def validate_coordination_admission!(tx, execution)
752
- owner = execution.metadata["coordination"]
753
- return unless owner
754
- case owner.fetch("kind")
755
- when "team"
756
- team = tx.team_executions.load(owner.fetch("team_execution_id"))
757
- unless team.team_id == owner.fetch("team_id") && team.active? && !team.metadata["cancel_requested"]
758
- raise Phronomy::CancellationError, "Team run is no longer dispatchable"
759
- end
760
- slot = if owner.fetch("slot") == "coordinator"
761
- team.coordinator
762
- else
763
- assignment = team.assignments.find { |entry| entry.fetch("task_id") == owner.fetch("slot") }
764
- worker = assignment && team.workers.fetch(assignment.fetch("worker"))
765
- worker&.merge("execution_id" => assignment.fetch("execution_id"))
766
- end
767
- unless slot && slot.fetch("execution_id") == execution.execution_id && slot.fetch("agent_id") == execution.agent_id
768
- raise Phronomy::Persistence::ConflictError, "Team reserved child identity mismatch"
769
- end
770
- when "subagent"
771
- parent = tx.executions.load(owner.fetch("parent_execution_id"))
772
- unless parent.agent_id == owner.fetch("parent_agent_id") && parent.active? && !parent.metadata["coordination_cancel_requested"]
773
- raise Phronomy::CancellationError, "Parent run is no longer dispatchable"
774
- end
775
- snapshot = tx.contents.fetch_json(parent.metadata.fetch("multi_agent_coordination_ref"))
776
- slot = snapshot.fetch("children").find { |entry| entry.fetch("slot") == owner.fetch("slot") }
777
- unless slot && slot.fetch("agent_id") == execution.agent_id && slot.fetch("execution_id") == execution.execution_id
778
- raise Phronomy::Persistence::ConflictError, "Parent reserved child identity mismatch"
779
- end
780
- when "handoff"
781
- routing = tx.handoff_states.load(owner.fetch("main_agent_id"))
782
- unless routing && routing.active_agent_id == execution.agent_id
783
- raise Phronomy::Persistence::ConflictError, "Handoff responsibility changed before admission"
784
- end
785
- if Array(routing.metadata["cancelled_execution_ids"]).include?(execution.execution_id)
786
- raise Phronomy::CancellationError, "Handoff reservation was cancelled"
787
- end
788
- if routing.phase != "stable" && routing.pending_target_execution_id != execution.execution_id
789
- raise Phronomy::Persistence::ConflictError, "Handoff reserved Target identity mismatch"
790
- end
791
- else
792
- raise Phronomy::ConfigurationError, "Unknown coordination owner kind"
793
- end
794
- end
795
-
796
- def initial_preparation_replayable?(input, config, approval_policy)
797
- return false unless input.is_a?(String)
798
- return false unless approval_policy.nil?
799
- if config.key?(:phronomy_handoff_bindings) ||
800
- config.key?(:phronomy_handoff_context)
801
- return false unless config[:phronomy_coordination]&.fetch("kind", nil) == "handoff"
802
- end
803
-
804
- invocation_context = config[:invocation_context]
805
- return true unless invocation_context
806
-
807
- %i[approval_policy redaction_policy token_budget].none? do |name|
808
- invocation_context.respond_to?(name) &&
809
- !invocation_context.public_send(name).nil?
810
- end
811
- end
812
-
813
- def commit_preparation_failure(execution, root, error)
814
- translated_error = translated(error)
815
- failed = next_root = appended = nil
816
-
817
- @agent.persistence.transaction do |tx|
818
- error_ref = tx.contents.put_json(
819
- "class" => translated_error.class.name,
820
- "message" => translated_error.message
821
- )
822
- audit_records = execution.working_records.map do |record|
823
- JournalRecord.from_h(
824
- record.to_h.merge("context_candidate" => false)
825
- )
826
- end
827
- terminal_status = terminal_status_for(translated_error)
828
- audit_records << JournalRecord.new(
829
- agent_id: @agent.agent_id,
830
- execution_id: execution.execution_id,
831
- kind: execution_terminal_kind(terminal_status),
832
- channel: :audit,
833
- content_ref: error_ref,
834
- context_generation: root.transcript_generation,
835
- context_candidate: false
836
- )
837
- appended = tx.journals.append(
838
- root.agent_id,
839
- expected_position: root.journal_position,
840
- records: audit_records
841
- )
842
- failed = execution.with(
843
- status: terminal_status,
844
- phase: terminal_status,
845
- working_records: [],
846
- error_ref: error_ref,
847
- terminal_reason: translated_error.class.name
848
- )
849
- tx.executions.save(
850
- execution.execution_id,
851
- expected_revision: execution.execution_revision,
852
- execution: failed
853
- )
854
- next_root = root.with(
855
- agent_revision: root.agent_revision + 1,
856
- journal_position: root.journal_position + appended.length,
857
- lifecycle_status: :idle
858
- )
859
- tx.agents.save(
860
- root.agent_id,
861
- expected_revision: root.agent_revision,
862
- root: next_root
863
- )
864
- end
865
- {
866
- error: translated_error,
867
- execution: failed,
868
- root: next_root,
869
- appended_records: Array(appended).freeze
870
- }.freeze
871
- end
872
-
873
- def apply_initial_preparation_on_event_loop(ready)
874
- request = ready.request
875
- event_loop = Phronomy::Runtime.instance.event_loop
876
- if ready.error
877
- event_loop.mark_agent_admission_recovery_required(
878
- @agent.agent_id,
879
- owner_token: request.admission_token
880
- )
881
- deliver_start_failure_on_event_loop(request, translated(ready.error))
882
- return
883
- end
884
-
885
- result = ready.result
886
- case result.admission_outcome
887
- when :not_established
888
- event_loop.release_agent_execution_admission(
889
- @agent.agent_id,
890
- owner_token: request.admission_token
891
- )
892
- deliver_start_failure_on_event_loop(request, result.error)
893
- return
894
- when :outcome_unknown, :recovery_required
895
- event_loop.mark_agent_admission_recovery_required(
896
- @agent.agent_id,
897
- owner_token: request.admission_token
898
- )
899
- deliver_start_failure_on_event_loop(request, result.error)
900
- return
901
- when :active, :terminal
902
- event_loop.bind_agent_execution_admission(
903
- @agent.agent_id,
904
- owner_token: request.admission_token,
905
- execution_id: result.execution.execution_id
906
- )
907
- else
908
- event_loop.mark_agent_admission_recovery_required(
909
- @agent.agent_id,
910
- owner_token: request.admission_token
911
- )
912
- raise Phronomy::Error,
913
- "unknown initial admission outcome: #{result.admission_outcome.inspect}"
914
- end
915
-
916
- apply_agent_live_state(
917
- root: result.root,
918
- appended_records: result.appended_records
919
- )
920
- if result.admission_outcome == :terminal
921
- event_loop.release_agent_execution_admission(
922
- @agent.agent_id,
923
- execution_id: result.execution.execution_id
924
- )
925
- deliver_start_failure_on_event_loop(request, result.error)
926
- return
927
- end
928
-
929
- register_initial_session_on_event_loop(request, result)
930
- rescue => error
931
- # Preparation may already be durably active. Terminalize through another
932
- # offloaded durable operation rather than releasing the admission blindly.
933
- begin_terminal_without_session_on_event_loop(
934
- request: request,
935
- prepared: ready.result,
936
- error: error
937
- )
938
- end
939
-
940
- def register_initial_session_on_event_loop(request, prepared)
941
- runtime = Phronomy::Runtime.instance
942
- event_loop = runtime.event_loop
943
- Phronomy::Tracing::Automatic.observe_task(
944
- request.result_task,
945
- "agent.execution",
946
- input: request.input,
947
- agent_id: @agent.agent_id,
948
- execution_id: prepared.execution.execution_id,
949
- mode: request.mode,
950
- **@agent.send(:_build_caller_meta, prepared.config)
951
- )
952
- effective_config = prepared.config.merge(
953
- phronomy_execution_coordinator: self,
954
- phronomy_runtime_projection: prepared.runtime_projection,
955
- phronomy_filtered_input: prepared.filtered_input,
956
- execution_id: prepared.execution.execution_id
957
- )
958
- session = Agent::AgentInvocationSessionBuilder.build(
959
- agent: @agent,
960
- input: prepared.filtered_input,
961
- config: effective_config,
962
- approval_policy: request.approval_policy,
963
- approval_listener: request.approval_listener,
964
- mode: request.mode,
965
- on_event: request.on_event,
966
- runtime: runtime
967
- )
968
- event_loop.install_agent_execution(
969
- execution_id: prepared.execution.execution_id,
970
- agent: @agent,
971
- coordinator: self,
972
- execution: prepared.execution,
973
- runtime_projection: prepared.runtime_projection,
974
- base_manifest: prepared.runtime_projection.manifest,
975
- invocation: session.context,
976
- fsm_session_id: session.id
977
- )
978
- event_loop.register_agent_completion_waiter(
979
- prepared.execution.execution_id,
980
- request.result_task
981
- )
982
- source_task = Phronomy::TaskResult.deferred(name: "#{request.result_task.name}-source")
983
- source_task.on_complete do |invocation, error|
984
- finish_on_event_loop(
985
- prepared.execution.execution_id,
986
- request.result_task,
987
- invocation || session.context,
988
- error,
989
- fsm_session_id: session.id
990
- )
991
- end
992
- event_loop.register(session, completion: source_task)
993
- # simplecov:disable
994
- rescue => _error
995
- event_loop&.release_agent_execution(prepared.execution.execution_id) if
996
- event_loop&.current? && event_loop.agent_execution_state(prepared.execution.execution_id)
997
- raise
998
- end
999
- # simplecov:enable
1000
-
1001
- def deliver_start_failure_on_event_loop(request, error)
1002
- callback_error = @agent.send(
1003
- :_deliver_stream_event,
1004
- request.on_event,
1005
- StreamEvent.new(
1006
- type: terminal_event_type(error),
1007
- payload: {error: error}
1008
- )
1009
- )
1010
- settle_after_terminal(
1011
- request.result_task,
1012
- callback_error,
1013
- terminal_event_type(error),
1014
- nil,
1015
- error
1016
- )
1017
- end
1018
-
1019
- # Restarts only the replay-safe initial preparation region of an already
1020
- # durably admitted logical Execution. Runtime-only caller state is not
1021
- # reconstructed here; the durable preparation inputs are authoritative.
1022
- # @api private
1023
- def start_initial_preparation_recovery_on_event_loop(
1024
- execution,
1025
- result_task,
1026
- load_completion:
1027
- )
1028
- runtime = Phronomy::Runtime.instance
1029
- event_loop = runtime.event_loop
1030
- assert_event_loop!(event_loop)
1031
- state = event_loop.agent_execution_state(execution.execution_id)
1032
- unless state && state.agent.equal?(@agent) &&
1033
- state.execution.execution_revision == execution.execution_revision &&
1034
- state.execution.status == :preparing &&
1035
- state.execution.phase.to_sym == :preparing
1036
- error = Phronomy::ExecutionRehydrationRequiredError.new(
1037
- "stale :preparing Recovery installation for #{execution.execution_id}"
1038
- )
1039
- fail_task(result_task, error)
1040
- load_completion.fail(error)
1041
- return
1042
- end
1043
-
1044
- root = @agent.agent_root
1045
- journal_records = @agent.send(:_journal_records_snapshot)
1046
- task = runtime.offload.submit(on_full: :raise) do
1047
- perform_initial_preparation_recovery(
1048
- execution,
1049
- root: root,
1050
- journal_records: journal_records
1051
- )
1052
- end
1053
- task.on_complete do |result, error|
1054
- ready = InitialPreparationRecoveryReady.new(
1055
- coordinator: self,
1056
- execution_id: execution.execution_id.to_s.freeze,
1057
- expected_execution_revision: execution.execution_revision,
1058
- result_task: result_task,
1059
- load_completion: load_completion,
1060
- result: result,
1061
- error: error
1062
- )
1063
- unless post_control(runtime, ready)
1064
- rejected = runtime_rejected_error(:initial_preparation_recovery)
1065
- fail_task(result_task, rejected)
1066
- load_completion.fail(rejected)
1067
- end
1068
- end
1069
- rescue => error
1070
- # simplecov:disable
1071
- begin
1072
- if defined?(event_loop) && event_loop.current?
1073
- release_initial_preparation_recovery_runtime_state(
1074
- event_loop,
1075
- execution.execution_id
1076
- )
1077
- end
1078
- rescue
1079
- nil
1080
- end
1081
- translated_error = translated(error)
1082
- fail_task(result_task, translated_error)
1083
- load_completion.fail(translated_error)
1084
- # simplecov:enable
1085
- end
1086
-
1087
- def perform_initial_preparation_recovery(
1088
- execution,
1089
- root:,
1090
- journal_records:
1091
- )
1092
- unless execution.metadata["preparation_replayable"] == true
1093
- raise Phronomy::ExecutionRehydrationRequiredError,
1094
- "execution #{execution.execution_id} has no replay-safe initial preparation contract"
1095
- end
1096
-
1097
- input_ref = execution.metadata.fetch("current_input_ref")
1098
- input = @agent.persistence.contents.fetch_text(input_ref)
1099
- mode = (
1100
- execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] || "invoke"
1101
- ).to_sym
1102
- config = {phronomy_recovery_mode: mode}.merge(@agent.__coordination_config)
1103
-
1104
- if execution.metadata.key?("durable_context_ref")
1105
- durable_context_ref = execution.metadata.fetch("durable_context_ref")
1106
- durable_context = @agent.persistence.contents.fetch_json(
1107
- durable_context_ref
1108
- )
1109
- unless durable_context.is_a?(Hash)
1110
- raise Phronomy::ExecutionRehydrationRequiredError,
1111
- "execution #{execution.execution_id} has a non-Hash durable_context"
1112
- end
1113
- Phronomy::Agent::Immutable.validate_canonical_json!(
1114
- durable_context,
1115
- label: "Recovered durable_context"
1116
- )
1117
- config[:durable_context] =
1118
- Phronomy::Agent::Immutable.copy(durable_context)
1119
- end
1120
-
1121
- perform_admitted_initial_preparation(
1122
- input: input,
1123
- config: config.freeze,
1124
- execution: execution,
1125
- active_root: root,
1126
- journal_records: journal_records
1127
- )
1128
- end
1129
-
1130
- def apply_initial_preparation_recovery_on_event_loop(ready)
1131
- event_loop = Phronomy::Runtime.instance.event_loop
1132
- state = event_loop.agent_execution_state(ready.execution_id)
1133
- unless state && state.agent.equal?(@agent) &&
1134
- state.execution.execution_revision == ready.expected_execution_revision &&
1135
- state.execution.status == :preparing &&
1136
- state.execution.phase.to_sym == :preparing
1137
- error = Phronomy::ExecutionRehydrationRequiredError.new(
1138
- "stale initial preparation Recovery result for #{ready.execution_id}"
1139
- )
1140
- fail_task(ready.result_task, error)
1141
- ready.load_completion.fail(error)
1142
- return
1143
- end
1144
-
1145
- if ready.error
1146
- release_initial_preparation_recovery_runtime_state(
1147
- event_loop,
1148
- ready.execution_id
1149
- )
1150
- error = translated(ready.error)
1151
- fail_task(ready.result_task, error)
1152
- ready.load_completion.fail(error)
1153
- return
1154
- end
1155
-
1156
- result = ready.result
1157
- case result.admission_outcome
1158
- when :terminal
1159
- apply_agent_live_state(
1160
- root: result.root,
1161
- appended_records: result.appended_records
1162
- )
1163
- event_loop.replace_agent_execution(
1164
- ready.execution_id,
1165
- execution: result.execution
1166
- )
1167
- event_loop.release_agent_execution(ready.execution_id)
1168
- event_loop.release_agent_execution_admission(
1169
- @agent.agent_id,
1170
- execution_id: ready.execution_id
1171
- )
1172
- event_type = terminal_event_type(result.error)
1173
- callback_error = deliver_terminal(
1174
- @agent.send(:_phronomy_event_listener),
1175
- event_type,
1176
- error: result.error
1177
- )
1178
- settle_after_terminal(
1179
- ready.result_task,
1180
- callback_error,
1181
- event_type,
1182
- nil,
1183
- result.error
1184
- )
1185
- ready.load_completion.complete(@agent)
1186
- when :active
1187
- apply_agent_live_state(
1188
- root: result.root,
1189
- appended_records: result.appended_records
1190
- )
1191
- event_loop.release_agent_execution(ready.execution_id)
1192
- mode = (
1193
- result.execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] ||
1194
- "invoke"
1195
- ).to_sym
1196
- request = StartCommand.new(
1197
- coordinator: self,
1198
- input: result.filtered_input,
1199
- config: result.config,
1200
- mode: mode,
1201
- approval_policy: nil,
1202
- approval_listener: nil,
1203
- on_event: @agent.send(:_phronomy_event_listener),
1204
- result_task: ready.result_task,
1205
- admission_token: Object.new.freeze
1206
- )
1207
- begin
1208
- register_initial_session_on_event_loop(request, result)
1209
- rescue => error
1210
- begin_terminal_without_session_on_event_loop(
1211
- request: request,
1212
- prepared: result,
1213
- error: error
1214
- )
1215
- end
1216
- ready.load_completion.complete(@agent)
1217
- else
1218
- error = Phronomy::ExecutionRehydrationRequiredError.new(
1219
- "unexpected initial preparation Recovery outcome: #{result.admission_outcome.inspect}"
1220
- )
1221
- release_initial_preparation_recovery_runtime_state(
1222
- event_loop,
1223
- ready.execution_id
1224
- )
1225
- fail_task(ready.result_task, error)
1226
- ready.load_completion.fail(error)
1227
- end
1228
- rescue => error
1229
- # simplecov:disable
1230
- begin
1231
- release_initial_preparation_recovery_runtime_state(
1232
- event_loop,
1233
- ready.execution_id
1234
- )
1235
- rescue
1236
- nil
1237
- end
1238
- translated_error = translated(error)
1239
- fail_task(ready.result_task, translated_error)
1240
- ready.load_completion.fail(translated_error)
1241
- # simplecov:enable
1242
- end
1243
-
1244
- def release_initial_preparation_recovery_runtime_state(
1245
- event_loop,
1246
- execution_id
1247
- )
1248
- state = event_loop.agent_execution_state(execution_id)
1249
- event_loop.release_agent_execution(execution_id) if state
1250
- event_loop.release_agent_execution_admission(
1251
- @agent.agent_id,
1252
- execution_id: execution_id
1253
- )
1254
- end
1255
-
1256
- # ----------------------------------------------------------------------
1257
- # External-operation causal durable barriers
1258
- # ----------------------------------------------------------------------
1259
-
1260
- def perform_provider_dispatch_preparation(operation)
1261
- metadata = operation.execution.metadata.dup
1262
- metadata.delete(RecoverySupport::TOOL_BATCH_METADATA_KEY)
1263
- metadata.delete(RecoverySupport::RECOVERY_METADATA_KEY)
1264
- metadata[RecoverySupport::PENDING_LLM_ID_KEY] =
1265
- operation.pending_llm_call_id
1266
- metadata[RecoverySupport::PENDING_LLM_STARTED_AT_KEY] =
1267
- operation.pending_llm_started_at
1268
- metadata[RecoverySupport::CONTRACT_VERSION_KEY] =
1269
- RecoverySupport::CONTRACT_VERSION
1270
- staged_execution = operation.execution.with(
1271
- execution_revision: operation.execution.execution_revision,
1272
- metadata: metadata
1273
- )
1274
-
1275
- manifest = manifest_ref = updated = intended = nil
1276
-
1277
- # Encode the immutable runtime snapshot first. This transaction may add
1278
- # content-addressed blobs, but it does not advance Execution state.
1279
- encoded_records = call_records = nil
1280
- @agent.persistence.transaction do |tx|
1281
- assert_local_durable_base!(tx, operation.root)
1282
- encoded_records, call_records = encode_runtime_records(
1283
- staged_execution,
1284
- tx: tx,
1285
- snapshot: operation.runtime_snapshot,
1286
- context_candidate: true,
1287
- agent_root: operation.root
1288
- )
1289
- end
1290
- staged = staged_execution.with(
1291
- execution_revision: staged_execution.execution_revision,
1292
- phase: :preparing_llm_call,
1293
- working_records: staged_execution.working_records + encoded_records,
1294
- llm_calls: staged_execution.llm_calls + call_records
1295
- )
1296
- patch = @agent.send(
1297
- :run_before_llm_input_hooks,
1298
- call_sequence: staged.llm_calls.length + 1,
1299
- config: operation.invocation_config
1300
- )
1301
- assembler = ContextAssembler.new(
1302
- agent: @agent,
1303
- persistence: @agent.persistence,
1304
- journal_records: operation.journal_records
1305
- )
1306
- prepared = assembler.prepare_followup(
1307
- base_manifest: operation.base_manifest,
1308
- agent_root: operation.root,
1309
- execution: staged,
1310
- config: operation.invocation_config,
1311
- patch: patch
1312
- )
1313
- @agent.send(
1314
- :check_cancellation!,
1315
- operation.invocation_config,
1316
- "invocation cancelled after context policy"
1317
- )
1318
-
1319
- begin
1320
- @agent.persistence.transaction do |tx|
1321
- assert_local_durable_base!(tx, operation.root)
1322
- manifest, manifest_ref = assembler.finalize(prepared, persistence: tx)
1323
- refs = Array(staged.metadata["manifest_refs"]) + [manifest_ref]
1324
- updated = staged.with(
1325
- phase: :calling_llm,
1326
- metadata: staged.metadata.merge(
1327
- "manifest_ref" => manifest_ref,
1328
- "manifest_refs" => refs
1329
- )
1330
- )
1331
- updated = @agent.__prepare_coordination_record(updated, tx: tx)
1332
- tx.executions.save(
1333
- operation.execution.execution_id,
1334
- expected_revision: operation.execution.execution_revision,
1335
- execution: updated
1336
- )
1337
- intended = ProviderDispatchPreparationResult.new(
1338
- execution: updated,
1339
- runtime_projection: nil,
1340
- error: nil
1341
- )
1342
- end
1343
- rescue => caught
1344
- raise if known_durable_preparation_failure?(caught) || intended.nil?
1345
-
1346
- raise PreparationOutcomeUnknownError.new(caught, intended)
1347
- end
1348
-
1349
- projection = materialization_error = nil
1350
- begin
1351
- projection = RubyLLMMaterializer.new(
1352
- agent: @agent,
1353
- persistence: @agent.persistence
1354
- ).materialize(manifest: manifest, manifest_ref: manifest_ref)
1355
- rescue => error
1356
- materialization_error = error
1357
- end
1358
- ProviderDispatchPreparationResult.new(
1359
- execution: updated,
1360
- runtime_projection: projection,
1361
- error: materialization_error
1362
- )
1363
- end
1364
-
1365
- def perform_tool_dispatch_preparation(operation)
1366
- intended = nil
1367
- begin
1368
- @agent.persistence.transaction do |tx|
1369
- assert_local_durable_base!(tx, operation.root)
1370
- encoded_records, call_records = encode_runtime_records(
1371
- operation.execution,
1372
- tx: tx,
1373
- snapshot: operation.runtime_snapshot,
1374
- context_candidate: true,
1375
- agent_root: operation.root
1376
- )
1377
- metadata = operation.execution.metadata.dup
1378
- metadata.delete(RecoverySupport::PENDING_LLM_ID_KEY)
1379
- metadata.delete(RecoverySupport::PENDING_LLM_STARTED_AT_KEY)
1380
- metadata.delete(RecoverySupport::RECOVERY_METADATA_KEY)
1381
- metadata.delete("framework_calls_pending")
1382
- metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY] =
1383
- RecoverySupport.canonical_copy(operation.tool_batch_snapshot)
1384
- metadata[RecoverySupport::CONTRACT_VERSION_KEY] =
1385
- RecoverySupport::CONTRACT_VERSION
1386
- updated = operation.execution.with(
1387
- phase: :dispatching_tools,
1388
- working_records: operation.execution.working_records + encoded_records,
1389
- llm_calls: operation.execution.llm_calls + call_records,
1390
- metadata: metadata
1391
- )
1392
- updated = @agent.__prepare_coordination_record(updated, tx: tx)
1393
- tx.executions.save(
1394
- operation.execution.execution_id,
1395
- expected_revision: operation.execution.execution_revision,
1396
- execution: updated
1397
- )
1398
- intended = ToolDispatchPreparationResult.new(execution: updated)
1399
- end
1400
- rescue => caught
1401
- raise if known_durable_preparation_failure?(caught) || intended.nil?
1402
-
1403
- raise PreparationOutcomeUnknownError.new(caught, intended)
1404
- end
1405
- intended
1406
- end
1407
-
1408
- def apply_provider_dispatch_preparation_on_event_loop(ready)
1409
- operation = ready.operation
1410
- state = authoritative_state_for_operation(
1411
- execution_id: operation.execution_id,
1412
- fsm_session_id: operation.fsm_session_id,
1413
- expected_execution_revision: operation.expected_execution_revision,
1414
- expected_fsm_state: :calling_llm
1415
- )
1416
- return unless state
1417
-
1418
- if ready.error
1419
- if ready.error.is_a?(PreparationOutcomeUnknownError)
1420
- submit_provider_dispatch_preparation_reconciliation(
1421
- operation,
1422
- ready.error
1423
- )
1424
- else
1425
- state.invocation.event_sink.post(:llm_setup_failed, translated(ready.error))
1426
- end
1427
- return
1428
- end
1429
-
1430
- apply_confirmed_provider_dispatch_preparation_on_event_loop(
1431
- operation,
1432
- ready.result,
1433
- state
1434
- )
1435
- rescue => error
1436
- state&.invocation&.event_sink&.post(:llm_setup_failed, translated(error))
1437
- end
1438
-
1439
- def apply_tool_dispatch_preparation_on_event_loop(ready)
1440
- operation = ready.operation
1441
- state = authoritative_state_for_operation(
1442
- execution_id: operation.execution_id,
1443
- fsm_session_id: operation.fsm_session_id,
1444
- expected_execution_revision: operation.expected_execution_revision,
1445
- expected_fsm_state: :dispatching_tools
1446
- )
1447
- return unless state
1448
-
1449
- if ready.error
1450
- if ready.error.is_a?(PreparationOutcomeUnknownError)
1451
- submit_tool_dispatch_preparation_reconciliation(
1452
- operation,
1453
- ready.error
1454
- )
1455
- else
1456
- state.invocation.event_sink.post(:tool_setup_failed, translated(ready.error))
1457
- end
1458
- return
1459
- end
1460
-
1461
- apply_confirmed_tool_dispatch_preparation_on_event_loop(
1462
- operation,
1463
- ready.result,
1464
- state
1465
- )
1466
- rescue => error
1467
- state&.invocation&.event_sink&.post(:tool_setup_failed, translated(error))
1468
- end
1469
-
1470
- def apply_confirmed_provider_dispatch_preparation_on_event_loop(
1471
- operation,
1472
- result,
1473
- state
1474
- )
1475
- event_loop = Phronomy::Runtime.instance.event_loop
1476
- if result.runtime_projection
1477
- event_loop.replace_agent_execution(
1478
- operation.execution_id,
1479
- execution: result.execution,
1480
- runtime_projection: result.runtime_projection
1481
- )
1482
- else
1483
- event_loop.replace_agent_execution(
1484
- operation.execution_id,
1485
- execution: result.execution
1486
- )
1487
- end
1488
- state.invocation.acknowledge_runtime_snapshot(operation.runtime_snapshot)
1489
-
1490
- if result.error
1491
- state.invocation.event_sink.post(:llm_setup_failed, translated(result.error))
1492
- return
1493
- end
1494
-
1495
- Agent::AgentInvocationSessionBuilder.start_prepared_provider_call(
1496
- agent: @agent,
1497
- runtime: Phronomy::Runtime.instance,
1498
- event_sink: state.invocation.event_sink,
1499
- invocation: state.invocation,
1500
- projection: result.runtime_projection,
1501
- streaming: operation.streaming
1502
- )
1503
- end
1504
-
1505
- def apply_confirmed_tool_dispatch_preparation_on_event_loop(
1506
- operation,
1507
- result,
1508
- state
1509
- )
1510
- event_loop = Phronomy::Runtime.instance.event_loop
1511
- event_loop.replace_agent_execution(
1512
- operation.execution_id,
1513
- execution: result.execution
1514
- )
1515
- state.invocation.acknowledge_runtime_snapshot(operation.runtime_snapshot)
1516
- Agent::AgentInvocationSessionBuilder.start_prepared_tool_dispatch(
1517
- runtime: Phronomy::Runtime.instance,
1518
- event_sink: state.invocation.event_sink,
1519
- invocation: state.invocation
1520
- )
1521
- end
1522
-
1523
- def perform_provider_dispatch_preparation_reconciliation(command)
1524
- # simplecov:disable
1525
- disposition, current = preparation_reconciliation_state(
1526
- command.operation,
1527
- command.intended_result.execution
1528
- )
1529
- preparation_result = nil
1530
- if disposition == :committed
1531
- projection = materialization_error = nil
1532
- begin
1533
- _manifest, projection = RecoverySupport.materialize_projection(
1534
- @agent,
1535
- current.metadata.fetch("manifest_ref")
1536
- )
1537
- rescue => error
1538
- materialization_error = error
1539
- end
1540
- preparation_result = ProviderDispatchPreparationResult.new(
1541
- execution: current,
1542
- runtime_projection: projection,
1543
- error: materialization_error
1544
- )
1545
- end
1546
- ProviderDispatchPreparationReconciliationResult.new(
1547
- disposition: disposition,
1548
- preparation_result: preparation_result
1549
- )
1550
- end
1551
-
1552
- def perform_tool_dispatch_preparation_reconciliation(command)
1553
- disposition, current = preparation_reconciliation_state(
1554
- command.operation,
1555
- command.intended_result.execution
1556
- )
1557
- preparation_result = if disposition == :committed
1558
- ToolDispatchPreparationResult.new(execution: current)
1559
- end
1560
- ToolDispatchPreparationReconciliationResult.new(
1561
- disposition: disposition,
1562
- preparation_result: preparation_result
1563
- )
1564
- end
1565
-
1566
- def apply_provider_dispatch_preparation_reconciliation_on_event_loop(ready)
1567
- command = ready.command
1568
- operation = command.operation
1569
- state = authoritative_state_for_operation(
1570
- execution_id: operation.execution_id,
1571
- fsm_session_id: operation.fsm_session_id,
1572
- expected_execution_revision: operation.expected_execution_revision,
1573
- expected_fsm_state: :calling_llm
1574
- )
1575
- return unless state
1576
-
1577
- if ready.error
1578
- mark_barrier_recovery_required(operation, ready.error)
1579
- return
1580
- end
1581
-
1582
- case ready.result.disposition
1583
- when :committed
1584
- apply_confirmed_provider_dispatch_preparation_on_event_loop(
1585
- operation,
1586
- ready.result.preparation_result,
1587
- state
1588
- )
1589
- when :not_committed
1590
- state.invocation.event_sink.post(
1591
- :llm_setup_failed,
1592
- translated(command.original_error)
1593
- )
1594
- when :conflict
1595
- mark_barrier_recovery_required(operation, command.original_error)
1596
- else
1597
- raise Phronomy::Error,
1598
- "unknown Provider dispatch reconciliation disposition: " \
1599
- "#{ready.result.disposition.inspect}"
1600
- end
1601
- rescue => error
1602
- mark_barrier_recovery_required(operation, error)
1603
- end
1604
-
1605
- def apply_tool_dispatch_preparation_reconciliation_on_event_loop(ready)
1606
- command = ready.command
1607
- operation = command.operation
1608
- state = authoritative_state_for_operation(
1609
- execution_id: operation.execution_id,
1610
- fsm_session_id: operation.fsm_session_id,
1611
- expected_execution_revision: operation.expected_execution_revision,
1612
- expected_fsm_state: :dispatching_tools
1613
- )
1614
- return unless state
1615
-
1616
- if ready.error
1617
- mark_barrier_recovery_required(operation, ready.error)
1618
- return
1619
- end
1620
-
1621
- case ready.result.disposition
1622
- when :committed
1623
- apply_confirmed_tool_dispatch_preparation_on_event_loop(
1624
- operation,
1625
- ready.result.preparation_result,
1626
- state
1627
- )
1628
- when :not_committed
1629
- state.invocation.event_sink.post(
1630
- :tool_setup_failed,
1631
- translated(command.original_error)
1632
- )
1633
- when :conflict
1634
- mark_barrier_recovery_required(operation, command.original_error)
1635
- else
1636
- raise Phronomy::Error,
1637
- "unknown Tool dispatch reconciliation disposition: " \
1638
- "#{ready.result.disposition.inspect}"
1639
- end
1640
- rescue => error
1641
- mark_barrier_recovery_required(operation, error)
1642
- # simplecov:enable
1643
- end
1644
-
1645
- # Worker-only Persistence readback for causal-barrier F1 reconciliation.
1646
- # EventLoop submits one operation-specific ReconciliationCommand and only
1647
- # applies this result after it returns through the normal control path.
1648
- def preparation_reconciliation_state(operation, intended_execution)
1649
- current = @agent.persistence.executions.load(operation.execution_id)
1650
- if current.execution_revision == intended_execution.execution_revision &&
1651
- current.to_h == intended_execution.to_h
1652
- return [:committed, current]
1653
- end
1654
- if current.execution_revision == operation.execution.execution_revision &&
1655
- current.to_h == operation.execution.to_h
1656
- return [:not_committed, current]
1657
- end
1658
-
1659
- [:conflict, current]
1660
- end
1661
-
1662
- def known_durable_preparation_failure?(error)
1663
- error.is_a?(Phronomy::Persistence::ConflictError) ||
1664
- error.is_a?(Phronomy::Persistence::NotFoundError) ||
1665
- error.is_a?(Phronomy::Persistence::SerializationError) ||
1666
- error.is_a?(Phronomy::Persistence::UnsupportedBackendError) ||
1667
- error.is_a?(ArgumentError) ||
1668
- error.is_a?(Phronomy::ConfigurationError)
1669
- end
1670
-
1671
- def mark_barrier_recovery_required(operation, error)
1672
- event_loop = Phronomy::Runtime.instance.event_loop
1673
- event_loop.mark_agent_execution_admission(
1674
- @agent.agent_id,
1675
- execution_id: operation.execution_id,
1676
- state: :recovery_required
1677
- )
1678
- Phronomy.configuration.logger&.warn(
1679
- "[Phronomy] causal durable barrier requires recovery for " \
1680
- "#{operation.execution_id}: #{error.class}: #{error.message}"
1681
- )
1682
- end
1683
-
1684
- # ----------------------------------------------------------------------
1685
- # Approval resume
1686
- # ----------------------------------------------------------------------
1687
-
1688
- def begin_resume_on_event_loop(request)
1689
- event_loop = Phronomy::Runtime.instance.event_loop
1690
- state = event_loop.agent_execution_state(request.execution_id)
1691
- if state&.agent&.equal?(@agent) && state&.invocation
1692
- @recovery_resume_snapshot_mutex ||= Mutex.new
1693
- snapshot = RecoverySupport.build_tool_batch_snapshot(
1694
- state.invocation
1695
- )
1696
- @recovery_resume_snapshot_mutex.synchronize do
1697
- @recovery_resume_snapshots ||= {}
1698
- @recovery_resume_snapshots[request.execution_id.to_s] =
1699
- snapshot
1700
- end
1701
- end
1702
-
1703
- runtime = Phronomy::Runtime.instance
1704
- event_loop = runtime.event_loop
1705
- state = event_loop.agent_execution_state(request.execution_id)
1706
- unless state
1707
- fail_task(
1708
- request.result_task,
1709
- Phronomy::ExecutionRehydrationRequiredError.new(
1710
- "no live execution owner for #{request.execution_id}; durable rehydration is required"
1711
- )
1712
- )
1713
- return
1714
- end
1715
-
1716
- unless state.agent.equal?(@agent) && state.execution.status == :suspended
1717
- fail_task(
1718
- request.result_task,
1719
- ArgumentError.new(
1720
- "execution is not a suspended execution of this agent: #{request.execution_id}"
1721
- )
1722
- )
1723
- return
1724
- end
1725
-
1726
- approval = state.execution.approval_request || {}
1727
- current_request_id = approval["id"] || approval[:id]
1728
- unless current_request_id.to_s == request.approval_request_id
1729
- fail_task(
1730
- request.result_task,
1731
- ArgumentError.new(
1732
- "approval request does not match execution #{request.execution_id}"
1733
- )
1734
- )
1735
- return
1736
- end
1737
-
1738
- operation = ResumeCommitCommand.new(
1739
- execution_id: state.execution_id,
1740
- expected_execution_revision: state.execution.execution_revision,
1741
- root: @agent.agent_root,
1742
- execution: state.execution,
1743
- approval_request_id: request.approval_request_id,
1744
- approved: request.approved
1745
- )
1746
- resume_transition_started = false
1747
- submitted = false
1748
- event_loop.mark_agent_execution_admission(
1749
- @agent.agent_id,
1750
- execution_id: state.execution_id,
1751
- state: :resuming
1752
- )
1753
- resume_transition_started = true
1754
- task = runtime.offload.submit(on_full: :raise) do
1755
- perform_resume_commit(operation)
1756
- end
1757
- submitted = true
1758
- task.on_complete do |result, error|
1759
- ready = ResumeCommitReady.new(
1760
- coordinator: self,
1761
- request: request,
1762
- operation: operation,
1763
- result: result,
1764
- error: error
1765
- )
1766
- fail_task(request.result_task, runtime_rejected_error(:resume_commit)) unless
1767
- post_control(runtime, ready)
1768
- end
1769
- rescue => error
1770
- if resume_transition_started
1771
- event_loop.mark_agent_execution_admission(
1772
- @agent.agent_id,
1773
- execution_id: state.execution_id,
1774
- state: submitted ? :recovery_required : :suspended
1775
- )
1776
- end
1777
- fail_task(request.result_task, translated(error))
1778
- raise unless resume_transition_started
1779
- end
1780
-
1781
- def perform_resume_commit(operation)
1782
- snapshot = nil
1783
- @recovery_resume_snapshot_mutex&.synchronize do
1784
- snapshot = @recovery_resume_snapshots&.delete(
1785
- operation.execution_id.to_s
1786
- )
1787
- end
1788
-
1789
- if snapshot
1790
- staged_execution = RecoverySupport.with_recovery_metadata(
1791
- operation.execution,
1792
- RecoverySupport::TOOL_BATCH_METADATA_KEY => snapshot
1793
- )
1794
- operation = operation.class.new(
1795
- **operation.to_h.merge(execution: staged_execution)
1796
- )
1797
- end
1798
-
1799
- current = operation.execution
1800
- current_root = operation.root
1801
- request = current.approval_request || {}
1802
- request_id = request["id"] || request[:id]
1803
- unless request_id.to_s == operation.approval_request_id
1804
- raise ArgumentError,
1805
- "approval request does not match execution #{current.execution_id}"
1806
- end
1807
-
1808
- updated = next_root = nil
1809
- @agent.persistence.transaction do |tx|
1810
- decision_ref = tx.contents.put_json(
1811
- "approval_request_id" => request_id.to_s,
1812
- "approved" => operation.approved
1813
- )
1814
- decision_record = JournalRecord.new(
1815
- agent_id: @agent.agent_id,
1816
- execution_id: current.execution_id,
1817
- kind: :approval_decided,
1818
- channel: :approval,
1819
- content_ref: decision_ref,
1820
- context_generation: current_root.transcript_generation,
1821
- context_candidate: false
1822
- )
1823
- updated = current.with(
1824
- status: :active,
1825
- phase: :resuming,
1826
- working_records: current.working_records + [decision_record],
1827
- approval_request: request.merge("approved" => operation.approved)
1828
- )
1829
- tx.executions.save(
1830
- current.execution_id,
1831
- expected_revision: current.execution_revision,
1832
- execution: updated
1833
- )
1834
- next_root = current_root.with(
1835
- agent_revision: current_root.agent_revision + 1,
1836
- lifecycle_status: :active
1837
- )
1838
- tx.agents.save(
1839
- current_root.agent_id,
1840
- expected_revision: current_root.agent_revision,
1841
- root: next_root
1842
- )
1843
- end
1844
- ResumeCommitResult.new(execution: updated, root: next_root)
1845
- end
1846
-
1847
- def apply_resume_commit_on_event_loop(ready)
1848
- request = ready.request
1849
- operation = ready.operation
1850
- event_loop = Phronomy::Runtime.instance.event_loop
1851
- state = event_loop.agent_execution_state(operation.execution_id)
1852
- unless state && state.agent.equal?(@agent) &&
1853
- state.execution.execution_revision == operation.expected_execution_revision &&
1854
- state.execution.status == :suspended
1855
- fail_task(
1856
- request.result_task,
1857
- Phronomy::Error.new(
1858
- "stale approval resume result for #{operation.execution_id}"
1859
- )
1860
- )
1861
- return
1862
- end
1863
-
1864
- if ready.error
1865
- event_loop.mark_agent_execution_admission(
1866
- @agent.agent_id,
1867
- execution_id: operation.execution_id,
1868
- state: :recovery_required
1869
- )
1870
- fail_task(request.result_task, translated(ready.error))
1871
- return
1872
- end
1873
-
1874
- apply_agent_live_state(root: ready.result.root, appended_records: [])
1875
- event_loop.mark_agent_execution_admission(
1876
- @agent.agent_id,
1877
- execution_id: operation.execution_id,
1878
- state: :executing
1879
- )
1880
- event_loop.replace_agent_execution(
1881
- operation.execution_id,
1882
- execution: ready.result.execution
1883
- )
1884
- event_loop.register_agent_completion_waiter(
1885
- operation.execution_id,
1886
- request.result_task
1887
- )
1888
- trace_config = state.invocation ?
1889
- state.invocation.config.merge(request.config) : request.config
1890
- trace_mode = state.invocation&.mode ||
1891
- (
1892
- ready.result.execution.metadata[
1893
- RecoverySupport::INVOCATION_MODE_KEY
1894
- ] || "invoke"
1895
- ).to_sym
1896
- Phronomy::Tracing::Automatic.observe_task(
1897
- request.result_task,
1898
- "agent.execution",
1899
- agent_id: @agent.agent_id,
1900
- execution_id: operation.execution_id,
1901
- mode: trace_mode,
1902
- **@agent.send(:_build_caller_meta, trace_config)
1903
- )
1904
- start_resume_on_event_loop(
1905
- operation.execution_id,
1906
- request.result_task,
1907
- approved: request.approved,
1908
- config: request.config
1909
- )
1910
- rescue => error
1911
- state = event_loop&.agent_execution_state(operation.execution_id) if event_loop&.current?
1912
- if state
1913
- begin_terminal_commit_on_event_loop(
1914
- state,
1915
- request.result_task,
1916
- state.invocation,
1917
- error,
1918
- fsm_session_id: state.fsm_session_id
1919
- )
1920
- else
1921
- fail_task(request.result_task, translated(error))
1922
- end
1923
- end
1924
-
1925
- def start_resume_on_event_loop(execution_id, result_task, approved:, config:)
1926
- runtime = Phronomy::Runtime.instance
1927
- event_loop = runtime.event_loop
1928
- state = event_loop.agent_execution_state(execution_id)
1929
- invocation = state.invocation
1930
- invocation.merge_config!(config)
1931
- invocation.begin_approval_resume!(approved: approved)
1932
- parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
1933
- agent_invocation: invocation,
1934
- resume_event: :resume,
1935
- resume_phase: :suspended,
1936
- runtime: runtime
1937
- )
1938
- event_loop.replace_agent_execution(
1939
- execution_id,
1940
- invocation: invocation,
1941
- fsm_session_id: parent_session.id
1942
- )
1943
- source_task = Phronomy::TaskResult.deferred(name: "#{result_task.name}-source")
1944
- source_task.on_complete do |completed, error|
1945
- finish_on_event_loop(
1946
- execution_id,
1947
- result_task,
1948
- completed || parent_session.context,
1949
- error,
1950
- fsm_session_id: parent_session.id
1951
- )
1952
- end
1953
- event_loop.register(parent_session, completion: source_task)
1954
- invocation.tool_invocations.each do |child|
1955
- child_session = if child.awaiting_approval?
1956
- Agent::ToolInvocationSessionBuilder.build_for_resume(
1957
- tool_invocation: child,
1958
- parent_event_sink: parent_session.event_sink,
1959
- resume_event: approved ? :approve : :reject,
1960
- resume_phase: :awaiting_approval,
1961
- runtime: runtime
1962
- )
1963
- elsif !approved && child.authorized?
1964
- Agent::ToolInvocationSessionBuilder.build_for_resume(
1965
- tool_invocation: child,
1966
- parent_event_sink: parent_session.event_sink,
1967
- resume_event: :cancel,
1968
- resume_phase: :authorized,
1969
- runtime: runtime
1970
- )
1971
- end
1972
- register_child(event_loop, child, child_session, parent_session.event_sink) if child_session
1973
- end
1974
- end
1975
-
1976
- def start_framework_tools_on_event_loop(execution_id, result_task)
1977
- runtime = Phronomy::Runtime.instance
1978
- event_loop = runtime.event_loop
1979
- state = event_loop.agent_execution_state(execution_id)
1980
- invocation = state.invocation
1981
- session = AgentInvocationSessionBuilder.build_for_resume(agent_invocation: invocation,
1982
- resume_event: :resume, resume_phase: :suspended, runtime: runtime)
1983
- event_loop.replace_agent_execution(execution_id, invocation: invocation, fsm_session_id: session.id)
1984
- event_loop.mark_agent_execution_admission(@agent.agent_id, execution_id: execution_id, state: :executing)
1985
- source = Phronomy::TaskResult.deferred(name: "framework-tool-recovery-source")
1986
- source.on_complete do |result, error|
1987
- finish_on_event_loop(execution_id, result_task, result || session.context, error, fsm_session_id: session.id)
1988
- end
1989
- event_loop.register(session, completion: source)
1990
- invocation.tool_invocations.select(&:authorized?).each do |child|
1991
- child_session = ToolInvocationSessionBuilder.build_for_resume(tool_invocation: child,
1992
- parent_event_sink: session.event_sink, resume_event: :dispatch, resume_phase: :authorized, runtime: runtime)
1993
- register_child(event_loop, child, child_session, session.event_sink)
1994
- end
1995
- end
1996
-
1997
- def register_child(event_loop, child, session, parent_event_sink)
1998
- completion = Phronomy::TaskResult.deferred(name: "tool-session:#{child.id}")
1999
- completion.on_complete do |_result, error|
2000
- next unless error
2001
-
2002
- child.mark_framework_failed!(error)
2003
- parent_event_sink.post(:tool_failed, {tool_invocation_id: child.id})
2004
- end
2005
- event_loop.register(session, completion: completion)
2006
- end
2007
-
2008
- # ----------------------------------------------------------------------
2009
- # Terminal durable barrier
2010
- # ----------------------------------------------------------------------
2011
-
2012
- def finish_on_event_loop(execution_id, result_task, invocation, error, fsm_session_id:)
2013
- event_loop = Phronomy::Runtime.instance.event_loop
2014
- assert_event_loop!(event_loop)
2015
- state = event_loop.agent_execution_state(execution_id)
2016
- return fail_task(result_task, runtime_rejected_error(:terminal)) unless state
2017
-
2018
- unless state.fsm_session_id.to_s == fsm_session_id.to_s &&
2019
- state.invocation.equal?(invocation)
2020
- return fail_task(
2021
- result_task,
2022
- Phronomy::Error.new("stale Agent terminal callback for #{execution_id}")
2023
- )
2024
- end
2025
-
2026
- terminal_error = error || invocation&.error
2027
- unless event_loop.agent_execution_quiescent?(execution_id)
2028
- wait_state = quiescence_sensitive_terminal_error?(terminal_error) ?
2029
- :cancelling : :terminalizing
2030
- event_loop.mark_agent_execution_admission(
2031
- @agent.agent_id,
2032
- execution_id: execution_id,
2033
- state: wait_state
2034
- )
2035
- event_loop.defer_agent_terminal_until_quiescent(
2036
- execution_id,
2037
- DeferredTerminalCommand.new(
2038
- coordinator: self,
2039
- execution_id: execution_id.to_s.freeze,
2040
- result_task: result_task,
2041
- invocation: invocation,
2042
- source_error: error,
2043
- fsm_session_id: fsm_session_id.to_s.freeze
2044
- )
2045
- )
2046
- return
2047
- end
2048
-
2049
- begin_terminal_commit_on_event_loop(
2050
- state,
2051
- result_task,
2052
- invocation,
2053
- error,
2054
- fsm_session_id: fsm_session_id
2055
- )
2056
- end
2057
-
2058
- def resume_deferred_terminal_on_event_loop(command)
2059
- event_loop = Phronomy::Runtime.instance.event_loop
2060
- state = event_loop.agent_execution_state(command.execution_id)
2061
- unless state && state.agent.equal?(@agent) &&
2062
- state.fsm_session_id.to_s == command.fsm_session_id.to_s &&
2063
- state.invocation.equal?(command.invocation)
2064
- Phronomy.configuration.logger&.warn(
2065
- "[Phronomy] Dropped stale deferred terminal for #{command.execution_id}"
2066
- )
2067
- return
2068
- end
2069
-
2070
- unless event_loop.agent_execution_quiescent?(command.execution_id)
2071
- raise Phronomy::Error,
2072
- "deferred terminal resumed before quiescence for #{command.execution_id}"
2073
- end
2074
-
2075
- begin_terminal_commit_on_event_loop(
2076
- state,
2077
- command.result_task,
2078
- command.invocation,
2079
- command.source_error,
2080
- fsm_session_id: command.fsm_session_id
2081
- )
2082
- end
2083
-
2084
- def quiescence_sensitive_terminal_error?(error)
2085
- error.is_a?(Phronomy::CancellationError) ||
2086
- error.is_a?(Phronomy::TimeoutError)
2087
- end
2088
-
2089
- def begin_terminal_commit_on_event_loop(
2090
- state,
2091
- result_task,
2092
- invocation,
2093
- source_error,
2094
- fsm_session_id:
2095
- )
2096
- if invocation&.phase == :suspended
2097
- snapshot = RecoverySupport.build_tool_batch_snapshot(invocation)
2098
- staged_execution = RecoverySupport.with_recovery_metadata(
2099
- state.execution,
2100
- RecoverySupport::TOOL_BATCH_METADATA_KEY => snapshot
2101
- )
2102
- state = state.class.new(
2103
- **state.to_h.merge(execution: staged_execution)
2104
- )
2105
- end
2106
-
2107
- operation = build_terminal_operation(
2108
- execution: state.execution,
2109
- root: @agent.agent_root,
2110
- journal_records: @agent.send(:_journal_records_snapshot),
2111
- invocation: invocation,
2112
- source_error: source_error,
2113
- fsm_session_id: fsm_session_id,
2114
- state_required: true
2115
- )
2116
- delivery = TerminalDelivery.new(
2117
- result_task: result_task,
2118
- application_listener: invocation&.event_listener,
2119
- approval_listener: invocation&.approval_listener,
2120
- handoff_request: invocation&.handoff_request,
2121
- handoff_manifest: state.runtime_projection&.manifest
2122
- )
2123
- submit_terminal_operation(operation, delivery)
2124
- end
2125
-
2126
- def begin_terminal_without_session_on_event_loop(request:, prepared:, error:)
2127
- return deliver_start_failure_on_event_loop(request, translated(error)) unless prepared&.execution
2128
-
2129
- operation = build_terminal_operation(
2130
- execution: prepared.execution,
2131
- root: prepared.root,
2132
- journal_records: @agent.send(:_journal_records_snapshot),
2133
- invocation: nil,
2134
- source_error: error,
2135
- fsm_session_id: nil,
2136
- state_required: false
2137
- )
2138
- delivery = TerminalDelivery.new(
2139
- result_task: request.result_task,
2140
- application_listener: request.on_event,
2141
- approval_listener: request.approval_listener,
2142
- handoff_request: nil,
2143
- handoff_manifest: nil
2144
- )
2145
- submit_terminal_operation(operation, delivery)
2146
- end
2147
-
2148
- def build_terminal_operation(
2149
- execution:,
2150
- root:,
2151
- journal_records:,
2152
- invocation:,
2153
- source_error:,
2154
- fsm_session_id:,
2155
- state_required:
2156
- )
2157
- TerminalCommitCommand.new(
2158
- execution_id: execution.execution_id.to_s.freeze,
2159
- fsm_session_id: fsm_session_id&.to_s&.freeze,
2160
- expected_execution_revision: execution.execution_revision,
2161
- root: root,
2162
- journal_records: journal_records,
2163
- execution: execution,
2164
- runtime_snapshot: invocation ? invocation.runtime_snapshot : empty_runtime_snapshot,
2165
- terminal_view: terminal_view(invocation, source_error),
2166
- state_required: state_required
2167
- )
2168
- end
2169
-
2170
- def submit_terminal_operation(operation, delivery)
2171
- runtime = Phronomy::Runtime.instance
2172
- event_loop = runtime.event_loop
2173
- terminal_transition_started = false
2174
- event_loop.mark_agent_execution_admission(
2175
- @agent.agent_id,
2176
- execution_id: operation.execution_id,
2177
- state: :terminalizing
2178
- )
2179
- terminal_transition_started = true
2180
- event_loop.register_agent_completion_waiter(
2181
- operation.execution_id,
2182
- delivery.result_task
2183
- )
2184
- task = runtime.offload.submit(on_full: :raise) do
2185
- # Only the operation-specific immutable durable snapshot crosses the
2186
- # worker boundary. TaskResult/listener delivery state stays outside it.
2187
- compute_terminal(operation)
2188
- end
2189
- task.on_complete do |outcome, error|
2190
- ready = TerminalCommitReady.new(
2191
- coordinator: self,
2192
- operation: operation,
2193
- delivery: delivery,
2194
- outcome: outcome,
2195
- error: error
2196
- )
2197
- fail_task(delivery.result_task, runtime_rejected_error(:terminal_apply)) unless
2198
- post_control(runtime, ready)
2199
- end
2200
- rescue => error
2201
- if terminal_transition_started
2202
- event_loop.mark_agent_execution_admission(
2203
- @agent.agent_id,
2204
- execution_id: operation.execution_id,
2205
- state: :recovery_required
2206
- )
2207
- Phronomy.configuration.logger&.warn(
2208
- "[Phronomy] Agent terminal transition requires recovery for " \
2209
- "#{operation.execution_id}: #{error.class}: #{error.message}"
2210
- )
2211
- return
2212
- end
2213
-
2214
- fail_task(delivery.result_task, translated(error))
2215
- raise
2216
- end
2217
-
2218
- def terminal_view(invocation, source_error)
2219
- TerminalView.new(
2220
- phase: invocation&.phase,
2221
- output: invocation&.output,
2222
- usage: invocation&.usage,
2223
- approval_request: invocation&.approval_request,
2224
- rejected: invocation ? !!invocation.rejected : false,
2225
- input_blocked: invocation ? invocation.input_blocked? : false,
2226
- output_blocked: invocation ? invocation.output_blocked? : false,
2227
- block_error: invocation&.block_error,
2228
- invocation_error: invocation&.error,
2229
- handoff: handoff_terminal_view(invocation&.handoff_request),
2230
- callback_failure: invocation&.callback_failure,
2231
- source_error: source_error,
2232
- cancel_requested: invocation&.config&.fetch(:cancellation_token, nil)&.cancelled? || false
2233
- )
2234
- end
2235
-
2236
- def handoff_terminal_view(request)
2237
- return unless request
2238
-
2239
- HandoffTerminalView.new(
2240
- target_agent_id: request.handoff.target_agent.agent_id.to_s.freeze,
2241
- responsibility: request.responsibility.to_s.freeze,
2242
- selection_intent: request.selection_intent.to_h do |category, included|
2243
- [category.to_s.freeze, !!included]
2244
- end.freeze,
2245
- llm_call_id: request.llm_call_id&.to_s&.freeze,
2246
- tool_call_id: request.tool_call_id&.to_s&.freeze,
2247
- policy: Immutable.copy(request.handoff.policy.to_h)
2248
- )
2249
- end
2250
-
2251
- def empty_runtime_snapshot
2252
- {llm_results: [].freeze, runtime_events: [].freeze, active_call: nil}.freeze
2253
- end
2254
-
2255
- def compute_terminal(operation)
2256
- view = operation.terminal_view
2257
- error = view.callback_failure&.to_stream_callback_error || view.source_error ||
2258
- view.block_error || view.invocation_error
2259
- if operation.execution.metadata["multi_agent_coordination_ref"] &&
2260
- (error.is_a?(Phronomy::ExecutionRehydrationRequiredError) || view.cancel_requested)
2261
- waiting = commit_coordination_wait(operation, error)
2262
- return waiting if waiting
2263
- end
2264
- raise error if error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
2265
- return commit_failed_outcome(operation, error) if error
2266
- return commit_suspended(operation) if view.phase == :suspended
2267
- commit_completed(operation)
2268
- rescue => caught
2269
- reconcile_terminal_error(operation, caught)
2270
- end
2271
-
2272
- # Reuse the Agent barrier to retain unfinished owned children. This is an
2273
- # active AgentExecution snapshot, not a second scheduler or terminal state.
2274
- def commit_coordination_wait(operation, error)
2275
- current = operation.execution
2276
- waiting = nil
2277
- @agent.persistence.transaction do |tx|
2278
- refreshed = @agent.__prepare_coordination_record(current, tx: tx)
2279
- snapshot = tx.contents.fetch_json(refreshed.metadata.fetch("multi_agent_coordination_ref"))
2280
- unresolved = snapshot.fetch("children").any? do |child|
2281
- !%w[completed failed cancelled rejected blocked handed_off].include?(child.fetch("state")) &&
2282
- !(operation.terminal_view.cancel_requested && child.fetch("state") == "reserved")
2283
- end
2284
- next unless unresolved
2285
- waiting = current.with(metadata: refreshed.metadata.merge(
2286
- "coordination_cancel_requested" => operation.terminal_view.cancel_requested || current.metadata["coordination_cancel_requested"] == true
2287
- ))
2288
- tx.executions.save(current.execution_id, expected_revision: current.execution_revision, execution: waiting)
2289
- end
2290
- return unless waiting
2291
- coordination_wait_outcome(operation, waiting, error)
2292
- rescue => failure
2293
- confirmed = @agent.persistence.executions.load(current.execution_id)
2294
- raise failure unless waiting && confirmed.to_h == waiting.to_h
2295
- coordination_wait_outcome(operation, confirmed, error)
2296
- end
2297
-
2298
- def coordination_wait_outcome(operation, execution, error)
2299
- TerminalOutcome.new(type: :coordination_wait, execution: execution, root: operation.root,
2300
- appended_records: [].freeze, result: nil,
2301
- error: error.is_a?(Phronomy::ExecutionRehydrationRequiredError) ? error :
2302
- Phronomy::ExecutionRehydrationRequiredError.new("Execution #{execution.execution_id} retains unfinished children for cancellation/recovery"),
2303
- approval_request: nil)
2304
- end
2305
-
2306
- # F1: terminal atomicity alone does not establish commit outcome certainty.
2307
- # Reuse an exact committed outcome; an absent/active/read-failed result is
2308
- # never permission to write a second terminal transition.
2309
- def reconcile_terminal_error(operation, error)
2310
- confirmed = @agent.persistence.executions.load(operation.execution_id)
2311
- raise error unless confirmed.terminal? &&
2312
- confirmed.agent_id == @agent.agent_id &&
2313
- confirmed.execution_revision == operation.expected_execution_revision + 1
2314
- root = @agent.persistence.agents.load(@agent.agent_id)
2315
- records = @agent.persistence.journals.read(@agent.agent_id,
2316
- after: operation.root.journal_position, limit: root.journal_position - operation.root.journal_position)
2317
- result = @agent.persistence.execution_result(confirmed.execution_id)
2318
- failure = result[:error] && RecoverySupport.error_from_failure(result[:error])
2319
- type = if failure
2320
- :failed
2321
- else
2322
- ((confirmed.status == :handed_off) ? :handed_off : :completed)
2323
- end
2324
- TerminalOutcome.new(type: type, execution: confirmed, root: root,
2325
- appended_records: records.freeze,
2326
- result: failure ? nil : result_base(confirmed, root).merge(output: result[:result]).freeze,
2327
- error: failure, approval_request: nil)
2328
- end
2329
-
2330
- def commit_suspended(operation)
2331
- current = operation.execution
2332
- root = operation.root
2333
- request = operation.terminal_view.approval_request
2334
- runtime_snapshot = operation.runtime_snapshot
2335
- suspended = next_root = nil
2336
-
2337
- @agent.persistence.transaction do |tx|
2338
- encoded_records, call_records = encode_runtime_records(
2339
- current,
2340
- tx: tx,
2341
- snapshot: runtime_snapshot,
2342
- context_candidate: true,
2343
- agent_root: root
2344
- )
2345
- request_ref = tx.contents.put_json(json_value(request.to_h))
2346
- approval_record = JournalRecord.new(
2347
- agent_id: @agent.agent_id,
2348
- execution_id: current.execution_id,
2349
- kind: :approval_required,
2350
- channel: :approval,
2351
- content_ref: request_ref,
2352
- context_generation: root.transcript_generation,
2353
- context_candidate: false
2354
- )
2355
- suspended = current.with(
2356
- status: :suspended,
2357
- phase: :approval,
2358
- working_records: current.working_records + encoded_records + [approval_record],
2359
- llm_calls: current.llm_calls + call_records,
2360
- approval_request: json_value(request.to_h)
2361
- )
2362
- tx.executions.save(
2363
- current.execution_id,
2364
- expected_revision: current.execution_revision,
2365
- execution: suspended
2366
- )
2367
- next_root = root.with(
2368
- agent_revision: root.agent_revision + 1,
2369
- lifecycle_status: :suspended
2370
- )
2371
- tx.agents.save(
2372
- root.agent_id,
2373
- expected_revision: root.agent_revision,
2374
- root: next_root
2375
- )
2376
- end
2377
- result = result_base(suspended, next_root).merge(
2378
- suspended: true,
2379
- approval_request: request
2380
- )
2381
- TerminalOutcome.new(
2382
- type: :suspended,
2383
- execution: suspended,
2384
- root: next_root,
2385
- appended_records: [].freeze,
2386
- result: result.freeze,
2387
- error: nil,
2388
- approval_request: request
2389
- )
2390
- end
2391
-
2392
- def synchronize_handoff_target(tx, execution)
2393
- coordination = execution.metadata["coordination"]
2394
- return unless coordination && coordination["kind"] == "handoff"
2395
- routing = tx.handoff_states.load(coordination.fetch("main_agent_id"))
2396
- return unless routing && routing.pending_target_execution_id == execution.execution_id && routing.phase != "stable"
2397
- unless routing.active_agent_id == execution.agent_id
2398
- raise Phronomy::Persistence::ConflictError, "Handoff Target owner mismatch"
2399
- end
2400
- tx.handoff_states.save(routing.main_agent_id, expected_revision: routing.handoff_revision,
2401
- state: routing.with(phase: "stable"))
2402
- end
2403
-
2404
- def commit_completed(operation)
2405
- current = operation.execution
2406
- root = operation.root
2407
- view = operation.terminal_view
2408
- runtime_snapshot = operation.runtime_snapshot
2409
- completed = next_root = appended = messages = nil
2410
-
2411
- @agent.persistence.transaction do |tx|
2412
- encoded_records, call_records = encode_runtime_records(
2413
- current,
2414
- tx: tx,
2415
- snapshot: runtime_snapshot,
2416
- context_candidate: true,
2417
- agent_root: root
2418
- )
2419
- output_ref = tx.contents.put_text(view.output.to_s)
2420
- final_output_record = JournalRecord.new(
2421
- agent_id: @agent.agent_id,
2422
- execution_id: current.execution_id,
2423
- kind: :final_output,
2424
- channel: :audit,
2425
- role: :assistant,
2426
- content_ref: output_ref,
2427
- context_generation: root.transcript_generation,
2428
- context_candidate: false
2429
- )
2430
- completed_record = JournalRecord.new(
2431
- agent_id: @agent.agent_id,
2432
- execution_id: current.execution_id,
2433
- kind: view.rejected ? :execution_rejected : :execution_completed,
2434
- channel: :audit,
2435
- content_ref: output_ref,
2436
- context_generation: root.transcript_generation,
2437
- context_candidate: false
2438
- )
2439
- all_records = current.working_records + encoded_records +
2440
- [final_output_record, completed_record]
2441
- appended = tx.journals.append(
2442
- root.agent_id,
2443
- expected_position: root.journal_position,
2444
- records: all_records
2445
- )
2446
- completed = current.with(
2447
- status: view.rejected ? :rejected : :completed,
2448
- phase: :completed,
2449
- working_records: [],
2450
- llm_calls: current.llm_calls + call_records,
2451
- approval_request: nil,
2452
- result_ref: output_ref,
2453
- terminal_reason: view.rejected ? "rejected" : "completed"
2454
- )
2455
- completed = @agent.__prepare_coordination_record(completed, tx: tx)
2456
- synchronize_handoff_target(tx, completed)
2457
- tx.executions.save(
2458
- current.execution_id,
2459
- expected_revision: current.execution_revision,
2460
- execution: completed
2461
- )
2462
- context_changed = appended.any?(&:context_candidate)
2463
- next_root = root.with(
2464
- agent_revision: root.agent_revision + 1,
2465
- context_revision: root.context_revision + (context_changed ? 1 : 0),
2466
- journal_position: root.journal_position + appended.length,
2467
- lifecycle_status: :idle
2468
- )
2469
- tx.agents.save(
2470
- root.agent_id,
2471
- expected_revision: root.agent_revision,
2472
- root: next_root
2473
- )
2474
-
2475
- # Materialize the caller-facing transcript through the transaction
2476
- # view before commit. If materialization fails, the terminal durable
2477
- # transition rolls back instead of committing and then attempting a
2478
- # second terminal transition from a stale execution revision.
2479
- records_after_commit = operation.journal_records + Array(appended)
2480
- messages = transcript_messages(
2481
- next_root,
2482
- records_after_commit,
2483
- persistence: tx
2484
- )
2485
- end
2486
- result = result_base(completed, next_root).merge(
2487
- output: view.output,
2488
- rejected: view.rejected || nil,
2489
- usage: view.usage,
2490
- messages: messages
2491
- ).compact
2492
- TerminalOutcome.new(
2493
- type: :completed,
2494
- execution: completed,
2495
- root: next_root,
2496
- appended_records: Array(appended).freeze,
2497
- result: result.freeze,
2498
- error: nil,
2499
- approval_request: nil
2500
- )
2501
- end
2502
-
2503
- def commit_failed_outcome(operation, error)
2504
- translated_error = translated(error)
2505
- current = operation.execution
2506
- root = operation.root
2507
- runtime_snapshot = operation.runtime_snapshot
2508
- failed = next_root = appended = nil
2509
-
2510
- @agent.persistence.transaction do |tx|
2511
- encoded_records, call_records = encode_runtime_records(
2512
- current,
2513
- tx: tx,
2514
- snapshot: runtime_snapshot,
2515
- context_candidate: false,
2516
- agent_root: root
2517
- )
2518
- error_ref = tx.contents.put_json(
2519
- "class" => translated_error.class.name,
2520
- "message" => translated_error.message
2521
- )
2522
- audit_records = (current.working_records + encoded_records).map do |record|
2523
- JournalRecord.from_h(
2524
- record.to_h.merge("context_candidate" => false)
2525
- )
2526
- end
2527
- terminal_status = terminal_status_for(translated_error)
2528
- audit_records << JournalRecord.new(
2529
- agent_id: @agent.agent_id,
2530
- execution_id: current.execution_id,
2531
- kind: execution_terminal_kind(terminal_status),
2532
- channel: :audit,
2533
- content_ref: error_ref,
2534
- context_generation: root.transcript_generation,
2535
- context_candidate: false
2536
- )
2537
- appended = tx.journals.append(
2538
- root.agent_id,
2539
- expected_position: root.journal_position,
2540
- records: audit_records
2541
- )
2542
- failed = current.with(
2543
- status: terminal_status,
2544
- phase: terminal_status,
2545
- working_records: [],
2546
- llm_calls: current.llm_calls + call_records,
2547
- error_ref: error_ref,
2548
- terminal_reason: translated_error.class.name
2549
- )
2550
- failed = @agent.__prepare_coordination_record(failed, tx: tx)
2551
- synchronize_handoff_target(tx, failed)
2552
- tx.executions.save(
2553
- current.execution_id,
2554
- expected_revision: current.execution_revision,
2555
- execution: failed
2556
- )
2557
- next_root = root.with(
2558
- agent_revision: root.agent_revision + 1,
2559
- journal_position: root.journal_position + appended.length,
2560
- lifecycle_status: :idle
2561
- )
2562
- tx.agents.save(
2563
- root.agent_id,
2564
- expected_revision: root.agent_revision,
2565
- root: next_root
2566
- )
2567
- end
2568
- TerminalOutcome.new(
2569
- type: :failed,
2570
- execution: failed,
2571
- root: next_root,
2572
- appended_records: Array(appended).freeze,
2573
- result: nil,
2574
- error: translated_error,
2575
- approval_request: nil
2576
- )
2577
- end
2578
-
2579
- def apply_terminal_commit_on_event_loop(ready)
2580
- operation = ready.operation
2581
- delivery = ready.delivery
2582
- event_loop = Phronomy::Runtime.instance.event_loop
2583
- state = event_loop.agent_execution_state(operation.execution_id)
2584
-
2585
- if operation.state_required
2586
- unless state && state.agent.equal?(@agent) &&
2587
- state.execution.execution_revision == operation.expected_execution_revision &&
2588
- state.fsm_session_id.to_s == operation.fsm_session_id.to_s
2589
- Phronomy.configuration.logger&.warn(
2590
- "[Phronomy] Dropped stale Agent terminal result for #{operation.execution_id}"
2591
- )
2592
- return
2593
- end
2594
- end
2595
-
2596
- if ready.error
2597
- if operation.execution.metadata["coordination"] || operation.execution.metadata["multi_agent_coordination_ref"]
2598
- event_loop.release_agent_execution(operation.execution_id) if state
2599
- event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
2600
- event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
2601
- task.fail(ready.error)
2602
- end
2603
- return
2604
- end
2605
- event_loop.mark_agent_execution_admission(
2606
- @agent.agent_id,
2607
- execution_id: operation.execution_id,
2608
- state: :recovery_required
2609
- )
2610
- Phronomy.configuration.logger&.warn(
2611
- "[Phronomy] Agent terminal durable outcome requires recovery for " \
2612
- "#{operation.execution_id}: #{ready.error.class}: #{ready.error.message}"
2613
- )
2614
- return
2615
- end
2616
-
2617
- outcome = ready.outcome
2618
- apply_agent_live_state(
2619
- root: outcome.root,
2620
- appended_records: outcome.appended_records
2621
- )
2622
- if state
2623
- state.invocation&.acknowledge_runtime_snapshot(operation.runtime_snapshot)
2624
- event_loop.replace_agent_execution(
2625
- operation.execution_id,
2626
- execution: outcome.execution,
2627
- fsm_session_id: (outcome.type == :suspended) ? nil : state.fsm_session_id
2628
- )
2629
- end
2630
-
2631
- case outcome.type
2632
- when :coordination_wait
2633
- event_loop.release_agent_execution(operation.execution_id) if state
2634
- event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
2635
- event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
2636
- when :suspended
2637
- event_loop.mark_agent_execution_admission(
2638
- @agent.agent_id,
2639
- execution_id: operation.execution_id,
2640
- state: :suspended
2641
- )
2642
- dispatch_approval_listener(delivery.approval_listener, outcome.approval_request)
2643
- callback_error = deliver_terminal(
2644
- delivery.application_listener,
2645
- :approval_required,
2646
- request: outcome.approval_request
2647
- )
2648
- report_nonterminal_callback_error(
2649
- callback_error,
2650
- :approval_required,
2651
- outcome.result
2652
- )
2653
- Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
2654
- task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
2655
- end
2656
- when :completed
2657
- event_loop.release_agent_execution(operation.execution_id) if state
2658
- event_loop.release_agent_execution_admission(
2659
- @agent.agent_id,
2660
- execution_id: operation.execution_id
2661
- )
2662
- callback_error = deliver_terminal(delivery.application_listener, :done, outcome.result)
2663
- settle_execution_waiters(
2664
- event_loop, operation.execution_id, delivery.result_task,
2665
- callback_error, :done, outcome.result
2666
- )
2667
- when :handed_off
2668
- event_loop.release_agent_execution(operation.execution_id) if state
2669
- event_loop.release_agent_execution_admission(
2670
- @agent.agent_id,
2671
- execution_id: operation.execution_id
2672
- )
2673
- result = outcome.result.merge(
2674
- handoff_request: delivery.handoff_request,
2675
- _phronomy_handoff_manifest: delivery.handoff_manifest
2676
- ).freeze
2677
- callback_error = deliver_terminal(delivery.application_listener, :handoff, result)
2678
- settle_execution_waiters(
2679
- event_loop, operation.execution_id, delivery.result_task,
2680
- callback_error, :handoff, result
2681
- )
2682
- when :failed
2683
- event_loop.release_agent_execution(operation.execution_id) if state
2684
- event_loop.release_agent_execution_admission(
2685
- @agent.agent_id,
2686
- execution_id: operation.execution_id
2687
- )
2688
- type = terminal_event_type(outcome.error)
2689
- callback_error = deliver_terminal(
2690
- delivery.application_listener,
2691
- type,
2692
- error: outcome.error
2693
- )
2694
- settle_execution_waiters(
2695
- event_loop, operation.execution_id, delivery.result_task,
2696
- callback_error, type, nil, outcome.error
2697
- )
2698
- else
2699
- fail_task(
2700
- delivery.result_task,
2701
- Phronomy::Error.new("unknown terminal outcome: #{outcome.type.inspect}")
2702
- )
2703
- end
2704
- end
2705
-
2706
- # ----------------------------------------------------------------------
2707
- # Durable record encoding helpers
2708
- # ----------------------------------------------------------------------
2709
-
2710
- def encode_runtime_records(
2711
- execution,
2712
- tx:,
2713
- snapshot:,
2714
- context_candidate:,
2715
- agent_root:
2716
- )
2717
- root = agent_root
2718
- records = []
2719
- calls = []
2720
-
2721
- snapshot.fetch(:llm_results).each_with_index do |item, index|
2722
- outcome = item[:response]
2723
- error = item[:error]
2724
- llm_call_id = item.fetch(:llm_call_id).to_s
2725
- intercepted = error.is_a?(ToolCallIntercepted)
2726
- call_error = intercepted ? nil : error
2727
- output_ref = assistant_output_ref(tx, outcome)
2728
- assistant_ref = assistant_message_ref(tx, outcome)
2729
- error_ref = if call_error
2730
- tx.contents.put_json(
2731
- "class" => call_error.class.name,
2732
- "message" => call_error.message
2733
- )
2734
- end
2735
- usage_ref = if outcome && !outcome.usage.empty?
2736
- tx.contents.put_json(json_value(outcome.usage))
2737
- end
2738
- call = LLMCallRecord.new(
2739
- llm_call_id: llm_call_id,
2740
- execution_id: execution.execution_id,
2741
- sequence: execution.llm_calls.length + index + 1,
2742
- status: call_error ? :failed : :completed,
2743
- manifest_ref: item.fetch(:manifest_ref),
2744
- output_ref: output_ref,
2745
- error_ref: error_ref,
2746
- usage_ref: usage_ref,
2747
- started_at: item.fetch(:started_at),
2748
- completed_at: Time.now.utc.iso8601(6),
2749
- metadata: {
2750
- "streaming" => item[:streaming],
2751
- "tool_call_intercepted" => intercepted,
2752
- "assistant_outcome_captured" => !outcome.nil?,
2753
- "tool_call_count" => outcome ? outcome.tool_calls.length : 0
2754
- }
2755
- )
2756
- calls << call
2757
- call_ref = tx.contents.put_json(call.to_h)
2758
- records << JournalRecord.new(
2759
- agent_id: @agent.agent_id,
2760
- execution_id: execution.execution_id,
2761
- llm_call_id: llm_call_id,
2762
- kind: :llm_call_recorded,
2763
- channel: :audit,
2764
- content_ref: call_ref,
2765
- context_generation: root.transcript_generation,
2766
- context_candidate: false
2767
- )
2768
-
2769
- if assistant_ref
2770
- records << JournalRecord.new(
2771
- agent_id: @agent.agent_id,
2772
- execution_id: execution.execution_id,
2773
- llm_call_id: llm_call_id,
2774
- kind: :assistant_message,
2775
- channel: :llm,
2776
- role: :assistant,
2777
- content_ref: assistant_ref,
2778
- context_generation: root.transcript_generation,
2779
- context_candidate: context_candidate,
2780
- metadata: assistant_message_metadata(outcome)
2781
- )
2782
- end
2783
- end
2784
-
2785
- if (active = snapshot[:active_call])
2786
- abandoned = LLMCallRecord.new(
2787
- llm_call_id: active.fetch(:llm_call_id),
2788
- execution_id: execution.execution_id,
2789
- sequence: execution.llm_calls.length + calls.length + 1,
2790
- status: :cancelled,
2791
- manifest_ref: active.fetch(:manifest_ref),
2792
- started_at: active.fetch(:started_at),
2793
- completed_at: Time.now.utc.iso8601(6),
2794
- metadata: {
2795
- "reason" => "execution_terminalized_before_provider_settlement"
2796
- }
2797
- )
2798
- calls << abandoned
2799
- records << JournalRecord.new(
2800
- agent_id: @agent.agent_id,
2801
- execution_id: execution.execution_id,
2802
- llm_call_id: abandoned.llm_call_id,
2803
- kind: :llm_call_recorded,
2804
- channel: :audit,
2805
- content_ref: tx.contents.put_json(abandoned.to_h),
2806
- context_generation: root.transcript_generation,
2807
- context_candidate: false
2808
- )
2809
- end
2810
-
2811
- snapshot.fetch(:runtime_events).each do |event|
2812
- case event.type
2813
- when :tool_call
2814
- next
2815
- when :tool_result
2816
- payload = event.payload
2817
- llm_call_id = payload[:llm_call_id] || payload["llm_call_id"]
2818
- tool_call_id = payload.fetch(:tool_call_id).to_s
2819
- tool_name = payload.fetch(:tool_name).to_s
2820
- next if execution.working_records.any? do |record|
2821
- record.kind == :tool_message && record.causation_id.to_s == tool_call_id && record.llm_call_id.to_s == llm_call_id.to_s
2822
- end
2823
- result_ref = put_runtime_content(tx, payload.fetch(:tool_result))
2824
- records << JournalRecord.new(
2825
- agent_id: @agent.agent_id,
2826
- execution_id: execution.execution_id,
2827
- llm_call_id: llm_call_id,
2828
- kind: :tool_result,
2829
- channel: :tool,
2830
- content_ref: result_ref,
2831
- causation_id: tool_call_id,
2832
- context_generation: root.transcript_generation,
2833
- context_candidate: false,
2834
- metadata: {
2835
- "tool_call_id" => tool_call_id,
2836
- "tool_name" => tool_name
2837
- }
2838
- )
2839
-
2840
- message_payload = if payload.key?(:tool_message)
2841
- payload.fetch(:tool_message)
2842
- else
2843
- payload.fetch("tool_message")
2844
- end
2845
- records << JournalRecord.new(
2846
- agent_id: @agent.agent_id,
2847
- execution_id: execution.execution_id,
2848
- llm_call_id: llm_call_id,
2849
- kind: :tool_message,
2850
- channel: :tool,
2851
- role: :tool,
2852
- content_ref: tx.contents.put_json(json_value(message_payload)),
2853
- causation_id: tool_call_id,
2854
- context_generation: root.transcript_generation,
2855
- context_candidate: context_candidate,
2856
- metadata: {
2857
- "tool_call_id" => tool_call_id,
2858
- "tool_name" => tool_name
2859
- }
2860
- )
2861
- end
2862
- end
2863
- [records, calls]
2864
- end
2865
-
2866
- def assistant_message_ref(tx, outcome)
2867
- return unless outcome
2868
-
2869
- payload = {
2870
- "role" => "assistant",
2871
- "content" => json_value(outcome.content),
2872
- "tool_calls" => json_value(Array(outcome.tool_calls)),
2873
- "model_id" => outcome.metadata["model_id"]
2874
- }.compact
2875
- tx.contents.put_json(payload)
2876
- end
2877
-
2878
- def assistant_message_metadata(outcome)
2879
- calls = Array(outcome&.tool_calls)
2880
- {
2881
- "tool_call_ids" => calls.map { |call| call.fetch("id").to_s },
2882
- "tool_names" => calls.map { |call| call.fetch("name").to_s }
2883
- }
2884
- end
2885
-
2886
- def assistant_output_ref(tx, outcome)
2887
- return unless outcome&.content_present?
2888
-
2889
- content = outcome.content
2890
- text = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
2891
- tx.contents.put_text(text)
2892
- end
2893
-
2894
- def put_runtime_content(tx, value)
2895
- value.is_a?(String) ?
2896
- tx.contents.put_text(value) : tx.contents.put_json(json_value(value))
2897
- end
2898
-
2899
- # ----------------------------------------------------------------------
2900
- # EventLoop validation/apply helpers
2901
- # ----------------------------------------------------------------------
2902
-
2903
- def authoritative_state_for_operation(
2904
- execution_id:,
2905
- fsm_session_id:,
2906
- expected_execution_revision:,
2907
- expected_fsm_state:
2908
- )
2909
- event_loop = Phronomy::Runtime.instance.event_loop
2910
- state = event_loop.agent_execution_state(execution_id)
2911
- authoritative = state &&
2912
- state.agent.equal?(@agent) &&
2913
- state.execution.execution_revision == expected_execution_revision &&
2914
- state.fsm_session_id.to_s == fsm_session_id.to_s &&
2915
- event_loop.fsm_session_state(fsm_session_id) == expected_fsm_state
2916
- return state if authoritative
2917
-
2918
- Phronomy.configuration.logger&.warn(
2919
- "[Phronomy] Dropped stale Agent operation result: " \
2920
- "execution_id=#{execution_id} fsm_session_id=#{fsm_session_id}"
2921
- )
2922
- nil
2923
- rescue
2924
- nil
2925
- end
2926
-
2927
- def validate_live_session!(state, invocation, fsm_session_id)
2928
- unless state && state.agent.equal?(@agent) &&
2929
- state.invocation.equal?(invocation) &&
2930
- state.fsm_session_id.to_s == fsm_session_id.to_s
2931
- raise Phronomy::Error,
2932
- "Agent operation does not belong to the current live FSMSession"
2933
- end
2934
- end
2935
-
2936
- def apply_agent_live_state(root:, appended_records:)
2937
- event_loop = Phronomy::Runtime.instance.event_loop
2938
- assert_event_loop!(event_loop)
2939
- @agent.send(:_append_journal_records, appended_records)
2940
- @agent.__replace_root(root)
2941
- end
2942
-
2943
- def assert_event_loop!(event_loop)
2944
- return if event_loop.current?
2945
-
2946
- raise Phronomy::Error,
2947
- "ExecutionCoordinator live-state apply must run on EventLoop"
2948
- end
2949
-
2950
- def assert_local_durable_base!(tx, root)
2951
- tx.assert_agent_watermark!(
2952
- agent_id: root.agent_id,
2953
- agent_revision: root.agent_revision,
2954
- journal_position: root.journal_position
2955
- )
2956
- end
2957
-
2958
- def transcript_messages(root, journal_records, persistence: @agent.persistence)
2959
- materializer = RubyLLMMaterializer.new(
2960
- agent: @agent,
2961
- persistence: persistence
2962
- )
2963
- materializer.materialize_journal_records(
2964
- JournalProjection.new(
2965
- agent_root: root,
2966
- records: journal_records
2967
- ).transcript_records
2968
- )
2969
- end
2970
-
2971
- def result_base(execution, root)
2972
- {
2973
- agent_id: root.agent_id,
2974
- execution_id: execution.execution_id,
2975
- agent_revision: root.agent_revision,
2976
- context_revision: root.context_revision,
2977
- journal_position: root.journal_position
2978
- }
2979
- end
2980
-
2981
- def deliver_terminal(listener, type, payload)
2982
- return nil unless listener
2983
-
2984
- @agent.send(
2985
- :_deliver_stream_event,
2986
- listener,
2987
- StreamEvent.new(type: type, payload: payload)
2988
- )
2989
- end
2990
-
2991
- def dispatch_approval_listener(listener, request)
2992
- return unless listener
2993
-
2994
- Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
2995
- listener.call(request)
2996
- end
2997
- rescue => error
2998
- Phronomy.configuration.logger&.warn(
2999
- "[Phronomy] approval listener dispatch failed: #{error.class}: #{error.message}"
3000
- )
3001
- end
3002
-
3003
- def settle_execution_waiters(
3004
- event_loop, execution_id, fallback_task, callback_error, event_type,
3005
- result, execution_error = nil
3006
- )
3007
- waiters = event_loop.take_agent_completion_waiters(
3008
- execution_id,
3009
- fallback: fallback_task
3010
- )
3011
- waiters.each do |task|
3012
- settle_after_terminal(
3013
- task, callback_error, event_type, result, execution_error
3014
- )
3015
- end
3016
- end
3017
-
3018
- def report_nonterminal_callback_error(callback_error, event_type, result)
3019
- return unless callback_error
3020
-
3021
- @agent.send(
3022
- :_report_stream_callback_error,
3023
- callback_error,
3024
- event: StreamEvent.new(type: event_type, payload: result),
3025
- execution_id: result&.fetch(:execution_id, nil),
3026
- callback_error_policy: Phronomy.configuration.stream_callback_error_policy
3027
- )
3028
- end
3029
-
3030
- def settle_after_terminal(
3031
- result_task,
3032
- callback_error,
3033
- event_type,
3034
- result,
3035
- execution_error = nil
3036
- )
3037
- if callback_error
3038
- policy = Phronomy.configuration.stream_callback_error_policy
3039
- @agent.send(
3040
- :_report_stream_callback_error,
3041
- callback_error,
3042
- event: StreamEvent.new(
3043
- type: event_type,
3044
- payload: result || {error: execution_error}
3045
- ),
3046
- execution_id: result&.fetch(:execution_id, nil),
3047
- callback_error_policy: policy
3048
- )
3049
- if policy == :fail_task && execution_error.nil?
3050
- return fail_task(
3051
- result_task,
3052
- @agent.send(
3053
- :_build_stream_callback_error,
3054
- event_type: event_type,
3055
- callback_error: callback_error,
3056
- result: result
3057
- )
3058
- )
3059
- end
3060
- end
3061
- if execution_error
3062
- if execution_error.is_a?(Phronomy::CancellationError)
3063
- result_task.cancel!(execution_error)
3064
- else
3065
- fail_task(result_task, execution_error)
3066
- end
3067
- else
3068
- complete_task(result_task, result)
3069
- end
3070
- end
3071
-
3072
- def terminal_event_type(error)
3073
- return :timeout if error.is_a?(Phronomy::TimeoutError)
3074
- return :cancelled if error.is_a?(Phronomy::CancellationError)
3075
-
3076
- :error
3077
- end
3078
-
3079
- def json_value(value)
3080
- case value
3081
- when Hash
3082
- value.to_h { |key, child| [key.to_s, json_value(child)] }
3083
- when Array
3084
- value.map { |child| json_value(child) }
3085
- when String, Integer, Float, TrueClass, FalseClass, NilClass
3086
- value
3087
- when Symbol
3088
- value.to_s
3089
- else
3090
- if value.respond_to?(:to_h)
3091
- json_value(value.to_h)
3092
- else
3093
- raise ArgumentError,
3094
- "unsupported canonical runtime value: #{value.class}"
3095
- end
3096
- end
3097
- end
3098
-
3099
- def terminal_status_for(error)
3100
- if defined?(Phronomy::CancellationError) &&
3101
- error.is_a?(Phronomy::CancellationError)
3102
- return :cancelled
3103
- end
3104
- if defined?(Phronomy::FilterBlockError) &&
3105
- error.is_a?(Phronomy::FilterBlockError)
3106
- return :blocked
3107
- end
3108
-
3109
- :failed
3110
- end
3111
-
3112
- def execution_terminal_kind(status)
3113
- {
3114
- cancelled: :execution_cancelled,
3115
- blocked: :execution_blocked,
3116
- failed: :execution_failed
3117
- }.fetch(status)
3118
- end
3119
-
3120
- def post_control(runtime, command)
3121
- runtime.event_loop.post(
3122
- Phronomy::Event.new(
3123
- type: :agent_control,
3124
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
3125
- payload: {command: command}
3126
- )
3127
- )
3128
- rescue Phronomy::RuntimeShutdownError
3129
- false
3130
- end
3131
-
3132
- def runtime_rejected_error(operation)
3133
- Phronomy::RuntimeShutdownError.new(
3134
- "EventLoop is not accepting Agent #{operation} control delivery"
3135
- )
3136
- end
3137
-
3138
- def translated(error)
3139
- @agent.send(:_translated_error, error)
3140
- end
3141
-
3142
- def complete_task(task, result)
3143
- @agent.send(:_complete_result_task, task, result)
3144
- end
3145
-
3146
- def fail_task(task, error)
3147
- @agent.send(:_fail_result_task, task, error)
3148
- end
3149
- end
3150
- end
3151
- end