phronomy 0.27.0 → 0.28.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 (475) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +9 -9
  3. data/CHANGELOG.md +52 -0
  4. data/CONTRIBUTING.md +8 -8
  5. data/README.md +9 -3
  6. data/benchmark/bench_agent_invoke.rb +25 -50
  7. data/benchmark/bench_context_assembler.rb +14 -11
  8. data/benchmark/bench_regression.rb +1 -1
  9. data/benchmark/bench_tool_schema.rb +1 -1
  10. data/docs/architecture/agent-chat-and-state-ownership.md +7 -1
  11. data/docs/architecture/agent-transition-ownership.md +13 -89
  12. data/docs/architecture/backend-groups.md +152 -0
  13. data/docs/architecture/before-llm-input.md +3 -3
  14. data/docs/architecture/context-management.md +1 -1
  15. data/docs/architecture/execution-abstraction-review.md +88 -0
  16. data/docs/architecture/execution-boundaries.md +28 -0
  17. data/docs/architecture/execution-metadata-and-values.md +6 -0
  18. data/docs/architecture/multi-agent-handoff.md +6 -5
  19. data/docs/architecture/persistence.md +27 -9
  20. data/docs/architecture/r8-unit1.md +104 -0
  21. data/docs/architecture/r8-unit10.md +72 -0
  22. data/docs/architecture/r8-unit11.md +71 -0
  23. data/docs/architecture/r8-unit12.md +67 -0
  24. data/docs/architecture/r8-unit13.md +61 -0
  25. data/docs/architecture/r8-unit14.md +72 -0
  26. data/docs/architecture/r8-unit15.md +65 -0
  27. data/docs/architecture/r8-unit16.md +54 -0
  28. data/docs/architecture/r8-unit17.md +73 -0
  29. data/docs/architecture/r8-unit18.md +92 -0
  30. data/docs/architecture/r8-unit19.md +61 -0
  31. data/docs/architecture/r8-unit2.md +55 -0
  32. data/docs/architecture/r8-unit20.md +51 -0
  33. data/docs/architecture/r8-unit21.md +79 -0
  34. data/docs/architecture/r8-unit22.md +74 -0
  35. data/docs/architecture/r8-unit23.md +81 -0
  36. data/docs/architecture/r8-unit3.md +130 -0
  37. data/docs/architecture/r8-unit4.md +79 -0
  38. data/docs/architecture/r8-unit5.md +115 -0
  39. data/docs/architecture/r8-unit6.md +71 -0
  40. data/docs/architecture/r8-unit7.md +70 -0
  41. data/docs/architecture/r8-unit8.md +78 -0
  42. data/docs/architecture/r8-unit9.md +73 -0
  43. data/docs/architecture/refactoring-closure.md +10 -1
  44. data/docs/architecture/remaining-refactoring-plan.md +16 -0
  45. data/docs/architecture/storage-execution-boundaries.md +54 -0
  46. data/docs/architecture/tool-schema-recording-gap.md +6 -0
  47. data/docs/architecture/tracing.md +1 -1
  48. data/docs/architecture/workflow-terminal-ownership-design.md +2 -2
  49. data/docs/architecture.md +24 -11
  50. data/docs/decisions/015-tool-public-facade-and-rbs-boundary.md +3 -3
  51. data/docs/decisions/027-llm-adapter-provider-boundary.md +2 -0
  52. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +2 -0
  53. data/docs/decisions/031-durable-multi-agent-coordination.md +2 -0
  54. data/docs/decisions/032-storage-backend-composition.md +5 -0
  55. data/docs/decisions/034-handoff-runner-coordination-ownership.md +2 -0
  56. data/docs/decisions/035-tool-executor-capability-ownership.md +1 -1
  57. data/docs/decisions/038-responsibility-based-source-layout.md +14 -0
  58. data/docs/decisions/039-runtime-configuration-lifecycle-ownership.md +8 -0
  59. data/docs/decisions/040-configuration-default-composition.md +12 -1
  60. data/docs/decisions/045-worker-input-restriction-ownership.md +4 -0
  61. data/docs/decisions/051-execution-outcome-worker-ownership.md +2 -0
  62. data/docs/decisions/056-workflow-terminal-policy-ownership.md +1 -1
  63. data/docs/decisions/058-neutral-storage-primitives.md +5 -0
  64. data/docs/decisions/059-backend-contracts-and-async-clients.md +74 -0
  65. data/docs/decisions/060-runtime-settings-and-application-configuration.md +86 -0
  66. data/docs/decisions/061-persistence-failure-contracts.md +89 -0
  67. data/docs/decisions/062-content-store-service.md +52 -0
  68. data/docs/decisions/063-execution-contracts-and-services.md +88 -0
  69. data/docs/decisions/064-agent-execution-and-multi-agent-responsibility.md +36 -0
  70. data/docs/decisions/065-vector-and-embeddings-common-operations.md +38 -0
  71. data/docs/decisions/066-llm-and-tool-operation-contracts.md +20 -0
  72. data/docs/decisions/067-agent-progress-and-engine-connection.md +29 -0
  73. data/docs/decisions/068-agent-tool-child-progress-and-tool-operation.md +36 -0
  74. data/docs/decisions/README.md +18 -1
  75. data/docs/execution-boundaries-preview.md +45 -0
  76. data/docs/features.md +8 -3
  77. data/docs/getting-started.md +9 -6
  78. data/docs/migrations/0.22.md +2 -2
  79. data/docs/migrations/durable-semantic-coordination-v2.md +2 -0
  80. data/docs/migrations/handoff-runner-multi-agent.md +2 -0
  81. data/docs/migrations/persistence-failure-contracts.md +67 -0
  82. data/docs/migrations/r8-unit10.md +36 -0
  83. data/docs/migrations/r8-unit11.md +33 -0
  84. data/docs/migrations/r8-unit12.md +31 -0
  85. data/docs/migrations/r8-unit13.md +36 -0
  86. data/docs/migrations/r8-unit14.md +31 -0
  87. data/docs/migrations/r8-unit15.md +31 -0
  88. data/docs/migrations/r8-unit16.md +26 -0
  89. data/docs/migrations/r8-unit17.md +28 -0
  90. data/docs/migrations/r8-unit18.md +29 -0
  91. data/docs/migrations/r8-unit19.md +28 -0
  92. data/docs/migrations/r8-unit20.md +24 -0
  93. data/docs/migrations/r8-unit21.md +28 -0
  94. data/docs/migrations/r8-unit22.md +29 -0
  95. data/docs/migrations/r8-unit23.md +20 -0
  96. data/docs/migrations/r8-unit4.md +67 -0
  97. data/docs/migrations/r8-unit5.md +75 -0
  98. data/docs/migrations/r8-unit6.md +109 -0
  99. data/docs/migrations/r8-unit7.md +31 -0
  100. data/docs/migrations/r8-unit8.md +33 -0
  101. data/docs/migrations/r8-unit9.md +28 -0
  102. data/docs/migrations/storage-async-client.md +103 -0
  103. data/docs/migrations/vector-async-clients.md +110 -0
  104. data/docs/persistence-backends.md +38 -21
  105. data/docs/runtime-and-concurrency.md +26 -1
  106. data/lib/phronomy/agent/admission.rb +78 -0
  107. data/lib/phronomy/agent/{lifecycle_contract/agent_already_exists_error.rb → agent_already_exists_error.rb} +1 -1
  108. data/lib/phronomy/agent/{lifecycle_contract/agent_busy_error.rb → agent_busy_error.rb} +1 -1
  109. data/lib/phronomy/agent/{lifecycle_contract/agent_purged_error.rb → agent_purged_error.rb} +1 -1
  110. data/lib/phronomy/{recovery → agent/api}/execution_rehydration_required_error.rb +1 -1
  111. data/lib/phronomy/agent/async_event_api.rb +37 -32
  112. data/lib/phronomy/agent/base.rb +74 -72
  113. data/lib/phronomy/agent/before_llm_input.rb +64 -0
  114. data/lib/phronomy/agent/composition/run_once.rb +1 -1
  115. data/lib/phronomy/agent/{context_assembly/context_candidate_resolver.rb → context_candidate_resolver.rb} +2 -2
  116. data/lib/phronomy/agent/{context_assembly/context_assembler.rb → context_preparation.rb} +35 -221
  117. data/lib/phronomy/agent/control_request.rb +21 -0
  118. data/lib/phronomy/agent/execution/agent_execution.rb +1 -1
  119. data/lib/phronomy/agent/execution/agent_invocation.rb +27 -53
  120. data/lib/phronomy/agent/execution/approval_resume_commit.rb +1 -1
  121. data/lib/phronomy/agent/execution/dispatch_preparation.rb +17 -127
  122. data/lib/phronomy/agent/execution/exact_execution.rb +44 -23
  123. data/lib/phronomy/agent/execution/execution_cancellation.rb +8 -4
  124. data/lib/phronomy/agent/execution/execution_coordinator.rb +214 -395
  125. data/lib/phronomy/agent/execution/execution_metadata.rb +1 -0
  126. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +70 -126
  127. data/lib/phronomy/agent/execution/execution_registry.rb +6 -2
  128. data/lib/phronomy/agent/execution/execution_session_runner.rb +22 -27
  129. data/lib/phronomy/agent/execution/initial_preparation.rb +18 -116
  130. data/lib/phronomy/agent/execution/{agent_invocation_session_builder.rb → invocation_actions.rb} +54 -194
  131. data/lib/phronomy/agent/execution/invocation_transitions.rb +39 -1
  132. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +7 -7
  133. data/lib/phronomy/agent/execution_change.rb +161 -0
  134. data/lib/phronomy/agent/execution_environment.rb +36 -0
  135. data/lib/phronomy/agent/execution_extension_state.rb +21 -0
  136. data/lib/phronomy/agent/execution_observation.rb +35 -0
  137. data/lib/phronomy/agent/filterable.rb +182 -0
  138. data/lib/phronomy/agent/handoff/{handoff_context.rb → transfer_context.rb} +4 -4
  139. data/lib/phronomy/agent/handoff/{handoff_projection.rb → transfer_projection.rb} +14 -33
  140. data/lib/phronomy/{llm_contract/transport_error.rb → agent/handoff_error.rb} +1 -1
  141. data/lib/phronomy/agent/journal/journal_projection.rb +1 -1
  142. data/lib/phronomy/agent/knowledge_item.rb +13 -0
  143. data/lib/phronomy/agent/lifecycle/agent_root.rb +1 -1
  144. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +18 -23
  145. data/lib/phronomy/agent/persistence/agent_repository.rb +29 -21
  146. data/lib/phronomy/agent/persistence/cancellation_repository.rb +51 -0
  147. data/lib/phronomy/agent/persistence/codec.rb +1 -21
  148. data/lib/phronomy/agent/persistence/execution_repository.rb +57 -41
  149. data/lib/phronomy/agent/persistence/journal_repository.rb +27 -18
  150. data/lib/phronomy/agent/persistence/records.rb +33 -0
  151. data/lib/phronomy/agent/persistence/retention_repository.rb +70 -0
  152. data/lib/phronomy/agent/persistence/storage_schema.rb +7 -2
  153. data/lib/phronomy/agent/persistence/watermark.rb +13 -11
  154. data/lib/phronomy/agent/recovery/invocation_restorer.rb +13 -21
  155. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +10 -9
  156. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +27 -27
  157. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +19 -21
  158. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +23 -16
  159. data/lib/phronomy/agent/recovery/recovery_rules.rb +119 -0
  160. data/lib/phronomy/agent/recovery/recovery_support.rb +8 -11
  161. data/lib/phronomy/agent/reserved_execution.rb +16 -0
  162. data/lib/phronomy/agent/retention.rb +17 -0
  163. data/lib/phronomy/agent/runtime_binding/engine_environment.rb +60 -0
  164. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +34 -0
  165. data/lib/phronomy/agent/runtime_binding/phase_machine_builder.rb +64 -0
  166. data/lib/phronomy/agent/runtime_binding/tool_session_builder.rb +34 -0
  167. data/lib/phronomy/agent/{context_assembly/ruby_llm_materializer.rb → runtime_input.rb} +18 -35
  168. data/lib/phronomy/agent/{context_assembly/saved_context_reader.rb → saved_context_reader.rb} +6 -4
  169. data/lib/phronomy/agent/settings.rb +47 -0
  170. data/lib/phronomy/agent/{context_assembly/state_writer.rb → state_writer.rb} +4 -3
  171. data/lib/phronomy/agent/store.rb +253 -0
  172. data/lib/phronomy/agent/{lifecycle_contract/stream_callback_error.rb → stream_callback_error.rb} +1 -1
  173. data/lib/phronomy/agent/tool_execution/tool_binding.rb +4 -64
  174. data/lib/phronomy/agent/tool_execution/tool_invocation.rb +50 -219
  175. data/lib/phronomy/agent/tool_execution/tool_invocation_actions.rb +107 -0
  176. data/lib/phronomy/agent/tool_execution/tool_invocation_transitions.rb +70 -0
  177. data/lib/phronomy/configuration/runtime_settings.rb +49 -0
  178. data/lib/phronomy/content_store/storage_schema.rb +1 -0
  179. data/lib/phronomy/content_store/stored_contents.rb +2 -1
  180. data/lib/phronomy/context/assembly.rb +202 -0
  181. data/lib/phronomy/{llm_contract/context_budget_exceeded_error.rb → context/budget_exceeded_error.rb} +3 -1
  182. data/lib/phronomy/context/candidate.rb +51 -0
  183. data/lib/phronomy/context/constraint.rb +47 -0
  184. data/lib/phronomy/{agent/context_contract → context}/context_plan.rb +1 -1
  185. data/lib/phronomy/{agent/context_contract → context}/context_plan_validator.rb +3 -3
  186. data/lib/phronomy/{agent/context_contract → context}/context_policy.rb +1 -1
  187. data/lib/phronomy/{agent/context_contract → context}/context_policy_input.rb +1 -1
  188. data/lib/phronomy/{agent/context_assembly → context}/context_policy_input_builder.rb +1 -1
  189. data/lib/phronomy/context/default_policy.rb +140 -0
  190. data/lib/phronomy/context/final_budget_validator.rb +33 -0
  191. data/lib/phronomy/{agent/context_contract → context}/llm_input_build_context.rb +1 -1
  192. data/lib/phronomy/{agent/context_contract → context}/llm_input_manifest.rb +19 -19
  193. data/lib/phronomy/{agent/context_contract → context}/llm_input_patch.rb +1 -1
  194. data/lib/phronomy/context/manifest_error.rb +10 -0
  195. data/lib/phronomy/context/prompt_template.rb +98 -0
  196. data/lib/phronomy/{vector_store → documents}/loader/base.rb +1 -1
  197. data/lib/phronomy/{vector_store → documents}/loader/csv_loader.rb +2 -2
  198. data/lib/phronomy/{vector_store → documents}/loader/markdown_loader.rb +3 -3
  199. data/lib/phronomy/{vector_store → documents}/loader/plain_text_loader.rb +2 -2
  200. data/lib/phronomy/{vector_store → documents}/splitter/base.rb +1 -1
  201. data/lib/phronomy/{vector_store → documents}/splitter/fixed_size_splitter.rb +2 -2
  202. data/lib/phronomy/{vector_store → documents}/splitter/recursive_splitter.rb +2 -2
  203. data/lib/phronomy/embeddings/async/async_client.rb +44 -0
  204. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +48 -0
  205. data/lib/phronomy/embeddings/base.rb +43 -0
  206. data/lib/phronomy/embeddings/invalid_result_error.rb +11 -0
  207. data/lib/phronomy/embeddings/transport_error.rb +11 -0
  208. data/lib/phronomy/engine/concurrency/offload_pool.rb +36 -21
  209. data/lib/phronomy/engine/concurrency/pool_registry.rb +47 -12
  210. data/lib/phronomy/engine/diagnostics.rb +1 -1
  211. data/lib/phronomy/engine/event_loop.rb +7 -7
  212. data/lib/phronomy/engine/execution_binding.rb +23 -0
  213. data/lib/phronomy/engine/execution_receiver_binding.rb +43 -0
  214. data/lib/phronomy/engine/fsm_protocol.rb +1 -6
  215. data/lib/phronomy/engine/fsm_session.rb +3 -3
  216. data/lib/phronomy/engine/metrics.rb +16 -14
  217. data/lib/phronomy/engine/runtime/timer_queue.rb +1 -1
  218. data/lib/phronomy/engine/runtime.rb +25 -12
  219. data/lib/phronomy/engine/terminal_decision.rb +7 -0
  220. data/lib/phronomy/execution/async_operation.rb +41 -0
  221. data/lib/phronomy/{engine → execution}/blocking.rb +2 -2
  222. data/lib/phronomy/{engine → execution}/concurrency/cancellation_token.rb +1 -1
  223. data/lib/phronomy/{engine → execution}/concurrency/deadline.rb +0 -17
  224. data/lib/phronomy/{engine → execution}/concurrency/physical_completion_task.rb +1 -1
  225. data/lib/phronomy/{engine → execution}/concurrency/result_collector.rb +1 -1
  226. data/lib/phronomy/{engine → execution}/concurrency/result_composition.rb +33 -12
  227. data/lib/phronomy/{engine/concurrency/subscriptions.rb → execution/concurrency/result_subscriptions.rb} +14 -18
  228. data/lib/phronomy/execution/concurrency/subscriptions.rb +25 -0
  229. data/lib/phronomy/{engine → execution}/execution.rb +61 -27
  230. data/lib/phronomy/execution/execution_receiver.rb +78 -0
  231. data/lib/phronomy/execution/execution_scope_state.rb +35 -0
  232. data/lib/phronomy/{engine → execution}/invocation_context.rb +0 -15
  233. data/lib/phronomy/execution/invocation_controls.rb +73 -0
  234. data/lib/phronomy/execution/runnable.rb +24 -0
  235. data/lib/phronomy/{engine → execution}/task_result.rb +3 -3
  236. data/lib/phronomy/execution/wait_policy.rb +24 -0
  237. data/lib/phronomy/filter/{contract/filter_block_error.rb → filter_block_error.rb} +1 -1
  238. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +65 -0
  239. data/lib/phronomy/generation/generator_verifier/default_parser.rb +27 -0
  240. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +2 -2
  241. data/lib/phronomy/generation/generator_verifier.rb +7 -7
  242. data/lib/phronomy/llm_adapter/async/async_client.rb +47 -0
  243. data/lib/phronomy/{llm_contract → llm_adapter}/authentication_error.rb +3 -1
  244. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +183 -0
  245. data/lib/phronomy/llm_adapter/base.rb +49 -68
  246. data/lib/phronomy/{llm_contract → llm_adapter}/context_length_error.rb +3 -1
  247. data/lib/phronomy/llm_adapter/invalid_result_error.rb +10 -0
  248. data/lib/phronomy/llm_adapter/message.rb +62 -0
  249. data/lib/phronomy/{llm_contract → llm_adapter}/rate_limit_error.rb +3 -1
  250. data/lib/phronomy/llm_adapter/request.rb +79 -0
  251. data/lib/phronomy/llm_adapter/response.rb +36 -0
  252. data/lib/phronomy/llm_adapter/stream_chunk.rb +18 -0
  253. data/lib/phronomy/llm_adapter/token_usage.rb +64 -0
  254. data/lib/phronomy/llm_adapter/transport_error.rb +9 -0
  255. data/lib/phronomy/llm_adapter.rb +3 -16
  256. data/lib/phronomy/multi_agent/admission_registry.rb +1 -8
  257. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +108 -84
  258. data/lib/phronomy/multi_agent/execution_environment.rb +29 -0
  259. data/lib/phronomy/{agent/handoff → multi_agent}/handoff.rb +2 -2
  260. data/lib/phronomy/{agent/handoff → multi_agent}/handoff_capability_factory.rb +21 -2
  261. data/lib/phronomy/multi_agent/handoff_participant.rb +79 -0
  262. data/lib/phronomy/{agent/handoff → multi_agent}/handoff_policy.rb +1 -1
  263. data/lib/phronomy/multi_agent/handoff_runner.rb +77 -100
  264. data/lib/phronomy/{agent/handoff → multi_agent}/handoff_state.rb +1 -1
  265. data/lib/phronomy/multi_agent/orchestrator.rb +61 -75
  266. data/lib/phronomy/multi_agent/persistence/codec.rb +20 -0
  267. data/lib/phronomy/multi_agent/persistence/handoff_state_repository.rb +77 -0
  268. data/lib/phronomy/multi_agent/persistence/records.rb +27 -0
  269. data/lib/phronomy/multi_agent/persistence/storage_schema.rb +21 -0
  270. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +58 -42
  271. data/lib/phronomy/multi_agent/persistence/team_repository.rb +29 -21
  272. data/lib/phronomy/multi_agent/reserved_child_admission.rb +88 -0
  273. data/lib/phronomy/multi_agent/runtime_binding/engine_environment.rb +35 -0
  274. data/lib/phronomy/multi_agent/shared_state.rb +2 -2
  275. data/lib/phronomy/multi_agent/store.rb +156 -0
  276. data/lib/phronomy/multi_agent/team_coordinator.rb +83 -74
  277. data/lib/phronomy/multi_agent/team_ownership_registry.rb +2 -12
  278. data/lib/phronomy/output_parser/parse_error.rb +7 -0
  279. data/lib/phronomy/persistence/content_repository.rb +26 -0
  280. data/lib/phronomy/persistence/errors.rb +16 -0
  281. data/lib/phronomy/persistence/migration/initial_format_migration.rb +2 -2
  282. data/lib/phronomy/persistence/persistence.rb +23 -0
  283. data/lib/phronomy/persistence/save_outcome.rb +24 -0
  284. data/lib/phronomy/persistence/snapshot_comparison.rb +81 -0
  285. data/lib/phronomy/persistence/storage_boundary.rb +32 -0
  286. data/lib/phronomy/persistence/transaction.rb +86 -0
  287. data/lib/phronomy/persistence_composition/storage_schema.rb +9 -4
  288. data/lib/phronomy/persistence_composition/stores.rb +34 -0
  289. data/lib/phronomy/persistence_composition/unit4_migration.rb +276 -0
  290. data/lib/phronomy/runtime_composition/agent_defaults.rb +5 -1
  291. data/lib/phronomy/runtime_composition/configuration.rb +146 -0
  292. data/lib/phronomy/runtime_composition/evaluation_defaults.rb +7 -0
  293. data/lib/phronomy/runtime_composition/execution_defaults.rb +5 -0
  294. data/lib/phronomy/runtime_composition/generation_defaults.rb +6 -0
  295. data/lib/phronomy/runtime_composition/global_configuration.rb +38 -0
  296. data/lib/phronomy/runtime_composition/multi_agent_defaults.rb +9 -0
  297. data/lib/phronomy/runtime_composition/workflow_defaults.rb +4 -0
  298. data/lib/phronomy/storage/async/async_client.rb +60 -0
  299. data/lib/phronomy/storage/backend.rb +8 -2
  300. data/lib/phronomy/testing/eval/metrics.rb +1 -1
  301. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +19 -3
  302. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +2 -2
  303. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +6 -6
  304. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +55 -52
  305. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +3 -3
  306. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +7 -7
  307. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +11 -11
  308. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +57 -51
  309. data/lib/phronomy/testing/persistence_contract/neutral_storage_primitives.rb +1 -1
  310. data/lib/phronomy/testing/persistence_contract/storage_transaction_boundaries.rb +23 -22
  311. data/lib/phronomy/tool/authorization.rb +77 -0
  312. data/lib/phronomy/tool/base.rb +549 -9
  313. data/lib/phronomy/tool/call_request.rb +40 -0
  314. data/lib/phronomy/{agent/tool_execution/tool_definition_set.rb → tool/definition_set.rb} +7 -8
  315. data/lib/phronomy/tool/operation.rb +88 -0
  316. data/lib/phronomy/tool/schema.rb +214 -0
  317. data/lib/phronomy/tool/settings.rb +31 -0
  318. data/lib/phronomy/tool/tool_error.rb +7 -0
  319. data/lib/phronomy/tool/tool_executor.rb +58 -0
  320. data/lib/phronomy/tools/agent.rb +22 -90
  321. data/lib/phronomy/tools/mcp.rb +21 -14
  322. data/lib/phronomy/tools/vector_search.rb +4 -4
  323. data/lib/phronomy/tracing/automatic.rb +11 -6
  324. data/lib/phronomy/tracing/base.rb +1 -1
  325. data/lib/phronomy/tracing/observation.rb +37 -0
  326. data/lib/phronomy/tracing/settings.rb +36 -0
  327. data/lib/phronomy/vector_store/{async_backend.rb → async/async_client.rb} +35 -29
  328. data/lib/phronomy/vector_store/{in_memory.rb → backends/in_memory.rb} +12 -10
  329. data/lib/phronomy/vector_store/{pgvector.rb → backends/pgvector.rb} +44 -16
  330. data/lib/phronomy/vector_store/{redis_search.rb → backends/redis_search.rb} +74 -37
  331. data/lib/phronomy/vector_store/base.rb +118 -49
  332. data/lib/phronomy/vector_store/invalid_result_error.rb +12 -0
  333. data/lib/phronomy/vector_store.rb +2 -17
  334. data/lib/phronomy/version.rb +1 -1
  335. data/lib/phronomy/workflow/completion.rb +11 -0
  336. data/lib/phronomy/workflow/execution/workflow.rb +7 -7
  337. data/lib/phronomy/workflow/execution/workflow_action_rules.rb +67 -0
  338. data/lib/phronomy/workflow/execution/workflow_context.rb +2 -2
  339. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +27 -0
  340. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +3 -5
  341. data/lib/phronomy/workflow/execution/workflow_runner.rb +58 -79
  342. data/lib/phronomy/workflow/execution/workflow_settings.rb +28 -0
  343. data/lib/phronomy/workflow/persistence/state_repository.rb +28 -22
  344. data/lib/phronomy/workflow/persistence/storage_schema.rb +13 -0
  345. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +38 -0
  346. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +156 -0
  347. data/lib/phronomy/workflow/{execution → runtime_binding}/workflow_terminal_policy.rb +4 -4
  348. data/lib/phronomy.rb +57 -16
  349. data/scripts/api_snapshot.rb +35 -15
  350. data/sig/_private/phronomy/agent/settings.rbs +19 -0
  351. data/sig/_private/phronomy/configuration/runtime_settings.rbs +20 -0
  352. data/sig/_private/phronomy/llm_adapter/async/async_client.rbs +10 -0
  353. data/sig/_private/phronomy/multi_agent/store.rbs +8 -0
  354. data/sig/_private/phronomy/persistence/storage_boundary.rbs +7 -0
  355. data/sig/_private/phronomy/tool/settings.rbs +13 -0
  356. data/sig/_private/phronomy/tracing/settings.rbs +15 -0
  357. data/sig/_private/phronomy/workflow/execution/workflow_settings.rbs +13 -0
  358. data/sig/phronomy/agent/admission.rbs +19 -0
  359. data/sig/phronomy/agent/execution_contract.rbs +132 -0
  360. data/sig/phronomy/agent/execution_environment.rbs +20 -0
  361. data/sig/phronomy/agent/store.rbs +73 -0
  362. data/sig/phronomy/agent.rbs +14 -138
  363. data/sig/phronomy/common.rbs +7 -0
  364. data/sig/phronomy/content_store/contracts.rbs +13 -0
  365. data/sig/phronomy/context.rbs +152 -0
  366. data/sig/phronomy/embeddings/async/async_client.rbs +9 -0
  367. data/sig/phronomy/embeddings/base.rbs +17 -0
  368. data/sig/phronomy/engine/concurrency/timer_bindings.rbs +22 -0
  369. data/sig/phronomy/engine/control_bindings.rbs +11 -0
  370. data/sig/phronomy/engine/control_protocols.rbs +23 -0
  371. data/sig/phronomy/engine/execution_binding.rbs +6 -0
  372. data/sig/phronomy/engine/execution_receiver.rbs +15 -0
  373. data/sig/phronomy/engine/observability.rbs +13 -0
  374. data/sig/phronomy/execution/async_operation.rbs +7 -0
  375. data/sig/phronomy/execution/blocking.rbs +5 -0
  376. data/sig/phronomy/execution/concurrency/cancellation_token.rbs +13 -0
  377. data/sig/phronomy/execution/concurrency/deadline.rbs +10 -0
  378. data/sig/phronomy/execution/concurrency/result_composition.rbs +7 -0
  379. data/sig/phronomy/execution/control_protocols.rbs +18 -0
  380. data/sig/phronomy/execution/errors.rbs +7 -0
  381. data/sig/phronomy/execution/execution.rbs +41 -0
  382. data/sig/phronomy/execution/execution_receiver.rbs +40 -0
  383. data/sig/phronomy/execution/execution_scope_state.rbs +9 -0
  384. data/sig/phronomy/execution/invocation_context.rbs +18 -0
  385. data/sig/phronomy/execution/invocation_controls.rbs +13 -0
  386. data/sig/phronomy/execution/task_result.rbs +29 -0
  387. data/sig/phronomy/extensions.rbs +0 -21
  388. data/sig/phronomy/handoff.rbs +10 -4
  389. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +6 -0
  390. data/sig/phronomy/llm_adapter/base.rbs +14 -0
  391. data/sig/phronomy/llm_adapter/usage.rbs +14 -0
  392. data/sig/phronomy/llm_adapter/values.rbs +43 -0
  393. data/sig/phronomy/llm_context_window.rbs +10 -0
  394. data/sig/phronomy/multi_agent/reserved_child_admission.rbs +8 -0
  395. data/sig/phronomy/multi_agent/store.rbs +54 -0
  396. data/sig/phronomy/multi_agent.rbs +6 -3
  397. data/sig/phronomy/persistence/errors.rbs +18 -0
  398. data/sig/phronomy/persistence/migration.rbs +13 -0
  399. data/sig/phronomy/persistence/transaction.rbs +19 -0
  400. data/sig/phronomy/persistence.rbs +1 -99
  401. data/sig/phronomy/persistence_composition.rbs +19 -0
  402. data/sig/phronomy/runtime_composition/configuration.rbs +36 -0
  403. data/sig/phronomy/storage/async/async_client.rbs +8 -0
  404. data/sig/phronomy/storage/backends/in_memory.rbs +9 -0
  405. data/sig/phronomy/{storage.rbs → storage/contracts.rbs} +2 -16
  406. data/sig/phronomy/tool.rbs +48 -4
  407. data/sig/phronomy/tracing/observation.rbs +7 -0
  408. data/sig/phronomy/vector_store/async/async_client.rbs +13 -0
  409. data/sig/phronomy/vector_store/base.rbs +23 -0
  410. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +14 -0
  411. data/sig/phronomy/workflow/runtime_binding/workflow_terminal_policy.rbs +7 -0
  412. data/sig/phronomy/workflow/store.rbs +8 -0
  413. data/sig/phronomy/workflow.rbs +1 -1
  414. data/sig/phronomy.rbs +0 -49
  415. metadata +318 -117
  416. data/lib/phronomy/agent/concerns/before_llm_input.rb +0 -66
  417. data/lib/phronomy/agent/concerns/error_translation.rb +0 -45
  418. data/lib/phronomy/agent/concerns/filterable.rb +0 -184
  419. data/lib/phronomy/agent/context/capability/base.rb +0 -548
  420. data/lib/phronomy/agent/context/capability/tool_executor.rb +0 -62
  421. data/lib/phronomy/agent/context/instruction/prompt_template.rb +0 -102
  422. data/lib/phronomy/agent/context_assembly/runtime_chat_builder.rb +0 -36
  423. data/lib/phronomy/agent/context_assembly/token_budget_resolver.rb +0 -22
  424. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +0 -37
  425. data/lib/phronomy/agent/context_policies/default.rb +0 -142
  426. data/lib/phronomy/agent/execution/phase_machine_builder.rb +0 -101
  427. data/lib/phronomy/agent/execution/provider_call_outcome.rb +0 -106
  428. data/lib/phronomy/agent/handoff/handoff_execution_coordinator.rb +0 -15
  429. data/lib/phronomy/agent/handoff/handoff_outcome_committer.rb +0 -131
  430. data/lib/phronomy/agent/handoff/handoff_request.rb +0 -45
  431. data/lib/phronomy/agent/lifecycle_contract/handoff_error.rb +0 -7
  432. data/lib/phronomy/agent/persistence/handoff_state_repository.rb +0 -58
  433. data/lib/phronomy/agent/persistence/queries.rb +0 -61
  434. data/lib/phronomy/agent/selection/candidate.rb +0 -53
  435. data/lib/phronomy/agent/selection/constraint.rb +0 -49
  436. data/lib/phronomy/agent/tool_execution/tool_call_intercepted.rb +0 -29
  437. data/lib/phronomy/agent/tool_execution/tool_invocation_session_builder.rb +0 -276
  438. data/lib/phronomy/configuration/configuration.rb +0 -68
  439. data/lib/phronomy/configuration/global_configuration.rb +0 -26
  440. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +0 -124
  441. data/lib/phronomy/engine/concurrency/operation_binding.rb +0 -43
  442. data/lib/phronomy/engine/execution_receiver.rb +0 -65
  443. data/lib/phronomy/engine/runnable.rb +0 -54
  444. data/lib/phronomy/llm_adapter/ruby_llm.rb +0 -47
  445. data/lib/phronomy/llm_contract/token_usage.rb +0 -85
  446. data/lib/phronomy/multi_agent/persistence/queries.rb +0 -30
  447. data/lib/phronomy/multi_agent/storage_contract/team_storage_schema.rb +0 -15
  448. data/lib/phronomy/output_parser/contract/parse_error.rb +0 -7
  449. data/lib/phronomy/persistence/api/persistence.rb +0 -140
  450. data/lib/phronomy/persistence_composition/repositories.rb +0 -77
  451. data/lib/phronomy/recovery/recovery.rb +0 -186
  452. data/lib/phronomy/tool/contract/tool_error.rb +0 -7
  453. data/lib/phronomy/vector_store/embeddings/base.rb +0 -44
  454. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +0 -47
  455. data/lib/phronomy/workflow/phase_machine_builder.rb +0 -205
  456. data/lib/phronomy/workflow/storage_contract/workflow_storage_schema.rb +0 -9
  457. data/sig/phronomy/execution_receiver.rbs +0 -34
  458. data/sig/phronomy/llm_adapter.rbs +0 -11
  459. data/sig/phronomy/runtime.rbs +0 -76
  460. /data/lib/phronomy/agent/{context_assembly/context_importer.rb → context_importer.rb} +0 -0
  461. /data/lib/phronomy/{engine → execution}/backpressure_error.rb +0 -0
  462. /data/lib/phronomy/{engine → execution}/cancellation_error.rb +0 -0
  463. /data/lib/phronomy/{engine → execution}/concurrency/worker_input_restricted.rb +0 -0
  464. /data/lib/phronomy/{engine → execution}/event_loop_reentrancy_error.rb +0 -0
  465. /data/lib/phronomy/{engine → execution}/execution_cancellation_error.rb +0 -0
  466. /data/lib/phronomy/{engine → execution}/execution_timeout_error.rb +0 -0
  467. /data/lib/phronomy/{engine → execution}/invalid_async_entry_action_error.rb +0 -0
  468. /data/lib/phronomy/{engine → execution}/invalid_async_transition_action_error.rb +0 -0
  469. /data/lib/phronomy/{engine → execution}/invalid_async_workflow_action_error.rb +0 -0
  470. /data/lib/phronomy/{engine → execution}/pool_shutdown_error.rb +0 -0
  471. /data/lib/phronomy/{engine → execution}/recursion_limit_error.rb +0 -0
  472. /data/lib/phronomy/{engine → execution}/runtime_shutdown_error.rb +0 -0
  473. /data/lib/phronomy/{engine → execution}/runtime_shutdown_reentrancy_error.rb +0 -0
  474. /data/lib/phronomy/{engine → execution}/scheduler_reentrancy_error.rb +0 -0
  475. /data/lib/phronomy/{engine → execution}/timeout_error.rb +0 -0
