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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -1,93 +0,0 @@
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::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::CancellationToken, nil]
42
- # @return [Array<Hash>] sorted by descending score
43
- # @api public
44
- def search(query_embedding:, k: 5, cancellation_token: nil)
45
- cancellation_token&.raise_if_cancelled!
46
- k = validate_k!(k)
47
- # search never establishes dimension; validate only when dimension is known.
48
- validate_embedding_dimension!(query_embedding, @expected_dimension)
49
- # Take an atomic snapshot before iterating. Hash#dup is a C-level
50
- # call that completes without releasing the GVL, so it is atomic with
51
- # respect to any other Ruby thread. Iterating the copy instead of
52
- # @documents directly prevents "can't add a new key into hash during
53
- # iteration" when a concurrent thread calls #add.
54
- snapshot = @documents.dup
55
- results = snapshot.map do |id, doc|
56
- score = cosine_similarity(query_embedding, doc[:embedding])
57
- {id: id, score: score, metadata: doc[:metadata]}
58
- end
59
- results.sort_by { |r| -r[:score] }.first(k)
60
- end
61
-
62
- def remove(id:)
63
- @documents.delete(id)
64
- self
65
- end
66
-
67
- def clear
68
- @documents.clear
69
- self
70
- end
71
-
72
- # @return [Integer] number of documents stored
73
- # @api public
74
- def size
75
- @documents.size
76
- end
77
-
78
- private
79
-
80
- def cosine_similarity(a, b)
81
- return 0.0 if a.empty? || b.empty?
82
-
83
- dot = a.zip(b).sum { |x, y| x * y }
84
- norm_a = Math.sqrt(a.sum { |x| x**2 })
85
- norm_b = Math.sqrt(b.sum { |x| x**2 })
86
-
87
- return 0.0 if norm_a.zero? || norm_b.zero?
88
-
89
- dot / (norm_a * norm_b)
90
- end
91
- end
92
- end
93
- end
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Phronomy
6
- module VectorStore
7
- # PostgreSQL-backed vector store using the pgvector extension.
8
- #
9
- # Requires:
10
- # - The +pgvector+ gem (add to your Gemfile)
11
- # - An ActiveRecord model class with the following columns:
12
- # id (string / uuid)
13
- # embedding (vector — from the pgvector column type)
14
- # metadata (text or jsonb — stores arbitrary metadata as JSON)
15
- #
16
- # @example Usage
17
- # store = Phronomy::VectorStore::Pgvector.new(model_class: VectorDocument)
18
- # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
19
- # results = store.search(query_embedding: [0.1, 0.8], k: 5)
20
- class Pgvector < Base
21
- # @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
22
- # @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
23
- # pre-validation. When nil, dimension enforcement is delegated to the
24
- # database schema; no pre-validation is performed by Phronomy.
25
- # @api public
26
- def initialize(model_class:, dimension: nil)
27
- begin
28
- require "pgvector"
29
- rescue LoadError
30
- raise LoadError,
31
- "pgvector gem is required for Phronomy::VectorStore::Pgvector. " \
32
- "Add `gem 'pgvector'` to your Gemfile."
33
- end
34
- @model_class = model_class
35
- @dimension = dimension
36
- end
37
-
38
- # @param id [String]
39
- # @param embedding [Array<Float>]
40
- # @param metadata [Hash]
41
- # @param cancellation_token [Phronomy::CancellationToken, nil]
42
- # @api public
43
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
44
- cancellation_token&.raise_if_cancelled!
45
- validate_embedding_dimension!(embedding, @dimension)
46
- @model_class.upsert(
47
- {id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
48
- unique_by: :id
49
- )
50
- self
51
- end
52
-
53
- # @param query_embedding [Array<Float>]
54
- # @param k [Integer]
55
- # @param cancellation_token [Phronomy::CancellationToken, nil]
56
- # @return [Array<Hash>] sorted by descending similarity score
57
- # @api public
58
- def search(query_embedding:, k: 5, cancellation_token: nil)
59
- cancellation_token&.raise_if_cancelled!
60
- k_safe = validate_k!(k)
61
- validate_embedding_dimension!(query_embedding, @dimension)
62
- vec = safe_vector_literal(query_embedding)
63
- conn = @model_class.connection
64
- quoted_vec = "#{conn.quote(vec)}::vector"
65
-
66
- @model_class
67
- .select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
68
- .order("embedding <=> #{quoted_vec}")
69
- .limit(k_safe)
70
- .map do |r|
71
- {
72
- id: r.id.to_s,
73
- score: r.score.to_f,
74
- metadata: parse_metadata(r.metadata)
75
- }
76
- end
77
- end
78
-
79
- def remove(id:)
80
- @model_class.where(id: id).delete_all
81
- self
82
- end
83
-
84
- def clear
85
- @model_class.delete_all
86
- self
87
- end
88
-
89
- # Returns the number of documents in the backing table.
90
- def size
91
- @model_class.count
92
- end
93
-
94
- private
95
-
96
- # Parses a metadata value returned by the pg driver.
97
- # Handles NULL (nil), already-parsed Hash, and JSON string forms.
98
- def parse_metadata(raw)
99
- return {} if raw.nil?
100
- return symbolize_hash_keys(raw) if raw.is_a?(Hash)
101
-
102
- parsed = JSON.parse(raw.to_s, symbolize_names: true)
103
- parsed.is_a?(Hash) ? parsed : {}
104
- rescue JSON::ParserError
105
- {}
106
- end
107
-
108
- # Recursively symbolizes keys for an already-parsed Hash.
109
- def symbolize_hash_keys(hash)
110
- hash.each_with_object({}) do |(k, v), h|
111
- h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
112
- end
113
- end
114
-
115
- # Validates that all elements are numeric and converts to a pgvector-
116
- # compatible literal string (e.g. "[1.0,0.5,-0.3]").
117
- def safe_vector_literal(embedding)
118
- "[#{embedding.map { |v| Float(v) }.join(",")}]"
119
- end
120
-
121
- # Returns a validated vector for the upsert call.
122
- def safe_vector(embedding)
123
- safe_vector_literal(embedding)
124
- end
125
- end
126
- end
127
- end
@@ -1,192 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Phronomy
6
- module VectorStore
7
- # Redis-backed vector store using the RediSearch module (FT.* commands).
8
- #
9
- # Requires:
10
- # - The +redis+ gem (add to your Gemfile)
11
- # - A Redis server with the RediSearch (RedisSearch) module enabled
12
- # (or Redis Stack which bundles RediSearch)
13
- #
14
- # Vectors are stored as FLOAT32 binary blobs in Redis Hash fields and
15
- # searched using the KNN approximate-nearest-neighbour algorithm.
16
- #
17
- # @example Usage
18
- # redis = Redis.new(url: "redis://localhost:6379")
19
- # store = Phronomy::VectorStore::RedisSearch.new(redis: redis, dimension: 1536)
20
- # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
21
- # results = store.search(query_embedding: [0.1, 0.8], k: 5)
22
- class RedisSearch < Base
23
- DOC_PREFIX = "phronomy_doc:"
24
- private_constant :DOC_PREFIX
25
-
26
- # @param redis [Redis] configured Redis client
27
- # @param index_name [String] RediSearch index name
28
- # @param dimension [Integer, nil] vector dimension; auto-detected on first add.
29
- # When connecting to an **existing** RediSearch index, you MUST pass
30
- # dimension: explicitly. Without it, a freshly constructed instance
31
- # treats the index as uninitialized until #add is called, and #search
32
- # silently returns [] in the meantime.
33
- # @api public
34
- def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
35
- begin
36
- require "redis"
37
- rescue LoadError
38
- raise LoadError,
39
- "redis gem is required for Phronomy::VectorStore::RedisSearch. " \
40
- "Add `gem 'redis'` to your Gemfile."
41
- end
42
- @redis = redis
43
- @index_name = index_name
44
- @dimension = dimension
45
- @index_created = false
46
- @mutex = Mutex.new
47
- end
48
-
49
- # @param id [String]
50
- # @param embedding [Array<Float>]
51
- # @param metadata [Hash]
52
- # @param cancellation_token [Phronomy::CancellationToken, nil]
53
- # @api public
54
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
55
- cancellation_token&.raise_if_cancelled!
56
- # Establish expected dimension on first add (not race-free for concurrent
57
- # first adds), then validate, then create/reuse the index.
58
- @dimension ||= embedding.size
59
- validate_embedding_dimension!(embedding, @dimension)
60
- ensure_index!(@dimension)
61
- @redis.call(
62
- "HSET", "#{DOC_PREFIX}#{id}",
63
- "embedding", pack_vector(embedding),
64
- "metadata", metadata.to_json
65
- )
66
- self
67
- end
68
-
69
- # @param query_embedding [Array<Float>]
70
- # @param k [Integer]
71
- # @param cancellation_token [Phronomy::CancellationToken, nil]
72
- # @return [Array<Hash>] sorted by descending similarity score
73
- # @api public
74
- def search(query_embedding:, k: 5, cancellation_token: nil)
75
- cancellation_token&.raise_if_cancelled!
76
- # search never establishes dimension. If dimension is unknown and the
77
- # index has not been created yet, there are no documents to return.
78
- return [] if @dimension.nil? && !@index_created
79
-
80
- validate_embedding_dimension!(query_embedding, @dimension)
81
- ensure_index!(@dimension)
82
- k_safe = validate_k!(k)
83
- blob = pack_vector(query_embedding)
84
-
85
- raw = @redis.call(
86
- "FT.SEARCH", @index_name,
87
- "*=>[KNN #{k_safe} @embedding $BLOB AS score]",
88
- "PARAMS", 2, "BLOB", blob,
89
- "SORTBY", "score",
90
- "RETURN", 2, "score", "metadata",
91
- "DIALECT", 2
92
- )
93
-
94
- parse_results(raw)
95
- end
96
-
97
- def remove(id:)
98
- @redis.call("DEL", "#{DOC_PREFIX}#{id}")
99
- self
100
- end
101
-
102
- # Returns the number of documents indexed.
103
- # Queries FT.INFO when the index has been created; returns 0 otherwise.
104
- def size
105
- return 0 unless @index_created
106
-
107
- raw = @redis.call("FT.INFO", @index_name)
108
- return 0 unless raw.is_a?(Array)
109
-
110
- idx = raw.index("num_docs")
111
- idx ? raw[idx + 1].to_i : 0
112
- rescue
113
- 0
114
- end
115
-
116
- def clear
117
- @mutex.synchronize do
118
- begin
119
- @redis.call("FT.DROPINDEX", @index_name, "DD")
120
- rescue => e
121
- raise unless e.message.to_s.include?("Unknown Index name")
122
- end
123
- @index_created = false
124
- end
125
- self
126
- end
127
-
128
- private
129
-
130
- def ensure_index!(dim)
131
- @mutex.synchronize do
132
- return if @index_created
133
-
134
- @dimension ||= dim
135
- begin
136
- @redis.call(
137
- "FT.CREATE", @index_name,
138
- "ON", "HASH",
139
- "PREFIX", 1, DOC_PREFIX,
140
- "SCHEMA",
141
- "embedding", "VECTOR", "FLAT", 6,
142
- "TYPE", "FLOAT32",
143
- "DIM", @dimension,
144
- "DISTANCE_METRIC", "COSINE",
145
- "metadata", "TEXT"
146
- )
147
- rescue => e
148
- raise unless e.message.to_s.include?("Index already exists")
149
- end
150
- @index_created = true
151
- end
152
- end
153
-
154
- # Pack a Float array as a FLOAT32 binary string for RediSearch.
155
- def pack_vector(embedding)
156
- embedding.map { |v| Float(v) }.pack("f*")
157
- end
158
-
159
- # Parse the raw FT.SEARCH response into the standard Hash format.
160
- #
161
- # Redis FT.SEARCH returns: [count, key1, [field, value, ...], key2, ...]
162
- def parse_results(raw)
163
- return [] if raw.nil? || !raw.is_a?(Array) || raw.size < 2
164
-
165
- results = []
166
- i = 1
167
- while i < raw.size
168
- key = raw[i]
169
- fields = raw[i + 1]
170
- i += 2
171
-
172
- next unless fields.is_a?(Array)
173
-
174
- field_hash = fields.each_slice(2).to_h
175
- score_str = field_hash["score"]
176
- metadata_str = field_hash["metadata"]
177
-
178
- next if score_str.nil?
179
-
180
- id = key.to_s.delete_prefix(DOC_PREFIX)
181
- # RediSearch returns cosine distance (0=identical, 2=opposite);
182
- # convert to cosine similarity for consistency with other backends.
183
- score = 1.0 - score_str.to_f
184
- metadata = metadata_str ? JSON.parse(metadata_str, symbolize_names: true) : {}
185
-
186
- results << {id: id, score: score, metadata: metadata}
187
- end
188
- results
189
- end
190
- end
191
- end
192
- end