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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +59 -0
- data/INTEGRATION_GUIDE.md +19 -1
- data/README.md +369 -21
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/docker-compose.yml +22 -0
- data/examples/README.md +36 -1
- data/examples/custom_contexts.rb +4 -4
- data/examples/web_search.rb +34 -0
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +346 -231
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +591 -402
- data/lib/prescient/client.rb +31 -4
- data/lib/prescient/configuration_loader.rb +511 -336
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +13 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -171
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +130 -0
- data/lib/prescient/tool/searxng.rb +128 -0
- data/lib/prescient/tool.rb +125 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +129 -55
- data/schema/prescient.configuration.schema.json +119 -0
- data/searxng/settings.yml +18 -0
- data/sig/prescient.rbs +228 -1
- metadata +33 -5
|
@@ -1,437 +1,612 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
151
|
+
# Load configuration from YAML content.
|
|
87
152
|
# @param content [String] YAML configuration content
|
|
88
|
-
# @param
|
|
153
|
+
# @param source [String, nil] Source label used in validation errors
|
|
89
154
|
# @return [Prescient::Configuration] Loaded configuration
|
|
90
|
-
def load_yaml(content,
|
|
91
|
-
|
|
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
|
|
162
|
+
# Load configuration from a Ruby hash.
|
|
95
163
|
# @param data [Hash] Configuration data
|
|
96
|
-
# @param
|
|
164
|
+
# @param source [String, nil] Source label used in validation errors
|
|
97
165
|
# @return [Prescient::Configuration] Loaded configuration
|
|
98
|
-
def load_hash(data,
|
|
99
|
-
new
|
|
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
|
-
|
|
104
|
-
@
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
217
|
+
def apply_default_provider(configuration, data, source:)
|
|
218
|
+
return unless scalar_present?(data, :default_provider)
|
|
160
219
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
return unless scalar_present?(data, :default_provider)
|
|
250
|
+
return unless scalar_present?(data, :sensitive_keys)
|
|
182
251
|
|
|
183
|
-
|
|
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
|
|
189
|
-
|
|
255
|
+
def apply_provider_settings(configuration, data, source:)
|
|
256
|
+
return unless key_present?(data, :providers)
|
|
190
257
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
303
|
+
def validate_provider_shape!(name, provider_data, source:)
|
|
304
|
+
return if provider_data.is_a?(Hash)
|
|
214
305
|
|
|
215
|
-
|
|
216
|
-
|
|
306
|
+
raise Prescient::Error, "Provider #{name.inspect}#{" in #{source}" if source} must be a mapping"
|
|
307
|
+
end
|
|
217
308
|
|
|
218
|
-
|
|
219
|
-
|
|
309
|
+
def validate_provider_type!(name, provider_data, source:)
|
|
310
|
+
return if provider_data.key?(:type)
|
|
220
311
|
|
|
221
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
334
|
+
unknown_keys = templates.keys.map(&:to_s) - PROMPT_TEMPLATE_KEYS
|
|
335
|
+
return if unknown_keys.empty?
|
|
249
336
|
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
346
|
+
validate_tool_type!(name, tool_data, source:)
|
|
347
|
+
validate_tool_keys!(name, tool_data, source:)
|
|
348
|
+
end
|
|
259
349
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
372
|
+
def validate_tool_shape!(name, tool_data, source:)
|
|
373
|
+
return if tool_data.is_a?(Hash)
|
|
277
374
|
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
"
|
|
413
|
+
"Unknown tool type #{tool_type.inspect}#{" in #{source}" if source}"
|
|
306
414
|
end
|
|
307
415
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
439
|
+
Prescient::Tool.const_get(tool_name, false)
|
|
440
|
+
end
|
|
324
441
|
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
470
|
+
[base_key, resolve_env_value(value, source:)]
|
|
351
471
|
else
|
|
352
|
-
|
|
472
|
+
[key, coerce_provider_option(key, resolve_value(value, source:))]
|
|
353
473
|
end
|
|
354
474
|
end
|
|
355
|
-
end
|
|
356
475
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
554
|
+
def key_present?(data, key)
|
|
555
|
+
data.key?(key) || data.key?(key.to_s)
|
|
556
|
+
end
|
|
399
557
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
558
|
+
def scalar_present?(data, key)
|
|
559
|
+
key_present?(data, key) || key_present?(data, :"#{key}_env")
|
|
560
|
+
end
|
|
403
561
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
566
|
+
Integer(value)
|
|
567
|
+
rescue ArgumentError, TypeError
|
|
568
|
+
raise Prescient::Error, "#{name} must be an integer"
|
|
569
|
+
end
|
|
411
570
|
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|