phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module VectorStore
8
+ # Mixin that defines the async interface for VectorStore backends.
9
+ #
10
+ # Mixing this module into a VectorStore class provides three choices:
11
+ #
12
+ # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
13
+ # that route through {BlockingAdapterPool} (the previous behaviour).
14
+ #
15
+ # 2. **Override selectively** — override only the async methods where the
16
+ # backend has a native async driver, while the remaining methods fall back
17
+ # to the pool.
18
+ #
19
+ # 3. **Implement all natively** — override all async methods to avoid pool
20
+ # allocation entirely.
21
+ #
22
+ # @example Native async search (no pool worker thread allocated)
23
+ # class MyFastStore < Phronomy::Agent::Context::Knowledge::VectorStore::Base
24
+ # include Phronomy::Agent::Context::Knowledge::VectorStore::AsyncBackend
25
+ #
26
+ # def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
27
+ # # Returns a PendingOperation backed by a native async driver.
28
+ # native_async_search(query_embedding, k)
29
+ # end
30
+ # end
31
+ #
32
+ # @api public
33
+ module AsyncBackend
34
+ # Async variant of {VectorStore::Base#add}.
35
+ #
36
+ # Submits the add call to {BlockingAdapterPool} by default.
37
+ # Override to use a native async driver.
38
+ #
39
+ # @param id [String]
40
+ # @param embedding [Array<Float>]
41
+ # @param metadata [Hash]
42
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
43
+ # @param timeout [Numeric, nil]
44
+ # @return [BlockingAdapterPool::PendingOperation]
45
+ # @api public
46
+ def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
47
+ Phronomy::Runtime.instance.blocking_io.submit(
48
+ timeout: timeout,
49
+ cancellation_token: cancellation_token
50
+ ) do
51
+ add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
52
+ end
53
+ end
54
+
55
+ # Async variant of {VectorStore::Base#search}.
56
+ #
57
+ # Submits the search call to {BlockingAdapterPool} by default.
58
+ # Override to use a native async driver.
59
+ #
60
+ # @param query_embedding [Array<Float>]
61
+ # @param k [Integer]
62
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
63
+ # @param timeout [Numeric, nil]
64
+ # @return [BlockingAdapterPool::PendingOperation]
65
+ # @api public
66
+ def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
67
+ Phronomy::Runtime.instance.blocking_io.submit(
68
+ timeout: timeout,
69
+ cancellation_token: cancellation_token
70
+ ) do
71
+ search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
72
+ end
73
+ end
74
+
75
+ # Async variant of {VectorStore::Base#remove}.
76
+ #
77
+ # Submits the remove call to {BlockingAdapterPool} by default.
78
+ # Override to use a native async driver.
79
+ #
80
+ # @param id [String]
81
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
82
+ # @param timeout [Numeric, nil]
83
+ # @return [BlockingAdapterPool::PendingOperation]
84
+ # @api public
85
+ def remove_async(id:, cancellation_token: nil, timeout: nil)
86
+ Phronomy::Runtime.instance.blocking_io.submit(
87
+ timeout: timeout,
88
+ cancellation_token: cancellation_token
89
+ ) do
90
+ remove(id: id)
91
+ end
92
+ end
93
+
94
+ # Async variant of {VectorStore::Base#clear}.
95
+ #
96
+ # Submits the clear call to {BlockingAdapterPool} by default.
97
+ # Override to use a native async driver.
98
+ #
99
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
100
+ # @param timeout [Numeric, nil]
101
+ # @return [BlockingAdapterPool::PendingOperation]
102
+ # @api public
103
+ def clear_async(cancellation_token: nil, timeout: nil)
104
+ Phronomy::Runtime.instance.blocking_io.submit(
105
+ timeout: timeout,
106
+ cancellation_token: cancellation_token
107
+ ) do
108
+ clear
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module VectorStore
8
+ # Abstract interface for vector stores.
9
+ #
10
+ # Implementations manage a collection of (embedding, metadata) pairs and
11
+ # support similarity search.
12
+ #
13
+ # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
14
+ # are provided by the {AsyncBackend} mixin which defaults to routing calls
15
+ # through {BlockingAdapterPool}. Backends with native async drivers may
16
+ # override individual async methods without touching the pool at all.
17
+ class Base
18
+ include AsyncBackend
19
+
20
+ # Add a document with its vector embedding.
21
+ #
22
+ # @param id [String] unique document identifier
23
+ # @param embedding [Array<Float>] vector embedding
24
+ # @param metadata [Hash] arbitrary metadata (e.g. the original message object)
25
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
26
+ # @api public
27
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
28
+ cancellation_token&.raise_if_cancelled!
29
+ raise NotImplementedError, "#{self.class}#add is not implemented"
30
+ end
31
+
32
+ # Return the k most similar documents to the query embedding.
33
+ #
34
+ # @param query_embedding [Array<Float>]
35
+ # @param k [Integer] number of results
36
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
37
+ # @return [Array<Hash>] each element: { id:, score:, metadata: }
38
+ # @api public
39
+ def search(query_embedding:, k: 5, cancellation_token: nil)
40
+ cancellation_token&.raise_if_cancelled!
41
+ raise NotImplementedError, "#{self.class}#search is not implemented"
42
+ end
43
+
44
+ # Remove a single document by id.
45
+ #
46
+ # @param id [String] document identifier
47
+ # @api public
48
+ def remove(id:)
49
+ raise NotImplementedError, "#{self.class}#remove is not implemented"
50
+ end
51
+
52
+ # Remove all documents.
53
+ def clear
54
+ raise NotImplementedError, "#{self.class}#clear is not implemented"
55
+ end
56
+
57
+ # Return the number of documents stored.
58
+ #
59
+ # @return [Integer]
60
+ # @api public
61
+ def size
62
+ raise NotImplementedError, "#{self.class}#size is not implemented"
63
+ end
64
+
65
+ private
66
+
67
+ # Validates that embedding has the expected dimension.
68
+ # Raises ArgumentError if sizes differ.
69
+ # A nil expected_dimension is a no-op (dimension not yet established).
70
+ def validate_embedding_dimension!(embedding, expected_dimension)
71
+ return unless expected_dimension
72
+
73
+ actual = embedding.size
74
+ return if actual == expected_dimension
75
+
76
+ raise ArgumentError,
77
+ "Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
78
+ end
79
+
80
+ # Validates that k is a positive integer.
81
+ # Accepts any value accepted by Integer() (e.g. "5"), but raises
82
+ # ArgumentError for non-integer strings, zero, and negative values.
83
+ def validate_k!(k)
84
+ int_k = Integer(k)
85
+ raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
86
+ int_k
87
+ rescue ArgumentError => e
88
+ raise ArgumentError, "k must be a positive integer: #{e.message}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Knowledge
7
+ module VectorStore
8
+ # Pure-Ruby in-memory vector store using cosine similarity.
9
+ #
10
+ # Intended for tests, short-lived agents, and Retrieval::Semantic scenarios where
11
+ # the message count is small enough that a linear scan is fast enough.
12
+ #
13
+ # @example
14
+ # store = Phronomy::Agent::Context::Knowledge::VectorStore::InMemory.new
15
+ # store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
16
+ # results = store.search(query_embedding: [0.1, 0.8], k: 3)
17
+ class InMemory < Base
18
+ # @param dimension [Integer, nil] expected embedding dimension.
19
+ # When nil, the dimension is inferred from the first call to #add.
20
+ # For multi-threaded use, pass dimension: explicitly; concurrent first
21
+ # adds are not guaranteed to be race-free.
22
+ # @api public
23
+ def initialize(dimension: nil)
24
+ @documents = {}
25
+ @expected_dimension = dimension
26
+ end
27
+
28
+ # @param id [String]
29
+ # @param embedding [Array<Float>]
30
+ # @param metadata [Hash]
31
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
32
+ # @api public
33
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
34
+ cancellation_token&.raise_if_cancelled!
35
+ # Establish expected dimension on first add, then validate.
36
+ @expected_dimension ||= embedding.size
37
+ validate_embedding_dimension!(embedding, @expected_dimension)
38
+ @documents[id] = {embedding: embedding, metadata: metadata}
39
+ self
40
+ end
41
+
42
+ # @param query_embedding [Array<Float>]
43
+ # @param k [Integer]
44
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
45
+ # @return [Array<Hash>] sorted by descending score
46
+ # @api public
47
+ # mutant:disable - genuine equivalent mutations: doc.fetch(:embedding) vs doc[:embedding] (key
48
+ # always present); {id:, score:, metadata: doc.fetch(:metadata)} shorthand+fetch vs []
49
+ # (key always present); -r.fetch(:score) vs -r[:score] (key always present); snapshot = @documents
50
+ # vs .dup is equivalent in single-threaded tests (GVL makes Hash#dup atomic, no behaviour
51
+ # difference under test isolation)
52
+ def search(query_embedding:, k: 5, cancellation_token: nil)
53
+ cancellation_token&.raise_if_cancelled!
54
+ k = validate_k!(k)
55
+ # search never establishes dimension; validate only when dimension is known.
56
+ validate_embedding_dimension!(query_embedding, @expected_dimension)
57
+ # Take an atomic snapshot before iterating. Hash#dup is a C-level
58
+ # call that completes without releasing the GVL, so it is atomic with
59
+ # respect to any other Ruby thread. Iterating the copy instead of
60
+ # @documents directly prevents "can't add a new key into hash during
61
+ # iteration" when a concurrent thread calls #add.
62
+ snapshot = @documents.dup
63
+ results = snapshot.map do |id, doc|
64
+ score = cosine_similarity(query_embedding, doc[:embedding])
65
+ {id: id, score: score, metadata: doc[:metadata]}
66
+ end
67
+ results.sort_by { |r| -r[:score] }.first(k)
68
+ end
69
+
70
+ def remove(id:)
71
+ @documents.delete(id)
72
+ self
73
+ end
74
+
75
+ def clear
76
+ @documents.clear
77
+ self
78
+ end
79
+
80
+ # @return [Integer] number of documents stored
81
+ # @api public
82
+ def size
83
+ @documents.size
84
+ end
85
+
86
+ private
87
+
88
+ # mutant:disable - empty-vector early-return condition variants (if false, if nil, if a.empty?,
89
+ # if b.empty?, if a.empty? && b.empty?, if a.empty? || false, if false || b.empty?,
90
+ # if nil || b.empty?, if nil && b.empty?) are genuine equivalents: dimension validation in
91
+ # #add and #search enforces same-size embeddings, so a.empty? iff b.empty?; when both are
92
+ # empty norm_a = sqrt(0) = 0 so the norm_a.zero? guard returns 0.0 anyway
93
+ def cosine_similarity(a, b)
94
+ return 0.0 if a.empty? || b.empty?
95
+
96
+ dot = a.zip(b).sum { |x, y| x * y }
97
+ norm_a = Math.sqrt(a.sum { |x| x**2 })
98
+ norm_b = Math.sqrt(b.sum { |x| x**2 })
99
+
100
+ return 0.0 if norm_a.zero? || norm_b.zero?
101
+
102
+ dot / (norm_a * norm_b)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module Context
8
+ module Knowledge
9
+ module VectorStore
10
+ # PostgreSQL-backed vector store using the pgvector extension.
11
+ #
12
+ # Requires:
13
+ # - The +pgvector+ gem (add to your Gemfile)
14
+ # - An ActiveRecord model class with the following columns:
15
+ # id (string / uuid)
16
+ # embedding (vector — from the pgvector column type)
17
+ # metadata (text or jsonb — stores arbitrary metadata as JSON)
18
+ #
19
+ # @example Usage
20
+ # store = Phronomy::Agent::Context::Knowledge::VectorStore::Pgvector.new(model_class: VectorDocument)
21
+ # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
22
+ # results = store.search(query_embedding: [0.1, 0.8], k: 5)
23
+ class Pgvector < Base
24
+ # @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
25
+ # @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
26
+ # pre-validation. When nil, dimension enforcement is delegated to the
27
+ # database schema; no pre-validation is performed by Phronomy.
28
+ # @api public
29
+ def initialize(model_class:, dimension: nil)
30
+ begin
31
+ require "pgvector"
32
+ rescue LoadError
33
+ raise LoadError,
34
+ "pgvector gem is required for Phronomy::Agent::Context::Knowledge::VectorStore::Pgvector. " \
35
+ "Add `gem 'pgvector'` to your Gemfile."
36
+ end
37
+ @model_class = model_class
38
+ @dimension = dimension
39
+ end
40
+
41
+ # @param id [String]
42
+ # @param embedding [Array<Float>]
43
+ # @param metadata [Hash]
44
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
45
+ # @api public
46
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
47
+ cancellation_token&.raise_if_cancelled!
48
+ validate_embedding_dimension!(embedding, @dimension)
49
+ @model_class.upsert(
50
+ {id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
51
+ unique_by: :id
52
+ )
53
+ self
54
+ end
55
+
56
+ # @param query_embedding [Array<Float>]
57
+ # @param k [Integer]
58
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
59
+ # @return [Array<Hash>] sorted by descending similarity score
60
+ # @api public
61
+ def search(query_embedding:, k: 5, cancellation_token: nil)
62
+ cancellation_token&.raise_if_cancelled!
63
+ k_safe = validate_k!(k)
64
+ validate_embedding_dimension!(query_embedding, @dimension)
65
+ vec = safe_vector_literal(query_embedding)
66
+ conn = @model_class.connection
67
+ quoted_vec = "#{conn.quote(vec)}::vector"
68
+
69
+ @model_class
70
+ .select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
71
+ .order("embedding <=> #{quoted_vec}")
72
+ .limit(k_safe)
73
+ .map do |r|
74
+ {
75
+ id: r.id.to_s,
76
+ score: r.score.to_f,
77
+ metadata: parse_metadata(r.metadata)
78
+ }
79
+ end
80
+ end
81
+
82
+ def remove(id:)
83
+ @model_class.where(id: id).delete_all
84
+ self
85
+ end
86
+
87
+ def clear
88
+ @model_class.delete_all
89
+ self
90
+ end
91
+
92
+ # Returns the number of documents in the backing table.
93
+ def size
94
+ @model_class.count
95
+ end
96
+
97
+ private
98
+
99
+ # Parses a metadata value returned by the pg driver.
100
+ # Handles NULL (nil), already-parsed Hash, and JSON string forms.
101
+ def parse_metadata(raw)
102
+ return {} if raw.nil?
103
+ return symbolize_hash_keys(raw) if raw.is_a?(Hash)
104
+
105
+ parsed = JSON.parse(raw.to_s, symbolize_names: true)
106
+ parsed.is_a?(Hash) ? parsed : {}
107
+ rescue JSON::ParserError
108
+ {}
109
+ end
110
+
111
+ # Recursively symbolizes keys for an already-parsed Hash.
112
+ def symbolize_hash_keys(hash)
113
+ hash.each_with_object({}) do |(k, v), h|
114
+ h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
115
+ end
116
+ end
117
+
118
+ # Validates that all elements are numeric and converts to a pgvector-
119
+ # compatible literal string (e.g. "[1.0,0.5,-0.3]").
120
+ def safe_vector_literal(embedding)
121
+ "[#{embedding.map { |v| Float(v) }.join(",")}]"
122
+ end
123
+
124
+ # Returns a validated vector for the upsert call.
125
+ def safe_vector(embedding)
126
+ safe_vector_literal(embedding)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module Context
8
+ module Knowledge
9
+ module VectorStore
10
+ # Redis-backed vector store using the RediSearch module (FT.* commands).
11
+ #
12
+ # Requires:
13
+ # - The +redis+ gem (add to your Gemfile)
14
+ # - A Redis server with the RediSearch (RedisSearch) module enabled
15
+ # (or Redis Stack which bundles RediSearch)
16
+ #
17
+ # Vectors are stored as FLOAT32 binary blobs in Redis Hash fields and
18
+ # searched using the KNN approximate-nearest-neighbour algorithm.
19
+ #
20
+ # @example Usage
21
+ # redis = Redis.new(url: "redis://localhost:6379")
22
+ # store = Phronomy::Agent::Context::Knowledge::VectorStore::RedisSearch.new(redis: redis, dimension: 1536)
23
+ # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
24
+ # results = store.search(query_embedding: [0.1, 0.8], k: 5)
25
+ class RedisSearch < Base
26
+ DOC_PREFIX = "phronomy_doc:"
27
+ private_constant :DOC_PREFIX
28
+
29
+ # @param redis [Redis] configured Redis client
30
+ # @param index_name [String] RediSearch index name
31
+ # @param dimension [Integer, nil] vector dimension; auto-detected on first add.
32
+ # When connecting to an **existing** RediSearch index, you MUST pass
33
+ # dimension: explicitly. Without it, a freshly constructed instance
34
+ # treats the index as uninitialized until #add is called, and #search
35
+ # silently returns [] in the meantime.
36
+ # @api public
37
+ def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
38
+ begin
39
+ require "redis"
40
+ rescue LoadError
41
+ raise LoadError,
42
+ "redis gem is required for Phronomy::Agent::Context::Knowledge::VectorStore::RedisSearch. " \
43
+ "Add `gem 'redis'` to your Gemfile."
44
+ end
45
+ @redis = redis
46
+ @index_name = index_name
47
+ @dimension = dimension
48
+ @index_created = false
49
+ @mutex = Mutex.new
50
+ end
51
+
52
+ # @param id [String]
53
+ # @param embedding [Array<Float>]
54
+ # @param metadata [Hash]
55
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
56
+ # @api public
57
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
58
+ cancellation_token&.raise_if_cancelled!
59
+ # Establish expected dimension on first add (not race-free for concurrent
60
+ # first adds), then validate, then create/reuse the index.
61
+ @dimension ||= embedding.size
62
+ validate_embedding_dimension!(embedding, @dimension)
63
+ ensure_index!(@dimension)
64
+ @redis.call(
65
+ "HSET", "#{DOC_PREFIX}#{id}",
66
+ "embedding", pack_vector(embedding),
67
+ "metadata", metadata.to_json
68
+ )
69
+ self
70
+ end
71
+
72
+ # @param query_embedding [Array<Float>]
73
+ # @param k [Integer]
74
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
75
+ # @return [Array<Hash>] sorted by descending similarity score
76
+ # @api public
77
+ def search(query_embedding:, k: 5, cancellation_token: nil)
78
+ cancellation_token&.raise_if_cancelled!
79
+ # search never establishes dimension. If dimension is unknown and the
80
+ # index has not been created yet, there are no documents to return.
81
+ return [] if @dimension.nil? && !@index_created
82
+
83
+ validate_embedding_dimension!(query_embedding, @dimension)
84
+ ensure_index!(@dimension)
85
+ k_safe = validate_k!(k)
86
+ blob = pack_vector(query_embedding)
87
+
88
+ raw = @redis.call(
89
+ "FT.SEARCH", @index_name,
90
+ "*=>[KNN #{k_safe} @embedding $BLOB AS score]",
91
+ "PARAMS", 2, "BLOB", blob,
92
+ "SORTBY", "score",
93
+ "RETURN", 2, "score", "metadata",
94
+ "DIALECT", 2
95
+ )
96
+
97
+ parse_results(raw)
98
+ end
99
+
100
+ def remove(id:)
101
+ @redis.call("DEL", "#{DOC_PREFIX}#{id}")
102
+ self
103
+ end
104
+
105
+ # Returns the number of documents indexed.
106
+ # Queries FT.INFO when the index has been created; returns 0 otherwise.
107
+ def size
108
+ return 0 unless @index_created
109
+
110
+ raw = @redis.call("FT.INFO", @index_name)
111
+ return 0 unless raw.is_a?(Array)
112
+
113
+ idx = raw.index("num_docs")
114
+ idx ? raw[idx + 1].to_i : 0
115
+ rescue
116
+ 0
117
+ end
118
+
119
+ def clear
120
+ @mutex.synchronize do
121
+ begin
122
+ @redis.call("FT.DROPINDEX", @index_name, "DD")
123
+ rescue => e
124
+ raise unless e.message.to_s.include?("Unknown Index name")
125
+ end
126
+ @index_created = false
127
+ end
128
+ self
129
+ end
130
+
131
+ private
132
+
133
+ def ensure_index!(dim)
134
+ @mutex.synchronize do
135
+ return if @index_created
136
+
137
+ @dimension ||= dim
138
+ begin
139
+ @redis.call(
140
+ "FT.CREATE", @index_name,
141
+ "ON", "HASH",
142
+ "PREFIX", 1, DOC_PREFIX,
143
+ "SCHEMA",
144
+ "embedding", "VECTOR", "FLAT", 6,
145
+ "TYPE", "FLOAT32",
146
+ "DIM", @dimension,
147
+ "DISTANCE_METRIC", "COSINE",
148
+ "metadata", "TEXT"
149
+ )
150
+ rescue => e
151
+ raise unless e.message.to_s.include?("Index already exists")
152
+ end
153
+ @index_created = true
154
+ end
155
+ end
156
+
157
+ # Pack a Float array as a FLOAT32 binary string for RediSearch.
158
+ def pack_vector(embedding)
159
+ embedding.map { |v| Float(v) }.pack("f*")
160
+ end
161
+
162
+ # Parse the raw FT.SEARCH response into the standard Hash format.
163
+ #
164
+ # Redis FT.SEARCH returns: [count, key1, [field, value, ...], key2, ...]
165
+ def parse_results(raw)
166
+ return [] if raw.nil? || !raw.is_a?(Array) || raw.size < 2
167
+
168
+ results = []
169
+ i = 1
170
+ while i < raw.size
171
+ key = raw[i]
172
+ fields = raw[i + 1]
173
+ i += 2
174
+
175
+ next unless fields.is_a?(Array)
176
+
177
+ field_hash = fields.each_slice(2).to_h
178
+ score_str = field_hash["score"]
179
+ metadata_str = field_hash["metadata"]
180
+
181
+ next if score_str.nil?
182
+
183
+ id = key.to_s.delete_prefix(DOC_PREFIX)
184
+ # RediSearch returns cosine distance (0=identical, 2=opposite);
185
+ # convert to cosine similarity for consistency with other backends.
186
+ score = 1.0 - score_str.to_f
187
+ metadata = metadata_str ? JSON.parse(metadata_str, symbolize_names: true) : {}
188
+
189
+ results << {id: id, score: score, metadata: metadata}
190
+ end
191
+ results
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end