@@ -19,9 +19,9 @@ module Phronomy
19
19
 
20
20
  def start(name, input: nil, **metadata)
21
21
  trace_pii = false
22
- configuration = Phronomy.configuration
23
- trace_pii = !!configuration.trace_pii
24
- tracer = configuration.tracer
22
+ settings = Settings.current
23
+ trace_pii = !!settings.trace_pii
24
+ tracer = settings.tracer
25
25
  span = tracer.start_span(
26
26
  name,
27
27
  input: traced_payload(input, trace_pii: trace_pii),
@@ -47,7 +47,7 @@ module Phronomy
47
47
  error: traced_error(error, trace_pii: handle.trace_pii)
48
48
  }.freeze
49
49
 
50
- if Phronomy::Runtime.in_event_loop_context?
50
+ if Phronomy::WaitPolicy.blocking_forbidden?
51
51
  submit_finish(handle, attributes)
52
52
  else
53
53
  finish_now(handle, attributes)
@@ -130,8 +130,13 @@ module Phronomy
130
130
  private_class_method :task_usage
131
131
 
132
132
  def submit_finish(handle, attributes)
133
- Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
133
+ Phronomy::Blocking.call_async do
134
134
  finish_now(handle, attributes)
135
+ end.on_complete do |_value, error|
136
+ if error
137
+ report_failure(:finish, handle.name, error,
138
+ include_message: handle.trace_pii)
139
+ end
135
140
  end
