agent-harness 0.39.0 → 0.41.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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm"
5
+
6
+ module AgentHarness
7
+ module Api
8
+ # Translates the normalized public chat values to RubyLLM public objects.
9
+ class RubyLlmChatAdapter
10
+ class UnsupportedOptionError < StandardError; end
11
+ class MissingCredentialError < StandardError; end
12
+
13
+ PROVIDER_CONFIG = {
14
+ anthropic: %i[anthropic_api_key anthropic_api_base],
15
+ openai: %i[openai_api_key openai_api_base]
16
+ }.freeze
17
+ OPENAI_UNSUPPLIED_CONFIG = %i[openai_organization_id openai_project_id openai_use_system_role].freeze
18
+
19
+ def call(candidate:, messages:, tools:, max_output_tokens:, temperature:, stream:, timeout:, cancellation:, &on_event)
20
+ context = build_context(candidate, timeout)
21
+ chat = context.chat(model: candidate[:model], provider: candidate[:provider], protocol: ruby_llm_protocol(candidate),
22
+ assume_model_exists: true)
23
+ configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
24
+ response = generate(chat, stream, cancellation, &on_event)
25
+ emit_completed_tool_calls(response, &on_event) if stream
26
+ normalize_response(response)
27
+ end
28
+
29
+ # RubyLLM keys streamed tool-call chunks by a stream index while
30
+ # continuation chunks carry no call id, so correlation state must live
31
+ # between chunks. Also remembers the latest cumulative token counts so
32
+ # duplicate cumulative reports are not re-emitted.
33
+ class StreamState
34
+ attr_accessor :input_tokens, :output_tokens
35
+
36
+ def initialize
37
+ @provider_id_by_key = {}
38
+ @started_ids = {}
39
+ @latest_provider_id = nil
40
+ @input_tokens = nil
41
+ @output_tokens = nil
42
+ end
43
+
44
+ # Links a stream chunk key to its provider call id, returning true
45
+ # only the first time +provider_id+ is seen.
46
+ def start(stream_key, provider_id)
47
+ @provider_id_by_key[stream_key] = provider_id unless stream_key.nil?
48
+ @latest_provider_id = provider_id
49
+ return false if @started_ids.key?(provider_id)
50
+
51
+ @started_ids[provider_id] = true
52
+ end
53
+
54
+ def provider_id(stream_key)
55
+ stream_key.nil? ? @latest_provider_id : @provider_id_by_key[stream_key]
56
+ end
57
+
58
+ def usage
59
+ total = (input_tokens + output_tokens) if input_tokens && output_tokens
60
+ {input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total}
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ruby_llm_protocol(candidate)
67
+ return :anthropic if candidate[:provider].to_sym == :anthropic && candidate[:protocol].to_sym == :messages
68
+
69
+ candidate[:protocol]
70
+ end
71
+
72
+ def build_context(candidate, timeout)
73
+ provider = candidate[:provider].to_sym
74
+ config_keys = PROVIDER_CONFIG[provider]
75
+ raise RubyLLM::ConfigurationError, "Unsupported chat provider: #{provider}" unless config_keys
76
+ raise MissingCredentialError, "API key is required" if candidate.dig(:credentials, :api_key).to_s.empty?
77
+
78
+ RubyLLM.context do |config|
79
+ config.public_send("#{config_keys[0]}=", candidate.dig(:credentials, :api_key))
80
+ config.public_send("#{config_keys[1]}=", candidate[:endpoint])
81
+ clear_unsupplied_openai_config(config) if provider == :openai
82
+ config.max_retries = 0
83
+ apply_timeout(config, timeout)
84
+ end
85
+ end
86
+
87
+ def clear_unsupplied_openai_config(config)
88
+ OPENAI_UNSUPPLIED_CONFIG.each { |key| config.public_send("#{key}=", nil) }
89
+ end
90
+
91
+ def apply_timeout(config, timeout)
92
+ if timeout&.dig(:connect_seconds)
93
+ raise UnsupportedOptionError, "RubyLLM does not support request-local connect timeouts"
94
+ end
95
+
96
+ seconds = timeout&.dig(:read_seconds)
97
+ config.request_timeout = seconds if seconds
98
+ end
99
+
100
+ def configure_chat(chat, candidate, messages, tools, max_output_tokens, temperature)
101
+ chat.messages = normalize_messages(messages)
102
+ chat.with_tools(tools.map { |tool| normalized_tool(tool) }) unless tools.empty?
103
+ chat.with_headers(candidate[:headers] || {})
104
+ chat.with_max_output_tokens(max_output_tokens) if max_output_tokens
105
+ chat.with_temperature(temperature) unless temperature.nil?
106
+ end
107
+
108
+ def generate(chat, stream, cancellation)
109
+ return generate_without_events(chat, cancellation) unless stream
110
+
111
+ state = StreamState.new
112
+ chat.generate do |chunk|
113
+ if cancelled?(cancellation)
114
+ chat.cancel
115
+ raise RubyLLM::CancelledError
116
+ end
117
+
118
+ stream_events(chunk, state).each { |event| yield event }
119
+ end
120
+ end
121
+
122
+ def generate_without_events(chat, cancellation)
123
+ raise RubyLLM::CancelledError if cancelled?(cancellation)
124
+
125
+ chat.generate
126
+ end
127
+
128
+ def cancelled?(token)
129
+ return false unless token
130
+
131
+ token.respond_to?(:cancelled?) ? token.cancelled? : token.call
132
+ end
133
+
134
+ def normalize_messages(messages)
135
+ stable_to_provider = tool_id_map(messages)
136
+ messages.map do |message|
137
+ role = message.fetch(:role).to_sym
138
+ normalized = {role: role, content: text_content(message[:content])}
139
+ normalized[:tool_calls] = normalize_input_tool_calls(message[:tool_calls]) if message[:tool_calls]
140
+ normalized[:tool_call_id] = stable_to_provider.fetch(message[:tool_call_id], message[:tool_call_id]) if role == :tool
141
+ normalized
142
+ end
143
+ end
144
+
145
+ def tool_id_map(messages)
146
+ messages.each_with_object({}) do |message, ids|
147
+ Array(message[:tool_calls]).each do |call|
148
+ ids[call[:id]] = call[:provider_id] || call[:id]
149
+ end
150
+ end
151
+ end
152
+
153
+ def normalize_input_tool_calls(tool_calls)
154
+ Array(tool_calls).to_h do |call|
155
+ provider_id = call[:provider_id] || call[:id]
156
+ arguments = JSON.parse(call.fetch(:arguments_json, "{}"))
157
+ [provider_id, {id: provider_id, name: call[:name], arguments: arguments}]
158
+ end
159
+ end
160
+
161
+ def text_content(content)
162
+ return content if content.is_a?(String) || content.nil?
163
+
164
+ Array(content).map do |part|
165
+ unless part[:type]&.to_sym == :text
166
+ raise UnsupportedOptionError, "unsupported content type: #{part[:type]}"
167
+ end
168
+
169
+ part[:text].to_s
170
+ end.join
171
+ end
172
+
173
+ def normalized_tool(tool)
174
+ definition = tool.transform_keys(&:to_sym)
175
+ name = definition.fetch(:name).to_s
176
+ Class.new(RubyLLM::Tool).tap do |klass|
177
+ klass.define_singleton_method(:tool_name) { name }
178
+ klass.description(definition[:description].to_s)
179
+ klass.parameters(definition[:input_schema] || definition[:parameters] || {type: "object", properties: {}})
180
+ klass.define_method(:execute) { |**| raise "AgentHarness transports never execute tools" }
181
+ end.new
182
+ end
183
+
184
+ def stream_events(chunk, state)
185
+ [text_event(chunk), *tool_call_events(chunk, state), usage_event(chunk, state)].compact
186
+ end
187
+
188
+ def text_event(chunk)
189
+ {type: :text_delta, content: chunk.content} unless chunk.content.nil? || chunk.content.empty?
190
+ end
191
+
192
+ def tool_call_events(chunk, state)
193
+ return [] unless chunk.tool_calls
194
+
195
+ chunk.tool_calls.flat_map { |stream_key, call| call_events(stream_key, call, state) }
196
+ end
197
+
198
+ # Continuation chunks have a nil id and carry the provider's raw JSON
199
+ # fragment as arguments; they join the call that started their key.
200
+ def call_events(stream_key, call, state)
201
+ provider_id = call.id || state.provider_id(stream_key)
202
+ return [] unless provider_id
203
+
204
+ events = []
205
+ if state.start(stream_key, provider_id)
206
+ events << {type: :tool_call_started, provider_id: provider_id, name: call.name}
207
+ end
208
+ fragment = argument_fragment(call.arguments)
209
+ events << {type: :tool_call_delta, provider_id: provider_id, arguments_json: fragment} if fragment
210
+ events
211
+ end
212
+
213
+ # Deltas stay appendable JSON text; a start chunk may instead carry a
214
+ # complete parsed Hash, which becomes JSON once.
215
+ def argument_fragment(arguments)
216
+ case arguments
217
+ when String then arguments.empty? ? nil : arguments
218
+ when Hash then arguments.empty? ? nil : JSON.generate(arguments)
219
+ end
220
+ end
221
+
222
+ def usage_event(chunk, state)
223
+ return unless cumulative_usage_changed?(chunk, state)
224
+
225
+ {type: :usage_updated, **state.usage}
226
+ end
227
+
228
+ def cumulative_usage_changed?(chunk, state)
229
+ tokens = chunk.tokens
230
+ return false unless tokens
231
+
232
+ changed = false
233
+ if tokens.input && tokens.input != state.input_tokens
234
+ state.input_tokens = tokens.input
235
+ changed = true
236
+ end
237
+ if tokens.output && tokens.output != state.output_tokens
238
+ state.output_tokens = tokens.output
239
+ changed = true
240
+ end
241
+ changed
242
+ end
243
+
244
+ def emit_completed_tool_calls(response)
245
+ Array(response.tool_calls&.values).each do |call|
246
+ yield(type: :tool_call_completed, **normalize_tool_call(call))
247
+ end
248
+ end
249
+
250
+ def normalize_response(response)
251
+ {
252
+ content: response.content || "",
253
+ model: response.model,
254
+ finish_reason: response.finish_reason,
255
+ usage: normalize_usage(response.tokens),
256
+ tool_calls: Array(response.tool_calls&.values).map { |call| normalize_tool_call(call) }
257
+ }
258
+ end
259
+
260
+ def normalize_tool_call(call)
261
+ {provider_id: call.id, name: call.name, arguments_json: JSON.generate(call.arguments || {})}
262
+ end
263
+
264
+ def normalize_usage(tokens)
265
+ input = tokens&.input
266
+ output = tokens&.output
267
+ return unless input || output
268
+
269
+ {input_tokens: input, output_tokens: output, total_tokens: (input && output) ? input + output : nil}
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/net_http"
5
+ require "json"
6
+
7
+ module AgentHarness
8
+ # Builds a request-local Faraday adapter for headers and cancellation.
9
+ module EmbeddingAdapter
10
+ module_function
11
+
12
+ def build(headers:, cancellation: nil)
13
+ Class.new(Faraday::Adapter::NetHttp) do
14
+ define_method(:call) do |env|
15
+ raise CancelledError, "Embedding request cancelled" if cancellation&.call
16
+
17
+ env.request_headers.update(headers)
18
+ super(env).on_complete { |response| EmbeddingAdapter.order_rows(response) }
19
+ end
20
+ end
21
+ end
22
+
23
+ def order_rows(response)
24
+ payload = JSON.parse(response.body)
25
+ rows = payload["data"]
26
+ return unless rows.is_a?(Array)
27
+
28
+ indices = rows.map { |row| row["index"] if row.is_a?(Hash) }
29
+ unless indices.all?(Integer) && indices.sort == (0...rows.length).to_a
30
+ raise MalformedEmbeddingError, "Provider returned invalid embedding indices"
31
+ end
32
+
33
+ payload["data"] = rows.sort_by { |row| row.fetch("index") }
34
+ response.body = JSON.generate(payload)
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentHarness
4
+ # Normalized result returned by AgentHarness.embed.
5
+ class EmbeddingResult
6
+ attr_reader :vectors, :model, :usage, :attempts
7
+
8
+ def initialize(vectors:, model:, input_tokens: nil, attempts: [])
9
+ @vectors = vectors
10
+ @model = model
11
+ @usage = {input_tokens: input_tokens}.freeze
12
+ @attempts = attempts.freeze
13
+ end
14
+
15
+ # Batch usage is never guessed or divided among individual vectors.
16
+ def per_vector_usage
17
+ nil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module AgentHarness
8
+ # Provider-neutral embedding execution backed by RubyLLM.
9
+ class Embeddings
10
+ DEFAULT_TIMEOUT = 300
11
+ DEFAULT_MAX_ATTEMPTS = 3
12
+ RETRY_BASE_DELAY = 0.25
13
+ RETRY_MAX_DELAY = 2.0
14
+ CANCELLATION_POLL_INTERVAL = 0.05
15
+ TRANSIENT_ERRORS = [RateLimitError, TimeoutError].freeze
16
+
17
+ def initialize(model:, credentials:, endpoint: nil, headers: {}, timeout: DEFAULT_TIMEOUT,
18
+ max_attempts: DEFAULT_MAX_ATTEMPTS, cancellation: nil, observer: nil, request_id: nil)
19
+ @model = model
20
+ @api_key = credential(credentials, :api_key)
21
+ @endpoint = endpoint
22
+ @headers = headers.to_h.transform_keys(&:to_s).freeze
23
+ @timeout = timeout
24
+ @max_attempts = max_attempts
25
+ @cancellation = cancellation
26
+ @observer = observer
27
+ @request_id = request_id || SecureRandom.uuid
28
+ validate!
29
+ end
30
+
31
+ def call(inputs:, dimensions: nil)
32
+ inputs = Array(inputs)
33
+ return EmbeddingResult.new(vectors: [], model: @model) if inputs.empty?
34
+
35
+ execute(inputs, dimensions)
36
+ end
37
+
38
+ private
39
+
40
+ def execute(inputs, dimensions)
41
+ attempts = []
42
+
43
+ 1.upto(@max_attempts) do |number|
44
+ check_cancellation!
45
+ result = attempt(inputs, dimensions, number, attempts)
46
+ return result
47
+ rescue RateLimitError, TimeoutError, ProviderError => e
48
+ raise unless retryable?(e) && number < @max_attempts
49
+
50
+ wait_before_retry(e, number)
51
+ end
52
+ end
53
+
54
+ def attempt(inputs, dimensions, number, attempts)
55
+ started_at = Time.now.utc
56
+ embedding = request_embedding(inputs, dimensions)
57
+ validate_result!(embedding.vectors, inputs.length)
58
+ report = success_report(embedding, number, started_at)
59
+ rescue CancelledError => e
60
+ record_attempt(attempts, failure_report(e, number, started_at, :cancelled, :cancelled))
61
+ raise
62
+ rescue AuthenticationError, AuthorizationError, RateLimitError, TimeoutError, ProviderError => e
63
+ record_attempt(attempts, failure_report(e, number, started_at, *error_classification(e)))
64
+ raise
65
+ rescue NoMethodError, TypeError => e
66
+ error = MalformedEmbeddingError.new("Malformed embedding response", original_error: e)
67
+ record_attempt(attempts, failure_report(error, number, started_at, *error_classification(error)))
68
+ raise error
69
+ else
70
+ record_attempt(attempts, report)
71
+ EmbeddingResult.new(
72
+ vectors: embedding.vectors, model: embedding.model,
73
+ input_tokens: embedding.tokens.input, attempts: attempts
74
+ )
75
+ end
76
+
77
+ def request_embedding(inputs, dimensions)
78
+ context.embed(
79
+ inputs,
80
+ model: @model,
81
+ provider: :openai,
82
+ assume_model_exists: true,
83
+ dimensions: dimensions
84
+ )
85
+ rescue RubyLLM::UnauthorizedError => e
86
+ raise AuthenticationError.new(e.message, provider: :openai, original_error: e)
87
+ rescue RubyLLM::ForbiddenError => e
88
+ raise AuthorizationError.new(e.message, provider: :openai, original_error: e)
89
+ rescue RubyLLM::RateLimitError => e
90
+ raise RateLimitError.new(e.message, provider: :openai, reset_time: retry_after(e), original_error: e)
91
+ rescue Faraday::TimeoutError, Timeout::Error => e
92
+ raise TimeoutError.new(e.message, original_error: e)
93
+ rescue Faraday::ConnectionFailed => e
94
+ raise connection_error(e)
95
+ rescue RubyLLM::ServerError, RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError => e
96
+ raise ProviderError.new(e.message, original_error: e)
97
+ rescue Faraday::ParsingError, NoMethodError, TypeError => e
98
+ raise MalformedEmbeddingError.new("Malformed embedding response", original_error: e)
99
+ rescue RubyLLM::Error => e
100
+ raise ProviderError.new(e.message, original_error: e)
101
+ end
102
+
103
+ def context
104
+ RubyLLM.context do |config|
105
+ config.openai_api_key = @api_key
106
+ config.openai_api_base = @endpoint if @endpoint
107
+ config.request_timeout = @timeout
108
+ config.max_retries = 0
109
+ config.retry_interval_randomness = 0
110
+ config.faraday_adapter = EmbeddingAdapter.build(headers: @headers, cancellation: @cancellation)
111
+ end
112
+ end
113
+
114
+ def success_report(embedding, number, started_at)
115
+ attempt_report(number, started_at).merge(
116
+ status: :succeeded,
117
+ model: embedding.model,
118
+ usage: usage(embedding.tokens.input),
119
+ provider_reported: !embedding.tokens.input.nil?,
120
+ error: nil
121
+ ).freeze
122
+ end
123
+
124
+ def failure_report(error, number, started_at, category, code)
125
+ attempt_report(number, started_at).merge(
126
+ status: (category == :cancelled) ? :cancelled : :failed,
127
+ usage: usage(nil),
128
+ provider_reported: false,
129
+ error: {category: category, code: code}.freeze
130
+ ).freeze
131
+ end
132
+
133
+ def attempt_report(number, started_at)
134
+ {
135
+ attempt_id: "attempt_#{SecureRandom.uuid}", request_id: @request_id,
136
+ number: number, provider: :openai, model: @model,
137
+ started_at: started_at.iso8601(6), finished_at: Time.now.utc.iso8601(6),
138
+ cost: nil, provider_request_id: nil
139
+ }
140
+ end
141
+
142
+ def usage(input_tokens)
143
+ {input_tokens: input_tokens, output_tokens: nil, total_tokens: input_tokens}.freeze
144
+ end
145
+
146
+ def record_attempt(attempts, report)
147
+ attempts << report
148
+ return unless @observer
149
+
150
+ @observer.respond_to?(:on_attempt) ? @observer.on_attempt(report) : @observer.call(report)
151
+ end
152
+
153
+ def error_classification(error)
154
+ return [error.error_category, error.error_code] if error.is_a?(AuthenticationError) || error.is_a?(AuthorizationError)
155
+ return [:transient, :rate_limited] if error.is_a?(RateLimitError)
156
+ return [:transient, :timeout] if error.is_a?(TimeoutError)
157
+ return transient_provider_classification(error) if transient_provider_error?(error)
158
+ return [:invalid_response, :malformed_response] if error.is_a?(MalformedEmbeddingError)
159
+
160
+ [:unknown, :unclassified_provider_error]
161
+ end
162
+
163
+ def transient_provider_classification(error)
164
+ original = error.original_error
165
+ return [:transient, :connection_failed] if original.is_a?(Faraday::ConnectionFailed)
166
+ return [:transient, :service_unavailable] if original.is_a?(RubyLLM::ServiceUnavailableError)
167
+ return [:transient, :overloaded] if original.is_a?(RubyLLM::OverloadedError)
168
+
169
+ [:transient, :server_error]
170
+ end
171
+
172
+ def retryable?(error)
173
+ TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) } || transient_provider_error?(error)
174
+ end
175
+
176
+ def transient_provider_error?(error)
177
+ original = error.original_error
178
+ original.is_a?(Faraday::ConnectionFailed) || original.is_a?(RubyLLM::ServerError) ||
179
+ original.is_a?(RubyLLM::ServiceUnavailableError) || original.is_a?(RubyLLM::OverloadedError)
180
+ end
181
+
182
+ def wait_before_retry(error, attempt_number)
183
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + retry_delay(error, attempt_number)
184
+ loop do
185
+ check_cancellation!
186
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+ break unless remaining.positive?
188
+
189
+ sleep([remaining, CANCELLATION_POLL_INTERVAL].min)
190
+ end
191
+ end
192
+
193
+ def retry_delay(error, attempt_number)
194
+ retry_after_delay = error.reset_time - Time.now if error.is_a?(RateLimitError) && error.reset_time
195
+ return retry_after_delay if retry_after_delay&.positive?
196
+
197
+ [RETRY_BASE_DELAY * (2**(attempt_number - 1)), RETRY_MAX_DELAY].min
198
+ end
199
+
200
+ def check_cancellation!
201
+ raise CancelledError, "Embedding request cancelled" if @cancellation&.call
202
+ end
203
+
204
+ def validate_result!(vectors, expected_count)
205
+ valid = vectors.is_a?(Array) && vectors.length == expected_count
206
+ valid &&= vectors.all? { |vector| valid_vector?(vector) }
207
+ raise MalformedEmbeddingError, "Provider returned an invalid embedding batch" unless valid
208
+ end
209
+
210
+ def valid_vector?(vector)
211
+ vector.is_a?(Array) && !vector.empty? && vector.all? { |value| value.is_a?(Numeric) && value.finite? }
212
+ end
213
+
214
+ def credential(credentials, key)
215
+ return credentials if credentials.is_a?(String)
216
+
217
+ credentials&.[](key) || credentials&.[](key.to_s)
218
+ end
219
+
220
+ def validate!
221
+ raise ArgumentError, "model must be a non-empty string" unless @model.is_a?(String) && !@model.empty?
222
+ raise ArgumentError, "credentials must include api_key" unless @api_key.is_a?(String) && !@api_key.empty?
223
+ if @headers.keys.any? { |header| header.casecmp?("authorization") }
224
+ raise ArgumentError, "headers cannot override Authorization; use credentials"
225
+ end
226
+ raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout.positive?
227
+ unless @max_attempts.is_a?(Integer) && @max_attempts.positive?
228
+ raise ArgumentError, "max_attempts must be a positive integer"
229
+ end
230
+ unless @request_id.is_a?(String) && !@request_id.empty?
231
+ raise ArgumentError, "request_id must be a non-empty string"
232
+ end
233
+ unless @observer.nil? || @observer.respond_to?(:on_attempt) || @observer.respond_to?(:call)
234
+ raise ArgumentError, "observer must respond to on_attempt or call"
235
+ end
236
+ end
237
+
238
+ def retry_after(error)
239
+ value = error.response&.response_headers&.[]("retry-after")
240
+ return unless value
241
+
242
+ seconds = Float(value, exception: false)
243
+ seconds ? Time.now + seconds : Time.httpdate(value)
244
+ rescue ArgumentError, TypeError
245
+ nil
246
+ end
247
+
248
+ def connection_error(error)
249
+ wrapped = error.wrapped_exception
250
+ if wrapped.is_a?(Timeout::Error)
251
+ TimeoutError.new(error.message, original_error: error)
252
+ else
253
+ ProviderError.new(error.message, original_error: error)
254
+ end
255
+ end
256
+ end
257
+ end
@@ -15,6 +15,9 @@ module AgentHarness
15
15
  # Provider-related errors
