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
@@ -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 'vector'
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, [:embedding_provider, :embedding_model], name: 'idx_doc_embeddings_provider_model'
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, [:document_id, :chunk_index], unique: true
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, [:embedding_provider, :embedding_model], name: 'idx_chunk_embeddings_provider_model'
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 'DROP FUNCTION IF EXISTS cosine_similarity(vector, vector);'
154
- execute 'DROP FUNCTION IF EXISTS euclidean_distance(vector, vector);'
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 'vector'
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
@@ -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 type notes doctor],
100
- format: 'Appointment for %{patient_name} on %{date} - %{type} with Dr. %{doctor}: %{notes}',
101
- embedding_fields: %w[type notes]
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
- 'type' => 'Follow-up',
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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'prescient'
5
- require 'prescient/cli'
4
+ require "prescient"
5
+ require "prescient/cli"
6
6
 
7
7
  exit Prescient::CLI.run(ARGV)
data/exe/prescient-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "prescient"
5
+ require "prescient/mcp"
6
+
7
+ Prescient::MCP::Stdio.new.run
@@ -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