prescient 0.6.0 → 0.7.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.
data/lib/prescient/cli.rb CHANGED
@@ -47,8 +47,8 @@ class Prescient::CLI
47
47
  chat_model: llama3.2:3b
48
48
  # prompt_templates:
49
49
  # system_prompt: You are a concise assistant.
50
- # no_context_template: "%<system_prompt>s\\n\\nUser: %<query>s"
51
- # with_context_template: "%<system_prompt>s\\n\\nContext:\\n%<context>s\\n\\nUser: %<query>s"
50
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
51
+ # with_context_template: "%<system_prompt>s\x5Cn\x5CnContext:\x5Cn%<context>s\x5Cn\x5CnUser: %<query>s"
52
52
 
53
53
  # Uncomment a cloud provider and set its credential in the environment.
54
54
  # openai:
@@ -58,7 +58,7 @@ class Prescient::CLI
58
58
  # chat_model: gpt-4.1-mini
59
59
  # prompt_templates:
60
60
  # system_prompt: You are a concise assistant.
61
- # no_context_template: "%<system_prompt>s\n\nUser: %<query>s"
61
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
62
62
 
63
63
  # anthropic:
64
64
  # type: anthropic
@@ -93,6 +93,62 @@ class Prescient::CLI
93
93
  # api_key_env: HUGGINGFACE_API_KEY
94
94
  # embedding_model: sentence-transformers/all-MiniLM-L6-v2
95
95
  # chat_model: google/gemma-2-2b-it
96
+
97
+ # External tools are opt-in and separate from AI providers. They can be
98
+ # used directly with `prescient search`, or as context with
99
+ # `prescient search --generate`.
100
+ #
101
+ # The CLI also registers `web_search` automatically when SEARXNG_URL is
102
+ # set and no YAML tool configuration is provided.
103
+ tools:
104
+ # Local SearXNG example. Uncomment this block to configure a tool in YAML.
105
+ # web_search:
106
+ # type: searxng
107
+ # url: http://localhost:8080
108
+ # timeout: 5
109
+ # max_results: 5
110
+ # language: en
111
+ # categories:
112
+ # - general
113
+ # - science
114
+ # max_response_bytes: 1048576
115
+
116
+ # SearchApi example. It uses SearchApi's Google engine by default and
117
+ # authenticates with a Bearer token from the environment.
118
+ # searchapi_web:
119
+ # type: searchapi
120
+ # api_key_env: SEARCHAPI_API_KEY
121
+ # engine: google
122
+ # location: New York
123
+ # hl: en
124
+ # gl: us
125
+ # timeout: 10
126
+ # max_results: 5
127
+
128
+ # Capability fallback. Adapters are tried in order, and fallback occurs
129
+ # only for transient connection or rate-limit failures.
130
+ # resilient_search:
131
+ # adapters:
132
+ # - type: searxng
133
+ # url_env: SEARXNG_URL
134
+ # - type: searchapi
135
+ # api_key_env: SEARCHAPI_API_KEY
136
+ # engine: google
137
+
138
+ # Prefer an environment reference when the URL differs by environment
139
+ # or should not be committed. Use `--tool research_search` to select a
140
+ # tool with a custom name.
141
+ # research_search:
142
+ # type: searxng
143
+ # url_env: SEARXNG_URL
144
+ # timeout_env: SEARXNG_TIMEOUT
145
+ # max_results_env: SEARXNG_MAX_RESULTS
146
+ # language_env: SEARXNG_LANGUAGE
147
+ # categories_env: SEARXNG_CATEGORIES
148
+
149
+ # Search results are returned directly by default. Add `--generate` to
150
+ # feed normalized results to the selected AI provider. Omit `--generate`
151
+ # when the caller should handle the search results itself.
96
152
  YAML
97
153
 
98
154
  # Raised when command-line arguments are invalid or incomplete.
@@ -149,6 +205,7 @@ class Prescient::CLI
149
205
  when 'health' then health
150
206
  when 'generate' then generate
151
207
  when 'embed' then embed
208
+ when 'search' then search
152
209
  when 'config' then config
153
210
  when 'help', '--help', '-h' then print_help(0)
154
211
  else
