agent-harness 0.39.0 → 0.40.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 +7 -0
- data/README.md +65 -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 +9 -0
- metadata +38 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee00d0e61838facc87483e6bdcac764ed26dc5bb227a15075b4e8f0daa8983d4
|
|
4
|
+
data.tar.gz: 6034f1733639711ad2b7df6bf9c26da839d45c26d1ddb6b0b4e5ba413854fc8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 130a2897522e8b1aa73b2a9cf6094bd24b3b529696445e84356a78faa02540e0c4c2677602e306fca60e1f1a3040e48c04b3ff0a9ca28f144a24de6fc3fa5887
|
|
7
|
+
data.tar.gz: 647ab39ee27728d28a3441ccbbd84edd3521e7e434a120ac236e411849b22aa431ae7fc6dbe499ec8c525aee6159c9fdba42a5ee8ff7ec329281dd7ea3a4ad33
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
|
|
6
6
|
* **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
|
|
7
7
|
|
|
8
|
+
## [0.40.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.39.0...agent-harness/v0.40.0) (2026-09-25)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Provide Native Embedding Support (RDR-072) ([#439](https://github.com/viamin/agent-harness/issues/439)) ([551378d](https://github.com/viamin/agent-harness/commit/551378d11627a5227059032f0b7418dc166f54e9))
|
|
14
|
+
|
|
8
15
|
## [0.39.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.38.0...agent-harness/v0.39.0) (2026-09-25)
|
|
9
16
|
|
|
10
17
|
|
data/README.md
CHANGED
|
@@ -39,6 +39,71 @@ puts response.output
|
|
|
39
39
|
response = AgentHarness.send_message("Explain this code", provider: :cursor)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Native Embeddings
|
|
43
|
+
|
|
44
|
+
`AgentHarness.embed` sends a whole input batch through RubyLLM and returns one
|
|
45
|
+
vector per input, in the same order. Credentials, endpoint, headers, timeout,
|
|
46
|
+
and retry limits are request-local; they do not change `RubyLLM.config` or the
|
|
47
|
+
CLI/subscription provider configuration.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
result = AgentHarness.embed(
|
|
51
|
+
inputs: ["first document", "second document"],
|
|
52
|
+
model: "text-embedding-3-small",
|
|
53
|
+
dimensions: 512,
|
|
54
|
+
endpoint: "https://api.openai.com/v1", # optional OpenAI-compatible base URL
|
|
55
|
+
credentials: {api_key: ENV.fetch("EMBEDDING_API_KEY")},
|
|
56
|
+
headers: {"X-Tenant-ID" => tenant.external_id},
|
|
57
|
+
timeout: 30,
|
|
58
|
+
max_attempts: 3,
|
|
59
|
+
cancellation: -> { request_cancelled? }
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result.vectors # one vector for each input
|
|
63
|
+
result.usage # { input_tokens: 42 }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`credentials` may also be the API key string. Extra headers cannot replace the
|
|
67
|
+
`Authorization` header; change credentials explicitly instead. `max_attempts`
|
|
68
|
+
includes the initial request. Agent Harness performs the only retry loop: 429,
|
|
69
|
+
timeout/connection, and transient 5xx failures are retried up to that bound,
|
|
70
|
+
while 401 and 403 responses fail immediately. The harness honors `Retry-After`
|
|
71
|
+
within its bounded retry policy. A cancellation callable is checked immediately
|
|
72
|
+
before every physical HTTP attempt.
|
|
73
|
+
|
|
74
|
+
Usage is the provider-reported total for the complete batch. When the provider
|
|
75
|
+
omits usage, `result.usage[:input_tokens]` remains `nil`. The harness does not
|
|
76
|
+
estimate usage or allocate a batch total across vectors, so
|
|
77
|
+
`result.per_vector_usage` is always `nil`.
|
|
78
|
+
|
|
79
|
+
Authentication failures raise `AgentHarness::AuthenticationError`, exhausted
|
|
80
|
+
rate limits raise `AgentHarness::RateLimitError`, timeouts raise
|
|
81
|
+
`AgentHarness::TimeoutError`, transient provider failures raise
|
|
82
|
+
`AgentHarness::ProviderError`, cancellations raise
|
|
83
|
+
`AgentHarness::CancelledError`, and incomplete or invalid vector batches raise
|
|
84
|
+
`AgentHarness::MalformedEmbeddingError`. Empty input returns an empty result
|
|
85
|
+
without contacting the provider.
|
|
86
|
+
|
|
87
|
+
### Migrating from Paid transport patches
|
|
88
|
+
|
|
89
|
+
This operation replaces downstream host/container embedding transport
|
|
90
|
+
extensions for OpenAI-compatible direct and proxy endpoints. After adopting an
|
|
91
|
+
agent-harness release containing this capability:
|
|
92
|
+
|
|
93
|
+
1. Run the downstream embedding contract suite against both the direct provider
|
|
94
|
+
and proxy endpoint, including tenant-specific credentials and headers.
|
|
95
|
+
2. Verify the released gem artifact includes `AgentHarness.embed` and record the
|
|
96
|
+
passing artifact version or digest. Issue closure or a Git tag alone is not
|
|
97
|
+
release evidence.
|
|
98
|
+
3. Switch only the embedding call site to `AgentHarness.embed`; leave unrelated
|
|
99
|
+
chat, schema, CLI, and subscription paths unchanged.
|
|
100
|
+
4. Remove the downstream embedding request/parser/retry patch so Agent Harness
|
|
101
|
+
owns the single bounded retry loop. Keep durable workflow recovery and
|
|
102
|
+
accounting in the downstream application.
|
|
103
|
+
|
|
104
|
+
The runtime dependency is Ruby 3.2 or newer and RubyLLM 2.x. No Rails database
|
|
105
|
+
or RubyLLM persistence tables are required for this plain-Ruby operation.
|
|
106
|
+
|
|
42
107
|
## Configuration
|
|
43
108
|
|
|
44
109
|
### Ruby DSL
|
|
@@ -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,6 +462,9 @@ 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"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agent-harness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.40.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bart Agapinan
|
|
@@ -29,6 +29,26 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '2.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: ruby_llm
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '2.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3.0'
|
|
32
52
|
- !ruby/object:Gem::Dependency
|
|
33
53
|
name: rake
|
|
34
54
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -71,6 +91,20 @@ dependencies:
|
|
|
71
91
|
- - "~>"
|
|
72
92
|
- !ruby/object:Gem::Version
|
|
73
93
|
version: '1.3'
|
|
94
|
+
- !ruby/object:Gem::Dependency
|
|
95
|
+
name: webmock
|
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - "~>"
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.0'
|
|
101
|
+
type: :development
|
|
102
|
+
prerelease: false
|
|
103
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - "~>"
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '3.0'
|
|
74
108
|
description: |
|
|
75
109
|
AgentHarness provides a unified interface for CLI-based AI coding agents like
|
|
76
110
|
Claude Code, Cursor, Gemini CLI, and others. It offers full orchestration with
|
|
@@ -107,6 +141,9 @@ files:
|
|
|
107
141
|
- lib/agent_harness/conversation.rb
|
|
108
142
|
- lib/agent_harness/dependency_updater.rb
|
|
109
143
|
- lib/agent_harness/docker_command_executor.rb
|
|
144
|
+
- lib/agent_harness/embedding_adapter.rb
|
|
145
|
+
- lib/agent_harness/embedding_result.rb
|
|
146
|
+
- lib/agent_harness/embeddings.rb
|
|
110
147
|
- lib/agent_harness/error_taxonomy.rb
|
|
111
148
|
- lib/agent_harness/errors.rb
|
|
112
149
|
- lib/agent_harness/execution_preparation.rb
|