prescient 0.7.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren
4
+ module Prescient::Agent
5
+ # Runs a bounded single-agent tool-calling loop through Prescient::Client.
6
+ class Runtime
7
+ def initialize(provider: nil, client: nil, tools: nil, tool_names: [], configuration: Configuration.new,
8
+ system_prompt: "You are a helpful assistant.", provider_options: {}, generation_options: {},
9
+ authorization: nil, telemetry: nil, enable_fallback: true,
10
+ request_context: {}, audit_log: nil)
11
+ @configuration = configuration
12
+ @telemetry = telemetry || configuration.telemetry
13
+ @audit_log = audit_log
14
+ @authorization = authorization || configuration.authorization
15
+ @request_context = request_context
16
+ @system_prompt = system_prompt
17
+ @generation_options = generation_options
18
+
19
+ begin
20
+ @client = client || Prescient.client(provider, enable_fallback:, provider_options:)
21
+ configured_tools = tools || tool_names.to_h { |name| [name, Prescient.tool(name)] }
22
+ @registry = ToolRegistry.new(configured_tools.compact)
23
+ rescue StandardError => e
24
+ emit_failure(e, phase: :initialization, loops_run: 0, actions: [])
25
+ raise
26
+ end
27
+ end
28
+
29
+ # Execute one bounded agent task.
30
+ # @param task [String] Task instruction
31
+ # @return [Result] Completed agent result
32
+ def run(task)
33
+ actions = []
34
+ loops_run = 0
35
+ validate_task(task)
36
+ context = build_context(task)
37
+
38
+ @configuration.max_loops.times do |index|
39
+ loops_run = index + 1
40
+ emit(:iteration, loop: index + 1)
41
+ response = @client.generate_response(task, context.to_a, **@generation_options)
42
+ validate_response!(response)
43
+ unless action_appended?(context, response, actions)
44
+ emit(:completed, loops_run: index + 1, actions: actions.dup, success: true)
45
+ return result(response, index + 1, actions)
46
+ end
47
+ end
48
+
49
+ emit(:max_loops_exceeded, loops_run: @configuration.max_loops, actions: actions.dup, success: false)
50
+ raise MaxLoopsExceededError, "agent exceeded maximum loops: #{@configuration.max_loops}"
51
+ rescue StandardError => e
52
+ emit_failure(e, phase: :execution, loops_run:, actions: actions.dup)
53
+ raise
54
+ end
55
+
56
+ private
57
+
58
+ def build_context(task)
59
+ Context.new(
60
+ system_prompt: PromptBuilder.build(system_instruction: @system_prompt, tools: @registry.all),
61
+ task: task,
62
+ max_bytes: @configuration.max_context_bytes
63
+ )
64
+ end
65
+
66
+ def action_appended?(context, response, actions)
67
+ action = Parser.parse(response[:response], max_bytes: @configuration.max_action_bytes)
68
+ return false unless action
69
+
70
+ observation = invoke_tool(action)
71
+ actions << action[:name].to_s
72
+ observation_text = bounded_observation(observation)
73
+ context.append(role: "assistant", content: response[:response])
74
+ context.append(role: "user", content: "Observation: #{observation_text}")
75
+ true
76
+ end
77
+
78
+ def bounded_observation(observation)
79
+ serialized = JSON.generate(observation)
80
+ return serialized if serialized.bytesize <= @configuration.max_observation_bytes
81
+
82
+ limit = @configuration.max_observation_bytes
83
+ envelope = lambda do |value|
84
+ JSON.generate(truncated: true, value: value)
85
+ end
86
+ return limit < 4 ? "0" : "null" if limit < envelope.call("").bytesize
87
+
88
+ low = 0
89
+ high = serialized.bytesize
90
+ while low < high
91
+ midpoint = (low + high + 1) / 2
92
+ candidate = envelope.call(utf8_prefix(serialized, midpoint))
93
+ if candidate.bytesize <= limit
94
+ low = midpoint
95
+ else
96
+ high = midpoint - 1
97
+ end
98
+ end
99
+
100
+ envelope.call(utf8_prefix(serialized, low))
101
+ end
102
+
103
+ def utf8_prefix(value, max_bytes)
104
+ value.byteslice(0, max_bytes).to_s.force_encoding(Encoding::UTF_8).scrub
105
+ end
106
+
107
+ def invoke_tool(action)
108
+ authorize_tool!(action)
109
+ @registry.invoke(action[:name], action[:arguments])
110
+ rescue Prescient::Error => e
111
+ raise if e.is_a?(Prescient::Agent::Error)
112
+
113
+ ErrorSerializer.serialize(e)
114
+ end
115
+
116
+ def authorize_tool!(action)
117
+ return unless @authorization
118
+ return if @authorization.call(
119
+ tool: action[:name],
120
+ arguments: action[:arguments].dup,
121
+ context: @request_context.dup
122
+ ) == true
123
+
124
+ raise UnauthorizedToolError, "agent tool not authorized: #{action[:name]}"
125
+ end
126
+
127
+ def validate_task(task)
128
+ valid = task.is_a?(String) && !task.strip.empty? && task.bytesize <= @configuration.max_task_bytes
129
+ return if valid
130
+
131
+ raise ConfigurationError, "agent task must be a non-empty string within #{@configuration.max_task_bytes} bytes"
132
+ end
133
+
134
+ def validate_response!(response)
135
+ text = response[:response]
136
+ return if text.is_a?(String) && text.bytesize <= @configuration.max_response_bytes
137
+
138
+ raise ConfigurationError,
139
+ "agent response must be a string within #{@configuration.max_response_bytes} bytes"
140
+ end
141
+
142
+ def result(response, loops_run, actions)
143
+ Result.new(
144
+ response: response[:response],
145
+ provider: response[:provider] || @client.provider_name,
146
+ model: response[:model],
147
+ loops_run: loops_run,
148
+ metadata: { actions: actions.dup, success: true }
149
+ )
150
+ end
151
+
152
+ def emit(event, attributes)
153
+ payload = { event:, **attributes }.freeze
154
+ safely_emit(@telemetry, payload)
155
+ safely_emit(@audit_log, payload)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ def safely_emit(sink, payload)
161
+ sink&.call(payload)
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
166
+ def emit_failure(error, phase:, loops_run:, actions:)
167
+ emit(
168
+ :failed,
169
+ phase:, loops_run:, actions:, success: false,
170
+ error: ErrorSerializer.serialize(error).fetch(:error)
171
+ )
172
+ end
173
+ end
174
+ end
175
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -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"