136
141
  nil
137
142
  rescue => error
@@ -164,7 +169,7 @@ module Phronomy
164
169
  message =
165
170
  "[Phronomy] automatic tracing #{stage} failed for " \
166
171
  "#{name}: #{error.class}#{detail}"
167
- logger = Phronomy.configuration.logger
172
+ logger = Phronomy::RuntimeSettings.current.logger
168
173
  logger ? logger.warn(message) : Kernel.warn(message)
169
174
  nil
170
175
  rescue
@@ -57,7 +57,7 @@ module Phronomy
57
57
  #
58
58
  # @param span [Object] the span returned by #start_span
59
59
  # @param output [Object, nil] successful output value
60
- # @param usage [Phronomy::TokenUsage, nil] token usage for this span
60
+ # @param usage [Phronomy::LLMAdapter::TokenUsage, nil] token usage for this span
61
61
  # @param error [Exception, nil] exception if the block raised
62
62
  # @api public
63
63
  def finish_span(span, output: nil, usage: nil, error: nil)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Tracing
5
+ module Observation
6
+ # Convenience wrapper that delegates to the global tracer.
7
+ # Yields a span; the block must return [result, usage] where usage is a
8
+ # Phronomy::LLMAdapter::TokenUsage or nil. Returns only the result value.
9
+ #
10
+ # When +trace_pii+ is disabled, both the input and the output (LLM response,
11
+ # tool result) are replaced with the literal string "[REDACTED]" before being
12
+ # forwarded to the tracing backend. The actual result is still returned to
13
+ # the caller — only the copy sent to the tracer is redacted.
14
+ #
15
+ # @example
16
+ # trace("my_chain", input: input) { [invoke(input), nil] }
17
+ # @api public
18
+ def self.trace(name, input: nil, **meta, &block)
19
+ traced_input = Settings.current.trace_pii ? input : "[REDACTED]"
20
+
21
+ if Settings.current.trace_pii
22
+ # PII recording is allowed: pass through unchanged.
23
+ Settings.current.tracer.trace(name, input: traced_input, **meta, &block)
24
+ else
25
+ # Redact both input (above) and output before forwarding to the tracer.
26
+ # Capture the real result so callers receive the unredacted value.
27
+ real_result = nil
28
+ Settings.current.tracer.trace(name, input: traced_input, **meta) do |span|
29
+ real_result, usage = block.call(span)
30
+ ["[REDACTED]", usage]
31
+ end
32
+ real_result
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../common/configuration_error"
4
+
5
+ module Phronomy
6
+ module Tracing
7
+ # Tracing owns recording options; application composition selects the tracer.
8
+ # No concrete backend, execution resource or application facade belongs here.
9
+ # @api private
10
+ class Settings
11
+ def self.install_provider(&provider)
12
+ raise ArgumentError, "Tracing settings provider requires a block" unless provider
13
+
14
+ @provider = provider
15
+ nil
16
+ end
17
+
18
+ # Follow reset and scoped restoration only when a consumer already reads.
19
+ def self.current
20
+ settings = @provider&.call
21
+ unless settings.is_a?(Settings)
22
+ raise ConfigurationError, "Tracing settings provider must return Tracing::Settings"
23
+ end
24
+
25
+ settings
26
+ end
27
+
28
+ attr_accessor :tracer, :trace_pii
29
+
30
+ def initialize(tracer:)
31
+ @tracer = tracer
32
+ @trace_pii = false
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,21 +2,25 @@
2
2
 