@@ -214,6 +271,52 @@ class Prescient::CLI
214
271
  0
215
272
  end
216
273
 
274
+ def search
275
+ options = parse_options(
276
+ 'Search with a configured external tool',
277
+ fallback: true, tool: true, limit: true, generate: true,
278
+ )
279
+ return options if options.is_a?(Integer)
280
+
281
+ query = read_text(options[:arguments], 'query')
282
+ tool_name = (options[:tool] || 'web_search').to_sym
283
+ return generate_search_response(query, tool_name, options) if options[:generate]
284
+
285
+ tool = Prescient.tool(tool_name)
286
+ raise UsageError, "tool not configured: #{tool_name}" unless tool
287
+
288
+ result = tool.search(query, limit: options[:limit])
289
+ if options[:format] == 'json'
290
+ print_json(result)
291
+ else
292
+ print_search_results(result[:results])
293
+ end
294
+ 0
295
+ end
296
+
297
+ def generate_search_response(query, tool_name, options)
298
+ response = Prescient.search_and_generate(
299
+ query,
300
+ tool: tool_name,
301
+ provider: options[:provider]&.to_sym,
302
+ limit: options[:limit],
303
+ enable_fallback: options[:fallback],
304
+ provider_options: provider_options(options),
305
+ **model_options(options),
306
+ )
307
+ options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
308
+ 0
309
+ end
310
+
311
+ def print_search_results(results)
312
+ results.each do |item|
313
+ @output.puts item[:title]
314
+ @output.puts item[:url]
315
+ @output.puts item[:snippet] unless item[:snippet].empty?
316
+ @output.puts
317
+ end
318
+ end
319
+
217
320
  def config
218
321
  subcommand = @arguments.shift
219
322
  case subcommand
@@ -251,16 +354,24 @@ class Prescient::CLI
251
354
  raise Prescient::Error, 'default provider is not configured'
252
355
  end
253
356
 
254
- configuration.providers.each_key { |name| configuration.provider(name) }
357
+ configuration.providers.each_key do |name|
358
+ configuration.provider(name)
359
+ end
360
+ configuration.tools.each_key do |name|
361
+ configuration.tool(name)
362
+ end
255
363
  end
256
364
 
257
- def parse_options(description, fallback: false)
365
+ def parse_options(description, fallback: false, tool: false, limit: false, generate: false)
258
366
  options = { format: 'text', fallback: fallback }
259
367
  parser = OptionParser.new do |parser|
260
368
  parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
261
369
  parser.separator description
370
+ parser.separator ''
371
+ parser.separator 'Global options:'
262
372
  add_common_options(parser, options)
263
373
  parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
374
+ add_tool_options(parser, options, tool:, limit:, generate:)
264
375
  parser.on('-h', '--help', 'Show command help') do
265
376
  @output.puts parser
266
377
  throw :help_shown, 0
@@ -274,6 +385,18 @@ class Prescient::CLI
274
385
  options
275
386
  end
276
387
 
388
+ def add_tool_options(parser, options, tool:, limit:, generate:)
389
+ return unless tool || limit || generate
390
+
391
+ parser.separator ''
392
+ parser.separator 'Search options:'
393
+ parser.on('--tool NAME', 'Use a configured external tool') { |value| options[:tool] = value } if tool
394
+ parser.on('--generate', 'Use search results as AI provider context') { options[:generate] = true } if generate
395
+ return unless limit
396
+
397
+ parser.on('--limit COUNT', Integer, 'Limit the number of results') { |value| options[:limit] = value }
398
+ end
399
+
277
400
  def add_common_options(parser, options)
278
401
  parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
279
402
  options[:config] = value
@@ -425,10 +548,11 @@ class Prescient::CLI
425
548
  health Check provider health
426
549
  generate TEXT Generate a text response
427
550
  embed TEXT Generate an embedding
551
+ search TEXT Search with a configured external tool
428
552
  config validate Validate the current configuration
429
553
  config example Generate an annotated YAML configuration example
430
554
 
431
- Options:
555
+ Global options:
432
556
  --config PATH Load configuration from a YAML file
433
557
  --provider NAME Select a provider
434
558
  --model NAME Override the selected operation's model
