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,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Validates the JSON-schema subset used by agent tools.
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
|
+
class SchemaValidator
|
|
8
|
+
# Validate a value against a schema.
|
|
9
|
+
# @param schema [Hash] Supported JSON-schema subset
|
|
10
|
+
# @param value [Object] Value to validate
|
|
11
|
+
# @return [Object] The validated value
|
|
12
|
+
def self.validate!(schema, value)
|
|
13
|
+
new(schema).validate!(value)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(schema)
|
|
17
|
+
@schema = schema.is_a?(Hash) ? schema : {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Validate a value against this validator's schema.
|
|
21
|
+
# @param value [Object] Value to validate
|
|
22
|
+
# @return [Object] The validated value
|
|
23
|
+
def validate!(value)
|
|
24
|
+
validate_schema(@schema, value, [])
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_schema(schema, value, path)
|
|
31
|
+
schema = resolve_reference(schema)
|
|
32
|
+
validate_composition(schema, value, path)
|
|
33
|
+
validate_enum(schema, value, path)
|
|
34
|
+
validate_type(schema, value, path)
|
|
35
|
+
validate_string_constraints(schema, value, path)
|
|
36
|
+
validate_number_constraints(schema, value, path)
|
|
37
|
+
validate_array_constraints(schema, value, path)
|
|
38
|
+
validate_object_constraints(schema, value, path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_composition(schema, value, path)
|
|
42
|
+
schemas = schema_value(schema, "oneOf")
|
|
43
|
+
return unless schemas
|
|
44
|
+
|
|
45
|
+
matches = schemas.count do |candidate|
|
|
46
|
+
validate_schema(candidate, value, path)
|
|
47
|
+
true
|
|
48
|
+
rescue MalformedActionError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
return if matches == 1
|
|
52
|
+
|
|
53
|
+
raise MalformedActionError, "agent tool arguments must match exactly one schema#{path_suffix(path)}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_enum(schema, value, path)
|
|
57
|
+
values = schema_value(schema, "enum")
|
|
58
|
+
return unless values
|
|
59
|
+
return if values.include?(value)
|
|
60
|
+
|
|
61
|
+
raise MalformedActionError, "agent tool argument is not an allowed value#{path_suffix(path)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_type(schema, value, path)
|
|
65
|
+
type = schema_value(schema, "type")
|
|
66
|
+
return unless type
|
|
67
|
+
|
|
68
|
+
valid = {
|
|
69
|
+
"object" => value.is_a?(Hash),
|
|
70
|
+
"array" => value.is_a?(Array),
|
|
71
|
+
"string" => value.is_a?(String),
|
|
72
|
+
"integer" => value.is_a?(Integer),
|
|
73
|
+
"number" => value.is_a?(Numeric) && !value.is_a?(Complex),
|
|
74
|
+
"boolean" => [true, false].include?(value)
|
|
75
|
+
}.fetch(type.to_s, false)
|
|
76
|
+
return if valid
|
|
77
|
+
|
|
78
|
+
raise MalformedActionError, "agent tool arguments must be a #{type}#{path_suffix(path)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_string_constraints(schema, value, path)
|
|
82
|
+
return unless value.is_a?(String)
|
|
83
|
+
|
|
84
|
+
minimum = schema_value(schema, "minLength")
|
|
85
|
+
maximum = schema_value(schema, "maxLength")
|
|
86
|
+
pattern = schema_value(schema, "pattern")
|
|
87
|
+
if minimum && value.length < minimum
|
|
88
|
+
raise MalformedActionError, "agent string is shorter than allowed#{path_suffix(path)}"
|
|
89
|
+
end
|
|
90
|
+
if maximum && value.length > maximum
|
|
91
|
+
raise MalformedActionError, "agent string is longer than allowed#{path_suffix(path)}"
|
|
92
|
+
end
|
|
93
|
+
return unless pattern && !Regexp.new(pattern).match?(value)
|
|
94
|
+
|
|
95
|
+
raise MalformedActionError, "agent string does not match the required pattern#{path_suffix(path)}"
|
|
96
|
+
rescue RegexpError
|
|
97
|
+
raise ConfigurationError, "agent tool schema contains an invalid pattern"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_number_constraints(schema, value, path)
|
|
101
|
+
return unless value.is_a?(Numeric) && !value.is_a?(Complex)
|
|
102
|
+
|
|
103
|
+
minimum = schema_value(schema, "minimum")
|
|
104
|
+
maximum = schema_value(schema, "maximum")
|
|
105
|
+
exclusive_minimum = schema_value(schema, "exclusiveMinimum")
|
|
106
|
+
exclusive_maximum = schema_value(schema, "exclusiveMaximum")
|
|
107
|
+
validate_lower_bound(value, minimum, path)
|
|
108
|
+
validate_upper_bound(value, maximum, path)
|
|
109
|
+
validate_lower_bound(value, exclusive_minimum, path, exclusive: true)
|
|
110
|
+
validate_upper_bound(value, exclusive_maximum, path, exclusive: true)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_array_constraints(schema, value, path)
|
|
114
|
+
return unless value.is_a?(Array)
|
|
115
|
+
|
|
116
|
+
minimum = schema_value(schema, "minItems")
|
|
117
|
+
maximum = schema_value(schema, "maxItems")
|
|
118
|
+
if minimum && value.length < minimum
|
|
119
|
+
raise MalformedActionError, "agent array has too few items#{path_suffix(path)}"
|
|
120
|
+
end
|
|
121
|
+
if maximum && value.length > maximum
|
|
122
|
+
raise MalformedActionError, "agent array has too many items#{path_suffix(path)}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
item_schema = schema_value(schema, "items")
|
|
126
|
+
return unless item_schema.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
value.each_with_index { |item, index| validate_schema(item_schema, item, path + [index]) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_object_constraints(schema, value, path)
|
|
132
|
+
return unless value.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
validate_required(value, schema, path)
|
|
135
|
+
validate_properties(value, schema, path)
|
|
136
|
+
validate_additional_properties(value, schema, path)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_required(value, schema, path)
|
|
140
|
+
(schema_value(schema, "required") || []).each do |key|
|
|
141
|
+
next if value.key?(key) || value.key?(key.to_sym)
|
|
142
|
+
|
|
143
|
+
raise MalformedActionError, "agent tool arguments missing: #{key}#{path_suffix(path)}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_properties(value, schema, path)
|
|
148
|
+
(schema_value(schema, "properties") || {}).each do |key, property_schema|
|
|
149
|
+
next unless value.key?(key) || value.key?(key.to_sym)
|
|
150
|
+
|
|
151
|
+
property_value = value.key?(key) ? value[key] : value[key.to_sym]
|
|
152
|
+
validate_schema(property_schema, property_value, path + [key])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_additional_properties(value, schema, path)
|
|
157
|
+
return unless schema_value(schema, "additionalProperties") == false
|
|
158
|
+
|
|
159
|
+
property_names = (schema_value(schema, "properties") || {}).keys.map(&:to_s)
|
|
160
|
+
value.each_key do |key|
|
|
161
|
+
next if property_names.include?(key.to_s)
|
|
162
|
+
|
|
163
|
+
raise MalformedActionError, "agent tool arguments not allowed: #{key}#{path_suffix(path)}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_lower_bound(value, bound, path, exclusive: false)
|
|
168
|
+
return unless bound.is_a?(Numeric)
|
|
169
|
+
return if exclusive ? value > bound : value >= bound
|
|
170
|
+
|
|
171
|
+
description = exclusive ? "exclusive minimum" : "minimum"
|
|
172
|
+
raise MalformedActionError, "agent number is below the #{description}#{path_suffix(path)}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_upper_bound(value, bound, path, exclusive: false)
|
|
176
|
+
return unless bound.is_a?(Numeric)
|
|
177
|
+
return if exclusive ? value < bound : value <= bound
|
|
178
|
+
|
|
179
|
+
description = exclusive ? "exclusive maximum" : "maximum"
|
|
180
|
+
raise MalformedActionError, "agent number exceeds the #{description}#{path_suffix(path)}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_reference(schema)
|
|
184
|
+
reference = schema_value(schema, "$ref")
|
|
185
|
+
return schema unless reference
|
|
186
|
+
return resolve_pointer(reference) if reference.start_with?("#/")
|
|
187
|
+
|
|
188
|
+
raise ConfigurationError, "agent tool schema only supports local $ref values"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def resolve_pointer(reference)
|
|
192
|
+
reference.delete_prefix("#/").split("/").reduce(@schema) do |current, token|
|
|
193
|
+
key = token.gsub("~1", "/").gsub("~0", "~")
|
|
194
|
+
current.fetch(key) { current.fetch(key.to_sym) }
|
|
195
|
+
end
|
|
196
|
+
rescue KeyError, NoMethodError
|
|
197
|
+
raise ConfigurationError, "agent tool schema contains an unresolved $ref"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def schema_value(schema, key)
|
|
201
|
+
return schema[key] if schema.key?(key)
|
|
202
|
+
return schema[key.to_sym] if schema.key?(key.to_sym)
|
|
203
|
+
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def path_suffix(path)
|
|
208
|
+
return "" if path.empty?
|
|
209
|
+
|
|
210
|
+
" at #{path.map { |part| part.is_a?(Integer) ? "[#{part}]" : part }.join(".")}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
# rubocop:enable Metrics/ClassLength
|
|
214
|
+
end
|
|
215
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Explicit allowlist and invocation boundary for agent tools.
|
|
6
|
+
class ToolRegistry
|
|
7
|
+
# @return [Class] Internal immutable tool descriptor
|
|
8
|
+
Tool = Struct.new(:name, :description, :schema, :callable, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(tools)
|
|
11
|
+
@tools = tools.to_h { |name, tool| [name.to_sym, build_tool(name, tool)] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Return all explicitly allowed tools.
|
|
15
|
+
# @return [Array<Tool>] Tool descriptors
|
|
16
|
+
def all
|
|
17
|
+
@tools.values
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def invoke(name, arguments)
|
|
21
|
+
tool = @tools[name.to_sym]
|
|
22
|
+
raise UnauthorizedToolError, "agent tool not allowed: #{name}" unless tool
|
|
23
|
+
|
|
24
|
+
SchemaValidator.validate!(tool.schema, arguments)
|
|
25
|
+
tool.callable.call(arguments)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_tool(name, tool)
|
|
31
|
+
unless tool.respond_to?(:search) || tool.respond_to?(:call)
|
|
32
|
+
raise ConfigurationError, "agent tool does not support invocation: #{name}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
validate_callable_schema!(name, tool) if tool.respond_to?(:call) && !tool.respond_to?(:search)
|
|
36
|
+
|
|
37
|
+
Tool.new(
|
|
38
|
+
name: name.to_sym,
|
|
39
|
+
description: tool_description(tool),
|
|
40
|
+
schema: tool_schema(tool),
|
|
41
|
+
callable: ->(arguments) { invoke_tool(tool, arguments) }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def invoke_tool(tool, arguments)
|
|
46
|
+
return tool.call(arguments) if tool.respond_to?(:call)
|
|
47
|
+
|
|
48
|
+
invoke_search(tool, arguments)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def invoke_search(tool, arguments)
|
|
52
|
+
query = arguments["query"] || arguments[:query]
|
|
53
|
+
valid_query = query.is_a?(String) && !query.strip.empty?
|
|
54
|
+
raise MalformedActionError, "search tool requires a non-empty query" unless valid_query
|
|
55
|
+
|
|
56
|
+
tool.search(query, limit: arguments["limit"] || arguments[:limit])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tool_description(tool)
|
|
60
|
+
return tool.description if tool.respond_to?(:description)
|
|
61
|
+
|
|
62
|
+
return "Search using the configured external capability." if tool.respond_to?(:search)
|
|
63
|
+
|
|
64
|
+
raise ConfigurationError, "generic agent tool requires a description"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tool_schema(tool)
|
|
68
|
+
return tool.schema if tool.respond_to?(:schema)
|
|
69
|
+
|
|
70
|
+
{ type: "object", required: ["query"] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_callable_schema!(name, tool)
|
|
74
|
+
schema = tool.respond_to?(:schema) ? tool.schema : nil
|
|
75
|
+
valid = schema.is_a?(Hash) && !schema.empty? && schema_value(schema, "type") == "object"
|
|
76
|
+
return if valid
|
|
77
|
+
|
|
78
|
+
raise ConfigurationError, "generic agent tool requires a non-empty object schema: #{name}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def schema_value(schema, key)
|
|
82
|
+
return schema[key] if schema.key?(key)
|
|
83
|
+
return schema[key.to_sym] if schema.key?(key.to_sym)
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Prescient
|
|
6
|
+
# Optional bounded agent orchestration over Prescient Core.
|
|
7
|
+
module Agent
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "agent/configuration"
|
|
12
|
+
require_relative "agent/audit_log"
|
|
13
|
+
require_relative "agent/errors"
|
|
14
|
+
require_relative "agent/error_serializer"
|
|
15
|
+
require_relative "agent/schema_validator"
|
|
16
|
+
require_relative "agent/cli_adapter"
|
|
17
|
+
require_relative "agent/result"
|
|
18
|
+
require_relative "agent/context"
|
|
19
|
+
require_relative "agent/parser"
|
|
20
|
+
require_relative "agent/prompt_builder"
|
|
21
|
+
require_relative "agent/tool_registry"
|
|
22
|
+
require_relative "agent/runtime"
|