prescient 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -1,608 +1,612 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'yaml'
5
-
6
- # Load and validate Prescient configuration data from YAML.
7
- class Prescient::ConfigurationLoader
8
- # Supported YAML configuration schema version.
9
- CONFIGURATION_VERSION = 1
10
-
11
- # Allowed top-level configuration keys.
12
- TOP_LEVEL_KEYS = [
13
- '$schema',
14
- 'default_provider',
15
- 'default_provider_env',
16
- 'fallback_providers',
17
- 'fallback_providers_env',
18
- 'providers',
19
- 'tools',
20
- 'retry_attempts',
21
- 'retry_attempts_env',
22
- 'retry_delay',
23
- 'retry_delay_env',
24
- 'sensitive_keys',
25
- 'sensitive_keys_env',
26
- 'timeout',
27
- 'timeout_env',
28
- 'version',
29
- ].freeze
30
-
31
- # Provider names mapped to their adapter classes.
32
- PROVIDER_TYPES = {
33
- 'ollama' => Prescient::Provider::Ollama,
34
- 'anthropic' => Prescient::Provider::Anthropic,
35
- 'openai' => Prescient::Provider::OpenAI,
36
- 'huggingface' => Prescient::Provider::HuggingFace,
37
- 'gemini' => Prescient::Provider::Gemini,
38
- 'mistral' => Prescient::Provider::Mistral,
39
- 'deepseek' => Prescient::Provider::DeepSeek,
40
- 'xai' => Prescient::Provider::XAI,
41
- }.freeze
42
-
43
- # Tool names mapped to lazily resolved adapter constants.
44
- TOOL_TYPES = {
45
- 'searchapi' => :SearchApi,
46
- 'searxng' => :SearXNG,
47
- }.freeze
48
-
49
- # Provider-specific keys shared by all supported adapters.
50
- COMMON_PROVIDER_KEYS = [
51
- 'api_key',
52
- 'api_key_env',
53
- 'chat_model',
54
- 'chat_model_env',
55
- 'context_configs',
56
- 'context_configs_env',
57
- 'embedding_dimensions',
58
- 'embedding_dimensions_env',
59
- 'embedding_model',
60
- 'embedding_model_env',
61
- 'model',
62
- 'model_env',
63
- 'prompt_templates',
64
- 'prompt_templates_env',
65
- 'timeout',
66
- 'timeout_env',
67
- 'url',
68
- 'url_env',
69
- ].freeze
70
-
71
- # Tool-specific keys accepted by all configured adapters.
72
- COMMON_TOOL_KEYS = [
73
- 'api_key',
74
- 'api_key_env',
75
- 'categories',
76
- 'categories_env',
77
- 'engine',
78
- 'engine_env',
79
- 'gl',
80
- 'gl_env',
81
- 'hl',
82
- 'hl_env',
83
- 'language',
84
- 'language_env',
85
- 'location',
86
- 'location_env',
87
- 'max_response_bytes',
88
- 'max_response_bytes_env',
89
- 'max_results',
90
- 'max_results_env',
91
- 'timeout',
92
- 'timeout_env',
93
- 'url',
94
- 'url_env',
95
- ].freeze
96
-
97
- # Prompt template keys supported by provider configuration.
98
- PROMPT_TEMPLATE_KEYS = ['system_prompt', 'no_context_template', 'with_context_template'].freeze
99
-
100
- # Configuration attributes supported by the loader.
101
- ATTR_KEYS = [
102
- 'default_provider',
103
- 'fallback_providers',
104
- 'retry_attempts',
105
- 'retry_delay',
106
- 'sensitive_keys',
107
- 'timeout',
108
- ].freeze
109
-
110
- class << self
111
- # Load and validate configuration from a YAML file.
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Prescient
7
+ # Load and validate Prescient configuration data from YAML.
8
+ # rubocop:disable Metrics/ClassLength
9
+ class ConfigurationLoader
10
+ # Supported YAML configuration schema version.
11
+ CONFIGURATION_VERSION = 1
12
+
13
+ # Allowed top-level configuration keys.
14
+ TOP_LEVEL_KEYS = [
15
+ "$schema",
16
+ "default_provider",
17
+ "default_provider_env",
18
+ "fallback_providers",
19
+ "fallback_providers_env",
20
+ "providers",
21
+ "tools",
22
+ "retry_attempts",
23
+ "retry_attempts_env",
24
+ "retry_delay",
25
+ "retry_delay_env",
26
+ "sensitive_keys",
27
+ "sensitive_keys_env",
28
+ "timeout",
29
+ "timeout_env",
30
+ "version"
31
+ ].freeze
32
+
33
+ # Provider names mapped to their adapter classes.
34
+ PROVIDER_TYPES = {
35
+ "ollama" => Prescient::Provider::Ollama,
36
+ "anthropic" => Prescient::Provider::Anthropic,
37
+ "openai" => Prescient::Provider::OpenAI,
38
+ "huggingface" => Prescient::Provider::HuggingFace,
39
+ "gemini" => Prescient::Provider::Gemini,
40
+ "mistral" => Prescient::Provider::Mistral,
41
+ "deepseek" => Prescient::Provider::DeepSeek,
42
+ "xai" => Prescient::Provider::XAI
43
+ }.freeze
44
+
45
+ # Tool names mapped to lazily resolved adapter constants.
46
+ TOOL_TYPES = {
47
+ "searchapi" => :SearchApi,
48
+ "searxng" => :SearXNG
49
+ }.freeze
50
+
51
+ # Provider-specific keys shared by all supported adapters.
52
+ COMMON_PROVIDER_KEYS = %w[
53
+ api_key
54
+ api_key_env
55
+ chat_model
56
+ chat_model_env
57
+ context_configs
58
+ context_configs_env
59
+ embedding_dimensions
60
+ embedding_dimensions_env
61
+ embedding_model
62
+ embedding_model_env
63
+ model
64
+ model_env
65
+ prompt_templates
66
+ prompt_templates_env
67
+ timeout
68
+ timeout_env
69
+ url
70
+ url_env
71
+ ].freeze
72
+
73
+ # Tool-specific keys accepted by all configured adapters.
74
+ COMMON_TOOL_KEYS = %w[
75
+ api_key
76
+ api_key_env
77
+ categories
78
+ categories_env
79
+ engine
80
+ engine_env
81
+ gl
82
+ gl_env
83
+ hl
84
+ hl_env
85
+ language
86
+ language_env
87
+ location
88
+ location_env
89
+ max_response_bytes
90
+ max_response_bytes_env
91
+ max_results
92
+ max_results_env
93
+ timeout
94
+ timeout_env
95
+ url
96
+ url_env
97
+ ].freeze
98
+
99
+ # Prompt template keys supported by provider configuration.
100
+ PROMPT_TEMPLATE_KEYS = %w[system_prompt no_context_template with_context_template].freeze
101
+
102
+ # Configuration attributes supported by the loader.
103
+ ATTR_KEYS = %w[
104
+ default_provider
105
+ fallback_providers
106
+ retry_attempts
107
+ retry_delay
108
+ sensitive_keys
109
+ timeout
110
+ ].freeze
111
+
112
+ class << self
113
+ # Load and validate configuration from a YAML file.
114
+ # @param path [String] Configuration file path
115
+ # @param env [Hash] Environment variables used during expansion
116
+ # @return [Prescient::Configuration] Loaded configuration
117
+ def load_file(path, env: ENV)
118
+ new(env).load_file(path)
119
+ end
120
+
121
+ # Load and validate configuration from YAML content.
122
+ # @param content [String] YAML configuration content
123
+ # @param env [Hash] Environment variables used during expansion
124
+ # @return [Prescient::Configuration] Loaded configuration
125
+ def load_yaml(content, env: ENV)
126
+ new(env).load_yaml(content)
127
+ end
128
+
129
+ # Load and validate configuration from a Ruby hash.
130
+ # @param data [Hash] Configuration data
131
+ # @param env [Hash] Environment variables used during expansion
132
+ # @return [Prescient::Configuration] Loaded configuration
133
+ def load_hash(data, env: ENV)
134
+ new(env).load_hash(data)
135
+ end
136
+ end
137
+
138
+ def initialize(env = ENV)
139
+ @env = env
140
+ end
141
+
142
+ # Load configuration from a YAML file.
112
143
  # @param path [String] Configuration file path