3
3
  module Phronomy
4
4
  module VectorStore
5
- # Framework-owned async convenience methods for VectorStore backends.
5
+ # Framework-owned asynchronous execution of a synchronous vector backend.
6
6
  #
7
- # The backend extension contract is the synchronous interface defined by
8
- # {VectorStore::Base}: add/search/remove/clear/size. These async convenience
9
- # methods are inherited by backends and route that synchronous work through
10
- # Phronomy's bounded {OffloadPool}. This keeps OS-thread creation, queue
11
- # backpressure, submit timeout/cancellation, and completion semantics owned by
12
- # the framework rather than by each backend.
7
+ # Backend authors implement only Base's synchronous operations. Applications
8
+ # use this client for asynchronous calls. Admission never waits for queue
9
+ # capacity; accepted calls return the pool's original TaskResult. Timeout
10
+ # includes queue wait and settles the result without interrupting running I/O.
11
+ # Construction does not start Runtime; the current default pool is resolved
12
+ # for each operation. An injected pool remains owned by its caller.
13
13
  #
14
- # A future genuine native-async backend may adapt its completion into a
15
- # {Phronomy::TaskResult} without an OffloadPool worker, but native async override is
16
- # not part of the current backend SPI.
17
- #
18
- # @api private
19
- module AsyncBackend
14
+ # @api public
15
+ class AsyncClient
16
+ # @param backend [VectorStore::Base] synchronous vector store
17
+ # @param pool [Concurrency::OffloadPool, nil] optional execution pool
18
+ # @api public
19
+ def initialize(backend:, pool: nil)
20
+ @backend = backend
21
+ @pool = pool
22
+ end
23
+
20
24
  # Async variant of {VectorStore::Base#add}.