16
16
  class ProviderError < Error; end
17
17
 
18
+ # Raised when an embedding provider response cannot satisfy the batch contract.
19
+ class MalformedEmbeddingError < ProviderError; end
20
+
18
21
  class ProviderInstallationError < ProviderError
19
22
  attr_reader :provider, :error_category
20
23
 
@@ -39,6 +42,9 @@ module AgentHarness
39
42
 
40
43
  class CommandExecutionError < Error; end
41
44
 
45
+ # Raised when a caller cancels a request before a transport attempt.
46
+ class CancelledError < Error; end
47
+
42
48
  # Rate limiting and circuit breaker errors
43
49
  class RateLimitError < Error
44
50
  attr_reader :reset_time, :provider, :error_category
@@ -62,10 +68,24 @@ module AgentHarness
62
68
 
63
69
  # Authentication errors
64
70
  class AuthenticationError < Error
65
- attr_reader :provider
71
+ attr_reader :provider, :error_category, :error_code
66
72
 
67
- def initialize(message = nil, provider: nil, **kwargs)
73
+ def initialize(message = nil, provider: nil, error_category: :authentication, error_code: :invalid_credential, **kwargs)
74
+ @provider = provider
75
+ @error_category = error_category
76
+ @error_code = error_code
77
+ super(message, **kwargs)
78
+ end
79
+ end
80
+
81
+ # Raised when valid credentials do not grant access to the requested resource.
82
+ class AuthorizationError < Error
83
+ attr_reader :provider, :error_category, :error_code
84
+
85
+ def initialize(message = nil, provider: nil, error_category: :authorization, error_code: :permission_denied, **kwargs)
68
86
  @provider = provider