113
- # @param env [Hash] Environment variables used during expansion
114
144
  # @return [Prescient::Configuration] Loaded configuration
115
- def load_file(path, env: ENV)
116
- new(env).load_file(path)
145
+ def load_file(path)
146
+ load_yaml(File.read(path), source: path)
147
+ rescue Errno::ENOENT
148
+ raise Prescient::Error, "Configuration file not found: #{path}"
117
149
  end
118
150
 
119
- # Load and validate configuration from YAML content.
151
+ # Load configuration from YAML content.
120
152
  # @param content [String] YAML configuration content
121
- # @param env [Hash] Environment variables used during expansion
153
+ # @param source [String, nil] Source label used in validation errors
122
154
  # @return [Prescient::Configuration] Loaded configuration
123
- def load_yaml(content, env: ENV)
124
- new(env).load_yaml(content)
155
+ def load_yaml(content, source: nil)
156
+ data = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
157
+ load_hash(data || {}, source:)
158
+ rescue Psych::SyntaxError => e
159
+ raise Prescient::Error, "Invalid YAML configuration#{" in #{source}" if source}: #{e.message}"
125
160
  end
126
161
 
127
- # Load and validate configuration from a Ruby hash.
162
+ # Load configuration from a Ruby hash.
128
163
  # @param data [Hash] Configuration data
129
- # @param env [Hash] Environment variables used during expansion
164
+ # @param source [String, nil] Source label used in validation errors
130
165
  # @return [Prescient::Configuration] Loaded configuration
131
- def load_hash(data, env: ENV)
132
- new(env).load_hash(data)
166
+ def load_hash(data, source: nil)
167
+ configuration = Prescient::Configuration.new
168
+ Prescient.send(:configure_default_providers, configuration, @env)
169
+ Prescient.send(:configure_default_tools, configuration, @env)
170
+ apply!(configuration, data, source:)
171
+ configuration
133
172
  end
134
- end
135
-
136
- def initialize(env = ENV)
137
- @env = env
138
- end
139
-
140
- # Load configuration from a YAML file.
141
- # @param path [String] Configuration file path
142
- # @return [Prescient::Configuration] Loaded configuration
143
- def load_file(path)
144
- load_yaml(File.read(path), source: path)
145
- rescue Errno::ENOENT
146
- raise Prescient::Error, "Configuration file not found: #{path}"
147
- end
148
173
 
149
- # Load configuration from YAML content.
150
- # @param content [String] YAML configuration content
151
- # @param source [String, nil] Source label used in validation errors
152
- # @return [Prescient::Configuration] Loaded configuration
153
- def load_yaml(content, source: nil)
154
- data = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
155
- load_hash(data || {}, source:)
156
- rescue Psych::SyntaxError => e
157
- raise Prescient::Error, "Invalid YAML configuration#{" in #{source}" if source}: #{e.message}"
158
- end
174
+ # Apply validated configuration data to an existing configuration object.
175
+ # @param configuration [Prescient::Configuration] Target configuration
176
+ # @param data [Hash] Configuration data
177
+ # @param source [String, nil] Source label used in validation errors
178
+ # @return [Prescient::Configuration] Updated configuration
179
+ def apply!(configuration, data, source: nil)
180
+ normalized = normalize_keys(data)
181
+ validate_root!(normalized, source:)
182
+ validate_version!(normalized, source:)
183
+
184
+ apply_scalar_settings(configuration, normalized, source:)
185
+ apply_provider_settings(configuration, normalized, source:)
186
+ apply_tool_settings(configuration, normalized, source:)
187
+ configuration
188
+ end
159
189
 