21
25
  #
22
26
  # @param id [String]
@@ -27,12 +31,11 @@ module Phronomy
27
31
  # @return [Phronomy::TaskResult]
28
32
  # @api public
29
33
  def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
30
- Phronomy::Runtime.instance.offload.submit(
34
+ Phronomy::Execution.submit(pool: default_pool,
31
35
  timeout: timeout,
32
36
  cancellation_token: cancellation_token,
33
- on_full: :raise
34
- ) do
35
- add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
37
+ on_full: :raise) do
38
+ @backend.add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
36
39
  end
37
40
  end
38
41
 
@@ -45,12 +48,11 @@ module Phronomy
45
48
  # @return [Phronomy::TaskResult]
46
49
  # @api public
47
50
  def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
48
- Phronomy::Runtime.instance.offload.submit(
51
+ Phronomy::Execution.submit(pool: default_pool,
49
52
  timeout: timeout,
50
53
  cancellation_token: cancellation_token,
51
- on_full: :raise
52
- ) do
53
- search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
54
+ on_full: :raise) do
55
+ @backend.search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
54
56
  end
55
57
  end
56
58
 
@@ -62,12 +64,11 @@ module Phronomy
62
64
  # @return [Phronomy::TaskResult]
63
65
  # @api public
64
66
  def remove_async(id:, cancellation_token: nil, timeout: nil)