@@ -444,6 +568,11 @@ class Prescient::CLI
444
568
  --api-key KEY Use an API key for the operation
445
569
  --api-key-env NAME Read the API key from an environment variable
446
570
  --format FORMAT Use text or json output
571
+
572
+ Search options:
573
+ --tool NAME Select an external tool for search
574
+ --generate Use search results as AI provider context
575
+ --limit COUNT Limit search results
447
576
  HELP
448
577
  status
449
578
  end
@@ -247,6 +247,32 @@ module Prescient
247
247
  client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
248
248
  end
249
249
 
250
+ # Search with an explicit external tool and optionally use the normalized
251
+ # results as context for a configured AI provider.
252
+ #
253
+ # @param query [String] Search query and generation prompt
254
+ # @param tool [Symbol, String] Configured external tool name
255
+ # @param provider [Symbol, nil] Provider to use for generation
256
+ # @param limit [Integer, nil] Maximum number of search results
257
+ # @param enable_fallback [Boolean] Whether provider fallback is enabled
258
+ # @param provider_options [Hash] Temporary provider configuration overrides
259
+ # @return [Hash] Normalized provider response
260
+ def self.search_and_generate(query, tool: :web_search, provider: nil, limit: nil,
261
+ enable_fallback: true, provider_options: {}, **options)
262
+ search_tool = self.tool(tool)
263
+ raise Prescient::ToolConfigurationError, "tool not configured: #{tool}" unless search_tool
264
+
265
+ search_result = search_tool.search(query, limit: limit)
266
+ context_items = search_result[:results]
267
+ raise Prescient::ToolInvalidResponseError, 'tool results must be an array' unless context_items.is_a?(Array)
268
+
269
+ client(provider, enable_fallback:, provider_options:).generate_response(
270
+ query,
271
+ context_items,
272
+ **options,
273
+ )
274
+ end
275
+
250
276
  # Return the health status of a configured provider.
251
277
  #
252
278
  # @param provider [Symbol, nil] Provider to check
@@ -16,6 +16,7 @@ class Prescient::ConfigurationLoader
16
16
  'fallback_providers',
17
17
  'fallback_providers_env',
18
18
  'providers',
19
+ 'tools',
19
20
  'retry_attempts',
20
21
  'retry_attempts_env',
21
22
  'retry_delay',
@@ -39,6 +40,12 @@ class Prescient::ConfigurationLoader
39
40
  'xai' => Prescient::Provider::XAI,
40
41
  }.freeze
41
42
 
43
+ # Tool names mapped to lazily resolved adapter constants.
44
+ TOOL_TYPES = {
45
+ 'searchapi' => :SearchApi,
46
+ 'searxng' => :SearXNG,
47
+ }.freeze
48
+
42
49
  # Provider-specific keys shared by all supported adapters.
43
50
  COMMON_PROVIDER_KEYS = [
44
51
  'api_key',
@@ -61,6 +68,32 @@ class Prescient::ConfigurationLoader
61
68
  'url_env',
62
69
  ].freeze
63
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
+
64
97
  # Prompt template keys supported by provider configuration.
65
98
  PROMPT_TEMPLATE_KEYS = ['system_prompt', 'no_context_template', 'with_context_template'].freeze
66
99
 
@@ -131,6 +164,7 @@ class Prescient::ConfigurationLoader
131
164
  def load_hash(data, source: nil)
132
165
  configuration = Prescient::Configuration.new
133
166
  Prescient.send(:configure_default_providers, configuration, @env)
167
+ Prescient.send(:configure_default_tools, configuration, @env)
134
168
  apply!(configuration, data, source:)
135
169
  configuration
136
170
  end
@@ -147,6 +181,7 @@ class Prescient::ConfigurationLoader
147
181
 
148
182
  apply_scalar_settings(configuration, normalized, source:)
149
183
  apply_provider_settings(configuration, normalized, source:)
184
+ apply_tool_settings(configuration, normalized, source:)
150
185
  configuration
151
186
  end
152
187
 
@@ -234,6 +269,28 @@ class Prescient::ConfigurationLoader
234
269
  end
235
270
  end
236
271
 