160
- # Load configuration from a Ruby hash.
161
- # @param data [Hash] Configuration data
162
- # @param source [String, nil] Source label used in validation errors
163
- # @return [Prescient::Configuration] Loaded configuration
164
- def load_hash(data, source: nil)
165
- configuration = Prescient::Configuration.new
166
- Prescient.send(:configure_default_providers, configuration, @env)
167
- Prescient.send(:configure_default_tools, configuration, @env)
168
- apply!(configuration, data, source:)
169
- configuration
170
- end
190
+ # Return the packaged JSON Schema path.
191
+ # @return [String] Absolute path to the configuration schema
192
+ def self.schema_path
193
+ File.expand_path("../../schema/prescient.configuration.schema.json", __dir__)
194
+ end
171
195
 
172
- # Apply validated configuration data to an existing configuration object.
173
- # @param configuration [Prescient::Configuration] Target configuration
174
- # @param data [Hash] Configuration data
175
- # @param source [String, nil] Source label used in validation errors
176
- # @return [Prescient::Configuration] Updated configuration
177
- def apply!(configuration, data, source: nil)
178
- normalized = normalize_keys(data)
179
- validate_root!(normalized, source:)
180
- validate_version!(normalized, source:)
181
-
182
- apply_scalar_settings(configuration, normalized, source:)
183
- apply_provider_settings(configuration, normalized, source:)
184
- apply_tool_settings(configuration, normalized, source:)
185
- configuration
186
- end
196
+ private
187
197
 
188
- # Return the packaged JSON Schema path.
189
- # @return [String] Absolute path to the configuration schema
190
- def self.schema_path
191
- File.expand_path('../../schema/prescient.configuration.schema.json', __dir__)
192
- end
198
+ def validate_root!(data, source:)
199
+ unless data.is_a?(Hash)
200
+ raise Prescient::Error, "Configuration#{" in #{source}" if source} must be a mapping"
201
+ end
193
202
 
194
- private
203
+ unknown_keys = data.keys.map(&:to_s) - TOP_LEVEL_KEYS
204
+ return if unknown_keys.empty?
195
205
 
196
- def validate_root!(data, source:)
197
- unless data.is_a?(Hash)
198
- raise Prescient::Error, "Configuration#{" in #{source}" if source} must be a mapping"
206
+ raise Prescient::Error,
207
+ "Unknown configuration key#{"s" if unknown_keys.length > 1}: #{unknown_keys.join(", ")}" \
208
+ "#{" in #{source}" if source}"
199
209
  end
200
210
 
201
- unknown_keys = data.keys.map(&:to_s) - TOP_LEVEL_KEYS
202
- return if unknown_keys.empty?
203
-
204
- raise Prescient::Error,
205
- "Unknown configuration key#{'s' if unknown_keys.length > 1}: #{unknown_keys.join(', ')}" \
206
- "#{" in #{source}" if source}"
207
- end
211
+ def apply_scalar_settings(configuration, data, source:)
212
+ apply_default_provider(configuration, data, source:)
213
+ apply_numeric_settings(configuration, data, source:)
214
+ apply_collection_settings(configuration, data, source:)
215
+ end
208
216
 
209
- def apply_scalar_settings(configuration, data, source:)
210
- apply_default_provider(configuration, data, source:)
211
- apply_numeric_settings(configuration, data, source:)
212
- apply_collection_settings(configuration, data, source:)
213
- end
217
+ def apply_default_provider(configuration, data, source:)
218
+ return unless scalar_present?(data, :default_provider)
214
219
 
215
- def apply_default_provider(configuration, data, source:)
216
- return unless scalar_present?(data, :default_provider)
220
+ default_provider = resolve_scalar(data, :default_provider, source:)
221
+ if default_provider.nil? || default_provider.to_s.empty?
222
+ raise Prescient::Error, "default_provider must be a non-empty string"
223
+ end
217
224
 
218
- default_provider = resolve_scalar(data, :default_provider, source:)
219
- if default_provider.nil? || default_provider.to_s.empty?
220
- raise Prescient::Error, 'default_provider must be a non-empty string'
225
+ configuration.default_provider = default_provider.to_sym
221
226
  end
222
227
 
223
- configuration.default_provider = default_provider.to_sym
224
- end
225
-
226
- def apply_numeric_settings(configuration, data, source:)
227
- numeric_settings = {
228
- timeout: [:coerce_integer, 'timeout'],
229
- retry_attempts: [:coerce_integer, 'retry_attempts'],
230
- retry_delay: [:coerce_float, 'retry_delay'],
231
- }
232
-
233
- numeric_settings.each do |key, (coercer, name)|
234
- next unless scalar_present?(data, key)
228
+ def apply_numeric_settings(configuration, data, source:)
229
+ numeric_settings = {
230
+ timeout: [:coerce_integer, "timeout"],
231
+ retry_attempts: [:coerce_integer, "retry_attempts"],
232
+ retry_delay: [:coerce_float, "retry_delay"]
233
+ }
235
234
 
236
- value = resolve_scalar(data, key, source:)
237
- setter = "#{key}="
238
- configuration.public_send(setter, send(coercer, value, name))
239
- end
240
- end
235
+ numeric_settings.each do |key, (coercer, name)|
236
+ next unless scalar_present?(data, key)
241
237
 