65
- Phronomy::Runtime.instance.offload.submit(
67
+ Phronomy::Execution.submit(pool: default_pool,
66
68
  timeout: timeout,
67
69
  cancellation_token: cancellation_token,
68
- on_full: :raise
69
- ) do
70
- remove(id: id)
70
+ on_full: :raise) do
71
+ @backend.remove(id: id)
71
72
  end
72
73
  end
73
74
 
@@ -78,14 +79,19 @@ module Phronomy
78
79
  # @return [Phronomy::TaskResult]
79
80
  # @api public
80
81
  def clear_async(cancellation_token: nil, timeout: nil)
81
- Phronomy::Runtime.instance.offload.submit(
82
+ Phronomy::Execution.submit(pool: default_pool,
82
83
  timeout: timeout,
83
84
  cancellation_token: cancellation_token,
84
- on_full: :raise
85
- ) do
86
- clear
85
+ on_full: :raise) do
86
+ @backend.clear
87
87
  end
88
88
  end
89
+
90
+ private
91
+
92
+ def default_pool
93
+ @pool
94
+ end
89
95
  end
90
96
  end
91
97
  end
@@ -22,16 +22,16 @@ module Phronomy
22
22
  @expected_dimension = dimension
23
23
  end
24
24
 
25
+ protected
26
+
25
27
  # @param id [String]
