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