242
- def apply_collection_settings(configuration, data, source:)
243
- if scalar_present?(data, :fallback_providers)
244
- value = resolve_scalar(data, :fallback_providers, source:)
245
- configuration.fallback_providers = Array(value).map(&:to_sym)
238
+ value = resolve_scalar(data, key, source:)
239
+ setter = "#{key}="
240
+ configuration.public_send(setter, send(coercer, value, name))
241
+ end
246
242
  end
247
243
 
248
- return unless scalar_present?(data, :sensitive_keys)
249
-
250
- configuration.sensitive_keys = Array(resolve_scalar(data, :sensitive_keys, source:))
251
- end
244
+ def apply_collection_settings(configuration, data, source:)
245
+ if scalar_present?(data, :fallback_providers)
246
+ value = resolve_scalar(data, :fallback_providers, source:)
247
+ configuration.fallback_providers = Array(value).map(&:to_sym)
248
+ end
252
249
 
253
- def apply_provider_settings(configuration, data, source:)
254
- return unless key_present?(data, :providers)
250
+ return unless scalar_present?(data, :sensitive_keys)
255
251
 
256
- providers = data[:providers]
257
- unless providers.is_a?(Hash)
258
- raise Prescient::Error, "Configuration#{" in #{source}" if source} providers must be a mapping"
252
+ configuration.sensitive_keys = Array(resolve_scalar(data, :sensitive_keys, source:))
259
253
  end
260
254
 
261
- providers.each do |name, provider_data|
262
- provider_name = name.to_sym
263
- provider_settings = normalize_keys(provider_data)
264
- validate_provider!(provider_name, provider_settings, source:)
255
+ def apply_provider_settings(configuration, data, source:)
256
+ return unless key_present?(data, :providers)
265
257
 
266
- provider_options = resolve_provider_options(provider_settings, source:)
267
- configuration.add_provider(provider_name, PROVIDER_TYPES[provider_settings.fetch(:type).to_s],
268
- **provider_options)
269
- end
270
- end
258
+ providers = data[:providers]
259
+ unless providers.is_a?(Hash)
260
+ raise Prescient::Error, "Configuration#{" in #{source}" if source} providers must be a mapping"
261
+ end
271
262
 
272
- def apply_tool_settings(configuration, data, source:)
273
- return unless key_present?(data, :tools)
263
+ providers.each do |name, provider_data|
264
+ provider_name = name.to_sym
265
+ provider_settings = normalize_keys(provider_data)
266
+ validate_provider!(provider_name, provider_settings, source:)
274
267
 
275
- tools = data[:tools]
276
- unless tools.is_a?(Hash)
277
- raise Prescient::Error, "Configuration#{" in #{source}" if source} tools must be a mapping"
268
+ provider_options = resolve_provider_options(provider_settings, source:)
269
+ configuration.add_provider(provider_name, PROVIDER_TYPES[provider_settings.fetch(:type).to_s],
270
+ **provider_options)
271
+ end
278
272
  end
279
273
 
280
- tools.each do |name, tool_data|
281
- tool_name = name.to_sym
282
- tool_settings = normalize_keys(tool_data)
283
- validate_tool!(tool_name, tool_settings, source:)
274
+ def apply_tool_settings(configuration, data, source:)
275
+ return unless key_present?(data, :tools)
284
276
 
285
- if tool_settings.key?(:adapters)
286
- configuration.add_tool_group(tool_name, resolve_tool_adapters(tool_settings, source:))
287
- else
288
- tool_options = resolve_tool_options(tool_settings, source:)
289
- configuration.add_tool(tool_name, tool_class_for(tool_settings.fetch(:type).to_s), **tool_options)
277
+ tools = data[:tools]
278
+ unless tools.is_a?(Hash)
279
+ raise Prescient::Error, "Configuration#{" in #{source}" if source} tools must be a mapping"
290
280
  end
291
- end
292
- end
293
281
 
294
- def validate_provider!(name, provider_data, source:)
295
- validate_provider_shape!(name, provider_data, source:)
296
- validate_provider_type!(name, provider_data, source:)
297
- validate_provider_keys!(name, provider_data, source:)
298
- validate_prompt_templates!(name, provider_data, source:)
299
- end
282
+ tools.each do |name, tool_data|
283
+ tool_name = name.to_sym
284
+ tool_settings = normalize_keys(tool_data)
285
+ validate_tool!(tool_name, tool_settings, source:)
300
286
 
301
- def validate_provider_shape!(name, provider_data, source:)
302
- return if provider_data.is_a?(Hash)
287
+ if tool_settings.key?(:adapters)
288
+ configuration.add_tool_group(tool_name, resolve_tool_adapters(tool_settings, source:))
289
+ else
290
+ tool_options = resolve_tool_options(tool_settings, source:)
291
+ configuration.add_tool(tool_name, tool_class_for(tool_settings.fetch(:type).to_s), **tool_options)
292
+ end
293
+ end
294
+ end
303
295
 
304
- raise Prescient::Error, "Provider #{name.inspect}#{" in #{source}" if source} must be a mapping"
305
- end
296
+ def validate_provider!(name, provider_data, source:)
297
+ validate_provider_shape!(name, provider_data, source:)
298
+ validate_provider_type!(name, provider_data, source:)
299
+ validate_provider_keys!(name, provider_data, source:)
300
+ validate_prompt_templates!(name, provider_data, source:)
301
+ end
306
302
 
307
- def validate_provider_type!(name, provider_data, source:)
308
- return if provider_data.key?(:type)
303
+ def validate_provider_shape!(name, provider_data, source:)
304
+ return if provider_data.is_a?(Hash)
309
305
 