87
+ @error_category = error_category
88
+ @error_code = error_code
69
89
  super(message, **kwargs)
70
90
  end
71
91
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.39.0"
4
+ VERSION = "0.41.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -87,6 +87,12 @@ module AgentHarness
87
87
  conductor.send_message(prompt, provider: provider, executor: executor, **options)
88
88
  end
89
89
 
90
+ # Generate embeddings for a batch of strings through a request-local RubyLLM context.
91
+ # @return [EmbeddingResult] vectors in input order and provider-reported batch usage
92
+ def embed(inputs:, model:, credentials:, dimensions: nil, **options)
93
+ Embeddings.new(model: model, credentials: credentials, **options).call(inputs: inputs, dimensions: dimensions)
94
+ end
95
+
90
96
  # Resolve a canonical extension definition by name or inline object.
91
97
  #
92
98
  # @param reference [Symbol, String, Extensions::Base]
@@ -456,12 +462,16 @@ require_relative "agent_harness/configuration"
456
462
  require_relative "agent_harness/command_executor"
457
463
  require_relative "agent_harness/docker_command_executor"
458
464
  require_relative "agent_harness/response"
465
+ require_relative "agent_harness/embedding_result"
466
+ require_relative "agent_harness/embedding_adapter"
467
+ require_relative "agent_harness/embeddings"
459
468
  require_relative "agent_harness/token_tracker"
460
469
  require_relative "agent_harness/token_usage_tracker"
461
470
  require_relative "agent_harness/error_taxonomy"
462
471
  require_relative "agent_harness/text_transport"
463
472
  require_relative "agent_harness/openai_compatible_transport"
464
473
  require_relative "agent_harness/conversation"
474
+ require_relative "agent_harness/api/chat_transport"
465
475
  require_relative "agent_harness/quota_status"
466
476
  require_relative "agent_harness/authentication"
467
477
  require_relative "agent_harness/provider_health_check"