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.
- checksums.yaml +4 -4
- data/README.md +31 -41
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +9 -1
- data/benchmark/bench_regression.rb +8 -8
- data/benchmark/bench_tool_schema.rb +2 -2
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +253 -351
- data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
- data/lib/phronomy/agent/context/capability/base.rb +689 -0
- data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
- data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/invocation_pipeline.rb +10 -1
- data/lib/phronomy/agent/react_agent.rb +24 -23
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +1 -1
- data/lib/phronomy/concurrency/gate_registry.rb +0 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/llm_context_window/assembler.rb +77 -44
- data/lib/phronomy/multi_agent/handoff.rb +4 -4
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +1 -2
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +89 -0
- data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
- data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
- data/lib/phronomy/vector_store/in_memory.rb +103 -0
- data/lib/phronomy/vector_store/loader/base.rb +27 -0
- data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
- data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
- data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
- data/lib/phronomy/vector_store/pgvector.rb +127 -0
- data/lib/phronomy/vector_store/redis_search.rb +192 -0
- data/lib/phronomy/vector_store/splitter/base.rb +49 -0
- data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
- data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
- data/lib/phronomy/vector_store.rb +16 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +2 -1
- data/scripts/api_snapshot.rb +11 -9
- metadata +28 -32
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
- data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
- data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -685
- data/lib/phronomy/tool/scope_policy.rb +0 -50
data/lib/phronomy/runtime.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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::
|
|
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
|
|
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::
|
|
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::
|
|
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
|
|
12
|
-
# A Phronomy::
|
|
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::
|
|
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
|
|
28
|
+
class Mcp < Phronomy::Agent::Context::Capability::Base
|
|
29
29
|
class << self
|
|
30
|
-
# Build a
|
|
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 [
|
|
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
|
|
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(
|
|
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::
|
|
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
|