310
- raise Prescient::Error, "Provider #{name}#{" in #{source}" if source} must define type"
311
- end
306
+ raise Prescient::Error, "Provider #{name.inspect}#{" in #{source}" if source} must be a mapping"
307
+ end
312
308
 
313
- def validate_provider_keys!(name, provider_data, source:)
314
- unknown_keys = provider_data.keys.map(&:to_s) - (['type'] + COMMON_PROVIDER_KEYS)
315
- return if unknown_keys.empty?
309
+ def validate_provider_type!(name, provider_data, source:)
310
+ return if provider_data.key?(:type)
316
311
 
317
- raise Prescient::Error,
318
- "Unknown provider configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
319
- "#{unknown_keys.join(', ')}" \
320
- "#{" in #{source}" if source}"
321
- end
312
+ raise Prescient::Error, "Provider #{name}#{" in #{source}" if source} must define type"
313
+ end
322
314
 
323
- def validate_prompt_templates!(name, provider_data, source:)
324
- templates = provider_data[:prompt_templates]
325
- return if templates.nil?
315
+ def validate_provider_keys!(name, provider_data, source:)
316
+ unknown_keys = provider_data.keys.map(&:to_s) - (["type"] + COMMON_PROVIDER_KEYS)
317
+ return if unknown_keys.empty?
326
318
 
327
- source_suffix = " in #{source}" if source
328
- unless templates.is_a?(Hash)
329
- raise Prescient::Error, "prompt_templates for #{name} must be a mapping#{source_suffix}"
319
+ raise Prescient::Error,
320
+ "Unknown provider configuration key#{"s" if unknown_keys.length > 1} for #{name}: " \
321
+ "#{unknown_keys.join(", ")}" \
322
+ "#{" in #{source}" if source}"
330
323
  end
331
324
 
332
- unknown_keys = templates.keys.map(&:to_s) - PROMPT_TEMPLATE_KEYS
333
- return if unknown_keys.empty?
334
-
335
- message = "Unknown prompt template key#{'s' if unknown_keys.length > 1} for #{name}: " \
336
- "#{unknown_keys.join(', ')}#{source_suffix}"
337
- raise Prescient::Error, message
338
- end
325
+ def validate_prompt_templates!(name, provider_data, source:)
326
+ templates = provider_data[:prompt_templates]
327
+ return if templates.nil?
339
328
 
340
- def validate_tool!(name, tool_data, source:)
341
- validate_tool_shape!(name, tool_data, source:)
342
- return validate_tool_group!(name, tool_data, source:) if tool_data.key?(:adapters)
329
+ source_suffix = " in #{source}" if source
330
+ unless templates.is_a?(Hash)
331
+ raise Prescient::Error, "prompt_templates for #{name} must be a mapping#{source_suffix}"
332
+ end
343
333
 
344
- validate_tool_type!(name, tool_data, source:)
345
- validate_tool_keys!(name, tool_data, source:)
346
- end
334
+ unknown_keys = templates.keys.map(&:to_s) - PROMPT_TEMPLATE_KEYS
335
+ return if unknown_keys.empty?
347
336
 
348
- def validate_tool_group!(name, tool_data, source:)
349
- unknown_keys = tool_data.keys.map(&:to_s) - ['adapters']
350
- unless unknown_keys.empty?
351
- source_suffix = " in #{source}" if source
352
- raise Prescient::Error,
353
- "Unknown tool group configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
354
- "#{unknown_keys.join(', ')}#{source_suffix}"
337
+ message = "Unknown prompt template key#{"s" if unknown_keys.length > 1} for #{name}: " \
338
+ "#{unknown_keys.join(", ")}#{source_suffix}"
339
+ raise Prescient::Error, message
355
340
  end
356
341
 
357
- adapters = tool_data[:adapters]
358
- unless adapters.is_a?(Array) && adapters.any?
359
- raise Prescient::Error, "Tool #{name} adapters must be a non-empty array"
360
- end
342
+ def validate_tool!(name, tool_data, source:)
343
+ validate_tool_shape!(name, tool_data, source:)
344
+ return validate_tool_group!(name, tool_data, source:) if tool_data.key?(:adapters)
361
345
 
362
- adapters.each_with_index do |adapter_data, index|
363
- adapter_name = "#{name} adapter #{index + 1}"
364
- validate_tool_shape!(adapter_name, adapter_data, source:)
365
- validate_tool_type!(adapter_name, adapter_data, source:)
366
- validate_tool_keys!(adapter_name, adapter_data, source:)
346
+ validate_tool_type!(name, tool_data, source:)
347
+ validate_tool_keys!(name, tool_data, source:)
367
348
  end
368
- end
369
349
 
370
- def validate_tool_shape!(name, tool_data, source:)
371
- return if tool_data.is_a?(Hash)
350
+ def validate_tool_group!(name, tool_data, source:)
351
+ unknown_keys = tool_data.keys.map(&:to_s) - ["adapters"]
352
+ unless unknown_keys.empty?
353
+ source_suffix = " in #{source}" if source
354
+ raise Prescient::Error,
355
+ "Unknown tool group configuration key#{"s" if unknown_keys.length > 1} for #{name}: " \
356
+ "#{unknown_keys.join(", ")}#{source_suffix}"
357
+ end
372
358
 
373
- raise Prescient::Error, "Tool #{name.inspect}#{" in #{source}" if source} must be a mapping"
374
- end
359
+ adapters = tool_data[:adapters]
360
+ unless adapters.is_a?(Array) && adapters.any?
361
+ raise Prescient::Error, "Tool #{name} adapters must be a non-empty array"
362
+ end
375
363
 
