phronomy 0.7.1 → 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.
- checksums.yaml +4 -4
- data/README.md +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +19 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +1 -0
- metadata +44 -42
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# A prompt template that substitutes {{variable}} placeholders in a string.
|
|
5
|
-
#
|
|
6
|
-
# @example Simple human template
|
|
7
|
-
# t = Phronomy::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
|
|
8
|
-
# t.format(lang: "French", text: "Hello")
|
|
9
|
-
# # => "Translate to French: Hello"
|
|
10
|
-
#
|
|
11
|
-
# @example With a system template
|
|
12
|
-
# t = Phronomy::PromptTemplate.new(
|
|
13
|
-
# template: "{{question}}",
|
|
14
|
-
# system_template: "You are a {{role}} assistant."
|
|
15
|
-
# )
|
|
16
|
-
# t.format_system(role: "helpful")
|
|
17
|
-
# # => "You are a helpful assistant."
|
|
18
|
-
#
|
|
19
|
-
# As a Runnable, #invoke accepts a Hash of variables and returns a Hash
|
|
20
|
-
# with :prompt (and optionally :system) keys.
|
|
21
|
-
class PromptTemplate
|
|
22
|
-
include Phronomy::Runnable
|
|
23
|
-
|
|
24
|
-
PLACEHOLDER = /\{\{(\w+)\}\}/
|
|
25
|
-
|
|
26
|
-
attr_reader :template, :system_template
|
|
27
|
-
|
|
28
|
-
# @param template [String] human message template with {{var}} placeholders
|
|
29
|
-
# @param system_template [String, nil] optional system message template
|
|
30
|
-
# @api public
|
|
31
|
-
def initialize(template:, system_template: nil)
|
|
32
|
-
@template = template
|
|
33
|
-
@system_template = system_template
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Substitute all {{var}} placeholders in the human template.
|
|
37
|
-
#
|
|
38
|
-
# @param variables [Hash{Symbol => String}]
|
|
39
|
-
# @return [String]
|
|
40
|
-
# @api public
|
|
41
|
-
def format(**variables)
|
|
42
|
-
substitute(@template, variables)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Substitute all {{var}} placeholders in the system template.
|
|
46
|
-
# Returns nil when no system template was set.
|
|
47
|
-
#
|
|
48
|
-
# @param variables [Hash{Symbol => String}]
|
|
49
|
-
# @return [String, nil]
|
|
50
|
-
# @api public
|
|
51
|
-
def format_system(**variables)
|
|
52
|
-
@system_template && substitute(@system_template, variables)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Runnable interface: accepts a Hash of variable values.
|
|
56
|
-
# Returns { prompt: String, system: String|nil }.
|
|
57
|
-
#
|
|
58
|
-
# @param input [Hash{Symbol => String}]
|
|
59
|
-
# @return [Hash]
|
|
60
|
-
# @api public
|
|
61
|
-
def invoke(input, config: {})
|
|
62
|
-
vars = normalize_input(input)
|
|
63
|
-
result = {prompt: format(**vars)}
|
|
64
|
-
sys = format_system(**vars)
|
|
65
|
-
result[:system] = sys if sys
|
|
66
|
-
result
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Returns the list of placeholder names found in both templates.
|
|
70
|
-
#
|
|
71
|
-
# @return [Array<Symbol>]
|
|
72
|
-
# @api public
|
|
73
|
-
def variables
|
|
74
|
-
names = @template.scan(PLACEHOLDER).flatten
|
|
75
|
-
names += @system_template.scan(PLACEHOLDER).flatten if @system_template
|
|
76
|
-
names.map(&:to_sym).uniq
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def substitute(text, variables)
|
|
82
|
-
text.gsub(PLACEHOLDER) do |match|
|
|
83
|
-
key = Regexp.last_match(1).to_sym
|
|
84
|
-
variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def normalize_input(input)
|
|
89
|
-
case input
|
|
90
|
-
when Hash then input
|
|
91
|
-
when String then {input: input}
|
|
92
|
-
else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Abstract base class for text splitters.
|
|
6
|
-
#
|
|
7
|
-
# A splitter takes a single document hash (or plain text) and returns an
|
|
8
|
-
# array of smaller chunk documents:
|
|
9
|
-
#
|
|
10
|
-
# [{ text: String, metadata: Hash }, ...]
|
|
11
|
-
#
|
|
12
|
-
# Subclasses must implement {#split}.
|
|
13
|
-
class Base
|
|
14
|
-
# Split +document+ into an array of chunk documents.
|
|
15
|
-
#
|
|
16
|
-
# @param document [Hash, String]
|
|
17
|
-
# Either a document hash (<tt>{ text: String, metadata: Hash }</tt>)
|
|
18
|
-
# returned by a Loader, or a plain String.
|
|
19
|
-
# @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
|
|
20
|
-
# @raise [NotImplementedError] when not overridden by a subclass
|
|
21
|
-
# @api public
|
|
22
|
-
def split(document)
|
|
23
|
-
raise NotImplementedError, "#{self.class}#split is not implemented"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Convenience method: split an array of documents.
|
|
27
|
-
#
|
|
28
|
-
# @param documents [Array<Hash, String>]
|
|
29
|
-
# @return [Array<Hash>]
|
|
30
|
-
# @api public
|
|
31
|
-
def split_all(documents)
|
|
32
|
-
documents.flat_map { |doc| split(doc) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
# Normalise a document-or-string argument into {text:, metadata:}.
|
|
38
|
-
def normalise(document)
|
|
39
|
-
case document
|
|
40
|
-
when Hash then {text: document[:text].to_s, metadata: document.fetch(:metadata, {})}
|
|
41
|
-
when String then {text: document, metadata: {}}
|
|
42
|
-
else raise ArgumentError, "document must be a Hash or String, got #{document.class}"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Splits text into fixed-size character chunks with optional overlap.
|
|
6
|
-
#
|
|
7
|
-
# @example
|
|
8
|
-
# splitter = Phronomy::Splitter::FixedSizeSplitter.new(chunk_size: 200, chunk_overlap: 20)
|
|
9
|
-
# chunks = splitter.split({ text: long_text, metadata: { source: "doc.txt" } })
|
|
10
|
-
# # => [
|
|
11
|
-
# # { text: "...(200 chars)...", metadata: { source: "doc.txt", chunk: 0 } },
|
|
12
|
-
# # { text: "...(200 chars, 20-char overlap)...", metadata: { source: "doc.txt", chunk: 1 } },
|
|
13
|
-
# # ]
|
|
14
|
-
class FixedSizeSplitter < Base
|
|
15
|
-
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
16
|
-
# @param chunk_overlap [Integer] characters to repeat at the start of each
|
|
17
|
-
# subsequent chunk (default: 200); must be less than chunk_size
|
|
18
|
-
# @api public
|
|
19
|
-
def initialize(chunk_size: 1000, chunk_overlap: 200)
|
|
20
|
-
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
21
|
-
|
|
22
|
-
@chunk_size = chunk_size
|
|
23
|
-
@chunk_overlap = chunk_overlap
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @param document [Hash, String]
|
|
27
|
-
# @return [Array<Hash>]
|
|
28
|
-
# @api public
|
|
29
|
-
def split(document)
|
|
30
|
-
doc = normalise(document)
|
|
31
|
-
text = doc[:text]
|
|
32
|
-
base_metadata = doc[:metadata]
|
|
33
|
-
|
|
34
|
-
chunks = []
|
|
35
|
-
start = 0
|
|
36
|
-
index = 0
|
|
37
|
-
|
|
38
|
-
while start < text.length
|
|
39
|
-
chunk_text = text[start, @chunk_size]
|
|
40
|
-
chunks << {text: chunk_text, metadata: base_metadata.merge(chunk: index)}
|
|
41
|
-
break if start + @chunk_size >= text.length
|
|
42
|
-
|
|
43
|
-
start += @chunk_size - @chunk_overlap
|
|
44
|
-
index += 1
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
chunks
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Splitter
|
|
5
|
-
# Splits text recursively using a prioritised list of separator strings.
|
|
6
|
-
#
|
|
7
|
-
# The splitter tries each separator in order. When a separator produces
|
|
8
|
-
# chunks that are still larger than +chunk_size+, it recurses with the
|
|
9
|
-
# next separator in the list. This mirrors LangChain's
|
|
10
|
-
# RecursiveCharacterTextSplitter behaviour.
|
|
11
|
-
#
|
|
12
|
-
# Default separators (in priority order):
|
|
13
|
-
# 1. "\n\n" — paragraph breaks
|
|
14
|
-
# 2. "\n" — line breaks
|
|
15
|
-
# 3. ". " — sentence boundaries
|
|
16
|
-
# 4. " " — word boundaries
|
|
17
|
-
# 5. "" — character-level fallback
|
|
18
|
-
#
|
|
19
|
-
# @example
|
|
20
|
-
# splitter = Phronomy::Splitter::RecursiveSplitter.new(chunk_size: 300, chunk_overlap: 30)
|
|
21
|
-
# chunks = splitter.split({ text: long_markdown, metadata: { source: "guide.md" } })
|
|
22
|
-
class RecursiveSplitter < Base
|
|
23
|
-
DEFAULT_SEPARATORS = ["\n\n", "\n", ". ", " ", ""].freeze
|
|
24
|
-
|
|
25
|
-
# @param chunk_size [Integer] maximum characters per chunk (default: 1000)
|
|
26
|
-
# @param chunk_overlap [Integer] overlap characters (default: 200)
|
|
27
|
-
# @param separators [Array<String>] separator list in priority order
|
|
28
|
-
# @api public
|
|
29
|
-
def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
|
|
30
|
-
raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
|
|
31
|
-
|
|
32
|
-
@chunk_size = chunk_size
|
|
33
|
-
@chunk_overlap = chunk_overlap
|
|
34
|
-
@separators = separators
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# @param document [Hash, String]
|
|
38
|
-
# @return [Array<Hash>]
|
|
39
|
-
# @api public
|
|
40
|
-
def split(document)
|
|
41
|
-
doc = normalise(document)
|
|
42
|
-
texts = recursive_split(doc[:text], @separators)
|
|
43
|
-
merge_with_overlap(texts).each_with_index.map do |text, idx|
|
|
44
|
-
{text: text, metadata: doc[:metadata].merge(chunk: idx)}
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
# Split +text+ using the first separator that yields non-trivial pieces,
|
|
51
|
-
# then recurse on any piece that is still too large.
|
|
52
|
-
def recursive_split(text, separators)
|
|
53
|
-
return [text] if text.length <= @chunk_size || separators.empty?
|
|
54
|
-
|
|
55
|
-
sep, *rest_seps = separators
|
|
56
|
-
|
|
57
|
-
# Character-level fallback: just slice
|
|
58
|
-
if sep == ""
|
|
59
|
-
return FixedSizeSplitter
|
|
60
|
-
.new(chunk_size: @chunk_size, chunk_overlap: @chunk_overlap)
|
|
61
|
-
.split(text)
|
|
62
|
-
.map { |c| c[:text] }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
parts = text.split(sep)
|
|
66
|
-
|
|
67
|
-
# If this separator doesn't split, try the next
|
|
68
|
-
return recursive_split(text, rest_seps) if parts.length <= 1
|
|
69
|
-
|
|
70
|
-
# Re-attach the separator to each part except the last so context is preserved
|
|
71
|
-
parts_with_sep = parts.each_with_index.map do |part, i|
|
|
72
|
-
(i < parts.length - 1) ? part + sep : part
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
parts_with_sep.flat_map do |part|
|
|
76
|
-
if part.length > @chunk_size
|
|
77
|
-
recursive_split(part, rest_seps)
|
|
78
|
-
else
|
|
79
|
-
[part]
|
|
80
|
-
end
|
|
81
|
-
end.reject { |t| t.strip.empty? }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Merge small adjacent pieces and apply overlap between chunks.
|
|
85
|
-
def merge_with_overlap(texts)
|
|
86
|
-
merged = []
|
|
87
|
-
current = +""
|
|
88
|
-
|
|
89
|
-
texts.each do |text|
|
|
90
|
-
if current.length + text.length <= @chunk_size
|
|
91
|
-
current << text
|
|
92
|
-
else
|
|
93
|
-
merged << current.strip unless current.strip.empty?
|
|
94
|
-
# Start next chunk with overlap from the end of current
|
|
95
|
-
overlap_text = (current.length > @chunk_overlap) ? current[-@chunk_overlap..] : current
|
|
96
|
-
current = overlap_text + text
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
merged << current.strip unless current.strip.empty?
|
|
101
|
-
merged
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Centralises tool execution routing based on {Tool::Base.execution_mode}.
|
|
5
|
-
#
|
|
6
|
-
# This is the single place in the framework that decides *how* a tool call is
|
|
7
|
-
# dispatched:
|
|
8
|
-
#
|
|
9
|
-
# - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
|
|
10
|
-
# scheduler. Under the +:fiber+ backend this avoids an
|
|
11
|
-
# extra OS thread; under the +:thread+ backend it is
|
|
12
|
-
# backed by +ThreadScheduler+ (one thread per task).
|
|
13
|
-
# - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
|
|
14
|
-
# provides a pool; falls back to +Runtime#spawn+ otherwise.
|
|
15
|
-
# - +:cpu_bound+ — emits a deprecation-style warning then falls back to
|
|
16
|
-
# +:blocking_io+ routing (no process pool available yet).
|
|
17
|
-
# - +:external_process+ — falls back to +:blocking_io+ routing (no process
|
|
18
|
-
# manager available yet).
|
|
19
|
-
#
|
|
20
|
-
# All paths return an object that responds to +#await+ (+Phronomy::Task+ or
|
|
21
|
-
# +BlockingAdapterPool::PendingOperation+), so callers can collect results
|
|
22
|
-
# uniformly.
|
|
23
|
-
#
|
|
24
|
-
# @note Non-goals
|
|
25
|
-
# ToolExecutor deliberately does NOT provide:
|
|
26
|
-
# - A CPU-bound process pool. CPU-intensive tool work must be handled at the
|
|
27
|
-
# application layer (e.g., fork, Sidekiq, separate OS processes). The
|
|
28
|
-
# framework will not add a +ProcessPoolExecutor+ equivalent.
|
|
29
|
-
# - An external process manager. Spawning or supervising subprocesses is
|
|
30
|
-
# out of scope for this module.
|
|
31
|
-
# - Additional core execution routes beyond scheduler-backed cooperative
|
|
32
|
-
# execution and BlockingAdapterPool-backed blocking I/O isolation.
|
|
33
|
-
# The +:cpu_bound+ and +:external_process+ modes are accepted for
|
|
34
|
-
# compatibility but both fall back to +:blocking_io+ routing with a
|
|
35
|
-
# one-time warning. If a genuinely new core execution route is needed,
|
|
36
|
-
# a new ADR is required.
|
|
37
|
-
# These non-goals follow from the cooperative-first, non-preemptive
|
|
38
|
-
# concurrency model (ADR-010): framework components must not assume the
|
|
39
|
-
# caller's concurrency model, and CPU/process management belongs to the
|
|
40
|
-
# application layer.
|
|
41
|
-
#
|
|
42
|
-
# @api private
|
|
43
|
-
module ToolExecutor
|
|
44
|
-
# Tracks tool classes that have already emitted an execution_mode warning so
|
|
45
|
-
# that the same warning is only logged once per process lifetime.
|
|
46
|
-
WARNED_MODES = Set.new
|
|
47
|
-
WARNED_MODES_MUTEX = Mutex.new
|
|
48
|
-
private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
|
|
49
|
-
|
|
50
|
-
# Dispatches a single tool call asynchronously according to its
|
|
51
|
-
# +execution_mode+ and returns an awaitable.
|
|
52
|
-
#
|
|
53
|
-
# @param tool [Phronomy::Tool::Base] the tool instance to invoke
|
|
54
|
-
# @param args [Hash] argument hash to pass to {Tool::Base#call}
|
|
55
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
56
|
-
# @param runtime [Phronomy::Runtime] runtime to use for spawning
|
|
57
|
-
# (defaults to {Runtime.instance}; injectable for tests)
|
|
58
|
-
# @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
|
|
59
|
-
# @api private
|
|
60
|
-
def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
|
|
61
|
-
ct = cancellation_token
|
|
62
|
-
mode = tool.class.execution_mode
|
|
63
|
-
|
|
64
|
-
# Warn and normalise unsupported modes to :blocking_io.
|
|
65
|
-
# Each (tool class, mode) pair emits the warning at most once per process
|
|
66
|
-
# lifetime to avoid log flooding in high-throughput scenarios.
|
|
67
|
-
if mode == :cpu_bound || mode == :external_process
|
|
68
|
-
warn_key = [tool.class.name, mode]
|
|
69
|
-
newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
|
|
70
|
-
if newly_warned
|
|
71
|
-
msg = if mode == :cpu_bound
|
|
72
|
-
"[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
|
|
73
|
-
"which has no dedicated executor. " \
|
|
74
|
-
"Falling back to blocking_io (BlockingAdapterPool). " \
|
|
75
|
-
"Use :blocking_io explicitly to suppress this warning."
|
|
76
|
-
else
|
|
77
|
-
"[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
|
|
78
|
-
"which has no dedicated process manager. " \
|
|
79
|
-
"Falling back to blocking_io (BlockingAdapterPool)."
|
|
80
|
-
end
|
|
81
|
-
if Phronomy.configuration.logger
|
|
82
|
-
Phronomy.configuration.logger.warn(msg)
|
|
83
|
-
else
|
|
84
|
-
warn msg
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
mode = :blocking_io
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
pool = begin
|
|
91
|
-
runtime&.blocking_io
|
|
92
|
-
rescue
|
|
93
|
-
nil
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
if mode == :cooperative || pool.nil?
|
|
97
|
-
runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
|
|
98
|
-
tool.call(args, cancellation_token: ct)
|
|
99
|
-
end
|
|
100
|
-
else
|
|
101
|
-
# Submit directly to pool — no wrapping Task thread required.
|
|
102
|
-
pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
@@ -1,110 +0,0 @@
|
|
|
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::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::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::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::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
|
|
@@ -1,89 +0,0 @@
|
|
|
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::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::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
|