26
28
  # @param embedding [Array<Float>]
27
29
  # @param metadata [Hash]
28
30
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
29
31
  # @api public
30
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
31
- cancellation_token&.raise_if_cancelled!
32
+ def perform_add(id:, embedding:, metadata: {}, cancellation_token: nil)
32
33
  # Establish expected dimension on first add, then validate.
33
34
  @expected_dimension ||= embedding.size
34
- validate_embedding_dimension!(embedding, @expected_dimension)
35
35
  @documents[id] = {embedding: embedding, metadata: metadata}
36
36
  self
37
37
  end
@@ -46,11 +46,8 @@ module Phronomy
46
46
  # (key always present); -r.fetch(:score) vs -r[:score] (key always present); snapshot = @documents
47
47
  # vs .dup is equivalent in single-threaded tests (GVL makes Hash#dup atomic, no behaviour
48
48
  # difference under test isolation)
49
- def search(query_embedding:, k: 5, cancellation_token: nil)
50
- cancellation_token&.raise_if_cancelled!
51
- k = validate_k!(k)
49
+ def perform_search(query_embedding:, k: 5, cancellation_token: nil)
52
50
  # search never establishes dimension; validate only when dimension is known.
53
- validate_embedding_dimension!(query_embedding, @expected_dimension)
54
51
  # Take an atomic snapshot before iterating. Hash#dup is a C-level
55
52
  # call that completes without releasing the GVL, so it is atomic with
56
53
  # respect to any other Ruby thread. Iterating the copy instead of
@@ -64,22 +61,27 @@ module Phronomy
64
61
  results.sort_by { |r| -r[:score] }.first(k)
65
62
  end
66
63
 
67
- def remove(id:)
64
+ def perform_remove(id:)
68
65
  @documents.delete(id)
69
66
  self
70
67
  end
71
68
 
72
- def clear
69
+ def perform_clear
73
70
  @documents.clear
74
71
  self
75
72
  end
76
73
 
77
74
  # @return [Integer] number of documents stored
78
75
  # @api public
79
- def size
76
+ def perform_size
80
77
  @documents.size
81
78
  end
82
79
 
80
+ # @api public
81
+ def embedding_dimension
82
+ @expected_dimension
83
+ end
84
+
83
85
  private
84
86
 
85
87
  # mutant:disable - empty-vector early-return condition variants (if false, if nil, if a.empty?,
@@ -35,14 +35,14 @@ module Phronomy
35
35
  @dimension = dimension
36
36
  end
37
37
 
38
+ protected
39
+
38
40
  # @param id [String]
39
41
  # @param embedding [Array<Float>]
40
42
  # @param metadata [Hash]
41
43
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
42
44
  # @api public