376
- def validate_tool_type!(name, tool_data, source:)
377
- return if tool_data.key?(:type)
364
+ adapters.each_with_index do |adapter_data, index|
365
+ adapter_name = "#{name} adapter #{index + 1}"
366
+ validate_tool_shape!(adapter_name, adapter_data, source:)
367
+ validate_tool_type!(adapter_name, adapter_data, source:)
368
+ validate_tool_keys!(adapter_name, adapter_data, source:)
369
+ end
370
+ end
378
371
 
379
- raise Prescient::Error, "Tool #{name}#{" in #{source}" if source} must define type"
380
- end
372
+ def validate_tool_shape!(name, tool_data, source:)
373
+ return if tool_data.is_a?(Hash)
381
374
 
382
- def validate_tool_keys!(name, tool_data, source:)
383
- unknown_keys = tool_data.keys.map(&:to_s) - (['type'] + COMMON_TOOL_KEYS)
384
- return if unknown_keys.empty?
375
+ raise Prescient::Error, "Tool #{name.inspect}#{" in #{source}" if source} must be a mapping"
376
+ end
385
377
 
386
- raise Prescient::Error,
387
- "Unknown tool configuration key#{'s' if unknown_keys.length > 1} for #{name}: " \
388
- "#{unknown_keys.join(', ')}" \
389
- "#{" in #{source}" if source}"
390
- end
378
+ def validate_tool_type!(name, tool_data, source:)
379
+ return if tool_data.key?(:type)
391
380
 
392
- def resolve_provider_options(provider_data, source:)
393
- provider_type = provider_data[:type].to_s
394
- provider_class = PROVIDER_TYPES[provider_type]
395
- unless provider_class
396
- raise Prescient::Error,
397
- "Unknown provider type #{provider_type.inspect}#{" in #{source}" if source}"
381
+ raise Prescient::Error, "Tool #{name}#{" in #{source}" if source} must define type"
398
382
  end
399
383
 
400
- provider_data.each_with_object({}) do |(key, value), options|
401
- option = resolve_provider_option(provider_data, key, value, source:)
402
- options[option.first] = option.last if option
403
- end
404
- end
384
+ def validate_tool_keys!(name, tool_data, source:)
385
+ unknown_keys = tool_data.keys.map(&:to_s) - (["type"] + COMMON_TOOL_KEYS)
386
+ return if unknown_keys.empty?
405
387
 
406
- def resolve_tool_options(tool_data, source:)
407
- tool_type = tool_data[:type].to_s
408
- tool_class = tool_class_for(tool_type)
409
- unless tool_class
410
388
  raise Prescient::Error,
411
- "Unknown tool type #{tool_type.inspect}#{" in #{source}" if source}"
389
+ "Unknown tool configuration key#{"s" if unknown_keys.length > 1} for #{name}: " \
390
+ "#{unknown_keys.join(", ")}" \
391
+ "#{" in #{source}" if source}"
412
392
  end
413
393
 
414
- tool_data.each_with_object({}) do |(key, value), options|
415
- option = resolve_tool_option(tool_data, key, value, source:)
416
- options[option.first] = option.last if option
417
- end
418
- end
394
+ def resolve_provider_options(provider_data, source:)
395
+ provider_type = provider_data[:type].to_s
396
+ provider_class = PROVIDER_TYPES[provider_type]
397
+ unless provider_class
398
+ raise Prescient::Error,
399
+ "Unknown provider type #{provider_type.inspect}#{" in #{source}" if source}"
400
+ end
419
401
 
420
- def resolve_tool_adapters(tool_data, source:)
421
- tool_data[:adapters].map do |adapter_data|
422
- tool_type = adapter_data[:type].to_s
423
- {
424
- class: tool_class_for(tool_type),
425
- options: resolve_tool_options(adapter_data, source:),
426
- }
402
+ provider_data.each_with_object({}) do |(key, value), options|
403
+ option = resolve_provider_option(provider_data, key, value, source:)
404
+ options[option.first] = option.last if option
405
+ end
427
406
  end
428
- end
429
407
 
430
- # Resolve a configured tool adapter only when configuration uses it.
431
- # @param tool_type [String] Configured tool type
432
- # @return [Class, nil] Tool adapter class
433
- def tool_class_for(tool_type)
434
- tool_name = TOOL_TYPES[tool_type]
435
- return unless tool_name
436
-
437
- Prescient::Tool.const_get(tool_name, false)
438
- end
408
+ def resolve_tool_options(tool_data, source:)
409
+ tool_type = tool_data[:type].to_s
410
+ tool_class = tool_class_for(tool_type)
411
+ unless tool_class
412
+ raise Prescient::Error,
413
+ "Unknown tool type #{tool_type.inspect}#{" in #{source}" if source}"
414
+ end
439
415
 
440
- def resolve_tool_option(tool_data, key, value, source:)
441
- return if key == :type
442
- return if key.to_s.end_with?('_env') && value.nil?
416
+ tool_data.each_with_object({}) do |(key, value), options|
417
+ option = resolve_tool_option(tool_data, key, value, source:)
418
+ options[option.first] = option.last if option
419
+ end
420
+ end
443
421
 
444
- if key.to_s.end_with?('_env')
445
- base_key = key.to_s.delete_suffix('_env').to_sym
446
- if tool_data.key?(base_key)
447
- raise Prescient::Error,
448
- "Tool configuration cannot combine #{base_key} and #{key}"
422
+ def resolve_tool_adapters(tool_data, source:)
423
+ tool_data[:adapters].map do |adapter_data|
424
+ tool_type = adapter_data[:type].to_s
425
+ {
426
+ class: tool_class_for(tool_type),
427
+ options: resolve_tool_options(adapter_data, source:)
428
+ }
449
429
  end
