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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +65 -0
- data/docs/provider-neutral-api-execution-contract.md +71 -7
- data/lib/agent_harness/api/chat_transport.rb +415 -0
- data/lib/agent_harness/api/ruby_llm_chat_adapter.rb +273 -0
- data/lib/agent_harness/embedding_adapter.rb +39 -0
- data/lib/agent_harness/embedding_result.rb +20 -0
- data/lib/agent_harness/embeddings.rb +257 -0
- data/lib/agent_harness/errors.rb +22 -2
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +10 -0
- metadata +40 -1
|
@@ -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
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -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
|
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"
|