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
data/examples/custom_contexts.rb
CHANGED
|
@@ -96,9 +96,9 @@ Prescient.configure do |config|
|
|
|
96
96
|
embedding_fields: %w[medical_conditions medications]
|
|
97
97
|
},
|
|
98
98
|
'appointment' => {
|
|
99
|
-
fields: %w[patient_name date
|
|
100
|
-
format: 'Appointment for %{patient_name} on %{date} - %{
|
|
101
|
-
embedding_fields: %w[
|
|
99
|
+
fields: %w[patient_name date appointment_type notes doctor],
|
|
100
|
+
format: 'Appointment for %{patient_name} on %{date} - %{appointment_type} with Dr. %{doctor}: %{notes}',
|
|
101
|
+
embedding_fields: %w[appointment_type notes]
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
104
|
prompt_templates: {
|
|
@@ -135,7 +135,7 @@ begin
|
|
|
135
135
|
'type' => 'appointment',
|
|
136
136
|
'patient_name' => 'Patient A',
|
|
137
137
|
'date' => '2024-01-20',
|
|
138
|
-
'
|
|
138
|
+
'appointment_type' => 'Follow-up',
|
|
139
139
|
'notes' => 'Blood sugar levels improving with current treatment',
|
|
140
140
|
'doctor' => 'Johnson'
|
|
141
141
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../lib/prescient'
|
|
5
|
+
|
|
6
|
+
generate = ARGV.delete('--generate')
|
|
7
|
+
if ARGV.include?('--help')
|
|
8
|
+
puts 'Usage: ruby examples/web_search.rb [--generate] [QUERY]'
|
|
9
|
+
puts ' --generate Feed normalized search results to the configured AI provider'
|
|
10
|
+
puts ' PRESCIENT_PROVIDER Provider used with --generate (default: configured provider)'
|
|
11
|
+
exit
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
query = ARGV.empty? ? 'Ruby HTTP clients' : ARGV.join(' ')
|
|
15
|
+
|
|
16
|
+
Prescient.configure do |config|
|
|
17
|
+
config.add_tool(
|
|
18
|
+
:web_search,
|
|
19
|
+
Prescient::Tool::SearXNG,
|
|
20
|
+
url: ENV.fetch('SEARXNG_URL', 'http://localhost:8080'),
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
result = if generate
|
|
25
|
+
Prescient.search_and_generate(
|
|
26
|
+
query,
|
|
27
|
+
provider: ENV['PRESCIENT_PROVIDER']&.to_sym,
|
|
28
|
+
limit: 20,
|
|
29
|
+
)
|
|
30
|
+
else
|
|
31
|
+
Prescient.tool(:web_search).search(query, limit: 20)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts JSON.pretty_generate(result)
|
data/exe/prescient
CHANGED
data/exe/prescient-mcp
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
8
|
+
module Prescient::Agent
|
|
9
|
+
# Persists safe Agent telemetry events as newline-delimited JSON.
|
|
10
|
+
class AuditLog
|
|
11
|
+
# Telemetry keys permitted in durable audit records.
|
|
12
|
+
# @return [Array<Symbol>] Safe event fields
|
|
13
|
+
FIELDS = %i[event loop loops_run actions success phase error].freeze
|
|
14
|
+
|
|
15
|
+
# @param path [String, nil] File path opened in append mode
|
|
16
|
+
# @param io [#write, nil] Writable stream owned by the caller
|
|
17
|
+
def initialize(path: nil, io: nil)
|
|
18
|
+
raise ArgumentError, "provide path or io, not both" if path && io
|
|
19
|
+
raise ArgumentError, "path or io is required" unless path || io
|
|
20
|
+
|
|
21
|
+
@io = io || File.open(path, "a")
|
|
22
|
+
@lock = Monitor.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Persist one allowlisted telemetry event.
|
|
26
|
+
# @param event [Hash] Agent event metadata
|
|
27
|
+
# @return [void]
|
|
28
|
+
def call(event)
|
|
29
|
+
record = event.slice(*FIELDS).merge(timestamp: Time.now.utc.iso8601)
|
|
30
|
+
@lock.synchronize do
|
|
31
|
+
@io.write("#{JSON.generate(record)}\n")
|
|
32
|
+
@io.flush if @io.respond_to?(:flush)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Adapts the bounded agent runtime to CLI streams and exit statuses.
|
|
6
|
+
class CLIAdapter
|
|
7
|
+
def initialize(output: $stdout, errors: $stderr)
|
|
8
|
+
@output = output
|
|
9
|
+
@errors = errors
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Run an agent task and render a text or JSON result.
|
|
13
|
+
# @return [Integer] Process exit status
|
|
14
|
+
def run(task:, client: nil, provider: nil, tool_names: [], max_loops: Configuration::DEFAULT_MAX_LOOPS,
|
|
15
|
+
format: "text", provider_options: {}, generation_options: {}, telemetry: nil)
|
|
16
|
+
runtime = Runtime.new(
|
|
17
|
+
client:, provider:, tool_names:, configuration: Configuration.new(max_loops:, telemetry:),
|
|
18
|
+
provider_options:, generation_options:
|
|
19
|
+
)
|
|
20
|
+
result = runtime.run(task)
|
|
21
|
+
format == "json" ? @output.puts(JSON.generate(result.to_h)) : @output.puts(result.response)
|
|
22
|
+
0
|
|
23
|
+
rescue Prescient::Error, ArgumentError => e
|
|
24
|
+
@errors.puts "prescient: #{e.message}"
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Runtime policies and safety limits for one agent execution.
|
|
6
|
+
class Configuration
|
|
7
|
+
# @return [Integer] Default maximum loop count
|
|
8
|
+
DEFAULT_MAX_LOOPS = 5
|
|
9
|
+
# @return [Integer] Default context size limit
|
|
10
|
+
DEFAULT_MAX_CONTEXT_BYTES = 64_000
|
|
11
|
+
# @return [Integer] Default action size limit
|
|
12
|
+
DEFAULT_MAX_ACTION_BYTES = 8_192
|
|
13
|
+
# @return [Integer] Default observation size limit
|
|
14
|
+
DEFAULT_MAX_OBSERVATION_BYTES = 16_384
|
|
15
|
+
# @return [Integer] Default task size limit
|
|
16
|
+
DEFAULT_MAX_TASK_BYTES = 8_192
|
|
17
|
+
# @return [Integer] Default generated response size limit
|
|
18
|
+
DEFAULT_MAX_RESPONSE_BYTES = 32_768
|
|
19
|
+
|
|
20
|
+
attr_reader :max_loops, :max_context_bytes, :max_action_bytes, :max_observation_bytes,
|
|
21
|
+
:max_task_bytes, :max_response_bytes, :authorization, :telemetry
|
|
22
|
+
|
|
23
|
+
# @param authorization [#call, nil] Host policy receiving tool, arguments,
|
|
24
|
+
# and request-scoped context. Only exactly true permits a tool call.
|
|
25
|
+
# @param telemetry [#call, nil] Bounded event sink for execution metadata.
|
|
26
|
+
def initialize(max_loops: DEFAULT_MAX_LOOPS, max_context_bytes: DEFAULT_MAX_CONTEXT_BYTES,
|
|
27
|
+
max_action_bytes: DEFAULT_MAX_ACTION_BYTES,
|
|
28
|
+
max_observation_bytes: DEFAULT_MAX_OBSERVATION_BYTES,
|
|
29
|
+
max_task_bytes: DEFAULT_MAX_TASK_BYTES,
|
|
30
|
+
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
|
|
31
|
+
authorization: nil, telemetry: nil)
|
|
32
|
+
@max_loops = positive_integer(max_loops, "max_loops")
|
|
33
|
+
@max_context_bytes = positive_integer(max_context_bytes, "max_context_bytes")
|
|
34
|
+
@max_action_bytes = positive_integer(max_action_bytes, "max_action_bytes")
|
|
35
|
+
@max_observation_bytes = positive_integer(max_observation_bytes, "max_observation_bytes")
|
|
36
|
+
@max_task_bytes = positive_integer(max_task_bytes, "max_task_bytes")
|
|
37
|
+
@max_response_bytes = positive_integer(max_response_bytes, "max_response_bytes")
|
|
38
|
+
@authorization = callable_or_nil(authorization, "authorization")
|
|
39
|
+
@telemetry = callable_or_nil(telemetry, "telemetry")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def positive_integer(value, name)
|
|
45
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
46
|
+
|
|
47
|
+
raise Prescient::Agent::ConfigurationError, "#{name} must be a positive integer"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def callable_or_nil(value, name)
|
|
51
|
+
return value if value.nil? || value.respond_to?(:call)
|
|
52
|
+
|
|
53
|
+
raise Prescient::Agent::ConfigurationError, "#{name} must respond to call"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Per-run, bounded conversation history rendered as provider context.
|
|
6
|
+
class Context
|
|
7
|
+
attr_reader :messages
|
|
8
|
+
|
|
9
|
+
def initialize(system_prompt:, task:, max_bytes: Configuration::DEFAULT_MAX_CONTEXT_BYTES)
|
|
10
|
+
@max_bytes = max_bytes
|
|
11
|
+
@messages = [
|
|
12
|
+
{ role: "system", content: system_prompt },
|
|
13
|
+
{ role: "user", content: task }
|
|
14
|
+
]
|
|
15
|
+
enforce_limit!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Append a bounded turn to the run history.
|
|
19
|
+
# @param role [String] Message role
|
|
20
|
+
# @param content [String] Message content
|
|
21
|
+
# @return [void]
|
|
22
|
+
def append(role:, content:)
|
|
23
|
+
@messages << { role: role, content: content.to_s }
|
|
24
|
+
enforce_limit!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return a copy of the provider-compatible history.
|
|
28
|
+
# @return [Array<Hash>]
|
|
29
|
+
def to_a
|
|
30
|
+
@messages.map(&:dup)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def enforce_limit!
|
|
36
|
+
return if serialized_bytes <= @max_bytes
|
|
37
|
+
|
|
38
|
+
compact!
|
|
39
|
+
return if serialized_bytes <= @max_bytes
|
|
40
|
+
|
|
41
|
+
raise Prescient::Agent::ConfigurationError, "agent context exceeds configured size limit"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def compact!
|
|
45
|
+
initial_messages = @messages.first(2)
|
|
46
|
+
recent_messages = @messages.drop(2).last(2)
|
|
47
|
+
omitted_message = { role: "system", content: "[Earlier agent context omitted due to size limit.]" }
|
|
48
|
+
@messages = initial_messages + [omitted_message] + recent_messages
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialized_bytes
|
|
52
|
+
JSON.generate(@messages).bytesize
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Converts provider and tool failures into bounded, safe agent data.
|
|
6
|
+
class ErrorSerializer
|
|
7
|
+
# @return [Array<Array<Class, String>>] Provider/tool error categories
|
|
8
|
+
CATEGORY_MAP = [
|
|
9
|
+
[Prescient::AuthenticationError, "authentication_failed"],
|
|
10
|
+
[Prescient::ConnectionError, "provider_unavailable"],
|
|
11
|
+
[Prescient::RateLimitError, "rate_limited"],
|
|
12
|
+
[Prescient::ModelNotAvailableError, "model_unavailable"],
|
|
13
|
+
[Prescient::InvalidResponseError, "invalid_provider_response"],
|
|
14
|
+
[Prescient::ProviderError, "provider_failure"],
|
|
15
|
+
[Prescient::ToolConfigurationError, "tool_configuration_error"],
|
|
16
|
+
[Prescient::ToolConnectionError, "tool_unavailable"],
|
|
17
|
+
[Prescient::ToolInvalidResponseError, "invalid_tool_response"],
|
|
18
|
+
[Prescient::ToolError, "tool_failure"]
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @param error [Exception] Failure to serialize
|
|
23
|
+
# @return [Hash] Safe error envelope
|
|
24
|
+
def serialize(error)
|
|
25
|
+
{
|
|
26
|
+
error: {
|
|
27
|
+
category: category_for(error),
|
|
28
|
+
message: safe_message(error)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def category_for(error)
|
|
36
|
+
CATEGORY_MAP.find { |error_class, _category| error.is_a?(error_class) }&.last || "internal_failure"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def safe_message(error)
|
|
40
|
+
return "The requested operation failed." unless error.is_a?(Prescient::Agent::Error)
|
|
41
|
+
|
|
42
|
+
error.message.to_s.byteslice(0, 512)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prescient
|
|
4
|
+
module Agent
|
|
5
|
+
# Base error for agent-specific failures.
|
|
6
|
+
class Error < Prescient::Error
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Raised for invalid agent configuration.
|
|
10
|
+
class ConfigurationError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raised when a provider response contains an invalid action.
|
|
14
|
+
class MalformedActionError < Error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when a model requests an unavailable tool.
|
|
18
|
+
class UnauthorizedToolError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when execution reaches its configured loop limit.
|
|
22
|
+
class MaxLoopsExceededError < Error
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Extracts and validates one tool action from provider text.
|
|
6
|
+
class Parser
|
|
7
|
+
# @return [Regexp] Fenced JSON action matcher
|
|
8
|
+
ACTION_PATTERN = /```json\s*(\{.*?\})\s*```/m
|
|
9
|
+
|
|
10
|
+
# Parse one optional action from provider output.
|
|
11
|
+
# @param text [String] Provider response text
|
|
12
|
+
# @param max_bytes [Integer] Maximum action size
|
|
13
|
+
# @return [Hash, nil] Parsed action or nil for a final response
|
|
14
|
+
def self.parse(text, max_bytes: Configuration::DEFAULT_MAX_ACTION_BYTES)
|
|
15
|
+
new(max_bytes:).parse(text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(max_bytes: Configuration::DEFAULT_MAX_ACTION_BYTES)
|
|
19
|
+
@max_bytes = max_bytes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parse one optional action from provider output.
|
|
23
|
+
# @param text [String] Provider response text
|
|
24
|
+
# @return [Hash, nil] Parsed action or nil for a final response
|
|
25
|
+
def parse(text)
|
|
26
|
+
match = text.to_s.match(ACTION_PATTERN)
|
|
27
|
+
return nil unless match
|
|
28
|
+
raise MalformedActionError, "agent action exceeds configured size limit" if match[1].bytesize > @max_bytes
|
|
29
|
+
|
|
30
|
+
payload = JSON.parse(match[1])
|
|
31
|
+
validate_payload(payload)
|
|
32
|
+
{ name: payload.fetch("action").to_sym, arguments: payload.fetch("args") }
|
|
33
|
+
rescue JSON::ParserError => e
|
|
34
|
+
raise MalformedActionError, "agent action contains invalid JSON: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_payload(payload)
|
|
40
|
+
unless payload.is_a?(Hash) && payload.keys.sort == %w[action args]
|
|
41
|
+
raise MalformedActionError, "agent action must contain only action and args"
|
|
42
|
+
end
|
|
43
|
+
return if payload["action"].is_a?(String) && !payload["action"].empty? && payload["args"].is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
raise MalformedActionError, "agent action must define a name and object args"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Builds deterministic instructions for the single-action agent protocol.
|
|
6
|
+
class PromptBuilder
|
|
7
|
+
# Build the system instruction for one agent run.
|
|
8
|
+
# @param system_instruction [String] Application-specific instruction
|
|
9
|
+
# @param tools [Array<ToolRegistry::Tool>] Allowed tools
|
|
10
|
+
# @return [String] Deterministic orchestration prompt
|
|
11
|
+
def self.build(system_instruction:, tools:)
|
|
12
|
+
tool_text = tools.empty? ? "None" : tools.map { |tool| tool_instruction(tool) }.join("\n")
|
|
13
|
+
<<~PROMPT
|
|
14
|
+
#{system_instruction}
|
|
15
|
+
|
|
16
|
+
You may either answer the task directly or request exactly one tool.
|
|
17
|
+
For a tool request, return one JSON object in a fenced json block:
|
|
18
|
+
{"action":"tool_name","args":{"key":"value"}}
|
|
19
|
+
Available tools:
|
|
20
|
+
#{tool_text}
|
|
21
|
+
Never request an unavailable tool. Do not return more than one action.
|
|
22
|
+
PROMPT
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.tool_instruction(tool)
|
|
26
|
+
"- #{tool.name}: #{tool.description} (arguments: #{JSON.generate(tool.schema)})"
|
|
27
|
+
end
|
|
28
|
+
private_class_method :tool_instruction
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
module Prescient::Agent
|
|
5
|
+
# Immutable summary of one agent run.
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :response, :provider, :model, :loops_run, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(response:, provider:, model:, loops_run:, success: true, metadata: {})
|
|
10
|
+
@response = response
|
|
11
|
+
@provider = provider
|
|
12
|
+
@model = model
|
|
13
|
+
@loops_run = loops_run
|
|
14
|
+
@success = success
|
|
15
|
+
@metadata = metadata
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def success?
|
|
19
|
+
@success
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Serialize the result into a safe structured response.
|
|
23
|
+
# @return [Hash] Result data
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
response: @response,
|
|
27
|
+
provider: @provider,
|
|
28
|
+
model: @model,
|
|
29
|
+
loops_run: @loops_run,
|
|
30
|
+
success: success?,
|
|
31
|
+
metadata: @metadata
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
# rubocop:enable Style/ClassAndModuleChildren
|
|
@@ -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
|