430
+ end
431
+
432
+ # Resolve a configured tool adapter only when configuration uses it.
433
+ # @param tool_type [String] Configured tool type
434
+ # @return [Class, nil] Tool adapter class
435
+ def tool_class_for(tool_type)
436
+ tool_name = TOOL_TYPES[tool_type]
437
+ return unless tool_name
450
438
 
451
- [base_key, resolve_env_value(value, source:)]
452
- else
453
- [key, coerce_tool_option(key, resolve_value(value, source:))]
439
+ Prescient::Tool.const_get(tool_name, false)
454
440
  end
455
- end
456
441
 
457
- def resolve_provider_option(provider_data, key, value, source:)
458
- return if key == :type
459
- return if key.to_s.end_with?('_env') && value.nil?
442
+ def resolve_tool_option(tool_data, key, value, source:)
443
+ return if key == :type
444
+ return if key.to_s.end_with?("_env") && value.nil?
460
445
 
461
- if key.to_s.end_with?('_env')
462
- base_key = key.to_s.delete_suffix('_env').to_sym
463
- if provider_data.key?(base_key)
464
- raise Prescient::Error,
465
- "Provider configuration cannot combine #{base_key} and #{key}"
466
- end
446
+ if key.to_s.end_with?("_env")
447
+ base_key = key.to_s.delete_suffix("_env").to_sym
448
+ if tool_data.key?(base_key)
449
+ raise Prescient::Error,
450
+ "Tool configuration cannot combine #{base_key} and #{key}"
451
+ end
467
452
 
468
- [base_key, resolve_env_value(value, source:)]
469
- else
470
- [key, coerce_provider_option(key, resolve_value(value, source:))]
453
+ [base_key, resolve_env_value(value, source:)]
454
+ else
455
+ [key, coerce_tool_option(key, resolve_value(value, source:))]
456
+ end
471
457
  end
472
- end
473
458
 
474
- def resolve_scalar(data, key, source:)
475
- env_key = :"#{key}_env"
476
- if key_present?(data, key) && key_present?(data, env_key)
477
- raise Prescient::Error, "Configuration cannot combine #{key} and #{key}_env"
459
+ def resolve_provider_option(provider_data, key, value, source:)
460
+ return if key == :type
461
+ return if key.to_s.end_with?("_env") && value.nil?
462
+
463
+ if key.to_s.end_with?("_env")
464
+ base_key = key.to_s.delete_suffix("_env").to_sym
465
+ if provider_data.key?(base_key)
466
+ raise Prescient::Error,
467
+ "Provider configuration cannot combine #{base_key} and #{key}"
468
+ end
469
+
470
+ [base_key, resolve_env_value(value, source:)]
471
+ else
472
+ [key, coerce_provider_option(key, resolve_value(value, source:))]
473
+ end
478
474
  end
479
475
 
480
- return resolve_env_value(data[env_key], source:) if key_present?(data, env_key)
476
+ def resolve_scalar(data, key, source:)
477
+ env_key = :"#{key}_env"
478
+ if key_present?(data, key) && key_present?(data, env_key)
479
+ raise Prescient::Error, "Configuration cannot combine #{key} and #{key}_env"
480
+ end
481
481
 
482
- value = data[key]
483
- return nil if value.nil?
482
+ return resolve_env_value(data[env_key], source:) if key_present?(data, env_key)
484
483
 
485
- resolve_value(value, source:)
486
- end
484
+ value = data[key]
485
+ return nil if value.nil?
487
486
 
488
- def resolve_value(value, source:)
489
- case value
490
- when Hash
491
- resolve_hash_value(value, source:)
492
- when Array
493
- value.map { |item| resolve_value(item, source:) }
494
- when String
495
- interpolate_env(value, source:)
496
- else
497
- value
487
+ resolve_value(value, source:)
498
488
  end
499
- end
500
-
501
- def resolve_hash_value(value, source:)
502
- value.each_with_object({}) do |(key, nested_value), result|
503
- key_name = key.to_s
504
- if key_name.end_with?('_env')
505
- base_key = key_name.delete_suffix('_env').to_sym
506
- if value.key?(base_key) || value.key?(base_key.to_s)
507
- raise Prescient::Error, "Configuration cannot combine #{base_key} and #{key_name}"
508
- end
509
489
 
510
- result[base_key] = resolve_env_value(nested_value, source:)
490
+ def resolve_value(value, source:)
491
+ case value
492
+ when Hash
493
+ resolve_hash_value(value, source:)
494
+ when Array
495
+ value.map { |item| resolve_value(item, source:) }
496
+ when String
497
+ interpolate_env(value, source:)
511
498
  else
512
- result[key.to_sym] = resolve_value(nested_value, source:)
499
+ value
513
500
  end
514
501
  end
515
- end
516
502
 
517
- def resolve_env_value(value, source:)
518
- env_name = resolve_value(value, source:)
519
- unless env_name.is_a?(String) && !env_name.empty?
520
- raise Prescient::Error, 'Environment variable name must be a non-empty string'
503
+ def resolve_hash_value(value, source:)
504
+ value.each_with_object({}) do |(key, nested_value), result|
505
+ key_name = key.to_s
506
+ if key_name.end_with?("_env")
507
+ base_key = key_name.delete_suffix("_env").to_sym
508
+ if value.key?(base_key) || value.key?(base_key.to_s)
509
+ raise Prescient::Error, "Configuration cannot combine #{base_key} and #{key_name}"
510
+ end
511
+
512
+ result[base_key] = resolve_env_value(nested_value, source:)
513
+ else
514
+ result[key.to_sym] = resolve_value(nested_value, source:)
515
+ end
516
+ end
521
517
  end
522
518
 
