phronomy 0.8.0 → 0.9.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -41
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +9 -1
  6. data/benchmark/bench_regression.rb +8 -8
  7. data/benchmark/bench_tool_schema.rb +2 -2
  8. data/benchmark/bench_vector_store.rb +1 -1
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +253 -351
  11. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  12. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  13. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  14. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  15. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  17. data/lib/phronomy/agent/invocation_pipeline.rb +10 -1
  18. data/lib/phronomy/agent/react_agent.rb +24 -23
  19. data/lib/phronomy/agent/shared_state.rb +2 -2
  20. data/lib/phronomy/agent/tool_executor.rb +1 -1
  21. data/lib/phronomy/concurrency/gate_registry.rb +0 -1
  22. data/lib/phronomy/configuration.rb +0 -6
  23. data/lib/phronomy/llm_context_window/assembler.rb +77 -44
  24. data/lib/phronomy/multi_agent/handoff.rb +4 -4
  25. data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
  26. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
  27. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  28. data/lib/phronomy/runtime.rb +1 -2
  29. data/lib/phronomy/tool.rb +3 -4
  30. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  31. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  32. data/lib/phronomy/tools/vector_search.rb +70 -0
  33. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  34. data/lib/phronomy/vector_store/base.rb +89 -0
  35. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  36. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  37. data/lib/phronomy/vector_store/in_memory.rb +103 -0
  38. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  39. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  40. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  41. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  42. data/lib/phronomy/vector_store/pgvector.rb +127 -0
  43. data/lib/phronomy/vector_store/redis_search.rb +192 -0
  44. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  45. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  46. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  47. data/lib/phronomy/vector_store.rb +16 -4
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy.rb +2 -1
  50. data/scripts/api_snapshot.rb +11 -9
  51. metadata +28 -32
  52. data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
  53. data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
  54. data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
  55. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
  56. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
  57. data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
  58. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
  59. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
  60. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
  61. data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
  62. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
  63. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
  64. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
  65. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
  66. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
  67. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
  68. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
  69. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
  70. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
  71. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
  72. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
  73. data/lib/phronomy/embeddings.rb +0 -11
  74. data/lib/phronomy/loader.rb +0 -13
  75. data/lib/phronomy/splitter.rb +0 -12
  76. data/lib/phronomy/tool/base.rb +0 -685
  77. data/lib/phronomy/tool/scope_policy.rb +0 -50
@@ -135,7 +135,7 @@ module Phronomy
135
135
  # is first accessed; subsequent calls return the cached gate. To change the
136
136
  # cap at runtime, call {#reset_gate} first.
137
137
  #
138
- # @param name [:agent, :tool, :workflow, :llm, :rag, :vector] resource name
138
+ # @param name [:agent, :tool, :workflow, :llm, :vector] resource name
139
139
  # @return [ConcurrencyGate]
140
140
  # @api private
141
141
  def gate(name)
@@ -279,7 +279,6 @@ module Phronomy
279
279
  # | `active_agent_tasks` | currently running agent spawns |
280
280
  # | `active_tool_tasks` | currently running tool spawns |
281
281
  # | `active_workflow_tasks` | currently running workflow spawns |
282
- # | `active_rag_tasks` | currently running RAG fetches |
283
282
  # | `active_llm_tasks` | currently running LLM calls |
284
283
  # | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
285
284
  # | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
data/lib/phronomy/tool.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "tool/base"
4
- require_relative "tool/mcp_tool"
5
- require_relative "tool/agent_tool"
6
-
3
+ # This file is intentionally empty.
4
+ # Tool definitions have moved to Phronomy::Agent::Context::Capability.
5
+ # See lib/phronomy/agent/context/capability/.
7
6
  module Phronomy
8
7
  module Tool
9
8
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Tool
4
+ module Tools
5
5
  # Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
6
6
  # ReactAgent (or any agent that supports tools) can delegate sub-tasks to a
7
7
  # fully-capable agent.
8
8
  #
9
- # Use AgentTool.from_agent to generate a concrete tool class. The generated
9
+ # Use Agent.from_agent to generate a concrete tool class. The generated
10
10
  # class is anonymous; assign it to a constant when you need a stable name.
11
11
  #
12
12
  # @example Wrap an existing agent
13
- # SummarizerTool = Phronomy::Tool::AgentTool.from_agent(
13
+ # SummarizerTool = Phronomy::Tools::Agent.from_agent(
14
14
  # SummarizerAgent,