272
+ def apply_tool_settings(configuration, data, source:)
273
+ return unless key_present?(data, :tools)
274
+
275
+ tools = data[:tools]
276
+ unless tools.is_a?(Hash)
277
+ raise Prescient::Error, "Configuration#{" in #{source}" if source} tools must be a mapping"
278
+ end
279
+
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:)
284
+
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)
290
+ end
291
+ end
292
+ end
293
+
237
294
  def validate_provider!(name, provider_data, source:)
238
295
  validate_provider_shape!(name, provider_data, source:)
239
296
  validate_provider_type!(name, provider_data, source:)
@@ -280,6 +337,58 @@ class Prescient::ConfigurationLoader
280
337
  raise Prescient::Error, message
281
338
  end
282
339
 
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)
343
+
344
+ validate_tool_type!(name, tool_data, source:)
345
+ validate_tool_keys!(name, tool_data, source:)
346
+ end
347
+
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}"
355
+ end
356
+
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
361
+
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:)
367
+ end
368
+ end
369
+
370
+ def validate_tool_shape!(name, tool_data, source:)
371
+ return if tool_data.is_a?(Hash)
372
+
373
+ raise Prescient::Error, "Tool #{name.inspect}#{" in #{source}" if source} must be a mapping"
374
+ end
375
+
376
+ def validate_tool_type!(name, tool_data, source:)
377
+ return if tool_data.key?(:type)
378
+
379
+ raise Prescient::Error, "Tool #{name}#{" in #{source}" if source} must define type"
380
+ end
381
+
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?
385
+
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
391
+
283
392
  def resolve_provider_options(provider_data, source:)
284
393
  provider_type = provider_data[:type].to_s
285
394
  provider_class = PROVIDER_TYPES[provider_type]
@@ -294,6 +403,57 @@ class Prescient::ConfigurationLoader
294
403
  end
295
404
  end
296
405
 
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
+ raise Prescient::Error,
411
+ "Unknown tool type #{tool_type.inspect}#{" in #{source}" if source}"
412
+ end
413
+
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
419
+
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
+ }
427
+ end
428
+ end
429
+
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
439
+
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?
443
+
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}"
449
+ end
450
+
451
+ [base_key, resolve_env_value(value, source:)]
452
+ else
453
+ [key, coerce_tool_option(key, resolve_value(value, source:))]
454
+ end
455
+ end
456
+
297
457
  def resolve_provider_option(provider_data, key, value, source:)
298
458
  return if key == :type
299
459
  return if key.to_s.end_with?('_env') && value.nil?
@@ -425,6 +585,17 @@ class Prescient::ConfigurationLoader
425
585
  end
426
586
  end
427
587
 
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
596
+ end
597
+ end
598
+
428
599
  def validate_version!(data, source:)
429
600
  return unless key_present?(data, :version)
430
601
 
@@ -37,6 +37,18 @@ module Prescient
37
37
  # Raised when an AI provider reports a transient service-side failure
38
38
  class ProviderError < Error; end
39
39
 
40
+ # Base error class for external tool failures.
41
+ class ToolError < Error; end
42
+
43
+ # Raised when an external tool is configured incorrectly.
44
+ class ToolConfigurationError < ToolError; end
45
+
46
+ # Raised when an external tool cannot be reached.
47
+ class ToolConnectionError < ToolError; end
48
+
49
+ # Raised when an external tool returns an invalid response.
50
+ class ToolInvalidResponseError < ToolError; end
51
+
40
52
  # Container module for AI provider implementations
41
53
  #
42
54
  # All provider classes should be defined within this module and inherit
@@ -83,8 +83,6 @@ class Prescient::Provider::OpenAI < Prescient::Base
83
83
 
84
84
  validate_response!(response, 'text generation')
85
85
 
86
- puts "response.parsed_response: #{response.parsed_response.inspect}"
87
-
88
86
  content = response.parsed_response.dig('choices', 0, 'message', 'content')
89
87
  raise Prescient::InvalidResponseError, 'No response generated' unless content
