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
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# Rails migration for Prescient gem vector database tables
|
|
4
4
|
# Copy this file to your Rails db/migrate directory and adjust the timestamp
|
|
5
|
-
|
|
6
5
|
class CreatePrescientTables < ActiveRecord::Migration[7.0]
|
|
7
6
|
def up
|
|
8
7
|
# Enable pgvector extension
|
|
9
|
-
enable_extension
|
|
8
|
+
enable_extension "vector"
|
|
10
9
|
|
|
11
10
|
# Documents table to store original content
|
|
12
11
|
create_table :documents do |t|
|
|
@@ -76,14 +75,14 @@ class CreatePrescientTables < ActiveRecord::Migration[7.0]
|
|
|
76
75
|
add_index :documents, :metadata, using: :gin
|
|
77
76
|
|
|
78
77
|
add_index :document_embeddings, :document_id
|
|
79
|
-
add_index :document_embeddings, [
|
|
78
|
+
add_index :document_embeddings, %i[embedding_provider embedding_model], name: "idx_doc_embeddings_provider_model"
|
|
80
79
|
add_index :document_embeddings, :embedding_dimensions
|
|
81
80
|
|
|
82
|
-
add_index :document_chunks, [
|
|
81
|
+
add_index :document_chunks, %i[document_id chunk_index], unique: true
|
|
83
82
|
|
|
84
83
|
add_index :chunk_embeddings, :chunk_id
|
|
85
84
|
add_index :chunk_embeddings, :document_id
|
|
86
|
-
add_index :chunk_embeddings, [
|
|
85
|
+
add_index :chunk_embeddings, %i[embedding_provider embedding_model], name: "idx_chunk_embeddings_provider_model"
|
|
87
86
|
|
|
88
87
|
add_index :search_queries, :created_at
|
|
89
88
|
add_index :query_results, :query_id
|
|
@@ -94,30 +93,30 @@ class CreatePrescientTables < ActiveRecord::Migration[7.0]
|
|
|
94
93
|
|
|
95
94
|
# Vector indexes for document embeddings
|
|
96
95
|
execute <<-SQL
|
|
97
|
-
CREATE INDEX idx_document_embeddings_cosine#{
|
|
98
|
-
ON document_embeddings#{
|
|
96
|
+
CREATE INDEX idx_document_embeddings_cosine#{" "}
|
|
97
|
+
ON document_embeddings#{" "}
|
|
99
98
|
USING hnsw (embedding vector_cosine_ops)
|
|
100
99
|
WITH (m = 16, ef_construction = 64);
|
|
101
100
|
SQL
|
|
102
101
|
|
|
103
102
|
execute <<-SQL
|
|
104
|
-
CREATE INDEX idx_document_embeddings_l2#{
|
|
105
|
-
ON document_embeddings#{
|
|
103
|
+
CREATE INDEX idx_document_embeddings_l2#{" "}
|
|
104
|
+
ON document_embeddings#{" "}
|
|
106
105
|
USING hnsw (embedding vector_l2_ops)
|
|
107
106
|
WITH (m = 16, ef_construction = 64);
|
|
108
107
|
SQL
|
|
109
108
|
|
|
110
109
|
# Vector indexes for chunk embeddings
|
|
111
110
|
execute <<-SQL
|
|
112
|
-
CREATE INDEX idx_chunk_embeddings_cosine#{
|
|
113
|
-
ON chunk_embeddings#{
|
|
111
|
+
CREATE INDEX idx_chunk_embeddings_cosine#{" "}
|
|
112
|
+
ON chunk_embeddings#{" "}
|
|
114
113
|
USING hnsw (embedding vector_cosine_ops)
|
|
115
114
|
WITH (m = 16, ef_construction = 64);
|
|
116
115
|
SQL
|
|
117
116
|
|
|
118
117
|
execute <<-SQL
|
|
119
|
-
CREATE INDEX idx_chunk_embeddings_l2#{
|
|
120
|
-
ON chunk_embeddings#{
|
|
118
|
+
CREATE INDEX idx_chunk_embeddings_l2#{" "}
|
|
119
|
+
ON chunk_embeddings#{" "}
|
|
121
120
|
USING hnsw (embedding vector_l2_ops)
|
|
122
121
|
WITH (m = 16, ef_construction = 64);
|
|
123
122
|
SQL
|
|
@@ -150,9 +149,9 @@ class CreatePrescientTables < ActiveRecord::Migration[7.0]
|
|
|
150
149
|
drop_table :document_embeddings
|
|
151
150
|
drop_table :documents
|
|
152
151
|
|
|
153
|
-
execute
|
|
154
|
-
execute
|
|
152
|
+
execute "DROP FUNCTION IF EXISTS cosine_similarity(vector, vector);"
|
|
153
|
+
execute "DROP FUNCTION IF EXISTS euclidean_distance(vector, vector);"
|
|
155
154
|
|
|
156
|
-
disable_extension
|
|
155
|
+
disable_extension "vector"
|
|
157
156
|
end
|
|
158
157
|
end
|
data/examples/README.md
CHANGED
|
@@ -18,7 +18,8 @@ bundle install
|
|
|
18
18
|
- `vector_search.rb` — `Prescient::Pgvector::Store` PostgreSQL/pgvector storage
|
|
19
19
|
and similarity search.
|
|
20
20
|
- `rest_api.ru` — a tiny Rack-compatible application that mounts
|
|
21
|
-
`Prescient::API` and lists its endpoints at
|
|
21
|
+
`Prescient::API` and lists its endpoints at `/`, including the bounded agent
|
|
22
|
+
and search-generation routes.
|
|
22
23
|
- `web_search.rb` — explicit SearXNG tool invocation with normalized JSON output.
|
|
23
24
|
|
|
24
25
|
The same `web_search` capability can use SearchApi instead of SearXNG when the
|
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
|
}
|
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
|