15
15
  # tool_name: "summarize",
16
16
  # description: "Summarizes a long text and returns a brief summary"
@@ -21,12 +21,12 @@ module Phronomy
21
21
  # instructions "You are an orchestrator that delegates to specialist agents."
22
22
  # tools SummarizerTool
23
23
  # end
24
- class AgentTool < Phronomy::Tool::Base
24
+ class Agent < Phronomy::Agent::Context::Capability::Base
25
25
  description "Wraps an agent as a tool"
26
26
  param :input, type: :string, desc: "The input to forward to the wrapped agent"
27
27
 
28
28
  class << self
29
- # Generates a Phronomy::Tool::AgentTool subclass that delegates #execute to
29
+ # Generates a Phronomy::Tools::Agent subclass that delegates #execute to
30
30
  # an instance of +agent_class+.
31
31
  #
32
32
  # @param agent_class [Class] a Phronomy::Agent::Base subclass
@@ -34,7 +34,7 @@ module Phronomy
34
34
  # defaults to a snake_case derivation of the agent class name
35
35
  # @param description [String, nil] description exposed to the LLM;
36
36
  # defaults to "Delegates to <AgentClassName>"
37
- # @return [Class] an anonymous Phronomy::Tool::AgentTool subclass
37
+ # @return [Class] an anonymous Phronomy::Tools::Agent subclass
38
38
  # @api public
39
39
  def from_agent(agent_class, tool_name: nil, description: nil)
40
40
  raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
@@ -8,8 +8,8 @@ require "shellwords"
8
8
  require "uri"
9
9
 
10
10
  module Phronomy
11
- module Tool
12
- # A Phronomy::Tool::Base subclass that wraps a tool exposed by an external
11
+ module Tools
12
+ # A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
13
13
  # MCP (Model Context Protocol) server.
14
14
  #
15
15
  # Supports two transport schemes:
@@ -19,15 +19,15 @@ module Phronomy
19
19
  # HTTP/SSE MCP server using +net/http+.
20
20
  #
21
21
  # @example
22
- # web_search = Phronomy::Tool::McpTool.from_server(
22
+ # web_search = Phronomy::Tools::Mcp.from_server(
23
23
  # "stdio://./mcp-server",
24
24
  # tool_name: "search_web"
25
25
  # )
26
26
  # agent = MyAgent.new
27
27
  # agent_class.tools(web_search)
28
- class McpTool < Base
28
+ class Mcp < Phronomy::Agent::Context::Capability::Base
29
29
  class << self
30
- # Build a McpTool instance by querying a running MCP server for the
30
+ # Build a Mcp instance by querying a running MCP server for the
31
31
  # tool definition identified by +tool_name+.
32
32
  #
33
33
  # @param server_uri [String] URI of the MCP server.
@@ -35,11 +35,11 @@ module Phronomy
35
35
  # - "stdio://<command>" — spawn a child process
36
36
  # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
37
  # @param tool_name [String] the tool name as registered in the MCP server
38
- # @return [McpTool] a configured subclass instance ready for use with an Agent
38
+ # @return [Mcp] a configured subclass instance ready for use with an Agent
39
39
  # @api public
40
40
  def from_server(server_uri, tool_name:)
41
41
  # Use a short-lived transport only to query the tool definition,
42
- # then close it. Each McpTool instance creates its own transport
42
+ # then close it. Each Mcp instance creates its own transport
43
43
  # so that concurrent callers never share IO streams.
44
44
  transport = build_transport(server_uri)
45
45
  begin
@@ -65,7 +65,7 @@ module Phronomy
65
65
  end
66
66
 
67
67
  def build_tool_class(tool_name, server_uri, tool_def)
68
- klass = Class.new(McpTool)
68
+ klass = Class.new(Mcp)
69
69
  klass.tool_name(tool_name)
70
70
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
71
71
 
@@ -289,7 +289,7 @@ module Phronomy
289
289
  # both the 2024-11-05 and 2025-03-26 MCP HTTP transport specifications.
290
290
  #
291
291
  # @example
292
- # tool = Phronomy::Tool::McpTool.from_server(
292
+ # tool = Phronomy::Tools::Mcp.from_server(
293
293
  # "http://localhost:8080/mcp",
294
294
  # tool_name: "weather_lookup"