90
88
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'net/http'
5
+ require 'timeout'
6
+
7
+ # SearchApi web-search tool adapter.
8
+ class Prescient::Tool::SearchApi < Prescient::Tool::Base
9
+ # SearchApi's stable search endpoint.
10
+ API_URL = 'https://www.searchapi.io/api/v1/search'
11
+ # Network failures that should be reported as connection errors.
12
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
13
+ Errno::ECONNREFUSED].freeze
14
+
15
+ # @param options [Hash] SearchApi configuration options
16
+ # @option options [String] :api_key SearchApi API key
17
+ # @option options [String] :engine SearchApi engine, defaulting to Google
18
+ # @option options [String] :location Optional search location
19
+ # @option options [String] :hl Optional interface language
20
+ # @option options [String] :gl Optional country code
21
+ def initialize(**options)
22
+ super
23
+ @api_key = required_api_key(options[:api_key])
24
+ end
25
+
26
+ # Search SearchApi and return normalized result metadata.
27
+ # @param query [String] Search query
28
+ # @param limit [Integer, nil] Maximum number of results
29
+ # @param timeout [Numeric, nil] Request timeout in seconds
30
+ # @return [Hash] Normalized search result envelope
31
+ def search(query, limit: nil, timeout: nil)
32
+ normalized_query = validate_query(query)
33
+ requested_limit = result_limit(limit)
34
+ requested_timeout = request_timeout(timeout)
35
+ response = HTTParty.get(
36
+ API_URL,
37
+ headers: request_headers,
38
+ query: request_parameters(normalized_query, requested_limit),
39
+ timeout: requested_timeout,
40
+ )
41
+ validate_response!(response)
42
+
43
+ {
44
+ tool: 'web_search',
45
+ query: normalized_query,
46
+ source: 'searchapi',
47
+ results: normalize_results(response.parsed_response, requested_limit),
48
+ }
49
+ rescue Prescient::Error
50
+ raise
51
+ rescue *NETWORK_ERRORS => e
52
+ raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
53
+ rescue StandardError => e
54
+ raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
55
+ end
56
+
57
+ protected
58
+
59
+ # Validate SearchApi-specific configuration.
60
+ # @return [void]
61
+ def validate_configuration!
62
+ required_api_key(@options[:api_key])
63
+ max_response_bytes
64
+ result_limit(nil)
65
+ request_timeout(nil)
66
+ end
67
+
68
+ private
69
+
70
+ def required_api_key(value)
71
+ return value if value.is_a?(String) && !value.empty?
72
+
73
+ raise Prescient::ToolConfigurationError, 'SearchApi requires an api_key'
74
+ end
75
+
76
+ def request_headers
77
+ { 'Authorization' => "Bearer #{@api_key}" }
78
+ end
79
+
80
+ def request_parameters(query, limit)
81
+ parameters = {
82
+ engine: @options.fetch(:engine, 'google'),
83
+ q: query,
84
+ num: limit,
85
+ }
86
+ [:location, :hl, :gl].each do |key|
87
+ parameters[key] = @options[key] if @options[key]
88
+ end
89
+ parameters
90
+ end
91
+
92
+ def validate_response!(response)
93
+ if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
94
+ raise Prescient::ToolInvalidResponseError, 'SearchApi response exceeds configured size limit'
95
+ end
96
+
97
+ return if response.success?
98
+
99
+ error_class = case response.code.to_i
100
+ when 401, 403 then Prescient::AuthenticationError
101
+ when 429 then Prescient::RateLimitError
102
+ when 500..599 then Prescient::ToolConnectionError
103
+ else Prescient::ToolError
104
+ end
105
+ raise error_class, "SearchApi returned HTTP #{response.code}"
106
+ end
107
+
108
+ def normalize_results(payload, limit)
109
+ raw_results = payload.is_a?(Hash) ? payload['organic_results'] : nil
110
+ unless raw_results.is_a?(Array)
111
+ raise Prescient::ToolInvalidResponseError, 'SearchApi response did not contain organic_results'
112
+ end
113
+
114
+ raw_results.filter_map { |result|
115
+ next unless result.is_a?(Hash)
116
+ next unless result['link'].is_a?(String) && !result['link'].empty?
117
+
118
+ {
119
+ title: result['title'].to_s,
120
+ url: result['link'],
121
+ snippet: result['snippet'].to_s,
122
+ source: 'searchapi',
123
+ }
124
+ }.first(limit)
125
+ end
126
+ end