523
- raw_value = @env.fetch(env_name)
524
- parsed_value = YAML.safe_load(raw_value, permitted_classes: [], permitted_symbols: [], aliases: true)
525
- parsed_value.nil? ? raw_value : parsed_value
526
- rescue KeyError
527
- raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
528
- end
519
+ def resolve_env_value(value, source:)
520
+ env_name = resolve_value(value, source:)
521
+ unless env_name.is_a?(String) && !env_name.empty?
522
+ raise Prescient::Error, "Environment variable name must be a non-empty string"
523
+ end
529
524
 
530
- def interpolate_env(value, source:)
531
- value.gsub(/\$\{([A-Z0-9_]+)\}/) do
532
- env_name = Regexp.last_match(1)
533
- @env.fetch(env_name)
525
+ raw_value = @env.fetch(env_name)
526
+ parsed_value = YAML.safe_load(raw_value, permitted_classes: [], permitted_symbols: [], aliases: true)
527
+ parsed_value.nil? ? raw_value : parsed_value
534
528
  rescue KeyError
535
529
  raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
536
530
  end
537
- end
538
531
 
539
- def normalize_keys(value)
540
- case value
541
- when Hash
542
- value.each_with_object({}) do |(key, nested_value), result|
543
- result[key.to_sym] = normalize_keys(nested_value)
532
+ def interpolate_env(value, source:)
533
+ value.gsub(/\$\{([A-Z0-9_]+)\}/) do
534
+ env_name = Regexp.last_match(1)
535
+ @env.fetch(env_name)
536
+ rescue KeyError
537
+ raise Prescient::Error, "Environment variable not set: #{env_name}#{" in #{source}" if source}"
544
538
  end
545
- when Array
546
- value.map { |item| normalize_keys(item) }
547
- else
548
- value
549
539
  end
550
- end
551
540
 
552
- def key_present?(data, key)
553
- data.key?(key) || data.key?(key.to_s)
554
- end
541
+ def normalize_keys(value)
542
+ case value
543
+ when Hash
544
+ value.each_with_object({}) do |(key, nested_value), result|
545
+ result[key.to_sym] = normalize_keys(nested_value)
546
+ end
547
+ when Array
548
+ value.map { |item| normalize_keys(item) }
549
+ else
550
+ value
551
+ end
552
+ end
555
553
 
556
- def scalar_present?(data, key)
557
- key_present?(data, key) || key_present?(data, :"#{key}_env")
558
- end
554
+ def key_present?(data, key)
555
+ data.key?(key) || data.key?(key.to_s)
556
+ end
559
557
 
560
- def coerce_integer(value, name)
561
- return value if value.is_a?(Integer)
562
- return value.to_i if value.is_a?(Numeric)
558
+ def scalar_present?(data, key)
559
+ key_present?(data, key) || key_present?(data, :"#{key}_env")
560
+ end
563
561
 
564
- Integer(value)
565
- rescue ArgumentError, TypeError
566
- raise Prescient::Error, "#{name} must be an integer"
567
- end
562
+ def coerce_integer(value, name)
563
+ return value if value.is_a?(Integer)
564
+ return value.to_i if value.is_a?(Numeric)
568
565
 
569
- def coerce_float(value, name)
570
- return value.to_f if value.is_a?(Numeric)
566
+ Integer(value)
567
+ rescue ArgumentError, TypeError
568
+ raise Prescient::Error, "#{name} must be an integer"
569
+ end
571
570
 
572
- Float(value)
573
- rescue ArgumentError, TypeError
574
- raise Prescient::Error, "#{name} must be a number"
575
- end
571
+ def coerce_float(value, name)
572
+ return value.to_f if value.is_a?(Numeric)
576
573
 
577
- def coerce_provider_option(key, value)
578
- case key.to_sym
579
- when :embedding_dimensions
580
- coerce_integer(value, 'embedding_dimensions')
581
- when :timeout
582
- coerce_integer(value, 'timeout')
583
- else
584
- value
574
+ Float(value)
575
+ rescue ArgumentError, TypeError
576
+ raise Prescient::Error, "#{name} must be a number"
585
577
  end
586
- end
587
578
 
588
- def coerce_tool_option(key, value)
589
- case key.to_sym
590
- when :max_results, :max_response_bytes
591
- coerce_integer(value, key.to_s)
592
- when :timeout
593
- coerce_float(value, 'timeout')
594
- else
595
- value
579
+ def coerce_provider_option(key, value)
580
+ case key.to_sym
581
+ when :embedding_dimensions
582
+ coerce_integer(value, "embedding_dimensions")
583
+ when :timeout
584
+ coerce_integer(value, "timeout")
585
+ else
586
+ value
587
+ end
588
+ end
589
+
590
+ def coerce_tool_option(key, value)
591
+ case key.to_sym
592
+ when :max_results, :max_response_bytes
593
+ coerce_integer(value, key.to_s)
594
+ when :timeout
595
+ coerce_float(value, "timeout")
596
+ else
597
+ value
598
+ end
596
599
  end
597
- end
598
600
 
599
- def validate_version!(data, source:)
600
- return unless key_present?(data, :version)
601
+ def validate_version!(data, source:)
602
+ return unless key_present?(data, :version)
601
603
 
602
- version = resolve_value(data[:version], source:)
603
- return if version == CONFIGURATION_VERSION
604
+ version = resolve_value(data[:version], source:)
605
+ return if version == CONFIGURATION_VERSION
604
606
 
605
- raise Prescient::Error,
606
- "Unsupported configuration version #{version.inspect}#{" in #{source}" if source}"
607
+ raise Prescient::Error,
608
+ "Unsupported configuration version #{version.inspect}#{" in #{source}" if source}"
609
+ end
607
610
  end
611
+ # rubocop:enable Metrics/ClassLength
608
612
  end