295
295
  # )
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Tools
5
+ # A Capability::Base subclass that wraps a {Phronomy::VectorStore::Base} and
6
+ # a {Phronomy::VectorStore::Embeddings::Base} adapter so that an agent can
7
+ # perform semantic search as a tool call.
8
+ #
9
+ # Do not instantiate this class directly. Use the factory method
10
+ # {.from_store} to produce a configured subclass, then pass it to your agent.
11
+ #
12
+ # @example
13
+ # store = Phronomy::VectorStore::InMemory.new
14
+ # emb = Phronomy::VectorStore::Embeddings::RubyLLMEmbeddings.new(model: "...")
15
+ # tool = Phronomy::Tools::VectorSearch.from_store(store, embeddings: emb,
16
+ # k: 3, tool_name: "search_docs",
17
+ # description: "Search the company knowledge base.")
18
+ # agent = MyAgent.new
19
+ # agent.tools tool
20
+ #
21
+ # @api public
22
+ class VectorSearch < Phronomy::Agent::Context::Capability::Base
23
+ description "Search for relevant documents using semantic similarity."
24
+ param :query, type: :string, desc: "The natural-language search query"
25
+
26
+ class << self
27
+ # Build a VectorSearch tool backed by the given store and embeddings adapter.
28
+ #
29
+ # @param store [Phronomy::VectorStore::Base]
30
+ # @param embeddings [Phronomy::VectorStore::Embeddings::Base]
31
+ # @param k [Integer] number of results to return (default 5)
32
+ # @param tool_name [String] name exposed to the LLM
33
+ # @param description [String, nil] optional description override
34
+ # @return [Class] anonymous subclass of VectorSearch configured with the given store
35
+ # @api public
36
+ def from_store(store, embeddings:, k: 5, tool_name: "vector_search", description: nil)
37
+ klass = Class.new(self)
38
+ klass.tool_name(tool_name)
39
+ klass.description(description || "Search the vector store for documents similar to the query.")
40
+
41
+ klass.define_method(:initialize) do
42
+ @store = store
43
+ @embeddings = embeddings
44
+ @k = k
45
+ end
46
+
47
+ klass.define_method(:execute) do |query:|
48
+ embedding = @embeddings.embed(query)
49
+ results = @store.search(query_embedding: embedding, k: @k)
50
+ return "No results found." if results.empty?
51
+
52
+ results.map.with_index(1) do |r, i|
53
+ content = r.dig(:metadata, :content) ||
54
+ r.dig(:metadata, :text) ||
55
+ r[:metadata].to_s
56
+ "[#{i}] (score: #{r[:score].round(3)}) #{content}"
57
+ end.join("\n")
58
+ end
59
+
60
+ klass
61
+ end
62
+ end
63
+
64
+ # @api public
65
+ def execute(query:)
66
+ raise NotImplementedError, "Use VectorSearch.from_store to create a configured instance"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ # Mixin that defines the async interface for VectorStore backends.
6
+ #
7
+ # Mixing this module into a VectorStore class provides three choices:
8
+ #
9
+ # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
+ # that route through {BlockingAdapterPool} (the previous behaviour).
11
+ #
12
+ # 2. **Override selectively** — override only the async methods where the
13
+ # backend has a native async driver, while the remaining methods fall back
14
+ # to the pool.
15
+ #
16
+ # 3. **Implement all natively** — override all async methods to avoid pool
17
+ # allocation entirely.
18
+ #
19
+ # @example Native async search (no pool worker thread allocated)
20
+ # class MyFastStore < Phronomy::VectorStore::Base
21
+ # include Phronomy::VectorStore::AsyncBackend
22
+ #
23
+ # def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
24
+ # # Returns a PendingOperation backed by a native async driver.
25
+ # native_async_search(query_embedding, k)
26
+ # end
27
+ # end
28
+ #
29
+ # @api public
30
+ module AsyncBackend
31
+ # Async variant of {VectorStore::Base#add}.
32
+ #
33
+ # Submits the add call to {BlockingAdapterPool} by default.
34
+ # Override to use a native async driver.
35
+ #
36
+ # @param id [String]
37
+ # @param embedding [Array<Float>]
38
+ # @param metadata [Hash]
39
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
40
+ # @param timeout [Numeric, nil]
41
+ # @return [BlockingAdapterPool::PendingOperation]
42
+ # @api public
43
+ def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
+ Phronomy::Runtime.instance.blocking_io.submit(
45
+ timeout: timeout,
46
+ cancellation_token: cancellation_token
47
+ ) do
48
+ add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
+ end
50
+ end
51
+
52
+ # Async variant of {VectorStore::Base#search}.
53
+ #
54
+ # Submits the search call to {BlockingAdapterPool} by default.
55
+ # Override to use a native async driver.
56
+ #
57
+ # @param query_embedding [Array<Float>]
58
+ # @param k [Integer]
59
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
60
+ # @param timeout [Numeric, nil]
61
+ # @return [BlockingAdapterPool::PendingOperation]
62
+ # @api public
63
+ def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
+ Phronomy::Runtime.instance.blocking_io.submit(
65
+ timeout: timeout,
66
+ cancellation_token: cancellation_token
67
+ ) do
68
+ search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
+ end
70
+ end
71
+
72
+ # Async variant of {VectorStore::Base#remove}.
73
+ #
74
+ # Submits the remove call to {BlockingAdapterPool} by default.
75
+ # Override to use a native async driver.
76
+ #
77
+ # @param id [String]
78
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
79
+ # @param timeout [Numeric, nil]
80
+ # @return [BlockingAdapterPool::PendingOperation]
81
+ # @api public
82
+ def remove_async(id:, cancellation_token: nil, timeout: nil)
83
+ Phronomy::Runtime.instance.blocking_io.submit(
84
+ timeout: timeout,
85
+ cancellation_token: cancellation_token
86
+ ) do
87
+ remove(id: id)
88
+ end
89
+ end
90
+
91
+ # Async variant of {VectorStore::Base#clear}.
92
+ #
93
+ # Submits the clear call to {BlockingAdapterPool} by default.
94
+ # Override to use a native async driver.
95
+ #
96
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
97
+ # @param timeout [Numeric, nil]
98
+ # @return [BlockingAdapterPool::PendingOperation]
99
+ # @api public
100
+ def clear_async(cancellation_token: nil, timeout: nil)
101
+ Phronomy::Runtime.instance.blocking_io.submit(
102
+ timeout: timeout,
103
+ cancellation_token: cancellation_token
104
+ ) do
105
+ clear
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ # Abstract interface for vector stores.
6
+ #
7
+ # Implementations manage a collection of (embedding, metadata) pairs and
8
+ # support similarity search.
9
+ #
10
+ # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
11
+ # are provided by the {AsyncBackend} mixin which defaults to routing calls
12
+ # through {BlockingAdapterPool}. Backends with native async drivers may
13
+ # override individual async methods without touching the pool at all.
14
+ class Base
15
+ include AsyncBackend
16
+
17
+ # Add a document with its vector embedding.
18
+ #
19
+ # @param id [String] unique document identifier
20
+ # @param embedding [Array<Float>] vector embedding
21
+ # @param metadata [Hash] arbitrary metadata (e.g. the original message object)
22
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
23
+ # @api public
24
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
25
+ cancellation_token&.raise_if_cancelled!
26
+ raise NotImplementedError, "#{self.class}#add is not implemented"
27
+ end
28
+
29
+ # Return the k most similar documents to the query embedding.
30
+ #
31
+ # @param query_embedding [Array<Float>]
32
+ # @param k [Integer] number of results
33
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
34
+ # @return [Array<Hash>] each element: { id:, score:, metadata: }
35
+ # @api public
36
+ def search(query_embedding:, k: 5, cancellation_token: nil)
37
+ cancellation_token&.raise_if_cancelled!
38
+ raise NotImplementedError, "#{self.class}#search is not implemented"
39
+ end
40
+
41
+ # Remove a single document by id.
42
+ #
43
+ # @param id [String] document identifier
44
+ # @api public
45
+ def remove(id:)
46
+ raise NotImplementedError, "#{self.class}#remove is not implemented"
47
+ end
48
+
49
+ # Remove all documents.
50
+ def clear
51
+ raise NotImplementedError, "#{self.class}#clear is not implemented"
52
+ end
53
+
54
+ # Return the number of documents stored.
55
+ #
56
+ # @return [Integer]
57
+ # @api public
58
+ def size
59
+ raise NotImplementedError, "#{self.class}#size is not implemented"
60
+ end
61
+
62
+ private
63
+
64
+ # Validates that embedding has the expected dimension.
65
+ # Raises ArgumentError if sizes differ.
66
+ # A nil expected_dimension is a no-op (dimension not yet established).
67
+ def validate_embedding_dimension!(embedding, expected_dimension)
68
+ return unless expected_dimension
69
+
70
+ actual = embedding.size
71
+ return if actual == expected_dimension
72
+
73
+ raise ArgumentError,
74
+ "Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
75
+ end
76
+
77
+ # Validates that k is a positive integer.
78
+ # Accepts any value accepted by Integer() (e.g. "5"), but raises
79
+ # ArgumentError for non-integer strings, zero, and negative values.
80
+ def validate_k!(k)
81
+ int_k = Integer(k)
82
+ raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
83
+ int_k
84
+ rescue ArgumentError => e
85
+ raise ArgumentError, "k must be a positive integer: #{e.message}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ module Embeddings
6
+ # Abstract interface for embedding adapters.
7
+ #
8
+ # Concrete implementations must override {#embed} and return a vector
9
+ # as an +Array<Float>+.
10
+ class Base
11
+ # Embed the given text and return a vector representation.
12
+ #
13
+ # @param text [String] the text to embed
14
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
15
+ # @return [Array<Float>] the embedding vector
16
+ # @api public
17
+ def embed(text, cancellation_token = nil)
18
+ cancellation_token&.raise_if_cancelled!
19
+ raise NotImplementedError, "#{self.class}#embed is not implemented"
20
+ end
21
+
22
+ # Submits an {#embed} call to {BlockingAdapterPool} and returns a
23
+ # {BlockingAdapterPool::PendingOperation}.
24
+ #
25
+ # @param text [String]
26
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
27
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
28
+ # @return [BlockingAdapterPool::PendingOperation]
29
+ # @api public
30
+ def embed_async(text, cancellation_token = nil, timeout: nil)
31
+ Phronomy::Runtime.instance.blocking_io.submit(
32
+ timeout: timeout,
33
+ cancellation_token: cancellation_token
34
+ ) do
35
+ embed(text, cancellation_token)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ module Embeddings
6
+ # Embeddings adapter backed by RubyLLM.
7
+ #
8
+ # Delegates to +RubyLLM.embed+ and returns the resulting vector as an
9
+ # +Array<Float>+.
10
+ #
11
+ # @example Default model
12
+ # embeddings = Phronomy::VectorStore::Embeddings::RubyLLMEmbeddings.new
13
+ # vector = embeddings.embed("Hello, world!")
14
+ #
15
+ # @example Explicit model
16
+ # embeddings = Phronomy::VectorStore::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
17
+ # vector = embeddings.embed("Hello, world!")
18
+ class RubyLLMEmbeddings < Base
19
+ # @param model [String, nil] embedding model identifier; nil uses the RubyLLM default
20
+ # @param provider [Symbol, nil] provider override (e.g. :openai); nil uses the RubyLLM default
21
+ # @param assume_model_exists [Boolean] when true, skips RubyLLM model-registry validation
22
+ # (useful for locally hosted models not in the registry)
23
+ # @api public
24
+ def initialize(model: nil, provider: nil, assume_model_exists: false)
25
+ @model = model
26
+ @provider = provider
27
+ @assume_model_exists = assume_model_exists
28
+ end
29
+
30
+ # Embed text via RubyLLM.
31
+ #
32
+ # @param text [String]
33
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
34
+ # @return [Array<Float>]
35
+ # @api public
36
+ def embed(text, cancellation_token = nil)
37
+ cancellation_token&.raise_if_cancelled!
38
+ opts = {}
39
+ opts[:model] = @model if @model
40
+ opts[:provider] = @provider if @provider
41
+ opts[:assume_model_exists] = true if @assume_model_exists
42
+ RubyLLM.embed(text, **opts).vectors
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ # Pure-Ruby in-memory vector store using cosine similarity.
6
+ #
7
+ # Intended for tests, short-lived agents, and Retrieval::Semantic scenarios where
8
+ # the message count is small enough that a linear scan is fast enough.
9
+ #
10
+ # @example
11
+ # store = Phronomy::VectorStore::InMemory.new
12
+ # store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
13
+ # results = store.search(query_embedding: [0.1, 0.8], k: 3)
14
+ class InMemory < Base
15
+ # @param dimension [Integer, nil] expected embedding dimension.
16
+ # When nil, the dimension is inferred from the first call to #add.
17
+ # For multi-threaded use, pass dimension: explicitly; concurrent first
18
+ # adds are not guaranteed to be race-free.
19
+ # @api public
20
+ def initialize(dimension: nil)
21
+ @documents = {}
22
+ @expected_dimension = dimension
23
+ end
24
+
25
+ # @param id [String]
26
+ # @param embedding [Array<Float>]
27
+ # @param metadata [Hash]
28
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
29
+ # @api public
30
+ def add(id:, embedding:, metadata: {}, cancellation_token: nil)
31
+ cancellation_token&.raise_if_cancelled!
32
+ # Establish expected dimension on first add, then validate.
33
+ @expected_dimension ||= embedding.size
34
+ validate_embedding_dimension!(embedding, @expected_dimension)
35
+ @documents[id] = {embedding: embedding, metadata: metadata}
36
+ self
37
+ end
38
+
39
+ # @param query_embedding [Array<Float>]
40
+ # @param k [Integer]
41
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
42
+ # @return [Array<Hash>] sorted by descending score
43
+ # @api public
44
+ # mutant:disable - genuine equivalent mutations: doc.fetch(:embedding) vs doc[:embedding] (key
45
+ # always present); {id:, score:, metadata: doc.fetch(:metadata)} shorthand+fetch vs []
46
+ # (key always present); -r.fetch(:score) vs -r[:score] (key always present); snapshot = @documents
47
+ # vs .dup is equivalent in single-threaded tests (GVL makes Hash#dup atomic, no behaviour
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)
52
+ # search never establishes dimension; validate only when dimension is known.
53
+ validate_embedding_dimension!(query_embedding, @expected_dimension)
54
+ # Take an atomic snapshot before iterating. Hash#dup is a C-level
55
+ # call that completes without releasing the GVL, so it is atomic with
56
+ # respect to any other Ruby thread. Iterating the copy instead of
57
+ # @documents directly prevents "can't add a new key into hash during
58
+ # iteration" when a concurrent thread calls #add.
59
+ snapshot = @documents.dup
60
+ results = snapshot.map do |id, doc|
61
+ score = cosine_similarity(query_embedding, doc[:embedding])
62
+ {id: id, score: score, metadata: doc[:metadata]}
63
+ end
64
+ results.sort_by { |r| -r[:score] }.first(k)
65
+ end
66
+
67
+ def remove(id:)
68
+ @documents.delete(id)
69
+ self
70
+ end
71
+
72
+ def clear
73
+ @documents.clear
74
+ self
75
+ end
76
+
77
+ # @return [Integer] number of documents stored
78
+ # @api public
79
+ def size
80
+ @documents.size
81
+ end
82
+
83
+ private
84
+
85
+ # mutant:disable - empty-vector early-return condition variants (if false, if nil, if a.empty?,
86
+ # if b.empty?, if a.empty? && b.empty?, if a.empty? || false, if false || b.empty?,
87
+ # if nil || b.empty?, if nil && b.empty?) are genuine equivalents: dimension validation in
88
+ # #add and #search enforces same-size embeddings, so a.empty? iff b.empty?; when both are
89
+ # empty norm_a = sqrt(0) = 0 so the norm_a.zero? guard returns 0.0 anyway
90
+ def cosine_similarity(a, b)
91
+ return 0.0 if a.empty? || b.empty?
92
+
93
+ dot = a.zip(b).sum { |x, y| x * y }
94
+ norm_a = Math.sqrt(a.sum { |x| x**2 })
95
+ norm_b = Math.sqrt(b.sum { |x| x**2 })
96
+
97
+ return 0.0 if norm_a.zero? || norm_b.zero?
98
+
99
+ dot / (norm_a * norm_b)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ module Loader
6
+ # Abstract base class for document loaders.
7
+ #
8
+ # A loader converts an external source (file path, URL, etc.) into an
9
+ # Array of document hashes understood by the rest of the pipeline:
10
+ #
11
+ # [{ text: String, metadata: Hash }, ...]
12
+ #
13
+ # Subclasses must implement {#load}.
14
+ class Base
15
+ # Load documents from +source+ and return an array of document hashes.
16
+ #
17
+ # @param source [String] file path, URL, or other source identifier
18
+ # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
19
+ # @raise [NotImplementedError] when not overridden by a subclass
20
+ # @api public
21
+ def load(source)
22
+ raise NotImplementedError, "#{self.class}#load is not implemented"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end