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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Prescient
|
|
8
|
+
module Tool
|
|
9
|
+
# SearchApi web-search tool adapter.
|
|
10
|
+
class SearchApi < Prescient::Tool::Base
|
|
11
|
+
# SearchApi's stable search endpoint.
|
|
12
|
+
API_URL = "https://www.searchapi.io/api/v1/search"
|
|
13
|
+
# Network failures that should be reported as connection errors.
|
|
14
|
+
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError,
|
|
15
|
+
Errno::ECONNREFUSED].freeze
|
|
16
|
+
|
|
17
|
+
# @param options [Hash] SearchApi configuration options
|
|
18
|
+
# @option options [String] :api_key SearchApi API key
|
|
19
|
+
# @option options [String] :engine SearchApi engine, defaulting to Google
|
|
20
|
+
# @option options [String] :location Optional search location
|
|
21
|
+
# @option options [String] :hl Optional interface language
|
|
22
|
+
# @option options [String] :gl Optional country code
|
|
23
|
+
def initialize(**options)
|
|
24
|
+
super
|
|
25
|
+
@api_key = required_api_key(options[:api_key])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Search SearchApi and return normalized result metadata.
|
|
29
|
+
# @param query [String] Search query
|
|
30
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
31
|
+
# @param timeout [Numeric, nil] Request timeout in seconds
|
|
32
|
+
# @return [Hash] Normalized search result envelope
|
|
33
|
+
def search(query, limit: nil, timeout: nil)
|
|
34
|
+
normalized_query = validate_query(query)
|
|
35
|
+
requested_limit = result_limit(limit)
|
|
36
|
+
requested_timeout = request_timeout(timeout)
|
|
37
|
+
response = HTTParty.get(
|
|
38
|
+
API_URL,
|
|
39
|
+
headers: request_headers,
|
|
40
|
+
query: request_parameters(normalized_query, requested_limit),
|
|
41
|
+
timeout: requested_timeout
|
|
42
|
+
)
|
|
43
|
+
validate_response!(response)
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
tool: "web_search",
|
|
47
|
+
query: normalized_query,
|
|
48
|
+
source: "searchapi",
|
|
49
|
+
results: normalize_results(response.parsed_response, requested_limit)
|
|
50
|
+
}
|
|
51
|
+
rescue Prescient::Error
|
|
52
|
+
raise
|
|
53
|
+
rescue *NETWORK_ERRORS => e
|
|
54
|
+
raise Prescient::ToolConnectionError, "SearchApi request failed: #{e.class}"
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
raise Prescient::ToolError, "SearchApi request failed: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
protected
|
|
60
|
+
|
|
61
|
+
# Validate SearchApi-specific configuration.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def validate_configuration!
|
|
64
|
+
required_api_key(@options[:api_key])
|
|
65
|
+
max_response_bytes
|
|
66
|
+
result_limit(nil)
|
|
67
|
+
request_timeout(nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def required_api_key(value)
|
|
73
|
+
return value if value.is_a?(String) && !value.empty?
|
|
74
|
+
|
|
75
|
+
raise Prescient::ToolConfigurationError, "SearchApi requires an api_key"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def request_headers
|
|
79
|
+
{ "Authorization" => "Bearer #{@api_key}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def request_parameters(query, limit)
|
|
83
|
+
parameters = {
|
|
84
|
+
engine: @options.fetch(:engine, "google"),
|
|
85
|
+
q: query,
|
|
86
|
+
num: limit
|
|
87
|
+
}
|
|
88
|
+
%i[location hl gl].each do |key|
|
|
89
|
+
parameters[key] = @options[key] if @options[key]
|
|
90
|
+
end
|
|
91
|
+
parameters
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_response!(response)
|
|
95
|
+
if response.respond_to?(:body) && response.body.to_s.bytesize > max_response_bytes
|
|
96
|
+
raise Prescient::ToolInvalidResponseError, "SearchApi response exceeds configured size limit"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if response.success?
|
|
100
|
+
|
|
101
|
+
error_class = case response.code.to_i
|
|
102
|
+
when 401, 403 then Prescient::AuthenticationError
|
|
103
|
+
when 429 then Prescient::RateLimitError
|
|
104
|
+
when 500..599 then Prescient::ToolConnectionError
|
|
105
|
+
else Prescient::ToolError
|
|
106
|
+
end
|
|
107
|
+
raise error_class, "SearchApi returned HTTP #{response.code}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_results(payload, limit)
|
|
111
|
+
raw_results = payload.is_a?(Hash) ? payload["organic_results"] : nil
|
|
112
|
+
unless raw_results.is_a?(Array)
|
|
113
|
+
raise Prescient::ToolInvalidResponseError, "SearchApi response did not contain organic_results"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raw_results.filter_map do |result|
|
|
117
|
+
next unless result.is_a?(Hash)
|
|
118
|
+
next unless result["link"].is_a?(String) && !result["link"].empty?
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
title: result["title"].to_s,
|
|
122
|
+
url: result["link"],
|
|
123
|
+
snippet: result["snippet"].to_s,
|
|
124
|
+
source: "searchapi"
|
|
125
|
+
}
|
|
126
|
+
end.first(limit)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Prescient
|
|
9
|
+
module Tool
|
|
10
|
+
# SearXNG web-search tool adapter.
|
|
11
|
+
class SearXNG < Prescient::Tool::Base
|
|
12
|
+
# Network failures that should be reported as connection errors.
|
|
13
|
+
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, SocketError, Errno::ECONNREFUSED].freeze
|
|
14
|
+
|
|
15
|
+
# @param options [Hash] SearXNG configuration options
|
|
16
|
+
# @option options [String] :url SearXNG base URL
|
|
17
|
+
# @option options [String] :language Optional search language
|
|
18
|
+
# @option options [String, Array<String>] :categories Optional categories
|
|
19
|
+
def initialize(**options)
|
|
20
|
+
super
|
|
21
|
+
@base_url = normalized_base_url(options.fetch(:url))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Search SearXNG and return normalized result metadata.
|
|
25
|
+
# @param query [String] Search query
|
|
26
|
+
# @param limit [Integer, nil] Maximum number of results
|
|
27
|
+
# @param timeout [Numeric, nil] Request timeout in seconds
|
|
28
|
+
# @return [Hash] Normalized search result envelope
|
|
29
|
+
def search(query, limit: nil, timeout: nil)
|
|
30
|
+
normalized_query = validate_query(query)
|
|
31
|
+
requested_limit = result_limit(limit)
|
|
32
|
+
requested_timeout = request_timeout(timeout)
|
|
33
|
+
response = HTTParty.get(
|
|
34
|
+
search_url,
|
|
35
|
+
query: request_parameters(normalized_query),
|
|
36
|
+
timeout: requested_timeout
|
|
37
|
+
)
|
|
38
|
+
validate_response!(response)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
tool: "web_search",
|
|
42
|
+
query: normalized_query,
|
|
43
|
+
source: "searxng",
|
|
44
|
+
results: normalize_results(response.parsed_response, requested_limit)
|
|
45
|
+
}
|
|
46
|
+
rescue Prescient::Error
|
|
47
|
+
raise
|
|
48
|
+
rescue *NETWORK_ERRORS => e
|
|
49
|
+
raise Prescient::ToolConnectionError, "SearXNG request failed: #{e.class}"
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
raise Prescient::ToolError, "SearXNG request failed: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
# Validate SearXNG-specific configuration.
|
|
57
|
+
# @return [void]
|
|
58
|
+
def validate_configuration!
|
|
59
|
+
normalized_base_url(@options.fetch(:url))
|
|
60
|
+
max_response_bytes
|
|
61
|
+
result_limit(nil)
|
|
62
|
+
request_timeout(nil)
|
|
63
|
+
rescue KeyError
|
|
64
|
+
raise Prescient::ToolConfigurationError, "SearXNG requires a url"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def normalized_base_url(value)
|
|
70
|
+
uri = URI.parse(value.to_s)
|
|
71
|
+
valid_scheme = %w[http https].include?(uri.scheme)
|
|
72
|
+
if !valid_scheme || uri.host.nil? || uri.userinfo
|
|
73
|
+
raise Prescient::ToolConfigurationError, "SearXNG url must be an HTTP(S) URL without credentials"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
uri.to_s.delete_suffix("/")
|
|
77
|
+
rescue URI::InvalidURIError
|
|
78
|
+
raise Prescient::ToolConfigurationError, "SearXNG url must be a valid HTTP(S) URL"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def search_url
|
|
82
|
+
"#{@base_url}/search"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def request_parameters(query)
|
|
86
|
+
parameters = { q: query, format: "json" }
|
|
87
|
+
parameters[:language] = @options[:language] if @options[:language]
|
|
88
|
+
parameters[:categories] = Array(@options[:categories]).join(",") if @options[:categories]
|
|
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, "SearXNG 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, "SearXNG returned HTTP #{response.code}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_results(payload, limit)
|
|
109
|
+
raw_results = payload.is_a?(Hash) ? payload["results"] : nil
|
|
110
|
+
unless raw_results.is_a?(Array)
|
|
111
|
+
raise Prescient::ToolInvalidResponseError, "SearXNG response did not contain results"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raw_results.filter_map do |result|
|
|
115
|
+
next unless result.is_a?(Hash)
|
|
116
|
+
next unless result["url"].is_a?(String) && !result["url"].empty?
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
title: result["title"].to_s,
|
|
120
|
+
url: result["url"],
|
|
121
|
+
snippet: result["content"].to_s,
|
|
122
|
+
source: "searxng"
|
|
123
|
+
}
|
|
124
|
+
end.first(limit)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prescient
|
|
4
|
+
# Namespace for external capability adapters.
|
|
5
|
+
module Tool
|
|
6
|
+
autoload :SearXNG, "prescient/tool/searxng"
|
|
7
|
+
autoload :SearchApi, "prescient/tool/search_api"
|
|
8
|
+
|
|
9
|
+
# Base contract for explicit external tool invocation.
|
|
10
|
+
class Base
|
|
11
|
+
# @return [Integer] Default request timeout in seconds
|
|
12
|
+
DEFAULT_TIMEOUT = 5
|
|
13
|
+
# @return [Integer] Default maximum number of returned results
|
|
14
|
+
DEFAULT_MAX_RESULTS = 5
|
|
15
|
+
# @return [Integer] Maximum accepted search query length
|
|
16
|
+
MAX_QUERY_LENGTH = 2_000
|
|
17
|
+
# @return [Integer] Maximum accepted tool response size in bytes
|
|
18
|
+
DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
19
|
+
|
|
20
|
+
# @return [Hash<Symbol, untyped>] Tool configuration options
|
|
21
|
+
attr_reader :options
|
|
22
|
+
|
|
23
|
+
# @param options [Hash] Tool-specific configuration options
|
|
24
|
+
def initialize(**options)
|
|
25
|
+
@options = options
|
|
26
|
+
validate_configuration!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execute a search using the tool.
|
|
30
|
+
# @param query [String] Search query
|
|
31
|
+
# @param options [Hash] Per-request options
|
|
32
|
+
# @return [Hash] Normalized tool result
|
|
33
|
+
# @raise [NotImplementedError] If the adapter does not support searching
|
|
34
|
+
def search(query, **options)
|
|
35
|
+
raise NotImplementedError, "#{self.class} must implement #search"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
# Validate adapter configuration.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def validate_configuration!
|
|
43
|
+
# Override in subclasses.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param query [String] Search query
|
|
47
|
+
# @return [String] Validated query
|
|
48
|
+
def validate_query(query)
|
|
49
|
+
unless query.is_a?(String) && !query.strip.empty?
|
|
50
|
+
raise Prescient::ToolConfigurationError, "search query must be a non-empty string"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
cleaned_query = query.strip
|
|
54
|
+
if cleaned_query.length > MAX_QUERY_LENGTH
|
|
55
|
+
raise Prescient::ToolConfigurationError,
|
|
56
|
+
"search query cannot contain more than #{MAX_QUERY_LENGTH} characters"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
cleaned_query
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param value [Object] Requested result limit
|
|
63
|
+
# @return [Integer] Validated result limit
|
|
64
|
+
def result_limit(value)
|
|
65
|
+
limit = value.nil? ? @options.fetch(:max_results, DEFAULT_MAX_RESULTS) : value
|
|
66
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
67
|
+
raise Prescient::ToolConfigurationError, "max_results must be a positive integer"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
[limit, 20].min
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param value [Object] Requested timeout
|
|
74
|
+
# @return [Numeric] Validated timeout
|
|
75
|
+
def request_timeout(value)
|
|
76
|
+
timeout = value.nil? ? @options.fetch(:timeout, DEFAULT_TIMEOUT) : value
|
|
77
|
+
unless timeout.is_a?(Numeric) && timeout.positive?
|
|
78
|
+
raise Prescient::ToolConfigurationError, "timeout must be a positive number"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
timeout
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Integer] Maximum accepted response size
|
|
85
|
+
def max_response_bytes
|
|
86
|
+
value = @options.fetch(:max_response_bytes, DEFAULT_MAX_RESPONSE_BYTES)
|
|
87
|
+
unless value.is_a?(Integer) && value.positive?
|
|
88
|
+
raise Prescient::ToolConfigurationError,
|
|
89
|
+
"max_response_bytes must be a positive integer"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Ordered implementations for one logical tool capability.
|
|
97
|
+
class Group < Base
|
|
98
|
+
# @return [Array<Base>] Ordered capability implementations
|
|
99
|
+
attr_reader :adapters
|
|
100
|
+
|
|
101
|
+
# @param adapters [Array<Base>] Ordered capability implementations
|
|
102
|
+
def initialize(adapters:)
|
|
103
|
+
super
|
|
104
|
+
@adapters = adapters
|
|
105
|
+
raise Prescient::ToolConfigurationError, "tool group requires an adapter" if @adapters.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Execute the first successful adapter, falling back only for transient
|
|
109
|
+
# connection and rate-limit failures.
|
|
110
|
+
# @param query [String] Search query
|
|
111
|
+
# @param options [Hash] Per-request options
|
|
112
|
+
# @return [Hash] Normalized tool result
|
|
113
|
+
def search(query, **options)
|
|
114
|
+
failures = []
|
|
115
|
+
@adapters.each do |adapter|
|
|
116
|
+
return adapter.search(query, **options)
|
|
117
|
+
rescue Prescient::ToolConnectionError, Prescient::RateLimitError => e
|
|
118
|
+
failures << e
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raise failures.last
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/prescient/version.rb
CHANGED