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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- 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 +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -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 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- 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"
|