43
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
44
- cancellation_token&.raise_if_cancelled!
45
- validate_embedding_dimension!(embedding, @dimension)
45
+ def perform_add(id:, embedding:, metadata: {}, cancellation_token: nil)
46
46
  @model_class.upsert(
47
47
  {id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
48
48
  unique_by: :id
@@ -55,10 +55,7 @@ module Phronomy
55
55
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
56
56
  # @return [Array<Hash>] sorted by descending similarity score
57
57
  # @api public
58
- def search(query_embedding:, k: 5, cancellation_token: nil)
59
- cancellation_token&.raise_if_cancelled!
60
- k_safe = validate_k!(k)
61
- validate_embedding_dimension!(query_embedding, @dimension)
58
+ def perform_search(query_embedding:, k: 5, cancellation_token: nil)
62
59
  vec = safe_vector_literal(query_embedding)
63
60
  conn = @model_class.connection
64
61
  quoted_vec = "#{conn.quote(vec)}::vector"
@@ -66,48 +63,79 @@ module Phronomy
66
63
  @model_class
67
64
  .select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
68
65
  .order("embedding <=> #{quoted_vec}")
69
- .limit(k_safe)
66
+ .limit(k)
70
67
  .map do |r|
71
68
  {
72
- id: r.id.to_s,
73
- score: r.score.to_f,
69
+ id: parse_id(r.id),
70
+ score: parse_score(r.score),
74
71
  metadata: parse_metadata(r.metadata)
75
72
  }
76
73
  end
77
74
  end
78
75
 
79
- def remove(id:)
76
+ def perform_remove(id:)
80
77
  @model_class.where(id: id).delete_all
81
78
  self
82
79
  end
83
80
 
84
- def clear
81
+ def perform_clear
85
82
  @model_class.delete_all
86
83
  self
87
84
  end
88
85
 
89
86
  # Returns the number of documents in the backing table.
90
- def size
87
+ def perform_size
91
88
  @model_class.count
92
89
  end
93
90
 
91
+ # @api public
92
+ def embedding_dimension
93
+ @dimension
94
+ end
95
+
94
96
  private
95
97
 
98
+ def parse_id(raw)
99
+ unless raw.is_a?(String) || raw.is_a?(Integer)
100
+ raise InvalidResultError, "Pgvector returned an invalid document id"
101
+ end
102
+ raw.to_s
103
+ end
104
+
105
+ def parse_score(raw)
106
+ valid = (raw.is_a?(String) && raw.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/)) ||
107
+ (raw.is_a?(Numeric) && raw.real?)
108
+ value = Float(raw, exception: false) if valid
109
+ unless value&.finite?
110
+ raise InvalidResultError, "Pgvector requires a finite numeric score"
111
+ end
112
+ value
113
+ end
114
+
96
115
  # Parses a metadata value returned by the pg driver.
97
116
  # Handles NULL (nil), already-parsed Hash, and JSON string forms.
98
117
  def parse_metadata(raw)
99
118
  return {} if raw.nil?
100
119
  return symbolize_hash_keys(raw) if raw.is_a?(Hash)
101
120
 
102
- parsed = JSON.parse(raw.to_s, symbolize_names: true)
103
- parsed.is_a?(Hash) ? parsed : {}
121
+ unless raw.is_a?(String)
122
+ raise InvalidResultError, "Pgvector metadata must contain a JSON object"
123
+ end
124
+ parsed = JSON.parse(raw, symbolize_names: true)
125
+ unless parsed.is_a?(Hash)
126
+ raise InvalidResultError, "Pgvector metadata must contain a JSON object"
127
+ end
128
+ parsed
104
129
  rescue JSON::ParserError
105
- {}
130
+ raise InvalidResultError, "Pgvector metadata contains invalid JSON"
106
131
  end
107
132
 
108
133
  # Recursively symbolizes keys for an already-parsed Hash.
109
134
  def symbolize_hash_keys(hash)
110
135
  hash.each_with_object({}) do |(k, v), h|
136
+ unless k.is_a?(String) || k.is_a?(Symbol)
137
+ raise InvalidResultError, "Pgvector metadata contains an invalid object key"
138
+ end
111
139
  h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
112
140
  end
113
141
  end
@@ -8,7 +8,7 @@ module Phronomy
8
8
  #
9
9
  # Requires:
10
10
  # - The +redis+ gem (add to your Gemfile)
11
- # - A Redis server with the RediSearch (RedisSearch) module enabled
11
+ # - A RESP2 Redis client and a server with the RediSearch module enabled
12
12
  # (or Redis Stack which bundles RediSearch)
13
13
  #
14
14
  # Vectors are stored as FLOAT32 binary blobs in Redis Hash fields and
@@ -46,17 +46,17 @@ module Phronomy
46
46
  @mutex = Mutex.new
47
47
  end
48
48
 
49
+ protected
50
+
49
51
  # @param id [String]
50
52
  # @param embedding [Array<Float>]
51
53
  # @param metadata [Hash]
52
54
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
53
55
  # @api public
54
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
55
- cancellation_token&.raise_if_cancelled!
56
+ def perform_add(id:, embedding:, metadata: {}, cancellation_token: nil)
56
57
  # Establish expected dimension on first add (not race-free for concurrent
57
- # first adds), then validate, then create/reuse the index.
58
+ # first adds), then create/reuse the index. Base has validated the input.
58
59
  @dimension ||= embedding.size
59
- validate_embedding_dimension!(embedding, @dimension)
60
60
  ensure_index!(@dimension)
61
61
  @redis.call(
62
62
  "HSET", "#{DOC_PREFIX}#{id}",
@@ -71,20 +71,17 @@ module Phronomy
71
71
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
72
72
  # @return [Array<Hash>] sorted by descending similarity score
73
73
  # @api public
74
- def search(query_embedding:, k: 5, cancellation_token: nil)
75
- cancellation_token&.raise_if_cancelled!
74
+ def perform_search(query_embedding:, k: 5, cancellation_token: nil)
76
75
  # search never establishes dimension. If dimension is unknown and the
77
76
  # index has not been created yet, there are no documents to return.
78
77
  return [] if @dimension.nil? && !@index_created
79
78
 
80
- validate_embedding_dimension!(query_embedding, @dimension)
81
79
  ensure_index!(@dimension)
82
- k_safe = validate_k!(k)
83
80
  blob = pack_vector(query_embedding)
84
81
 
85
82
  raw = @redis.call(
86
83
  "FT.SEARCH", @index_name,
87
- "*=>[KNN #{k_safe} @embedding $BLOB AS score]",
84
+ "*=>[KNN #{k} @embedding $BLOB AS score]",
88
85
  "PARAMS", 2, "BLOB", blob,
89
86
  "SORTBY", "score",
90
87
  "RETURN", 2, "score", "metadata",
@@ -94,26 +91,26 @@ module Phronomy
94
91
  parse_results(raw)
95
92
  end
96
93
 
97
- def remove(id:)
94
+ def perform_remove(id:)
98
95
  @redis.call("DEL", "#{DOC_PREFIX}#{id}")
99
96
  self
100
97
  end
101
98
 
102
99
  # Returns the number of documents indexed.
103
100
  # Queries FT.INFO when the index has been created; returns 0 otherwise.
104
- def size
101
+ def perform_size
105
102
  return 0 unless @index_created
106
103
 
107
- raw = @redis.call("FT.INFO", @index_name)
108
- return 0 unless raw.is_a?(Array)
109
-
110
- idx = raw.index("num_docs")
111
- idx ? raw[idx + 1].to_i : 0
112
- rescue
113
- 0
104
+ fields = parse_fields(@redis.call("FT.INFO", @index_name), "FT.INFO")
105
+ count = fields["num_docs"]
106
+ count = Integer(count, 10) if count.is_a?(String) && count.match?(/\A\d+\z/)
107
+ unless count.is_a?(Integer) && count >= 0
108
+ raise InvalidResultError, "Redis FT.INFO requires a non-negative integer num_docs"
109
+ end
110
+ count
114
111
  end
115
112
 
116
- def clear
113
+ def perform_clear
117
114
  @mutex.synchronize do
118
115
  begin
119
116
  @redis.call("FT.DROPINDEX", @index_name, "DD")
@@ -125,6 +122,11 @@ module Phronomy
125
122
  self
126
123
  end
127
124
 
125
+ # @api public
126
+ def embedding_dimension
127
+ @dimension
128
+ end
129
+
128
130
  private
129
131
 
130
132
  def ensure_index!(dim)
@@ -160,33 +162,68 @@ module Phronomy
160
162
  #
161
163
  # Redis FT.SEARCH returns: [count, key1, [field, value, ...], key2, ...]
162
164
  def parse_results(raw)
163
- return [] if raw.nil? || !raw.is_a?(Array) || raw.size < 2
165
+ unless raw.is_a?(Array) && raw.size.odd? && raw.first.is_a?(Integer) &&
166
+ raw.first >= (raw.size - 1) / 2 && raw.first.zero? == (raw.size == 1)
167
+ raise InvalidResultError, "Redis FT.SEARCH requires a count and document/field pairs"
168
+ end
164
169
 
165
170
  results = []
166
- i = 1
167
- while i < raw.size
168
- key = raw[i]
169
- fields = raw[i + 1]
170
- i += 2
171
-
172
- next unless fields.is_a?(Array)
173
-
174
- field_hash = fields.each_slice(2).to_h
175
- score_str = field_hash["score"]
176
- metadata_str = field_hash["metadata"]
177
-
178
- next if score_str.nil?
171
+ raw.drop(1).each_slice(2) do |key, fields|
172
+ unless key.is_a?(String) && key.start_with?(DOC_PREFIX)
173
+ raise InvalidResultError, "Redis FT.SEARCH returned an invalid document key"
174
+ end
175
+ # Redis documents this null content when a key expires/is updated
176
+ # during the search. The key remains in the total count.
177
+ next if fields.nil?
179
178
 
180
- id = key.to_s.delete_prefix(DOC_PREFIX)
179
+ field_hash = parse_fields(fields, "FT.SEARCH")
180
+ id = key.delete_prefix(DOC_PREFIX)
181
181
  # RediSearch returns cosine distance (0=identical, 2=opposite);
182
182
  # convert to cosine similarity for consistency with other backends.
183
- score = 1.0 - score_str.to_f
184
- metadata = metadata_str ? JSON.parse(metadata_str, symbolize_names: true) : {}
183
+ score = 1.0 - parse_distance(field_hash["score"])
184
+ metadata = parse_metadata(field_hash["metadata"])
185
185
 
186
186
  results << {id: id, score: score, metadata: metadata}
187
187
  end
188
188
  results
189
189
  end
190
+
191
+ # RESP2 field/value decoding belongs to this backend, not the Contract.
192
+ def parse_fields(raw, command)
193
+ unless raw.is_a?(Array) && raw.size.even?
194
+ raise InvalidResultError, "Redis #{command} requires field/value pairs"
195
+ end
196
+ raw.each_slice(2).each_with_object({}) do |(key, value), fields|
197
+ unless key.is_a?(String) && !fields.key?(key)
198
+ raise InvalidResultError, "Redis #{command} returned an invalid or duplicate field"
199
+ end
200
+ fields[key] = value
201
+ end
202
+ end
203
+
204
+ def parse_distance(raw)
205
+ valid = (raw.is_a?(String) && raw.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/)) ||
206
+ (raw.is_a?(Numeric) && raw.real?)
207
+ value = Float(raw, exception: false) if valid
208
+ unless value&.finite?
209
+ raise InvalidResultError, "Redis FT.SEARCH requires a finite numeric score"
210
+ end
211
+ value
212
+ end
213
+
214
+ def parse_metadata(raw)
215
+ return {} if raw.nil?
216
+ unless raw.is_a?(String)
217
+ raise InvalidResultError, "Redis metadata must contain a JSON object"
218
+ end
219
+ parsed = JSON.parse(raw, symbolize_names: true)
220
+ unless parsed.is_a?(Hash)
221
+ raise InvalidResultError, "Redis metadata must contain a JSON object"
222
+ end
223
+ parsed
224
+ rescue JSON::ParserError
225
+ raise InvalidResultError, "Redis metadata contains invalid JSON"
226
+ end
190
227
  end
191
228
  end
192
229
  end